mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 05:10:36 +00:00
Initial commit: ImpactFlow Discovery + Google OAuth auth layer
Discovery service (pre-existing): FastAPI + async SQLAlchemy + Alembic +
SQLite + Anthropic, with a five-prompt static UI that produces an Enneagram
+ Ikigai profile.
Auth implementation (this change set) follows
Impact_Flow_Auth_Plan_OAuth.html, adapted to the discovery_conversation /
discovery_profile schema:
- app/auth.py: Google OAuth registration, JWT issue/decode, dual-auth
dependency (Bearer JWT or X-API-Key), refresh-token hashing, domain
allow-list, synthetic api-key-admin user
- app/tracking.py: ActivityTrackingMiddleware + log_activity helper;
tags machine-to-machine calls source=mcp
- app/routers/auth.py: /api/auth/{login,callback,refresh,logout},
/api/me, /api/me/{stats,sessions,sessions/{id}}
- app/routers/activity.py: /api/activity, /api/activity/summary,
/api/admin/activity, plus prune_old_activity (90-day retention)
- app/routers/discovery.py: every route now user-scoped via the auth
dependency; /discovery/profile/{user_id} -> /discovery/profile/me
- alembic/versions/002_add_auth.py: users, refresh_tokens, activity_log
- tests/test_auth.py: 8 tests covering 401 paths, X-API-Key admin
resolution, JWT round-trip, admin gating, domain allow-list
- README.md: Authentication section, expanded env-var table, updated
data-model and API-reference tables
- .env.example: new GOOGLE_*, JWT_*, IMPACTFLOW_API_KEY, CORS_*,
ALLOWED_EMAIL_DOMAINS placeholders
- .gitignore: also exclude data/*.log
Tests: 19/19 pass (11 pre-existing + 8 new). smoke_test.py exercises the
full discovery flow under X-API-Key plus 401 paths, OAuth login redirect,
activity logging, and /api/me/stats.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
.env
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
data/*.db
|
||||
data/*.db-journal
|
||||
data/*.log
|
||||
data/*.err.log
|
||||
@@ -0,0 +1,29 @@
|
||||
ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
||||
DATABASE_URL=sqlite+aiosqlite:///./data/discovery.db
|
||||
HOST_BIND_IP=0.0.0.0
|
||||
HOST_PORT=8011
|
||||
|
||||
# Optional: override the Anthropic model used for extraction
|
||||
ANTHROPIC_MODEL=claude-sonnet-4-5
|
||||
|
||||
# Google OAuth (web client type). Register the redirect URI below as an
|
||||
# authorized redirect URI in the Google Cloud Console for this client.
|
||||
GOOGLE_CLIENT_ID=xxxx.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=GOCSPX-xxxx
|
||||
OAUTH_REDIRECT_URI=http://localhost:8000/api/auth/callback
|
||||
|
||||
# Random 64-char URL-safe strings. Generate with:
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
JWT_SECRET=generate-a-random-64-char-string
|
||||
IMPACTFLOW_API_KEY=generate-another-random-64-char-string
|
||||
|
||||
# JWT lifetimes (minutes / days).
|
||||
JWT_ACCESS_MINUTES=15
|
||||
JWT_REFRESH_DAYS=7
|
||||
|
||||
# Comma-separated allow-list of browser origins for CORS.
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:8000,http://100.103.206.4:8000
|
||||
|
||||
# Optional comma-separated email-domain allow-list (e.g. "computerim.com").
|
||||
# Empty means any verified Google email is accepted.
|
||||
ALLOWED_EMAIL_DOMAINS=
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
data/*.db
|
||||
data/*.db-journal
|
||||
data/*.log
|
||||
data/*.err.log
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# Validate that migrations apply against a throwaway DB inside the image layer.
|
||||
# At runtime the ./data volume shadows /app/data, so migrations are also run on
|
||||
# startup (see CMD) to populate the mounted volume.
|
||||
RUN alembic upgrade head
|
||||
|
||||
EXPOSE 8011
|
||||
|
||||
CMD ["sh", "-c", "python -m app.migration_bootstrap && alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8011"]
|
||||
@@ -0,0 +1,465 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Impact Flow Auth Plan — OAuth Edition</title>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #fff; color: #1a1a1a; padding: 24px; line-height: 1.6; }
|
||||
h1 { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
|
||||
.subtitle { color: #64748b; font-size: 13px; margin-bottom: 24px; }
|
||||
.phase { margin-bottom: 28px; border: 1px solid #e2e8f0; border-radius: 10px; overflow: hidden; }
|
||||
.phase-header { padding: 14px 18px; display: flex; align-items: center; gap: 10px; cursor: pointer; user-select: none; }
|
||||
.phase-header:hover { filter: brightness(0.97); }
|
||||
.phase-num { font-size: 12px; font-weight: 700; color: #fff; background: #2563eb; border-radius: 50%; width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.phase.p2 .phase-num { background: #7c3aed; }
|
||||
.phase.p3 .phase-num { background: #059669; }
|
||||
.phase.p4 .phase-num { background: #d97706; }
|
||||
.phase-title { font-size: 15px; font-weight: 600; }
|
||||
.phase-est { margin-left: auto; font-size: 12px; color: #64748b; background: #f1f5f9; padding: 2px 10px; border-radius: 12px; flex-shrink: 0; }
|
||||
.phase-body { padding: 0 18px 18px; display: none; }
|
||||
.phase.open .phase-body { display: block; }
|
||||
.chevron { margin-left: 4px; transition: transform 0.2s; font-size: 12px; color: #94a3b8; }
|
||||
.phase.open .chevron { transform: rotate(90deg); }
|
||||
.step { margin-bottom: 16px; }
|
||||
.step-title { font-size: 13px; font-weight: 600; margin-bottom: 4px; color: #334155; }
|
||||
.step p, .step li { font-size: 13px; color: #475569; }
|
||||
.step ul { padding-left: 18px; margin-top: 4px; }
|
||||
.step li { margin-bottom: 3px; }
|
||||
.file-tag { display: inline-block; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px; background: #f1f5f9; color: #475569; padding: 1px 6px; border-radius: 4px; margin: 1px 2px; }
|
||||
.schema { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 12px 14px; margin: 8px 0; font-family: 'SF Mono', monospace; font-size: 11px; color: #334155; white-space: pre-wrap; line-height: 1.5; }
|
||||
.warn { background: #fef3c7; border-left: 3px solid #f59e0b; padding: 8px 12px; border-radius: 0 6px 6px 0; margin: 8px 0; font-size: 12px; color: #92400e; }
|
||||
.good { background: #dcfce7; border-left: 3px solid #22c55e; padding: 8px 12px; border-radius: 0 6px 6px 0; margin: 8px 0; font-size: 12px; color: #166534; }
|
||||
.overview { background: #f0f4ff; border-radius: 8px; padding: 16px; margin-bottom: 24px; }
|
||||
.overview h2 { font-size: 14px; font-weight: 600; margin-bottom: 8px; color: #1e40af; }
|
||||
.overview p { font-size: 13px; color: #334155; }
|
||||
.stack-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
|
||||
.stack-chip { font-size: 11px; background: #dbeafe; color: #1e40af; padding: 2px 10px; border-radius: 12px; font-weight: 500; }
|
||||
.removed { text-decoration: line-through; color: #94a3b8; }
|
||||
.vs-table { width: 100%; border-collapse: collapse; margin: 10px 0; font-size: 12px; }
|
||||
.vs-table th { background: #f1f5f9; padding: 6px 10px; text-align: left; font-weight: 600; color: #334155; border-bottom: 2px solid #e2e8f0; }
|
||||
.vs-table td { padding: 6px 10px; border-bottom: 1px solid #f1f5f9; color: #475569; }
|
||||
.vs-table .yes { color: #16a34a; font-weight: 600; }
|
||||
.vs-table .no { color: #dc2626; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Impact Flow: Auth & User Tracking Plan</h1>
|
||||
<p class="subtitle">OAuth Edition — Google Sign-In + API Key for MCP</p>
|
||||
|
||||
<div class="overview">
|
||||
<h2>Why OAuth over Password Auth</h2>
|
||||
<table class="vs-table">
|
||||
<tr><th>Concern</th><th>Password (JWT-only)</th><th>OAuth (Google)</th></tr>
|
||||
<tr><td>Password storage</td><td class="no">You manage bcrypt hashes</td><td class="yes">Google handles it</td></tr>
|
||||
<tr><td>Password reset flow</td><td class="no">You build it</td><td class="yes">Not needed</td></tr>
|
||||
<tr><td>Email verification</td><td class="no">You build it</td><td class="yes">Google verifies</td></tr>
|
||||
<tr><td>Brute-force protection</td><td class="no">You implement rate limiting</td><td class="yes">Google handles it</td></tr>
|
||||
<tr><td>Account recovery</td><td class="no">You build it</td><td class="yes">Google handles it</td></tr>
|
||||
<tr><td>Initial setup</td><td class="yes">No external deps</td><td>Register Google Cloud app (~15 min)</td></tr>
|
||||
<tr><td>Ongoing maintenance</td><td class="no">High</td><td class="yes">Near zero</td></tr>
|
||||
</table>
|
||||
<div class="stack-row">
|
||||
<span class="stack-chip">Google OAuth 2.0</span>
|
||||
<span class="stack-chip">authlib</span>
|
||||
<span class="stack-chip">FastAPI</span>
|
||||
<span class="stack-chip">SQLite</span>
|
||||
<span class="stack-chip">API Key (MCP)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PHASE 1 -->
|
||||
<div class="phase p1 open" onclick="this.classList.toggle('open')">
|
||||
<div class="phase-header">
|
||||
<span class="phase-num">1</span>
|
||||
<span class="phase-title">Google OAuth + Simplified User Model</span>
|
||||
<span class="phase-est">~1 day</span>
|
||||
<span class="chevron">▶</span>
|
||||
</div>
|
||||
<div class="phase-body">
|
||||
|
||||
<div class="step">
|
||||
<div class="step-title">1.1 Google Cloud Console setup</div>
|
||||
<ul>
|
||||
<li>Go to <strong>console.cloud.google.com</strong> → APIs & Services → Credentials</li>
|
||||
<li>Create an OAuth 2.0 Client ID (Web application)</li>
|
||||
<li>Authorized redirect URI: <code>http://100.103.206.4:8000/api/auth/callback</code></li>
|
||||
<li>Also add <code>http://localhost:8000/api/auth/callback</code> for local dev</li>
|
||||
<li>Save the <strong>Client ID</strong> and <strong>Client Secret</strong></li>
|
||||
</ul>
|
||||
<div class="warn">Store these in .env, never commit them. Add .env to .gitignore if not already there.</div>
|
||||
<div class="schema">GOOGLE_CLIENT_ID=xxxx.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=GOCSPX-xxxx
|
||||
JWT_SECRET=generate-a-random-64-char-string
|
||||
IMPACTFLOW_API_KEY=generate-another-random-64-char-string</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-title">1.2 Simplified users table (no passwords!)</div>
|
||||
<div class="schema">CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY, -- UUID
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
avatar_url TEXT, -- from Google profile
|
||||
google_id TEXT UNIQUE NOT NULL, -- Google sub claim
|
||||
role TEXT DEFAULT 'user', -- user | admin
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
last_login_at TEXT
|
||||
);</div>
|
||||
<div class="good">No password_hash, no password reset, no email verification. Google handles all of that.</div>
|
||||
<p>Files: <span class="file-tag">app/db.py</span> <span class="file-tag">app/models.py</span></p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-title">1.3 Install dependencies</div>
|
||||
<div class="schema">pip install authlib httpx python-jose[cryptography]</div>
|
||||
<p><strong>authlib</strong> handles the OAuth dance. <strong>python-jose</strong> for JWT signing. No bcrypt/passlib needed.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-title">1.4 OAuth flow — 3 endpoints</div>
|
||||
<div class="schema">from authlib.integrations.starlette_client import OAuth
|
||||
|
||||
oauth = OAuth()
|
||||
oauth.register(
|
||||
name="google",
|
||||
client_id=os.getenv("GOOGLE_CLIENT_ID"),
|
||||
client_secret=os.getenv("GOOGLE_CLIENT_SECRET"),
|
||||
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
||||
client_kwargs={"scope": "openid email profile"},
|
||||
)</div>
|
||||
|
||||
<p><strong>GET /api/auth/login</strong> — Redirects browser to Google consent screen</p>
|
||||
<div class="schema">@app.get("/api/auth/login")
|
||||
async def login(request: Request):
|
||||
redirect_uri = request.url_for("auth_callback")
|
||||
return await oauth.google.authorize_redirect(request, redirect_uri)</div>
|
||||
|
||||
<p><strong>GET /api/auth/callback</strong> — Google redirects back here with auth code</p>
|
||||
<div class="schema">@app.get("/api/auth/callback")
|
||||
async def auth_callback(request: Request):
|
||||
token = await oauth.google.authorize_access_token(request)
|
||||
user_info = token.get("userinfo")
|
||||
|
||||
# Find or create user
|
||||
user = db.get_user_by_google_id(user_info["sub"])
|
||||
if not user:
|
||||
user = db.create_user(
|
||||
email=user_info["email"],
|
||||
display_name=user_info["name"],
|
||||
avatar_url=user_info.get("picture"),
|
||||
google_id=user_info["sub"],
|
||||
)
|
||||
|
||||
# Update last login
|
||||
db.update_last_login(user.id)
|
||||
|
||||
# Issue our own JWT
|
||||
access_token = create_jwt({"sub": user.id, "email": user.email})
|
||||
return {"access_token": access_token, "token_type": "bearer", "user": user}</div>
|
||||
|
||||
<p><strong>GET /api/auth/me</strong> — Returns current user from JWT</p>
|
||||
<div class="schema">@app.get("/api/auth/me")
|
||||
async def get_me(user = Depends(get_current_user)):
|
||||
return user</div>
|
||||
|
||||
<p>Files: <span class="file-tag">app/auth.py</span> (new) <span class="file-tag">app/main.py</span></p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-title">1.5 Dual auth: JWT + API Key</div>
|
||||
<p>The auth dependency accepts either a valid JWT <strong>or</strong> the <code>X-API-Key</code> header. This keeps the MCP server working without OAuth.</p>
|
||||
<div class="schema">async def get_current_user(
|
||||
request: Request,
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(
|
||||
HTTPBearer(auto_error=False)
|
||||
),
|
||||
db = Depends(get_db),
|
||||
) -> User:
|
||||
# Check API key first (for MCP server)
|
||||
api_key = request.headers.get("x-api-key")
|
||||
if api_key and api_key == os.getenv("IMPACTFLOW_API_KEY"):
|
||||
return db.get_admin_user() # API key acts as admin
|
||||
|
||||
# Then check JWT (for browser users)
|
||||
if credentials:
|
||||
payload = decode_jwt(credentials.credentials)
|
||||
user = db.get_user(payload["sub"])
|
||||
if user:
|
||||
return user
|
||||
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")</div>
|
||||
<div class="good">MCP server just adds <code>X-API-Key: {key}</code> header to every request. No OAuth dance needed for machine-to-machine.</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-title">1.6 Session middleware (for OAuth state)</div>
|
||||
<p>authlib needs Starlette sessions to store the OAuth state parameter during the redirect flow. Add:</p>
|
||||
<div class="schema">from starlette.middleware.sessions import SessionMiddleware
|
||||
app.add_middleware(SessionMiddleware, secret_key=os.getenv("JWT_SECRET"))</div>
|
||||
<div class="warn">This is only for the OAuth redirect flow. Your API auth still uses stateless JWTs, not session cookies.</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PHASE 2 -->
|
||||
<div class="phase p2" onclick="this.classList.toggle('open')">
|
||||
<div class="phase-header">
|
||||
<span class="phase-num">2</span>
|
||||
<span class="phase-title">Protect Endpoints & Multi-User Data</span>
|
||||
<span class="phase-est">~0.5 day</span>
|
||||
<span class="chevron">▶</span>
|
||||
</div>
|
||||
<div class="phase-body">
|
||||
|
||||
<div class="step">
|
||||
<div class="step-title">2.1 Add user_id to all data tables</div>
|
||||
<div class="schema">ALTER TABLE projects ADD COLUMN user_id TEXT REFERENCES users(id);
|
||||
ALTER TABLE visions ADD COLUMN user_id TEXT REFERENCES users(id);
|
||||
ALTER TABLE journal_entries ADD COLUMN user_id TEXT REFERENCES users(id);
|
||||
|
||||
CREATE INDEX idx_projects_user ON projects(user_id);
|
||||
CREATE INDEX idx_visions_user ON visions(user_id);
|
||||
CREATE INDEX idx_journal_user ON journal_entries(user_id);</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-title">2.2 Migration script</div>
|
||||
<p>One-time migration that preserves all existing data:</p>
|
||||
<ul>
|
||||
<li>Create users table</li>
|
||||
<li>Create your admin user (first Google login auto-promotes to admin)</li>
|
||||
<li>Add user_id columns</li>
|
||||
<li>Backfill existing rows with admin user_id</li>
|
||||
<li>Add indexes</li>
|
||||
</ul>
|
||||
<div class="schema">-- Auto-promote first user to admin
|
||||
-- In auth_callback, if no users exist yet:
|
||||
if db.count_users() == 0:
|
||||
user.role = "admin"</div>
|
||||
<p>Files: <span class="file-tag">app/migrations/001_add_auth.py</span> (new)</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-title">2.3 Scope all queries</div>
|
||||
<p>Every CRUD operation filters by the authenticated user:</p>
|
||||
<div class="schema"># Before (no auth)
|
||||
def get_projects(db):
|
||||
return db.execute("SELECT * FROM projects")
|
||||
|
||||
# After (user-scoped)
|
||||
def get_projects(db, user_id: str):
|
||||
return db.execute(
|
||||
"SELECT * FROM projects WHERE user_id = ?", [user_id]
|
||||
)</div>
|
||||
<ul>
|
||||
<li>GET list endpoints: filter by user_id</li>
|
||||
<li>GET detail endpoints: filter by user_id (prevents accessing other users' data)</li>
|
||||
<li>POST: auto-set user_id from authenticated user</li>
|
||||
<li>PUT/PATCH/DELETE: verify ownership before modifying</li>
|
||||
</ul>
|
||||
<p>Files: <span class="file-tag">app/db.py</span> <span class="file-tag">app/main.py</span></p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-title">2.4 Route classification</div>
|
||||
<table class="vs-table">
|
||||
<tr><th>Route</th><th>Auth</th><th>Notes</th></tr>
|
||||
<tr><td><code>/health</code></td><td>Public</td><td>Health check</td></tr>
|
||||
<tr><td><code>/api/auth/login</code></td><td>Public</td><td>Initiates OAuth</td></tr>
|
||||
<tr><td><code>/api/auth/callback</code></td><td>Public</td><td>OAuth callback</td></tr>
|
||||
<tr><td><code>/api/auth/me</code></td><td>Protected</td><td>Current user profile</td></tr>
|
||||
<tr><td><code>/api/projects/*</code></td><td>Protected</td><td>User-scoped</td></tr>
|
||||
<tr><td><code>/api/visions/*</code></td><td>Protected</td><td>User-scoped</td></tr>
|
||||
<tr><td><code>/api/journal/*</code></td><td>Protected</td><td>User-scoped</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PHASE 3 -->
|
||||
<div class="phase p3" onclick="this.classList.toggle('open')">
|
||||
<div class="phase-header">
|
||||
<span class="phase-num">3</span>
|
||||
<span class="phase-title">Activity Tracking</span>
|
||||
<span class="phase-est">~1 day</span>
|
||||
<span class="chevron">▶</span>
|
||||
</div>
|
||||
<div class="phase-body">
|
||||
|
||||
<div class="step">
|
||||
<div class="step-title">3.1 Activity log table</div>
|
||||
<div class="schema">CREATE TABLE activity_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
action TEXT NOT NULL, -- create|update|delete|view|login|export
|
||||
resource TEXT NOT NULL, -- project|vision|journal|auth
|
||||
resource_id TEXT, -- FK to the affected record
|
||||
metadata TEXT, -- JSON for extra context
|
||||
source TEXT DEFAULT 'web', -- web|mcp|api
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_activity_user ON activity_log(user_id, created_at DESC);
|
||||
CREATE INDEX idx_activity_resource ON activity_log(resource, resource_id);</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-title">3.2 Tracking middleware</div>
|
||||
<div class="schema">@app.middleware("http")
|
||||
async def track_activity(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
user = getattr(request.state, "user", None)
|
||||
if user and response.status_code < 400:
|
||||
# Determine source
|
||||
source = "mcp" if request.headers.get("x-api-key") else "web"
|
||||
log_activity(
|
||||
user_id=user.id,
|
||||
action=method_to_action(request.method),
|
||||
resource=path_to_resource(request.url.path),
|
||||
source=source,
|
||||
ip=request.client.host,
|
||||
ua=request.headers.get("user-agent", ""),
|
||||
)
|
||||
return response
|
||||
|
||||
def method_to_action(method: str) -> str:
|
||||
return {"GET": "view", "POST": "create", "PUT": "update",
|
||||
"PATCH": "update", "DELETE": "delete"}.get(method, "other")</div>
|
||||
<p>Files: <span class="file-tag">app/tracking.py</span> (new)</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-title">3.3 What to track</div>
|
||||
<ul>
|
||||
<li><strong>Auth events:</strong> login (via OAuth callback), failed attempts</li>
|
||||
<li><strong>CRUD ops:</strong> create/update/delete on projects, visions, journal</li>
|
||||
<li><strong>Detail views:</strong> GET on individual records (not list endpoints)</li>
|
||||
<li><strong>MCP calls:</strong> tagged with <code>source="mcp"</code> to distinguish Claude-initiated actions</li>
|
||||
<li><strong>Exports:</strong> any data export or report generation</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-title">3.4 Query endpoints</div>
|
||||
<div class="schema">GET /api/activity?page=1&limit=20
|
||||
-- Paginated activity feed for current user
|
||||
|
||||
GET /api/activity/summary?days=30
|
||||
-- Returns: { actions_per_day, top_resources, mcp_vs_web_ratio }
|
||||
|
||||
GET /api/admin/activity?user_id=... (admin only)
|
||||
-- All users' activity</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-title">3.5 Retention</div>
|
||||
<p>Auto-prune logs older than 90 days. Run on app startup:</p>
|
||||
<div class="schema">@app.on_event("startup")
|
||||
async def prune_old_activity():
|
||||
db.execute("""
|
||||
DELETE FROM activity_log
|
||||
WHERE created_at < datetime('now', '-90 days')
|
||||
""")</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PHASE 4 -->
|
||||
<div class="phase p4" onclick="this.classList.toggle('open')">
|
||||
<div class="phase-header">
|
||||
<span class="phase-num">4</span>
|
||||
<span class="phase-title">Discovery & Polish</span>
|
||||
<span class="phase-est">~1 day</span>
|
||||
<span class="chevron">▶</span>
|
||||
</div>
|
||||
<div class="phase-body">
|
||||
|
||||
<div class="step">
|
||||
<div class="step-title">4.1 User profile endpoints</div>
|
||||
<ul>
|
||||
<li><code>GET /api/me</code> — full profile + stats</li>
|
||||
<li><code>PATCH /api/me</code> — update display_name (email managed by Google)</li>
|
||||
<li><code>GET /api/me/stats</code> — personal usage: projects created, journal streaks, active days</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-title">4.2 Token management</div>
|
||||
<div class="schema">CREATE TABLE refresh_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
token_hash TEXT NOT NULL,
|
||||
device TEXT, -- from user-agent
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL
|
||||
);</div>
|
||||
<ul>
|
||||
<li><code>GET /api/me/sessions</code> — list active refresh tokens (device, created)</li>
|
||||
<li><code>DELETE /api/me/sessions/{id}</code> — revoke a token</li>
|
||||
<li>Issue refresh token on OAuth callback, access token from refresh</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-title">4.3 Update MCP server</div>
|
||||
<ul>
|
||||
<li>Add <code>X-API-Key</code> header to every request in MCP server config</li>
|
||||
<li>New MCP tool: <code>get_activity_summary</code> — "What have I been working on?"</li>
|
||||
<li>New MCP tool: <code>get_recent_changes</code> — Feed of what changed recently</li>
|
||||
<li>New MCP tool: <code>get_user_profile</code> — Current user info and stats</li>
|
||||
</ul>
|
||||
<p>Files: <span class="file-tag">mcp_server/tools.py</span></p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-title">4.4 Security hardening</div>
|
||||
<ul>
|
||||
<li>CORS: restrict origins to Tailscale IP + localhost</li>
|
||||
<li>Rate limit OAuth callback (prevent abuse)</li>
|
||||
<li>Allowed email domains: optionally restrict to specific domains</li>
|
||||
<li>JWT expiry: 15 min access, 7 day refresh</li>
|
||||
<li>All secrets in .env, .env in .gitignore</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-title">4.5 What you no longer need to build</div>
|
||||
<div class="good">
|
||||
<strong>Removed from scope (Google handles these):</strong><br>
|
||||
<span class="removed">POST /api/auth/register</span> —
|
||||
<span class="removed">Password hashing (bcrypt)</span> —
|
||||
<span class="removed">Password reset flow</span> —
|
||||
<span class="removed">Email verification</span> —
|
||||
<span class="removed">Brute-force protection on login</span> —
|
||||
<span class="removed">Account recovery</span> —
|
||||
<span class="removed">passlib dependency</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-title">4.6 Future considerations</div>
|
||||
<ul>
|
||||
<li><strong>Additional OAuth providers:</strong> GitHub, Microsoft — authlib makes adding providers trivial (same pattern, new registration)</li>
|
||||
<li><strong>Invite system:</strong> Admin generates invite links, new users must have invite to create account</li>
|
||||
<li><strong>Team/org model:</strong> Shared projects between users (if Impact Flow grows beyond personal use)</li>
|
||||
<li><strong>Allowed domains:</strong> Restrict sign-up to @computerim.com emails</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// First phase open by default
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,723 @@
|
||||
# ImpactFlow Self-Discovery Module
|
||||
|
||||
ImpactFlow Self-Discovery is a standalone FastAPI service that turns five
|
||||
short reflection stories into a structured Enneagram + Ikigai profile. It is
|
||||
designed to run independently from the main ImpactFlow app on port `8011` and
|
||||
can be integrated into the larger product later.
|
||||
|
||||
The service presents a lightweight browser flow, stores the user's narrative
|
||||
answers in SQLite, sends those answers to Anthropic for structured extraction,
|
||||
persists the resulting profile, and displays a plain-language profile page.
|
||||
|
||||
## Quick Explanation For AI Assistants
|
||||
|
||||
If you need to explain this app in detail, use this mental model:
|
||||
|
||||
1. A user opens `/static/discovery.html`.
|
||||
2. The browser sends them through Google sign-in at `GET /api/auth/login`;
|
||||
`GET /api/auth/callback` mints an access JWT + refresh token and the
|
||||
browser stores the access JWT for subsequent requests.
|
||||
3. With the JWT in the `Authorization: Bearer …` header, the browser starts
|
||||
a discovery conversation with `POST /discovery/start` — the backend
|
||||
derives the `user_id` from the JWT, not from the request body.
|
||||
4. The user answers five open-ended prompts.
|
||||
5. The browser saves all five answers with
|
||||
`PUT /discovery/{conversation_id}/respond`.
|
||||
6. The browser asks the backend to analyze the saved answers with
|
||||
`POST /discovery/{conversation_id}/complete`.
|
||||
7. The backend calls Anthropic through `DiscoveryExtractor`.
|
||||
8. The extractor asks for JSON containing Enneagram, instinctual variant,
|
||||
Ikigai summaries, confidence flags, and optional extraction notes.
|
||||
9. The backend stores that JSON as a `DiscoveryProfile` row owned by the
|
||||
authenticated user.
|
||||
10. The browser redirects to `/static/profile.html`.
|
||||
11. The profile page loads the newest profile with
|
||||
`GET /discovery/profile/me` and lets the user lock it with
|
||||
`PUT /discovery/profile/me/confirm`.
|
||||
|
||||
Machine-to-machine callers (e.g. the MCP server) skip the OAuth dance and
|
||||
authenticate with `X-API-Key: $IMPACTFLOW_API_KEY` instead. That header
|
||||
resolves to a synthetic admin user (`api-key-admin`) so foreign keys stay
|
||||
valid and admin-only routes work without a real Google sign-in.
|
||||
|
||||
The app is intentionally small: static HTML/CSS for the UI, FastAPI for the
|
||||
API, async SQLAlchemy for persistence, Alembic for migrations, SQLite for local
|
||||
storage, Google OAuth + JWT for browser auth, and Anthropic for the analysis
|
||||
step.
|
||||
|
||||
## What The App Does
|
||||
|
||||
The module collects five narrative prompts:
|
||||
|
||||
| Stored field | User-facing prompt | Purpose in extraction |
|
||||
| --- | --- | --- |
|
||||
| `prompt_alive` | The Alive Moment | Reveals energy, motivation, strengths, and core need |
|
||||
| `prompt_friction` | The Friction Moment | Strongest signal for Enneagram triad |
|
||||
| `prompt_pull` | The Natural Pull | Helps infer instinctual variant and recurring interests |
|
||||
| `prompt_recognition` | The Recognition Moment | Reveals what the person values being seen for |
|
||||
| `prompt_future` | The Future Pull | Helps infer mission, vocation, and ideal future direction |
|
||||
|
||||
The generated profile includes:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `triad` | One of `gut`, `heart`, or `head` |
|
||||
| `probable_type` | Likely Enneagram type number, `1` through `9` |
|
||||
| `wing` | Adjacent Enneagram wing type |
|
||||
| `instinctual_variant` | One of `sp`, `so`, or `sx` |
|
||||
| `instinctual_stack` | Ordered stack such as `sp/so/sx` |
|
||||
| `love_summary` | Ikigai: what the user loves |
|
||||
| `strength_summary` | Ikigai: what the user is good at |
|
||||
| `mission_summary` | Ikigai: what the world needs from the user |
|
||||
| `vocation_summary` | Ikigai: what the user can be paid for |
|
||||
| `overlap_narrative` | Plain-language convergence narrative |
|
||||
| `confidence` | Confidence flags for triad, type, variant, and Ikigai |
|
||||
| `extraction_notes` | Optional ambiguity or caveat from the model |
|
||||
| `locked` | Whether the user has confirmed the profile |
|
||||
|
||||
The profile page deliberately avoids showing the raw Enneagram type number to
|
||||
the user. It translates the triad into plain-language pattern descriptions and
|
||||
shows the Ikigai summaries as cards.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
Browser static UI MCP server / other machines
|
||||
discovery.html X-API-Key: $IMPACTFLOW_API_KEY
|
||||
profile.html
|
||||
| |
|
||||
| Google OAuth + |
|
||||
| Bearer JWT |
|
||||
v v
|
||||
+--------------------------------------------+
|
||||
| FastAPI app |
|
||||
| app/main.py (middleware stack) |
|
||||
| app/auth.py (dual-auth dependency)|
|
||||
| app/routers/auth.py /api/auth, /api/me
|
||||
| app/routers/activity.py /api/activity
|
||||
| app/routers/discovery.py /discovery/* |
|
||||
| app/tracking.py (activity middleware) |
|
||||
+--------------------------------------------+
|
||||
|
|
||||
v
|
||||
Async SQLAlchemy + SQLite
|
||||
app/database.py
|
||||
app/models.py (users, refresh_tokens, activity_log,
|
||||
discovery_conversation, discovery_profile)
|
||||
data/discovery.db
|
||||
|
|
||||
v
|
||||
Anthropic extraction
|
||||
app/services/extractor.py
|
||||
```
|
||||
|
||||
Important files:
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| `app/main.py` | FastAPI entrypoint, middleware wiring, lifespan hooks, health check, static file mount, root redirect |
|
||||
| `app/auth.py` | Google OAuth registration, JWT issue/decode, dual-auth dependency, refresh-token hashing, domain allow-list |
|
||||
| `app/tracking.py` | `ActivityTrackingMiddleware` and `log_activity` helper |
|
||||
| `app/routers/auth.py` | OAuth endpoints, `/api/me`, sessions, refresh, logout, `/api/me/stats` |
|
||||
| `app/routers/activity.py` | Activity feed, per-user summary, admin activity view, 90-day retention pruner |
|
||||
| `app/routers/discovery.py` | Discovery API routes; all routes user-scoped via the auth dependency |
|
||||
| `app/schemas.py` | Pydantic request and response models |
|
||||
| `app/models.py` | SQLAlchemy ORM models for users, refresh tokens, activity log, conversations, and profiles |
|
||||
| `app/database.py` | Async database engine, session factory, SQLite directory setup |
|
||||
| `app/services/extractor.py` | Anthropic client wrapper, prompt, JSON parsing, retry logic |
|
||||
| `app/migration_bootstrap.py` | Stamps pre-Alembic SQLite DBs as revision `001` so `alembic upgrade head` succeeds on older local databases |
|
||||
| `app/static/discovery.html` | Browser-based five-prompt flow |
|
||||
| `app/static/profile.html` | Browser-based profile display and confirm action |
|
||||
| `app/static/style.css` | Shared UI styling |
|
||||
| `alembic/versions/001_initial.py` | Initial database schema migration |
|
||||
| `alembic/versions/002_add_auth.py` | Adds `users`, `refresh_tokens`, and `activity_log` tables |
|
||||
| `tests/test_extractor.py` | Unit tests for extraction plumbing and retry behavior |
|
||||
| `tests/test_auth.py` | Tests for the dual-auth dependency, JWT minting/decode, admin enforcement, and domain allow-list |
|
||||
| `tests/test_migration_bootstrap.py` | Unit tests for the pre-Alembic SQLite stamping helper |
|
||||
| `tests/test_static_discovery.py` | Guard test for the insecure-context UUID fallback in `discovery.html` |
|
||||
| `tests/fixtures/{gut,head,heart}_type_responses.json` | Synthetic five-prompt responses used by extractor tests |
|
||||
| `smoke_test.py` | In-process end-to-end API smoke test with fake extraction; also exercises the auth and activity tracking surface |
|
||||
|
||||
## Authentication
|
||||
|
||||
The API supports two ways to authenticate, both resolved by a single
|
||||
`get_current_user` dependency in `app/auth.py`:
|
||||
|
||||
| Caller | Mechanism | Notes |
|
||||
| --- | --- | --- |
|
||||
| Browser users | Google OAuth + signed JWT in `Authorization: Bearer …` | Issued by `/api/auth/callback` after a successful Google sign-in |
|
||||
| Machine-to-machine (MCP server, scripts) | `X-API-Key: $IMPACTFLOW_API_KEY` | Resolves to a synthetic admin user `api-key-admin` so FK constraints stay valid |
|
||||
|
||||
The first real Google user to sign in is auto-promoted to `role=admin`;
|
||||
every subsequent user defaults to `role=user`. Admin-only routes (e.g.
|
||||
`/api/admin/activity`) check the role on the resolved user.
|
||||
|
||||
### One-Time Google Cloud Setup
|
||||
|
||||
1. In <https://console.cloud.google.com> open APIs & Services → Credentials.
|
||||
2. Create an **OAuth 2.0 Client ID** of type **Web application**.
|
||||
3. Add authorized redirect URIs that match `OAUTH_REDIRECT_URI` in `.env`:
|
||||
- `http://localhost:8000/api/auth/callback` for local dev
|
||||
- `http://<deploy-host>:8000/api/auth/callback` for the deployed instance
|
||||
4. Copy the client id and client secret into `.env` as `GOOGLE_CLIENT_ID` and
|
||||
`GOOGLE_CLIENT_SECRET`.
|
||||
5. Generate two random 64-character strings and put them in `.env` as
|
||||
`JWT_SECRET` and `IMPACTFLOW_API_KEY`:
|
||||
|
||||
```bash
|
||||
.venv/Scripts/python.exe -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
```
|
||||
|
||||
### OAuth Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/auth/login` | 302s the browser to Google's consent screen |
|
||||
| `GET` | `/api/auth/callback` | Google redirects here with `code`; we exchange it for a Google access token, find-or-create the user, then return `{access_token, refresh_token, user}` |
|
||||
| `POST` | `/api/auth/refresh` | Body `{refresh_token}` → new short-lived access token |
|
||||
| `POST` | `/api/auth/logout` | Body `{refresh_token}` → revokes that refresh token (idempotent) |
|
||||
|
||||
`access_token` lifetime defaults to 15 minutes; `refresh_token` lifetime
|
||||
defaults to 7 days. Both are configurable through `.env`. Refresh tokens are
|
||||
stored as SHA-256 hashes — the raw value only exists in the response from
|
||||
`/api/auth/callback` and `/api/auth/refresh`.
|
||||
|
||||
### Profile And Session Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/me`, `/api/auth/me` | Current user profile |
|
||||
| `PATCH` | `/api/me` | Update `display_name` (email is owned by Google) |
|
||||
| `GET` | `/api/me/stats` | Conversations / profiles / locked-profiles / 30-day activity counts |
|
||||
| `GET` | `/api/me/sessions` | List active refresh tokens (`id`, `device`, `created_at`, `expires_at`) |
|
||||
| `DELETE` | `/api/me/sessions/{id}` | Revoke a refresh token |
|
||||
|
||||
### Activity Tracking
|
||||
|
||||
`ActivityTrackingMiddleware` records one `activity_log` row per authenticated,
|
||||
successful (`status < 400`), non-noisy request. The `source` column is set to
|
||||
`mcp` when the request carries an `X-API-Key` header and `web` otherwise, so
|
||||
Claude-initiated calls are distinguishable from browser activity. Rows older
|
||||
than 90 days are pruned on app startup.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/activity?page=…&limit=…` | Paginated activity feed for the current user |
|
||||
| `GET` | `/api/activity/summary?days=30` | Per-day counts, top resources, web/mcp ratio |
|
||||
| `GET` | `/api/admin/activity?user_id=…` | Admin-only cross-user feed |
|
||||
|
||||
## Runtime Flow
|
||||
|
||||
### 1. App Startup
|
||||
|
||||
`app/main.py` creates the FastAPI app, wires the middleware stack
|
||||
(`ActivityTrackingMiddleware` innermost, then `SessionMiddleware` for the
|
||||
OAuth state cookie, `CORSMiddleware` outermost), includes the auth, activity,
|
||||
and discovery routers, mounts `/static`, and redirects `/` to
|
||||
`/static/discovery.html`.
|
||||
|
||||
The lifespan hook:
|
||||
|
||||
1. Calls `Base.metadata.create_all()` as a local development safety net.
|
||||
Alembic remains the source of truth for schema changes.
|
||||
2. Calls `ensure_api_key_admin()` so the synthetic admin user backing
|
||||
`X-API-Key` exists before the first request arrives.
|
||||
3. Calls `prune_old_activity()` to drop activity log rows older than 90 days.
|
||||
|
||||
In Docker, the container's `CMD` runs three steps in order before serving:
|
||||
|
||||
1. `python -m app.migration_bootstrap` — if `data/discovery.db` already
|
||||
exists with the app tables but no `alembic_version` row (older local DBs
|
||||
created via `create_all` before Alembic existed), stamp it as revision
|
||||
`001` so step 2 does not try to recreate existing tables.
|
||||
2. `alembic upgrade head` — apply any outstanding migrations.
|
||||
3. `uvicorn app.main:app --host 0.0.0.0 --port 8011` — serve the app.
|
||||
|
||||
The Dockerfile also runs `alembic upgrade head` at build time against a
|
||||
throwaway in-image DB as a sanity check that migrations apply cleanly. At
|
||||
runtime the `./data` volume shadows `/app/data`, so the runtime migration
|
||||
step above is what populates the persistent database.
|
||||
|
||||
### 2. Starting A Conversation
|
||||
|
||||
`POST /discovery/start` requires authentication (Bearer JWT or `X-API-Key`).
|
||||
It takes no body — the `user_id` is derived from the authenticated user.
|
||||
|
||||
It creates a `DiscoveryConversation` row with:
|
||||
|
||||
- a UUID conversation id
|
||||
- `user_id` set to the authenticated user's id
|
||||
- `started_at` in UTC
|
||||
- empty prompt fields
|
||||
|
||||
It returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"conversation_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
The static UI starts this conversation after the browser has a JWT from the
|
||||
OAuth callback, and retries on submit if the first start call failed.
|
||||
|
||||
### 3. Saving Answers
|
||||
|
||||
`PUT /discovery/{conversation_id}/respond` accepts all five prompt responses:
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt_alive": "I felt most alive when...",
|
||||
"prompt_friction": "Something felt wrong when...",
|
||||
"prompt_pull": "I naturally keep returning to...",
|
||||
"prompt_recognition": "I felt seen when...",
|
||||
"prompt_future": "If I could not fail..."
|
||||
}
|
||||
```
|
||||
|
||||
It stores the responses on the existing conversation and returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"conversation_id": "uuid",
|
||||
"status": "responses_saved"
|
||||
}
|
||||
```
|
||||
|
||||
If the conversation id does not exist, it returns `404`.
|
||||
|
||||
### 4. Completing Analysis
|
||||
|
||||
`POST /discovery/{conversation_id}/complete` loads the conversation, builds a
|
||||
compact response dictionary with keys `alive`, `friction`, `pull`,
|
||||
`recognition`, and `future`, then calls `DiscoveryExtractor.extract()`.
|
||||
|
||||
The route rejects completion with:
|
||||
|
||||
- `404` if the conversation does not exist
|
||||
- `400` if all five responses are blank
|
||||
- `502` if Anthropic extraction fails or returns unusable output after retry
|
||||
|
||||
On success, it stores a new `DiscoveryProfile`, marks the conversation
|
||||
`completed_at`, and returns the profile.
|
||||
|
||||
### 5. Loading The Profile
|
||||
|
||||
`GET /discovery/profile/me` fetches the newest profile for the authenticated
|
||||
user by descending `generated_at`.
|
||||
|
||||
The profile page uses this route after redirect. This means one user can have
|
||||
multiple completed conversations, but the UI always displays the latest one.
|
||||
|
||||
### 6. Confirming The Profile
|
||||
|
||||
`PUT /discovery/profile/me/confirm` locks the newest profile by setting
|
||||
`locked = true`.
|
||||
|
||||
This is the current confirmation mechanism for "This is me" on the profile
|
||||
page. It does not prevent future conversations from generating newer profiles.
|
||||
|
||||
## API Reference
|
||||
|
||||
All `/discovery/*`, `/api/me*`, `/api/activity*`, and `/api/admin/*` routes
|
||||
require authentication (Bearer JWT or `X-API-Key`). `/api/auth/login`,
|
||||
`/api/auth/callback`, `/health`, `/`, and `/static/*` are public.
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET` | `/` | public | Redirects to `/static/discovery.html` |
|
||||
| `GET` | `/health` | public | Liveness check, returns `{"status": "ok"}` |
|
||||
| `GET` | `/api/auth/login` | public | 302 to Google consent screen |
|
||||
| `GET` | `/api/auth/callback` | public | OAuth callback; issues `{access_token, refresh_token, user}` |
|
||||
| `POST` | `/api/auth/refresh` | public (token in body) | Exchange refresh token for a new access token |
|
||||
| `POST` | `/api/auth/logout` | public (token in body) | Revoke a refresh token |
|
||||
| `GET` | `/api/me`, `/api/auth/me` | yes | Current user profile |
|
||||
| `PATCH` | `/api/me` | yes | Update `display_name` |
|
||||
| `GET` | `/api/me/stats` | yes | Per-user usage stats |
|
||||
| `GET` | `/api/me/sessions` | yes | List active refresh tokens |
|
||||
| `DELETE` | `/api/me/sessions/{id}` | yes | Revoke a refresh token |
|
||||
| `GET` | `/api/activity` | yes | Paginated activity feed for current user |
|
||||
| `GET` | `/api/activity/summary` | yes | Aggregate activity stats |
|
||||
| `GET` | `/api/admin/activity` | admin | All-users activity feed |
|
||||
| `POST` | `/discovery/start` | yes | Begin a conversation (user derived from auth) |
|
||||
| `PUT` | `/discovery/{conversation_id}/respond` | yes | Save all five responses |
|
||||
| `POST` | `/discovery/{conversation_id}/complete` | yes | Run extraction, store profile, return profile |
|
||||
| `GET` | `/discovery/profile/me` | yes | Fetch newest profile for the authenticated user |
|
||||
| `PUT` | `/discovery/profile/me/confirm` | yes | Lock newest profile for the authenticated user |
|
||||
| `GET` | `/discovery/conversation/{conversation_id}` | yes | Fetch stored conversation responses (owner only) |
|
||||
|
||||
## Data Model
|
||||
|
||||
### `users`
|
||||
|
||||
One row per signed-in human, plus a single synthetic `api-key-admin` row
|
||||
backing the `X-API-Key` dual-auth path.
|
||||
|
||||
| Column | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `id` | string | UUID primary key; `api-key-admin` for the synthetic row |
|
||||
| `email` | string | Unique, from Google |
|
||||
| `display_name` | string | From Google `name` claim; editable via `PATCH /api/me` |
|
||||
| `avatar_url` | string nullable | Google profile picture |
|
||||
| `google_id` | string nullable, unique | Google `sub` claim; null only for the API-key row |
|
||||
| `role` | string | `user` or `admin`; first real user is auto-promoted |
|
||||
| `created_at` | datetime | UTC |
|
||||
| `last_login_at` | datetime nullable | Updated on every OAuth callback |
|
||||
|
||||
### `refresh_tokens`
|
||||
|
||||
| Column | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `id` | string | UUID primary key |
|
||||
| `user_id` | string | FK to `users.id`, indexed |
|
||||
| `token_hash` | string | SHA-256 of the raw token; unique |
|
||||
| `device` | string nullable | Captured from `User-Agent` at issue time |
|
||||
| `created_at` | datetime | UTC |
|
||||
| `expires_at` | datetime | UTC |
|
||||
| `revoked_at` | datetime nullable | Set by logout or `DELETE /api/me/sessions/{id}` |
|
||||
|
||||
### `activity_log`
|
||||
|
||||
Append-only audit trail. Pruned to 90 days on app startup.
|
||||
|
||||
| Column | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `id` | string | UUID primary key |
|
||||
| `user_id` | string | FK to `users.id`, indexed |
|
||||
| `action` | string | `view`, `create`, `update`, `delete`, or `other` |
|
||||
| `resource` | string | Coarse resource name parsed from the URL path |
|
||||
| `resource_id` | string nullable | When a specific record id is identifiable |
|
||||
| `metadata_json` | text nullable | JSON-encoded extra context |
|
||||
| `source` | string | `web` or `mcp` (set from presence of `X-API-Key`) |
|
||||
| `ip_address` | string nullable | From `request.client.host` |
|
||||
| `user_agent` | text nullable | Truncated to 1000 chars |
|
||||
| `created_at` | datetime | UTC, indexed |
|
||||
|
||||
### `discovery_conversation`
|
||||
|
||||
Stores one five-prompt response set.
|
||||
|
||||
| Column | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `id` | string | UUID primary key |
|
||||
| `user_id` | string | FK to `users.id`, indexed |
|
||||
| `started_at` | datetime | UTC timestamp |
|
||||
| `completed_at` | datetime nullable | Set after successful profile generation |
|
||||
| `prompt_alive` | text nullable | First narrative response |
|
||||
| `prompt_friction` | text nullable | Second narrative response |
|
||||
| `prompt_pull` | text nullable | Third narrative response |
|
||||
| `prompt_recognition` | text nullable | Fourth narrative response |
|
||||
| `prompt_future` | text nullable | Fifth narrative response |
|
||||
|
||||
### `discovery_profile`
|
||||
|
||||
Stores one extracted profile for one conversation.
|
||||
|
||||
| Column | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `id` | string | UUID primary key |
|
||||
| `user_id` | string | FK to `users.id`, indexed |
|
||||
| `conversation_id` | string | Foreign key to `discovery_conversation.id` |
|
||||
| `generated_at` | datetime | UTC timestamp |
|
||||
| `triad` | string nullable | `gut`, `heart`, or `head` |
|
||||
| `probable_type` | integer nullable | Enneagram type number |
|
||||
| `wing` | integer nullable | Adjacent Enneagram wing |
|
||||
| `instinctual_variant` | string nullable | `sp`, `so`, or `sx` |
|
||||
| `instinctual_stack` | string nullable | Ordered stack |
|
||||
| `love_summary` | text nullable | Ikigai love summary |
|
||||
| `strength_summary` | text nullable | Ikigai strength summary |
|
||||
| `mission_summary` | text nullable | Ikigai mission summary |
|
||||
| `vocation_summary` | text nullable | Ikigai vocation summary |
|
||||
| `overlap_narrative` | text nullable | Convergence narrative |
|
||||
| `confidence_json` | text nullable | JSON string for confidence flags |
|
||||
| `locked` | boolean | Defaults to false |
|
||||
|
||||
## Extraction Details
|
||||
|
||||
`DiscoveryExtractor` is intentionally responsible for plumbing, not business
|
||||
logic hidden elsewhere. It:
|
||||
|
||||
1. Validates that `ANTHROPIC_API_KEY` exists.
|
||||
2. Builds one user message from the five responses.
|
||||
3. Calls Anthropic with `SYSTEM_PROMPT`.
|
||||
4. Parses the model response as JSON.
|
||||
5. Strips Markdown code fences if present.
|
||||
6. Validates that all required top-level keys exist.
|
||||
7. Validates that `confidence` contains `triad`, `type`, `variant`, and
|
||||
`ikigai`.
|
||||
8. Retries once with an explicit JSON-only reminder if the first response
|
||||
cannot be parsed or is missing required keys.
|
||||
9. Raises `DiscoveryExtractionError` if the API call fails or retry also fails.
|
||||
|
||||
Default extraction settings:
|
||||
|
||||
| Setting | Value |
|
||||
| --- | --- |
|
||||
| Default model | `claude-sonnet-4-5` |
|
||||
| Max tokens | `2000` |
|
||||
| Required response type | Single JSON object |
|
||||
| Retry count | One retry after invalid JSON or missing keys |
|
||||
|
||||
The system prompt instructs the model to infer:
|
||||
|
||||
- Enneagram triad and type from emotional center signals
|
||||
- instinctual variant from recurring attention patterns
|
||||
- Ikigai love, strength, mission, and vocation from repeated story themes
|
||||
- confidence levels based on consistency and strength of evidence
|
||||
|
||||
## Frontend Behavior
|
||||
|
||||
The frontend is static HTML with embedded JavaScript.
|
||||
|
||||
`discovery.html`:
|
||||
|
||||
- stores a generated `impactflow_user_id` in `localStorage`
|
||||
- generates that id via `createUserId()`, which prefers `crypto.randomUUID()`
|
||||
when available and falls back to `crypto.getRandomValues()` so the flow
|
||||
still works in insecure contexts (e.g. plain `http://` over LAN)
|
||||
- shows five prompts one at a time
|
||||
- keeps answers in memory while navigating back and next
|
||||
- starts a conversation on page load
|
||||
- saves all responses on submit
|
||||
- triggers extraction
|
||||
- redirects to the profile page on success
|
||||
- shows an error box and reload button if submission fails
|
||||
|
||||
`profile.html`:
|
||||
|
||||
- reads `user_id` from the query string
|
||||
- fetches the newest profile for that user
|
||||
- escapes all model-generated text before rendering
|
||||
- shows Ikigai cards and a triad description
|
||||
- uses confidence dots for triad and Ikigai confidence
|
||||
- sends the confirm request when the user clicks "This is me"
|
||||
|
||||
## Configuration
|
||||
|
||||
Populate `.env` with at minimum the Anthropic key and the auth-related
|
||||
secrets:
|
||||
|
||||
```text
|
||||
ANTHROPIC_API_KEY=your-key-here
|
||||
GOOGLE_CLIENT_ID=xxxx.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=GOCSPX-xxxx
|
||||
JWT_SECRET=random-64-char-string
|
||||
IMPACTFLOW_API_KEY=random-64-char-string
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
|
||||
| Env var | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `ANTHROPIC_API_KEY` | required | Used by `DiscoveryExtractor` |
|
||||
| `DATABASE_URL` | `sqlite+aiosqlite:///./data/discovery.db` | Async SQLAlchemy database URL |
|
||||
| `HOST_BIND_IP` | `0.0.0.0` | Docker host IP for publishing port `8011`; use this when Docker runs inside WSL |
|
||||
| `HOST_PORT` | `8011` | WSL/Docker host port; Windows portproxy exposes the same port |
|
||||
| `ANTHROPIC_MODEL` | `claude-sonnet-4-5` | Override extraction model |
|
||||
| `GOOGLE_CLIENT_ID` | required for browser auth | OAuth 2.0 client id from Google Cloud Console |
|
||||
| `GOOGLE_CLIENT_SECRET` | required for browser auth | OAuth 2.0 client secret |
|
||||
| `OAUTH_REDIRECT_URI` | derived from request | Override the callback URL Google redirects to; must be registered in the Cloud Console |
|
||||
| `JWT_SECRET` | required | HS256 signing key for access tokens; also used for the OAuth `state` session cookie |
|
||||
| `JWT_ACCESS_MINUTES` | `15` | Access-token lifetime |
|
||||
| `JWT_REFRESH_DAYS` | `7` | Refresh-token lifetime |
|
||||
| `IMPACTFLOW_API_KEY` | required for MCP/machine auth | Header value for `X-API-Key`; resolves to the synthetic admin user |
|
||||
| `CORS_ALLOWED_ORIGINS` | `http://localhost:8000` | Comma-separated allow-list of browser origins |
|
||||
| `ALLOWED_EMAIL_DOMAINS` | empty (any) | Comma-separated allow-list of email domains; empty means accept any verified Google email |
|
||||
|
||||
`app/database.py` creates the SQLite directory automatically when the URL uses
|
||||
a local SQLite file path.
|
||||
|
||||
## Quick Start With Docker
|
||||
|
||||
This path requires Docker Desktop or another Docker Engine installation with
|
||||
the Compose plugin available as `docker compose`.
|
||||
|
||||
If Docker is installed inside WSL, run these commands from your WSL shell and
|
||||
change into the Windows-mounted project directory first:
|
||||
|
||||
```bash
|
||||
cd /mnt/c/SyncData/impactflow-discovery
|
||||
```
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# edit .env and add ANTHROPIC_API_KEY
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
- Discovery flow: <http://100.103.206.4:8011/static/discovery.html>
|
||||
- Health check: <http://100.103.206.4:8011/health>
|
||||
|
||||
When Docker runs inside WSL and you want the app to behave like the other
|
||||
WSL-published services, keep `HOST_BIND_IP=0.0.0.0` in `.env`, then create a
|
||||
Windows portproxy from an elevated PowerShell prompt:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\setup-wsl-bridge.ps1
|
||||
```
|
||||
|
||||
That creates an entry like `0.0.0.0 8011 -> <WSL-IP> 8011`, so the app is
|
||||
available on localhost, LAN addresses, and the Tailscale address
|
||||
`100.103.206.4` without colliding with any existing WSL service on port `8001`.
|
||||
|
||||
If you cannot run an elevated PowerShell prompt, use the non-admin TCP bridge
|
||||
fallback while the app is running:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\start-tcp-bridge.ps1
|
||||
```
|
||||
|
||||
Keep that window open for as long as you need `http://100.103.206.4:8011` to
|
||||
forward to the app.
|
||||
|
||||
The SQLite file lives in `./data/discovery.db` and persists across restarts
|
||||
because `docker-compose.yml` mounts `./data` into the container.
|
||||
|
||||
## Local Development
|
||||
|
||||
Windows:
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv/Scripts/python.exe -m pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
alembic upgrade head
|
||||
uvicorn app.main:app --reload --port 8011
|
||||
```
|
||||
|
||||
macOS/Linux:
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
alembic upgrade head
|
||||
uvicorn app.main:app --reload --port 8011
|
||||
```
|
||||
|
||||
## Tests And Verification
|
||||
|
||||
The extractor tests mock Anthropic, so they do not need a real API key or
|
||||
network access:
|
||||
|
||||
```bash
|
||||
.venv/Scripts/python.exe -m pytest
|
||||
```
|
||||
|
||||
`smoke_test.py` drives the whole API in process with `httpx.ASGITransport` and
|
||||
patches `DiscoveryExtractor` so it also avoids a real Anthropic call:
|
||||
|
||||
```bash
|
||||
.venv/Scripts/python.exe smoke_test.py
|
||||
```
|
||||
|
||||
The smoke test verifies:
|
||||
|
||||
- `/health`
|
||||
- unauthenticated requests return `401`
|
||||
- `X-API-Key` resolves to the synthetic admin user via `/api/me`
|
||||
- a malformed bearer token is rejected
|
||||
- `/api/auth/login` redirects to `accounts.google.com`
|
||||
- conversation creation, response saving, conversation fetching
|
||||
- profile completion, fetching, and confirmation
|
||||
- the activity log captures the calls and tags them `source=mcp`
|
||||
- `/api/me/stats` reports the correct conversation, profile, and locked counts
|
||||
- key `404` paths
|
||||
|
||||
## Error Handling And Edge Cases
|
||||
|
||||
The backend currently handles:
|
||||
|
||||
| Scenario | Result |
|
||||
| --- | --- |
|
||||
| Missing conversation on respond, complete, or fetch | `404` |
|
||||
| Completing a conversation with all blank answers | `400` |
|
||||
| Missing Anthropic API key | `502` from complete route |
|
||||
| Anthropic transport or SDK error | `502` from complete route |
|
||||
| Invalid model JSON on first try | One retry |
|
||||
| Invalid model JSON after retry | `502` from complete route |
|
||||
| Missing profile for user | `404` |
|
||||
|
||||
The frontend currently handles submission and profile-load failures by showing a
|
||||
simple error box. It does not persist partially typed answers across a full page
|
||||
reload, except for the browser's normal form restoration behavior.
|
||||
|
||||
## Integration Notes
|
||||
|
||||
This service is ready to be called from a larger ImpactFlow app. The
|
||||
authenticated user identity now comes from Google OAuth on the browser side
|
||||
and `X-API-Key` on the machine-to-machine side; the per-call `user_id` body
|
||||
parameter is gone.
|
||||
|
||||
Likely integration points:
|
||||
|
||||
- route users into `/static/discovery.html` (or recreate the flow in the main
|
||||
UI) and rely on the JWT issued by `/api/auth/callback` for subsequent calls
|
||||
- the static frontend still needs to be updated to consume the new auth flow
|
||||
(read the JWT from the callback response, store it, and send it as a
|
||||
`Bearer` header on every `/discovery/*` call)
|
||||
- the MCP server should send `X-API-Key: $IMPACTFLOW_API_KEY` on every
|
||||
request — no OAuth dance needed
|
||||
- use `locked` as the user's confirmation signal
|
||||
- decide whether future profiles should supersede locked profiles or be
|
||||
versioned in the main product experience
|
||||
|
||||
## Privacy And Data Notes
|
||||
|
||||
The app stores personal narrative answers and model-generated personality
|
||||
summaries in SQLite. Treat `data/discovery.db` as sensitive user data.
|
||||
|
||||
Do not commit:
|
||||
|
||||
- `.env`
|
||||
- real API keys
|
||||
- production SQLite databases
|
||||
- exported user response data
|
||||
|
||||
The repository includes test fixtures with synthetic responses for extractor
|
||||
tests.
|
||||
|
||||
## Common Changes
|
||||
|
||||
When changing prompts:
|
||||
|
||||
1. Update the prompt text in `app/static/discovery.html`.
|
||||
2. Keep the request body keys aligned with `RespondRequest` in
|
||||
`app/schemas.py`.
|
||||
3. Update `PROMPT_LABELS` in `app/services/extractor.py` if the extraction
|
||||
labels should change.
|
||||
4. Adjust tests or fixtures if the extractor prompt expectations change.
|
||||
|
||||
When changing the profile schema:
|
||||
|
||||
1. Update `app/schemas.py`.
|
||||
2. Update `app/models.py`.
|
||||
3. Add a new Alembic migration.
|
||||
4. Update `REQUIRED_KEYS` and `SYSTEM_PROMPT` in `app/services/extractor.py`.
|
||||
5. Update `app/routers/discovery.py` mapping logic.
|
||||
6. Update `app/static/profile.html` rendering.
|
||||
7. Add or update tests.
|
||||
|
||||
When changing extraction behavior:
|
||||
|
||||
1. Update `SYSTEM_PROMPT` in `app/services/extractor.py`.
|
||||
2. Keep the JSON contract explicit.
|
||||
3. Update `REQUIRED_KEYS` or `REQUIRED_CONFIDENCE_KEYS` only when the response
|
||||
contract changes.
|
||||
4. Add extractor tests for parsing, retry, or validation changes.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- The static discovery page keeps in-progress answers in memory only.
|
||||
- The static profile page always loads the newest profile for a user.
|
||||
- Confirming a profile does not prevent a newer profile from being generated.
|
||||
- The database is SQLite by default and intended for standalone/local service
|
||||
operation.
|
||||
- Extraction quality depends on model output and the clarity of the user's
|
||||
stories.
|
||||
- The system prompt asks for structured interpretation, but personality results
|
||||
should be treated as reflective guidance rather than clinical or diagnostic
|
||||
truth.
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# Alembic configuration for the ImpactFlow self-discovery module.
|
||||
# The actual sqlalchemy.url is resolved from the DATABASE_URL env var in
|
||||
# alembic/env.py (the async "+aiosqlite" driver is stripped for migrations).
|
||||
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url = sqlite:///./data/discovery.db
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Alembic environment.
|
||||
|
||||
Migrations run synchronously, so the async ``+aiosqlite`` driver in
|
||||
DATABASE_URL is stripped to a plain ``sqlite://`` URL here.
|
||||
"""
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
load_dotenv()
|
||||
|
||||
config = context.config
|
||||
|
||||
# Resolve the database URL from the environment, falling back to the ini value.
|
||||
_db_url = os.getenv("DATABASE_URL")
|
||||
if _db_url:
|
||||
sync_url = _db_url.replace("+aiosqlite", "")
|
||||
config.set_main_option("sqlalchemy.url", sync_url)
|
||||
else:
|
||||
sync_url = config.get_main_option("sqlalchemy.url")
|
||||
|
||||
# Make sure the directory for the SQLite file exists before connecting.
|
||||
if sync_url and ":///" in sync_url and "sqlite" in sync_url:
|
||||
_path = sync_url.split(":///", 1)[1]
|
||||
_dir = os.path.dirname(_path)
|
||||
if _dir:
|
||||
os.makedirs(_dir, exist_ok=True)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Migrations are written by hand, so no autogenerate target metadata is needed.
|
||||
target_metadata = None
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,80 @@
|
||||
"""initial schema: discovery_conversation and discovery_profile
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2026-05-25
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"discovery_conversation",
|
||||
sa.Column("id", sa.String(), primary_key=True),
|
||||
sa.Column("user_id", sa.String(), nullable=False),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("prompt_alive", sa.Text(), nullable=True),
|
||||
sa.Column("prompt_friction", sa.Text(), nullable=True),
|
||||
sa.Column("prompt_pull", sa.Text(), nullable=True),
|
||||
sa.Column("prompt_recognition", sa.Text(), nullable=True),
|
||||
sa.Column("prompt_future", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_discovery_conversation_user_id",
|
||||
"discovery_conversation",
|
||||
["user_id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"discovery_profile",
|
||||
sa.Column("id", sa.String(), primary_key=True),
|
||||
sa.Column("user_id", sa.String(), nullable=False),
|
||||
sa.Column(
|
||||
"conversation_id",
|
||||
sa.String(),
|
||||
sa.ForeignKey("discovery_conversation.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("generated_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("triad", sa.String(), nullable=True),
|
||||
sa.Column("probable_type", sa.Integer(), nullable=True),
|
||||
sa.Column("wing", sa.Integer(), nullable=True),
|
||||
sa.Column("instinctual_variant", sa.String(), nullable=True),
|
||||
sa.Column("instinctual_stack", sa.String(), nullable=True),
|
||||
sa.Column("love_summary", sa.Text(), nullable=True),
|
||||
sa.Column("strength_summary", sa.Text(), nullable=True),
|
||||
sa.Column("mission_summary", sa.Text(), nullable=True),
|
||||
sa.Column("vocation_summary", sa.Text(), nullable=True),
|
||||
sa.Column("overlap_narrative", sa.Text(), nullable=True),
|
||||
sa.Column("confidence_json", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"locked",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_discovery_profile_user_id", "discovery_profile", ["user_id"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_discovery_profile_user_id", table_name="discovery_profile")
|
||||
op.drop_table("discovery_profile")
|
||||
op.drop_index(
|
||||
"ix_discovery_conversation_user_id",
|
||||
table_name="discovery_conversation",
|
||||
)
|
||||
op.drop_table("discovery_conversation")
|
||||
@@ -0,0 +1,104 @@
|
||||
"""add users, refresh_tokens, activity_log; FK existing tables to users
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2026-05-27
|
||||
|
||||
The Phase 1-3 schema from the OAuth plan, adapted to this repo's existing
|
||||
tables. SQLite doesn't enforce FK constraints by default but the columns
|
||||
and indexes are still useful for query planning.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "002"
|
||||
down_revision: Union[str, None] = "001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.String(), primary_key=True),
|
||||
sa.Column("email", sa.String(), nullable=False, unique=True),
|
||||
sa.Column("display_name", sa.String(), nullable=False),
|
||||
sa.Column("avatar_url", sa.String(), nullable=True),
|
||||
sa.Column("google_id", sa.String(), nullable=True, unique=True),
|
||||
sa.Column(
|
||||
"role",
|
||||
sa.String(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'user'"),
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("last_login_at", sa.DateTime(), nullable=True),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"refresh_tokens",
|
||||
sa.Column("id", sa.String(), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.String(),
|
||||
sa.ForeignKey("users.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("token_hash", sa.String(), nullable=False, unique=True),
|
||||
sa.Column("device", sa.String(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("revoked_at", sa.DateTime(), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_refresh_tokens_user_id", "refresh_tokens", ["user_id"]
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"activity_log",
|
||||
sa.Column("id", sa.String(), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.String(),
|
||||
sa.ForeignKey("users.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("action", sa.String(), nullable=False),
|
||||
sa.Column("resource", sa.String(), nullable=False),
|
||||
sa.Column("resource_id", sa.String(), nullable=True),
|
||||
sa.Column("metadata_json", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"source",
|
||||
sa.String(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'web'"),
|
||||
),
|
||||
sa.Column("ip_address", sa.String(), nullable=True),
|
||||
sa.Column("user_agent", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_activity_log_user_id", "activity_log", ["user_id"]
|
||||
)
|
||||
op.create_index(
|
||||
"ix_activity_log_created_at", "activity_log", ["created_at"]
|
||||
)
|
||||
op.create_index(
|
||||
"ix_activity_log_resource",
|
||||
"activity_log",
|
||||
["resource", "resource_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_activity_log_resource", table_name="activity_log")
|
||||
op.drop_index("ix_activity_log_created_at", table_name="activity_log")
|
||||
op.drop_index("ix_activity_log_user_id", table_name="activity_log")
|
||||
op.drop_table("activity_log")
|
||||
|
||||
op.drop_index("ix_refresh_tokens_user_id", table_name="refresh_tokens")
|
||||
op.drop_table("refresh_tokens")
|
||||
|
||||
op.drop_table("users")
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
"""Authentication: Google OAuth flow, JWT issuance, dual-auth dependency.
|
||||
|
||||
Two ways to authenticate:
|
||||
- JWT in `Authorization: Bearer <token>` (browser users, issued by /api/auth/callback)
|
||||
- X-API-Key header matching IMPACTFLOW_API_KEY (machine-to-machine; MCP server)
|
||||
|
||||
The API key resolves to a synthetic admin user (`API_KEY_ADMIN_ID`) seeded into
|
||||
the users table on first use, so foreign keys from data tables stay valid.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import RefreshToken, User
|
||||
|
||||
API_KEY_ADMIN_ID = "api-key-admin"
|
||||
JWT_ALGORITHM = "HS256"
|
||||
|
||||
oauth = OAuth()
|
||||
oauth.register(
|
||||
name="google",
|
||||
client_id=os.getenv("GOOGLE_CLIENT_ID"),
|
||||
client_secret=os.getenv("GOOGLE_CLIENT_SECRET"),
|
||||
server_metadata_url=(
|
||||
"https://accounts.google.com/.well-known/openid-configuration"
|
||||
),
|
||||
client_kwargs={"scope": "openid email profile"},
|
||||
)
|
||||
|
||||
|
||||
def _jwt_secret() -> str:
|
||||
secret = os.getenv("JWT_SECRET")
|
||||
if not secret:
|
||||
raise RuntimeError("JWT_SECRET is not configured")
|
||||
return secret
|
||||
|
||||
|
||||
def _access_minutes() -> int:
|
||||
return int(os.getenv("JWT_ACCESS_MINUTES", "15"))
|
||||
|
||||
|
||||
def _refresh_days() -> int:
|
||||
return int(os.getenv("JWT_REFRESH_DAYS", "7"))
|
||||
|
||||
|
||||
def create_access_token(user_id: str, email: str) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"email": email,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + timedelta(minutes=_access_minutes())).timestamp()),
|
||||
"type": "access",
|
||||
}
|
||||
return jwt.encode(payload, _jwt_secret(), algorithm=JWT_ALGORITHM)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict:
|
||||
try:
|
||||
payload = jwt.decode(token, _jwt_secret(), algorithms=[JWT_ALGORITHM])
|
||||
except JWTError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f"Invalid token: {exc}",
|
||||
) from exc
|
||||
if payload.get("type") != "access":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Wrong token type",
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def hash_refresh_token(raw: str) -> str:
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def issue_refresh_token(
|
||||
db: AsyncSession, user: User, device: Optional[str]
|
||||
) -> str:
|
||||
raw = secrets.token_urlsafe(48)
|
||||
now = datetime.now(timezone.utc)
|
||||
row = RefreshToken(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user.id,
|
||||
token_hash=hash_refresh_token(raw),
|
||||
device=(device or "")[:255] or None,
|
||||
created_at=now,
|
||||
expires_at=now + timedelta(days=_refresh_days()),
|
||||
)
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
return raw
|
||||
|
||||
|
||||
async def ensure_api_key_admin(db: AsyncSession) -> User:
|
||||
"""Idempotently return the synthetic admin user backing the API key."""
|
||||
existing = await db.get(User, API_KEY_ADMIN_ID)
|
||||
if existing is not None:
|
||||
return existing
|
||||
now = datetime.now(timezone.utc)
|
||||
admin = User(
|
||||
id=API_KEY_ADMIN_ID,
|
||||
email="api-key@impactflow.local",
|
||||
display_name="API Key (MCP)",
|
||||
role="admin",
|
||||
created_at=now,
|
||||
)
|
||||
db.add(admin)
|
||||
await db.commit()
|
||||
await db.refresh(admin)
|
||||
return admin
|
||||
|
||||
|
||||
_bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(
|
||||
_bearer_scheme
|
||||
),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""Dual-auth dependency. Tries X-API-Key first (cheap, no JWT decode),
|
||||
then falls back to the Authorization bearer JWT."""
|
||||
expected_api_key = os.getenv("IMPACTFLOW_API_KEY")
|
||||
presented_api_key = request.headers.get("x-api-key")
|
||||
if (
|
||||
expected_api_key
|
||||
and presented_api_key
|
||||
and hmac.compare_digest(presented_api_key, expected_api_key)
|
||||
):
|
||||
admin = await ensure_api_key_admin(db)
|
||||
request.state.user = admin
|
||||
request.state.auth_source = "api_key"
|
||||
return admin
|
||||
|
||||
if credentials is not None and credentials.scheme.lower() == "bearer":
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
user = await db.get(User, payload["sub"])
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User no longer exists",
|
||||
)
|
||||
request.state.user = user
|
||||
request.state.auth_source = "jwt"
|
||||
return user
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
async def require_admin(user: User = Depends(get_current_user)) -> User:
|
||||
if user.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin role required",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def email_domain_allowed(email: str) -> bool:
|
||||
raw = os.getenv("ALLOWED_EMAIL_DOMAINS", "").strip()
|
||||
if not raw:
|
||||
return True
|
||||
allowed = {d.strip().lower() for d in raw.split(",") if d.strip()}
|
||||
domain = email.rsplit("@", 1)[-1].lower()
|
||||
return domain in allowed
|
||||
|
||||
|
||||
async def find_or_create_google_user(
|
||||
db: AsyncSession, user_info: dict
|
||||
) -> User:
|
||||
"""Idempotent: looks up by google_id, falls back to email, otherwise
|
||||
creates. First created human user is auto-promoted to admin."""
|
||||
google_id = user_info["sub"]
|
||||
email = user_info["email"]
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
stmt = select(User).where(User.google_id == google_id)
|
||||
user = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if user is None:
|
||||
stmt = select(User).where(User.email == email)
|
||||
user = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if user is not None and user.google_id is None:
|
||||
user.google_id = google_id
|
||||
|
||||
if user is None:
|
||||
# Auto-promote the first real human user. The synthetic
|
||||
# API_KEY_ADMIN_ID record is excluded from the count.
|
||||
stmt = select(User).where(User.id != API_KEY_ADMIN_ID)
|
||||
is_first = (await db.execute(stmt)).first() is None
|
||||
user = User(
|
||||
id=str(uuid.uuid4()),
|
||||
email=email,
|
||||
display_name=user_info.get("name") or email,
|
||||
avatar_url=user_info.get("picture"),
|
||||
google_id=google_id,
|
||||
role="admin" if is_first else "user",
|
||||
created_at=now,
|
||||
last_login_at=now,
|
||||
)
|
||||
db.add(user)
|
||||
else:
|
||||
user.last_login_at = now
|
||||
if user_info.get("name"):
|
||||
user.display_name = user_info["name"]
|
||||
if user_info.get("picture"):
|
||||
user.avatar_url = user_info["picture"]
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Async SQLAlchemy engine, session factory, and declarative base.
|
||||
|
||||
The SQLite database file lives under ./data so it can be persisted via a
|
||||
Docker volume mount. The data directory is created on import if missing.
|
||||
"""
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL", "sqlite+aiosqlite:///./data/discovery.db"
|
||||
)
|
||||
|
||||
|
||||
def _ensure_sqlite_dir(url: str) -> None:
|
||||
"""Make sure the directory holding the SQLite file exists."""
|
||||
if "sqlite" not in url:
|
||||
return
|
||||
# everything after the scheme's :/// is the filesystem path
|
||||
if ":///" not in url:
|
||||
return
|
||||
path = url.split(":///", 1)[1]
|
||||
directory = os.path.dirname(path)
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
|
||||
|
||||
_ensure_sqlite_dir(DATABASE_URL)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Declarative base shared by all ORM models."""
|
||||
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, echo=False, future=True)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""FastAPI dependency that yields a scoped async session."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
"""FastAPI entrypoint for the ImpactFlow self-discovery module."""
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app import models # noqa: F401 - register ORM models with Base.metadata
|
||||
from app.auth import ensure_api_key_admin
|
||||
from app.database import AsyncSessionLocal, Base, engine
|
||||
from app.routers import activity, auth as auth_router, discovery
|
||||
from app.routers.activity import prune_old_activity
|
||||
from app.tracking import ActivityTrackingMiddleware
|
||||
|
||||
# Path to the static directory, resolved relative to this file so it works
|
||||
# regardless of the current working directory.
|
||||
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Alembic owns schema migrations in Docker; this create_all is an
|
||||
# idempotent safety net so the app also runs cleanly in local/dev where
|
||||
# migrations may not have been applied.
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await ensure_api_key_admin(db)
|
||||
await prune_old_activity(db)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="ImpactFlow Self-Discovery", lifespan=lifespan)
|
||||
|
||||
# add_middleware wraps the previous app, so the LAST call becomes the
|
||||
# OUTERMOST middleware. Register inner-to-outer:
|
||||
# tracking (innermost — sees request.state.user set by deps) ->
|
||||
# session (needs to wrap routes for the OAuth state cookie) ->
|
||||
# CORS (outermost — preflights must short-circuit before anything else).
|
||||
app.add_middleware(ActivityTrackingMiddleware)
|
||||
|
||||
_session_secret = os.getenv("JWT_SECRET") or "dev-session-secret-change-me"
|
||||
app.add_middleware(SessionMiddleware, secret_key=_session_secret)
|
||||
|
||||
_cors_origins = [
|
||||
o.strip()
|
||||
for o in os.getenv(
|
||||
"CORS_ALLOWED_ORIGINS", "http://localhost:8000"
|
||||
).split(",")
|
||||
if o.strip()
|
||||
]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth_router.router)
|
||||
app.include_router(activity.router)
|
||||
app.include_router(discovery.router)
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return RedirectResponse(url="/static/discovery.html")
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Helpers for bringing older local SQLite databases under Alembic control."""
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
DEFAULT_DATABASE_URL = "sqlite+aiosqlite:///./data/discovery.db"
|
||||
CURRENT_REVISION = "001"
|
||||
REQUIRED_TABLES = {"discovery_conversation", "discovery_profile"}
|
||||
|
||||
|
||||
def _sqlite_path(database_url: str) -> str | None:
|
||||
if not database_url.startswith("sqlite") or ":///" not in database_url:
|
||||
return None
|
||||
return database_url.split(":///", 1)[1]
|
||||
|
||||
|
||||
def stamp_existing_sqlite_schema(
|
||||
database_url: str, revision: str = CURRENT_REVISION
|
||||
) -> bool:
|
||||
"""Stamp a pre-Alembic SQLite DB when it already has the app tables.
|
||||
|
||||
Early local/dev runs could create tables through SQLAlchemy create_all()
|
||||
before Alembic was applied. In that case, `alembic upgrade head` tries to
|
||||
create tables that already exist. This marks that compatible schema as
|
||||
revision 001 so normal migrations can continue.
|
||||
"""
|
||||
path = _sqlite_path(database_url)
|
||||
if not path or not os.path.exists(path):
|
||||
return False
|
||||
|
||||
with sqlite3.connect(path) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
||||
).fetchall()
|
||||
tables = {row[0] for row in rows}
|
||||
|
||||
if not REQUIRED_TABLES.issubset(tables):
|
||||
return False
|
||||
|
||||
if "alembic_version" in tables:
|
||||
versions = conn.execute(
|
||||
"SELECT version_num FROM alembic_version"
|
||||
).fetchall()
|
||||
if versions:
|
||||
return False
|
||||
else:
|
||||
conn.execute(
|
||||
"CREATE TABLE alembic_version "
|
||||
"(version_num VARCHAR(32) NOT NULL)"
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO alembic_version (version_num) VALUES (?)",
|
||||
(revision,),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
|
||||
def main() -> None:
|
||||
load_dotenv()
|
||||
database_url = os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL)
|
||||
stamped = stamp_existing_sqlite_schema(database_url)
|
||||
if stamped:
|
||||
print(f"Stamped existing SQLite schema as Alembic revision {CURRENT_REVISION}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
"""SQLAlchemy ORM models for the self-discovery module."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""A signed-in human (Google OAuth) or the synthetic admin record used
|
||||
by the X-API-Key dual-auth path for the MCP server."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
email: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
||||
display_name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
avatar_url: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
# Google's "sub" claim. Null only for the synthetic API-key admin user.
|
||||
google_id: Mapped[Optional[str]] = mapped_column(
|
||||
String, unique=True, nullable=True
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String, nullable=False, default="user")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
last_login_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, nullable=True
|
||||
)
|
||||
|
||||
|
||||
class RefreshToken(Base):
|
||||
"""One row per issued refresh token. token_hash stores a SHA-256 of the
|
||||
raw token so a DB leak can't be replayed back at the auth endpoint."""
|
||||
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String, ForeignKey("users.id"), nullable=False, index=True
|
||||
)
|
||||
token_hash: Mapped[str] = mapped_column(
|
||||
String, unique=True, nullable=False
|
||||
)
|
||||
device: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
revoked_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, nullable=True
|
||||
)
|
||||
|
||||
|
||||
class ActivityLog(Base):
|
||||
"""Append-only audit trail. Pruned to 90 days on app startup."""
|
||||
|
||||
__tablename__ = "activity_log"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String, ForeignKey("users.id"), nullable=False, index=True
|
||||
)
|
||||
action: Mapped[str] = mapped_column(String, nullable=False)
|
||||
resource: Mapped[str] = mapped_column(String, nullable=False)
|
||||
resource_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
metadata_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
source: Mapped[str] = mapped_column(String, nullable=False, default="web")
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, index=True
|
||||
)
|
||||
|
||||
|
||||
class DiscoveryConversation(Base):
|
||||
"""A single self-discovery conversation: the five narrative responses."""
|
||||
|
||||
__tablename__ = "discovery_conversation"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String, ForeignKey("users.id"), nullable=False, index=True
|
||||
)
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
completed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, nullable=True
|
||||
)
|
||||
|
||||
prompt_alive: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
prompt_friction: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
prompt_pull: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
prompt_recognition: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True
|
||||
)
|
||||
prompt_future: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class DiscoveryProfile(Base):
|
||||
"""The extracted enneagram + Ikigai profile for a conversation."""
|
||||
|
||||
__tablename__ = "discovery_profile"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String, ForeignKey("users.id"), nullable=False, index=True
|
||||
)
|
||||
conversation_id: Mapped[str] = mapped_column(
|
||||
String, ForeignKey("discovery_conversation.id"), nullable=False
|
||||
)
|
||||
generated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
|
||||
triad: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
probable_type: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
wing: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
instinctual_variant: Mapped[Optional[str]] = mapped_column(
|
||||
String, nullable=True
|
||||
)
|
||||
instinctual_stack: Mapped[Optional[str]] = mapped_column(
|
||||
String, nullable=True
|
||||
)
|
||||
|
||||
love_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
strength_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
mission_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
vocation_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
overlap_narrative: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True
|
||||
)
|
||||
|
||||
confidence_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
locked: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False
|
||||
)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Activity feed + admin endpoints."""
|
||||
from collections import Counter
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import get_current_user, require_admin
|
||||
from app.database import get_db
|
||||
from app.models import ActivityLog, User
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["activity"])
|
||||
|
||||
|
||||
class ActivityOut(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
action: str
|
||||
resource: str
|
||||
resource_id: str | None
|
||||
source: str
|
||||
created_at: datetime
|
||||
ip_address: str | None
|
||||
user_agent: str | None
|
||||
|
||||
|
||||
class ActivitySummary(BaseModel):
|
||||
days: int
|
||||
total: int
|
||||
actions_per_day: dict[str, int]
|
||||
top_resources: list[tuple[str, int]]
|
||||
web_count: int
|
||||
mcp_count: int
|
||||
|
||||
|
||||
def _to_out(row: ActivityLog) -> ActivityOut:
|
||||
return ActivityOut(
|
||||
id=row.id,
|
||||
user_id=row.user_id,
|
||||
action=row.action,
|
||||
resource=row.resource,
|
||||
resource_id=row.resource_id,
|
||||
source=row.source,
|
||||
created_at=row.created_at,
|
||||
ip_address=row.ip_address,
|
||||
user_agent=row.user_agent,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/activity", response_model=list[ActivityOut])
|
||||
async def list_my_activity(
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(20, ge=1, le=200),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = (
|
||||
select(ActivityLog)
|
||||
.where(ActivityLog.user_id == user.id)
|
||||
.order_by(ActivityLog.created_at.desc())
|
||||
.offset((page - 1) * limit)
|
||||
.limit(limit)
|
||||
)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
return [_to_out(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/activity/summary", response_model=ActivitySummary)
|
||||
async def my_activity_summary(
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
since = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
stmt = (
|
||||
select(ActivityLog)
|
||||
.where(ActivityLog.user_id == user.id)
|
||||
.where(ActivityLog.created_at >= since)
|
||||
)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
|
||||
per_day: Counter[str] = Counter()
|
||||
resource_counts: Counter[str] = Counter()
|
||||
web = mcp = 0
|
||||
for r in rows:
|
||||
per_day[r.created_at.date().isoformat()] += 1
|
||||
resource_counts[r.resource] += 1
|
||||
if r.source == "mcp":
|
||||
mcp += 1
|
||||
else:
|
||||
web += 1
|
||||
|
||||
return ActivitySummary(
|
||||
days=days,
|
||||
total=len(rows),
|
||||
actions_per_day=dict(per_day),
|
||||
top_resources=resource_counts.most_common(5),
|
||||
web_count=web,
|
||||
mcp_count=mcp,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/activity", response_model=list[ActivityOut])
|
||||
async def admin_list_activity(
|
||||
user_id: str | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
_admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(ActivityLog).order_by(ActivityLog.created_at.desc())
|
||||
if user_id:
|
||||
stmt = stmt.where(ActivityLog.user_id == user_id)
|
||||
stmt = stmt.offset((page - 1) * limit).limit(limit)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
return [_to_out(r) for r in rows]
|
||||
|
||||
|
||||
async def prune_old_activity(db: AsyncSession, days: int = 90) -> int:
|
||||
"""Delete rows older than N days. Returns rows deleted."""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
stmt = select(func.count()).select_from(ActivityLog).where(
|
||||
ActivityLog.created_at < cutoff
|
||||
)
|
||||
count = (await db.execute(stmt)).scalar_one()
|
||||
if count:
|
||||
from sqlalchemy import delete
|
||||
|
||||
await db.execute(delete(ActivityLog).where(
|
||||
ActivityLog.created_at < cutoff
|
||||
))
|
||||
await db.commit()
|
||||
return int(count or 0)
|
||||
@@ -0,0 +1,288 @@
|
||||
"""OAuth + JWT auth routes (mounted at /api/auth and /api/me)."""
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from authlib.integrations.base_client import OAuthError
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import (
|
||||
create_access_token,
|
||||
email_domain_allowed,
|
||||
find_or_create_google_user,
|
||||
get_current_user,
|
||||
hash_refresh_token,
|
||||
issue_refresh_token,
|
||||
oauth,
|
||||
)
|
||||
from app.database import get_db
|
||||
from app.models import (
|
||||
ActivityLog,
|
||||
DiscoveryConversation,
|
||||
DiscoveryProfile,
|
||||
RefreshToken,
|
||||
User,
|
||||
)
|
||||
from sqlalchemy import func
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["auth"])
|
||||
|
||||
|
||||
# -- response shapes ----------------------------------------------------------
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
display_name: str
|
||||
avatar_url: str | None = None
|
||||
role: str
|
||||
|
||||
@classmethod
|
||||
def from_orm_user(cls, user: User) -> "UserOut":
|
||||
return cls(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
display_name=user.display_name,
|
||||
avatar_url=user.avatar_url,
|
||||
role=user.role,
|
||||
)
|
||||
|
||||
|
||||
class TokenBundle(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserOut
|
||||
|
||||
|
||||
class RefreshIn(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class AccessOut(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class DisplayNamePatch(BaseModel):
|
||||
display_name: str
|
||||
|
||||
|
||||
class SessionOut(BaseModel):
|
||||
id: str
|
||||
device: str | None
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
# -- OAuth flow ---------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/auth/login")
|
||||
async def login(request: Request):
|
||||
"""Kick off the OAuth dance: 302 to Google's consent screen."""
|
||||
redirect_uri = os.getenv("OAUTH_REDIRECT_URI") or str(
|
||||
request.url_for("auth_callback")
|
||||
)
|
||||
return await oauth.google.authorize_redirect(request, redirect_uri)
|
||||
|
||||
|
||||
@router.get("/auth/callback", name="auth_callback")
|
||||
async def auth_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
"""Google redirects here with `code`; exchange it, mint our tokens."""
|
||||
try:
|
||||
token = await oauth.google.authorize_access_token(request)
|
||||
except OAuthError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"OAuth error: {exc.error or exc}",
|
||||
) from exc
|
||||
|
||||
user_info = token.get("userinfo")
|
||||
if not user_info or not user_info.get("email") or not user_info.get("sub"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Google did not return email/sub",
|
||||
)
|
||||
if user_info.get("email_verified") is False:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Email not verified by Google",
|
||||
)
|
||||
if not email_domain_allowed(user_info["email"]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Email domain not allowed",
|
||||
)
|
||||
|
||||
user = await find_or_create_google_user(db, user_info)
|
||||
access = create_access_token(user.id, user.email)
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/auth/refresh", response_model=AccessOut)
|
||||
async def refresh_access_token(
|
||||
body: RefreshIn, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
stmt = select(RefreshToken).where(
|
||||
RefreshToken.token_hash == hash_refresh_token(body.refresh_token)
|
||||
)
|
||||
row = (await db.execute(stmt)).scalar_one_or_none()
|
||||
now = datetime.now(timezone.utc)
|
||||
if row is None or row.revoked_at is not None or _expired(row.expires_at, now):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Refresh token invalid or expired",
|
||||
)
|
||||
user = await db.get(User, row.user_id)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User no longer exists",
|
||||
)
|
||||
access = create_access_token(user.id, user.email)
|
||||
return AccessOut(access_token=access)
|
||||
|
||||
|
||||
def _expired(expires_at: datetime, now: datetime) -> bool:
|
||||
# SQLite stores naive datetimes; compare in UTC.
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
return expires_at < now
|
||||
|
||||
|
||||
@router.post("/auth/logout")
|
||||
async def logout(
|
||||
body: RefreshIn, 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)
|
||||
)
|
||||
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()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# -- profile / sessions -------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/auth/me", response_model=UserOut)
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def get_me(user: User = Depends(get_current_user)):
|
||||
return UserOut.from_orm_user(user)
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserOut)
|
||||
async def patch_me(
|
||||
body: DisplayNamePatch,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
new_name = body.display_name.strip()
|
||||
if not new_name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="display_name cannot be empty",
|
||||
)
|
||||
user.display_name = new_name
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return UserOut.from_orm_user(user)
|
||||
|
||||
|
||||
class MeStats(BaseModel):
|
||||
conversations: int
|
||||
profiles: int
|
||||
locked_profiles: int
|
||||
activity_last_30d: int
|
||||
last_login_at: datetime | None
|
||||
|
||||
|
||||
@router.get("/me/stats", response_model=MeStats)
|
||||
async def my_stats(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
convo_count = (await db.execute(
|
||||
select(func.count())
|
||||
.select_from(DiscoveryConversation)
|
||||
.where(DiscoveryConversation.user_id == user.id)
|
||||
)).scalar_one()
|
||||
profile_count = (await db.execute(
|
||||
select(func.count())
|
||||
.select_from(DiscoveryProfile)
|
||||
.where(DiscoveryProfile.user_id == user.id)
|
||||
)).scalar_one()
|
||||
locked_count = (await db.execute(
|
||||
select(func.count())
|
||||
.select_from(DiscoveryProfile)
|
||||
.where(DiscoveryProfile.user_id == user.id)
|
||||
.where(DiscoveryProfile.locked.is_(True))
|
||||
)).scalar_one()
|
||||
since = datetime.now(timezone.utc) - timedelta(days=30)
|
||||
activity_count = (await db.execute(
|
||||
select(func.count())
|
||||
.select_from(ActivityLog)
|
||||
.where(ActivityLog.user_id == user.id)
|
||||
.where(ActivityLog.created_at >= since)
|
||||
)).scalar_one()
|
||||
return MeStats(
|
||||
conversations=int(convo_count or 0),
|
||||
profiles=int(profile_count or 0),
|
||||
locked_profiles=int(locked_count or 0),
|
||||
activity_last_30d=int(activity_count or 0),
|
||||
last_login_at=user.last_login_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me/sessions", response_model=list[SessionOut])
|
||||
async def list_sessions(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = (
|
||||
select(RefreshToken)
|
||||
.where(RefreshToken.user_id == user.id)
|
||||
.where(RefreshToken.revoked_at.is_(None))
|
||||
.order_by(RefreshToken.created_at.desc())
|
||||
)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
return [
|
||||
SessionOut(
|
||||
id=r.id,
|
||||
device=r.device,
|
||||
created_at=r.created_at,
|
||||
expires_at=r.expires_at,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/me/sessions/{session_id}")
|
||||
async def revoke_session(
|
||||
session_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
row = await db.get(RefreshToken, session_id)
|
||||
if row is None or row.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found",
|
||||
)
|
||||
if row.revoked_at is None:
|
||||
row.revoked_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return {"status": "revoked"}
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Discovery API routes: start a conversation, save responses, generate and
|
||||
confirm a profile.
|
||||
|
||||
All routes are user-scoped via the dual-auth dependency. The MCP server uses
|
||||
the X-API-Key header and operates under the synthetic admin user; the
|
||||
browser app uses a Google-issued JWT.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import schemas
|
||||
from app.auth import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models import DiscoveryConversation, DiscoveryProfile, User
|
||||
from app.services.extractor import DiscoveryExtractionError, DiscoveryExtractor
|
||||
|
||||
router = APIRouter(prefix="/discovery", tags=["discovery"])
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _to_profile_response(
|
||||
profile: DiscoveryProfile, extraction_notes: str | None = None
|
||||
) -> schemas.ProfileResponse:
|
||||
"""Build a ProfileResponse from a stored profile row."""
|
||||
confidence = None
|
||||
if profile.confidence_json:
|
||||
try:
|
||||
confidence = schemas.Confidence(**json.loads(profile.confidence_json))
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
confidence = None
|
||||
|
||||
return schemas.ProfileResponse(
|
||||
id=profile.id,
|
||||
user_id=profile.user_id,
|
||||
conversation_id=profile.conversation_id,
|
||||
generated_at=profile.generated_at,
|
||||
triad=profile.triad,
|
||||
probable_type=profile.probable_type,
|
||||
wing=profile.wing,
|
||||
instinctual_variant=profile.instinctual_variant,
|
||||
instinctual_stack=profile.instinctual_stack,
|
||||
love_summary=profile.love_summary,
|
||||
strength_summary=profile.strength_summary,
|
||||
mission_summary=profile.mission_summary,
|
||||
vocation_summary=profile.vocation_summary,
|
||||
overlap_narrative=profile.overlap_narrative,
|
||||
confidence=confidence,
|
||||
locked=profile.locked,
|
||||
extraction_notes=extraction_notes,
|
||||
)
|
||||
|
||||
|
||||
async def _latest_profile(
|
||||
db: AsyncSession, user_id: str
|
||||
) -> DiscoveryProfile | None:
|
||||
stmt = (
|
||||
select(DiscoveryProfile)
|
||||
.where(DiscoveryProfile.user_id == user_id)
|
||||
.order_by(DiscoveryProfile.generated_at.desc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def _owned_conversation(
|
||||
db: AsyncSession, conversation_id: str, user: User
|
||||
) -> DiscoveryConversation:
|
||||
"""Fetch a conversation and assert the caller owns it (admins bypass).
|
||||
Returns 404 for both 'not found' and 'not yours' so the existence of
|
||||
other users' conversations isn't leaked."""
|
||||
conversation = await db.get(DiscoveryConversation, conversation_id)
|
||||
if conversation is None:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
if conversation.user_id != user.id and user.role != "admin":
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
return conversation
|
||||
|
||||
|
||||
@router.post("/start", response_model=schemas.StartResponse)
|
||||
async def start_conversation(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
conversation = DiscoveryConversation(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user.id,
|
||||
started_at=_now(),
|
||||
)
|
||||
db.add(conversation)
|
||||
await db.commit()
|
||||
return schemas.StartResponse(conversation_id=conversation.id)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{conversation_id}/respond", response_model=schemas.RespondResponse
|
||||
)
|
||||
async def save_responses(
|
||||
conversation_id: str,
|
||||
payload: schemas.RespondRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
conversation = await _owned_conversation(db, conversation_id, user)
|
||||
|
||||
conversation.prompt_alive = payload.prompt_alive
|
||||
conversation.prompt_friction = payload.prompt_friction
|
||||
conversation.prompt_pull = payload.prompt_pull
|
||||
conversation.prompt_recognition = payload.prompt_recognition
|
||||
conversation.prompt_future = payload.prompt_future
|
||||
await db.commit()
|
||||
|
||||
return schemas.RespondResponse(
|
||||
conversation_id=conversation_id, status="responses_saved"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{conversation_id}/complete", response_model=schemas.ProfileResponse
|
||||
)
|
||||
async def complete_conversation(
|
||||
conversation_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
conversation = await _owned_conversation(db, conversation_id, user)
|
||||
|
||||
responses = {
|
||||
"alive": conversation.prompt_alive or "",
|
||||
"friction": conversation.prompt_friction or "",
|
||||
"pull": conversation.prompt_pull or "",
|
||||
"recognition": conversation.prompt_recognition or "",
|
||||
"future": conversation.prompt_future or "",
|
||||
}
|
||||
if not any(text.strip() for text in responses.values()):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="No responses available to analyze"
|
||||
)
|
||||
|
||||
api_key = os.getenv("ANTHROPIC_API_KEY")
|
||||
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-5")
|
||||
|
||||
try:
|
||||
extractor = DiscoveryExtractor(api_key=api_key, model=model)
|
||||
data = await extractor.extract(responses)
|
||||
except DiscoveryExtractionError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
profile = DiscoveryProfile(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=conversation.user_id,
|
||||
conversation_id=conversation.id,
|
||||
generated_at=_now(),
|
||||
triad=data.get("triad"),
|
||||
probable_type=_as_int(data.get("probable_type")),
|
||||
wing=_as_int(data.get("wing")),
|
||||
instinctual_variant=data.get("instinctual_variant"),
|
||||
instinctual_stack=data.get("instinctual_stack"),
|
||||
love_summary=data.get("love_summary"),
|
||||
strength_summary=data.get("strength_summary"),
|
||||
mission_summary=data.get("mission_summary"),
|
||||
vocation_summary=data.get("vocation_summary"),
|
||||
overlap_narrative=data.get("overlap_narrative"),
|
||||
confidence_json=json.dumps(data.get("confidence", {})),
|
||||
locked=False,
|
||||
)
|
||||
conversation.completed_at = _now()
|
||||
db.add(profile)
|
||||
await db.commit()
|
||||
|
||||
return _to_profile_response(
|
||||
profile, extraction_notes=data.get("extraction_notes")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/profile/me", response_model=schemas.ProfileResponse)
|
||||
async def get_my_profile(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
profile = await _latest_profile(db, user.id)
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=404, detail="No profile for this user")
|
||||
return _to_profile_response(profile)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/profile/me/confirm", response_model=schemas.ConfirmResponse
|
||||
)
|
||||
async def confirm_my_profile(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
profile = await _latest_profile(db, user.id)
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=404, detail="No profile for this user")
|
||||
profile.locked = True
|
||||
await db.commit()
|
||||
return schemas.ConfirmResponse(status="locked")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/conversation/{conversation_id}",
|
||||
response_model=schemas.ConversationResponse,
|
||||
)
|
||||
async def get_conversation(
|
||||
conversation_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
conversation = await _owned_conversation(db, conversation_id, user)
|
||||
return schemas.ConversationResponse.model_validate(conversation)
|
||||
|
||||
|
||||
def _as_int(value) -> int | None:
|
||||
"""Coerce the model's numeric fields to int, tolerating strings/None."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Pydantic request/response models for the discovery API."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class StartResponse(BaseModel):
|
||||
conversation_id: str
|
||||
|
||||
|
||||
class RespondRequest(BaseModel):
|
||||
prompt_alive: str = ""
|
||||
prompt_friction: str = ""
|
||||
prompt_pull: str = ""
|
||||
prompt_recognition: str = ""
|
||||
prompt_future: str = ""
|
||||
|
||||
|
||||
class RespondResponse(BaseModel):
|
||||
conversation_id: str
|
||||
status: str
|
||||
|
||||
|
||||
class Confidence(BaseModel):
|
||||
triad: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
variant: Optional[str] = None
|
||||
ikigai: Optional[str] = None
|
||||
|
||||
|
||||
class ProfileResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
conversation_id: str
|
||||
generated_at: datetime
|
||||
triad: Optional[str] = None
|
||||
probable_type: Optional[int] = None
|
||||
wing: Optional[int] = None
|
||||
instinctual_variant: Optional[str] = None
|
||||
instinctual_stack: Optional[str] = None
|
||||
love_summary: Optional[str] = None
|
||||
strength_summary: Optional[str] = None
|
||||
mission_summary: Optional[str] = None
|
||||
vocation_summary: Optional[str] = None
|
||||
overlap_narrative: Optional[str] = None
|
||||
confidence: Optional[Confidence] = None
|
||||
locked: bool = False
|
||||
extraction_notes: Optional[str] = None
|
||||
|
||||
|
||||
class ConfirmResponse(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
class ConversationResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
started_at: datetime
|
||||
completed_at: Optional[datetime] = None
|
||||
prompt_alive: Optional[str] = None
|
||||
prompt_friction: Optional[str] = None
|
||||
prompt_pull: Optional[str] = None
|
||||
prompt_recognition: Optional[str] = None
|
||||
prompt_future: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -0,0 +1,214 @@
|
||||
"""DiscoveryExtractor: turns five narrative responses into a structured
|
||||
enneagram + Ikigai profile via the Anthropic API.
|
||||
|
||||
The extractor is responsible only for plumbing: building the message,
|
||||
calling the model, parsing/validating the JSON it returns, and retrying
|
||||
once if the first response is not valid JSON. The actual analysis lives
|
||||
in the model behind ``SYSTEM_PROMPT``.
|
||||
"""
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
|
||||
from anthropic import AsyncAnthropic
|
||||
|
||||
DEFAULT_MODEL = "claude-sonnet-4-5"
|
||||
MAX_TOKENS = 2000
|
||||
|
||||
# Ordered mapping of response keys -> the human-facing prompt label, used to
|
||||
# label each section of the concatenated user message.
|
||||
PROMPT_LABELS = {
|
||||
"alive": "The Alive Moment",
|
||||
"friction": "The Friction Moment",
|
||||
"pull": "The Natural Pull",
|
||||
"recognition": "The Recognition Moment",
|
||||
"future": "The Future Pull",
|
||||
}
|
||||
|
||||
# Keys the model must return for a profile to be considered well-formed.
|
||||
REQUIRED_KEYS = (
|
||||
"triad",
|
||||
"probable_type",
|
||||
"wing",
|
||||
"instinctual_variant",
|
||||
"instinctual_stack",
|
||||
"love_summary",
|
||||
"strength_summary",
|
||||
"mission_summary",
|
||||
"vocation_summary",
|
||||
"overlap_narrative",
|
||||
"confidence",
|
||||
)
|
||||
REQUIRED_CONFIDENCE_KEYS = ("triad", "type", "variant", "ikigai")
|
||||
|
||||
SYSTEM_PROMPT = """You are a skilled personality analyst trained in the Enneagram system and the Ikigai framework.
|
||||
You will receive five narrative responses from a person answering open-ended reflection prompts.
|
||||
Your job is to extract a structured self-discovery profile from their stories.
|
||||
|
||||
ENNEAGRAM EXTRACTION RULES:
|
||||
- The nine types cluster into three triads based on emotional center:
|
||||
- Gut (instinctive): Types 8, 9, 1 — driven by anger, focused on control, body-based decisions
|
||||
- Heart (feeling): Types 2, 3, 4 — driven by shame, focused on image and connection
|
||||
- Head (thinking): Types 5, 6, 7 — driven by fear, focused on safety and understanding
|
||||
- The Friction Moment response reveals the triad most clearly — gut types act against injustice, heart types feel exposed or unseen, head types analyze and strategize
|
||||
- The Alive Moment and Recognition Moment reveal the type's core need
|
||||
- The Natural Pull reveals instinctual variant: sp (self-preservation) = tasks/systems/stability, so (social) = groups/community/belonging, sx (sexual/one-to-one) = intensity/connection/depth
|
||||
- The Future Pull reveals the type's idealized self and Ikigai vocation
|
||||
|
||||
IKIGAI EXTRACTION RULES:
|
||||
- Love: what activities, topics, or experiences appear across responses with energy and enthusiasm
|
||||
- Strength: what the person describes doing well or being recognized for
|
||||
- Mission: what problem or need in the world their stories orbit around
|
||||
- Vocation: where their strength and the world's need intersect with economic potential
|
||||
|
||||
CONFIDENCE RULES:
|
||||
- high: strong consistent signal across 2+ responses
|
||||
- medium: signal present but only in one response or partially contradicted
|
||||
- low: weak or absent signal — do not guess, flag it
|
||||
|
||||
OUTPUT FORMAT:
|
||||
Respond ONLY with valid JSON. No preamble, no explanation, no markdown fences.
|
||||
|
||||
{
|
||||
"triad": "gut | heart | head",
|
||||
"probable_type": 1-9,
|
||||
"wing": 1-9 (must be adjacent to probable_type),
|
||||
"instinctual_variant": "sp | so | sx",
|
||||
"instinctual_stack": "e.g. sp/so/sx",
|
||||
"love_summary": "2-3 sentence summary of what they love",
|
||||
"strength_summary": "2-3 sentence summary of what they are good at",
|
||||
"mission_summary": "2-3 sentence summary of what the world needs from them",
|
||||
"vocation_summary": "2-3 sentence summary of what they can be paid for",
|
||||
"overlap_narrative": "One paragraph (4-6 sentences) describing where their four Ikigai circles converge and how their enneagram type shapes that intersection. Write directly to the person in second person (you/your). Do not mention enneagram type numbers — describe the pattern in plain language.",
|
||||
"confidence": {
|
||||
"triad": "high | medium | low",
|
||||
"type": "high | medium | low",
|
||||
"variant": "high | medium | low",
|
||||
"ikigai": "high | medium | low"
|
||||
},
|
||||
"extraction_notes": "Optional: flag anything ambiguous, contradictory, or uncertain that the user should know"
|
||||
}"""
|
||||
|
||||
RETRY_REMINDER = (
|
||||
"Your previous response could not be parsed as JSON. "
|
||||
"Respond ONLY with the single valid JSON object described in your "
|
||||
"instructions — no preamble, no explanation, and no markdown code fences."
|
||||
)
|
||||
|
||||
|
||||
class DiscoveryExtractionError(Exception):
|
||||
"""Raised when extraction fails (API error or unparseable output)."""
|
||||
|
||||
|
||||
class DiscoveryExtractor:
|
||||
"""Extracts a self-discovery profile from narrative responses."""
|
||||
|
||||
def __init__(self, api_key: str, model: str = DEFAULT_MODEL):
|
||||
if not api_key:
|
||||
raise DiscoveryExtractionError(
|
||||
"ANTHROPIC_API_KEY is not set; cannot run extraction."
|
||||
)
|
||||
self.model = model
|
||||
self.client = AsyncAnthropic(api_key=api_key)
|
||||
|
||||
def _build_user_message(self, responses: Dict[str, str]) -> str:
|
||||
"""Concatenate the five responses, each under its prompt heading."""
|
||||
sections = []
|
||||
for key, label in PROMPT_LABELS.items():
|
||||
text = (responses.get(key) or "").strip()
|
||||
sections.append(f"## {label}\n{text if text else '(no response)'}")
|
||||
return "\n\n".join(sections)
|
||||
|
||||
async def _call_model(self, user_message: str) -> str:
|
||||
"""Make a single Anthropic API call and return the raw text."""
|
||||
response = await self.client.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=MAX_TOKENS,
|
||||
system=SYSTEM_PROMPT,
|
||||
messages=[{"role": "user", "content": user_message}],
|
||||
)
|
||||
return response.content[0].text
|
||||
|
||||
async def extract(self, responses: Dict[str, str]) -> Dict[str, Any]:
|
||||
"""Run extraction. Retries once if the first output is not valid JSON.
|
||||
|
||||
Args:
|
||||
responses: dict with keys alive, friction, pull, recognition, future.
|
||||
|
||||
Returns:
|
||||
Parsed profile dict matching the system-prompt schema.
|
||||
|
||||
Raises:
|
||||
DiscoveryExtractionError: on API failure or repeated parse failure.
|
||||
"""
|
||||
user_message = self._build_user_message(responses)
|
||||
|
||||
try:
|
||||
raw = await self._call_model(user_message)
|
||||
except Exception as exc: # noqa: BLE001 - surface any SDK/transport error
|
||||
raise DiscoveryExtractionError(
|
||||
f"Anthropic API call failed: {exc}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
return self._parse(raw)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# One retry with an explicit JSON-only reminder appended.
|
||||
retry_message = f"{user_message}\n\n{RETRY_REMINDER}"
|
||||
try:
|
||||
raw_retry = await self._call_model(retry_message)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise DiscoveryExtractionError(
|
||||
f"Anthropic API call failed on retry: {exc}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
return self._parse(raw_retry)
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
raise DiscoveryExtractionError(
|
||||
f"Model did not return valid JSON after retry: {exc}"
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def _strip_fences(text: str) -> str:
|
||||
"""Remove a leading/trailing markdown code fence if present."""
|
||||
stripped = text.strip()
|
||||
if stripped.startswith("```"):
|
||||
# drop the opening fence line (``` or ```json)
|
||||
newline = stripped.find("\n")
|
||||
if newline != -1:
|
||||
stripped = stripped[newline + 1 :]
|
||||
if stripped.rstrip().endswith("```"):
|
||||
stripped = stripped.rstrip()[: -len("```")]
|
||||
return stripped.strip()
|
||||
|
||||
@classmethod
|
||||
def _parse(cls, raw: str) -> Dict[str, Any]:
|
||||
"""Parse and validate the model's JSON output.
|
||||
|
||||
Raises json.JSONDecodeError if the text is not JSON, or ValueError if
|
||||
required keys are missing — both of which trigger a retry upstream.
|
||||
"""
|
||||
if not raw or not raw.strip():
|
||||
raise ValueError("empty response from model")
|
||||
|
||||
data = json.loads(cls._strip_fences(raw))
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("top-level JSON value is not an object")
|
||||
|
||||
missing = [k for k in REQUIRED_KEYS if k not in data]
|
||||
if missing:
|
||||
raise ValueError(f"missing required keys: {', '.join(missing)}")
|
||||
|
||||
confidence = data.get("confidence")
|
||||
if not isinstance(confidence, dict):
|
||||
raise ValueError("confidence must be an object")
|
||||
missing_conf = [
|
||||
k for k in REQUIRED_CONFIDENCE_KEYS if k not in confidence
|
||||
]
|
||||
if missing_conf:
|
||||
raise ValueError(
|
||||
f"missing confidence keys: {', '.join(missing_conf)}"
|
||||
)
|
||||
|
||||
return data
|
||||
@@ -0,0 +1,223 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ImpactFlow — Self Discovery</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Question flow -->
|
||||
<div class="wrap" id="flow">
|
||||
<div class="brand">ImpactFlow · Self Discovery</div>
|
||||
<div class="progress" id="progress">1 of 5</div>
|
||||
<h2 class="prompt-title" id="promptTitle"></h2>
|
||||
<p class="prompt-text" id="promptText"></p>
|
||||
<textarea
|
||||
id="answer"
|
||||
placeholder="Take your time. Tell the whole story…"
|
||||
></textarea>
|
||||
<div class="nav">
|
||||
<button class="btn-ghost" id="backBtn">Back</button>
|
||||
<button class="btn-primary" id="nextBtn">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div class="loading" id="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Reading your story…</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const PROMPTS = [
|
||||
{
|
||||
key: "prompt_alive",
|
||||
title: "The Alive Moment",
|
||||
text:
|
||||
"Tell me about a time you felt most alive and useful. What were you doing, who was involved, and what made it matter?",
|
||||
},
|
||||
{
|
||||
key: "prompt_friction",
|
||||
title: "The Friction Moment",
|
||||
text:
|
||||
"Describe a situation where something felt deeply wrong or unfair. What was it, and what did you do about it?",
|
||||
},
|
||||
{
|
||||
key: "prompt_pull",
|
||||
title: "The Natural Pull",
|
||||
text:
|
||||
"What do you find yourself doing or thinking about in your free time, even when you're supposed to be doing something else?",
|
||||
},
|
||||
{
|
||||
key: "prompt_recognition",
|
||||
title: "The Recognition Moment",
|
||||
text:
|
||||
"When have you felt most seen or valued — and what were you being recognized for?",
|
||||
},
|
||||
{
|
||||
key: "prompt_future",
|
||||
title: "The Future Pull",
|
||||
text:
|
||||
"If you knew you couldn't fail and money wasn't a factor, what would you spend the next five years building or doing?",
|
||||
},
|
||||
];
|
||||
|
||||
function createUserId() {
|
||||
const webCrypto = globalThis.crypto;
|
||||
if (webCrypto && typeof webCrypto.randomUUID === "function") {
|
||||
return webCrypto.randomUUID();
|
||||
}
|
||||
|
||||
if (webCrypto && typeof webCrypto.getRandomValues === "function") {
|
||||
const bytes = new Uint8Array(16);
|
||||
webCrypto.getRandomValues(bytes);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (b) =>
|
||||
b.toString(16).padStart(2, "0")
|
||||
).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(
|
||||
12,
|
||||
16
|
||||
)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
return `local-${Date.now()}-${Math.random()
|
||||
.toString(16)
|
||||
.slice(2)}`;
|
||||
}
|
||||
|
||||
// Persistent per-browser user id.
|
||||
function getUserId() {
|
||||
let id = localStorage.getItem("impactflow_user_id");
|
||||
if (!id) {
|
||||
id = createUserId();
|
||||
localStorage.setItem("impactflow_user_id", id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
const userId = getUserId();
|
||||
const answers = new Array(PROMPTS.length).fill("");
|
||||
let index = 0;
|
||||
let conversationId = null;
|
||||
|
||||
const el = {
|
||||
progress: document.getElementById("progress"),
|
||||
title: document.getElementById("promptTitle"),
|
||||
text: document.getElementById("promptText"),
|
||||
answer: document.getElementById("answer"),
|
||||
back: document.getElementById("backBtn"),
|
||||
next: document.getElementById("nextBtn"),
|
||||
flow: document.getElementById("flow"),
|
||||
loading: document.getElementById("loading"),
|
||||
};
|
||||
|
||||
function render() {
|
||||
const p = PROMPTS[index];
|
||||
el.progress.textContent = `${index + 1} of ${PROMPTS.length}`;
|
||||
el.title.textContent = p.title;
|
||||
el.text.textContent = p.text;
|
||||
el.answer.value = answers[index];
|
||||
el.back.style.visibility = index === 0 ? "hidden" : "visible";
|
||||
el.next.textContent =
|
||||
index === PROMPTS.length - 1 ? "Submit" : "Next";
|
||||
el.answer.focus();
|
||||
}
|
||||
|
||||
function saveCurrent() {
|
||||
answers[index] = el.answer.value;
|
||||
}
|
||||
|
||||
el.back.addEventListener("click", () => {
|
||||
saveCurrent();
|
||||
if (index > 0) {
|
||||
index -= 1;
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
el.next.addEventListener("click", async () => {
|
||||
saveCurrent();
|
||||
if (index < PROMPTS.length - 1) {
|
||||
index += 1;
|
||||
render();
|
||||
} else {
|
||||
await submit();
|
||||
}
|
||||
});
|
||||
|
||||
async function startConversation() {
|
||||
const res = await fetch("/discovery/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_id: userId }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Could not start conversation");
|
||||
const data = await res.json();
|
||||
conversationId = data.conversation_id;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
el.flow.style.display = "none";
|
||||
el.loading.classList.add("active");
|
||||
|
||||
try {
|
||||
if (!conversationId) await startConversation();
|
||||
|
||||
const body = {};
|
||||
PROMPTS.forEach((p, i) => {
|
||||
body[p.key] = answers[i];
|
||||
});
|
||||
|
||||
const respondRes = await fetch(
|
||||
`/discovery/${conversationId}/respond`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
if (!respondRes.ok) throw new Error("Could not save responses");
|
||||
|
||||
const completeRes = await fetch(
|
||||
`/discovery/${conversationId}/complete`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
if (!completeRes.ok) {
|
||||
const detail = await completeRes
|
||||
.json()
|
||||
.catch(() => ({ detail: "Extraction failed" }));
|
||||
throw new Error(detail.detail || "Extraction failed");
|
||||
}
|
||||
|
||||
window.location.href = `/static/profile.html?user_id=${encodeURIComponent(
|
||||
userId
|
||||
)}`;
|
||||
} catch (err) {
|
||||
el.loading.classList.remove("active");
|
||||
el.flow.style.display = "block";
|
||||
el.flow.innerHTML =
|
||||
`<div class="error-box"><strong>Something went wrong.</strong><br/>` +
|
||||
`${err.message}<br/><br/>Your answers are still here — ` +
|
||||
`please try submitting again.</div>` +
|
||||
`<div class="nav"><span></span>` +
|
||||
`<button class="btn-primary" onclick="location.reload()">Reload</button></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Kick off a conversation as soon as the page loads so the id is ready.
|
||||
startConversation().catch(() => {
|
||||
/* will retry on submit */
|
||||
});
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,157 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ImpactFlow — Your Profile</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="brand">ImpactFlow · Your Direction</div>
|
||||
<div id="content">
|
||||
<p>Loading your profile…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Plain-language description of each enneagram triad. We never show the
|
||||
// type number on this page — only the pattern.
|
||||
const TRIAD_INFO = {
|
||||
gut: {
|
||||
label: "You lead with instinct and will",
|
||||
text:
|
||||
"You move through the world from your gut. You sense what's right before you can fully explain it, and you're driven to act, to protect what matters, and to set things right. Autonomy and integrity are non-negotiable for you, and you'd rather meet a problem head-on than wait for permission.",
|
||||
},
|
||||
heart: {
|
||||
label: "You lead with feeling and connection",
|
||||
text:
|
||||
"You navigate through emotion and relationship. You're finely attuned to how things land — for you and for the people around you — and you care deeply about being genuinely seen for who you are. Your energy comes alive in connection, recognition, and making others feel that they matter.",
|
||||
},
|
||||
head: {
|
||||
label: "You lead with thought and perception",
|
||||
text:
|
||||
"You meet the world through your mind. You like to understand before you commit — gathering information, mapping possibilities, and anticipating what's ahead so you can feel prepared and secure. Your gift is seeing patterns and thinking your way toward clarity others miss.",
|
||||
},
|
||||
};
|
||||
|
||||
const IKIGAI = [
|
||||
{ key: "love_summary", title: "What you love", icon: "♥" },
|
||||
{ key: "strength_summary", title: "What you're good at", icon: "★" },
|
||||
{ key: "mission_summary", title: "What the world needs", icon: "◆" },
|
||||
{ key: "vocation_summary", title: "What you can be paid for", icon: "$" },
|
||||
];
|
||||
|
||||
function getParam(name) {
|
||||
return new URLSearchParams(window.location.search).get(name);
|
||||
}
|
||||
|
||||
function confClass(level) {
|
||||
if (level === "high") return "high";
|
||||
if (level === "medium") return "medium";
|
||||
return "low";
|
||||
}
|
||||
|
||||
function dot(level) {
|
||||
const c = confClass(level);
|
||||
const title = `Confidence: ${level || "low"}`;
|
||||
return `<span class="dot ${c}" title="${title}"></span>`;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (s == null) return "";
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const userId = getParam("user_id");
|
||||
const content = document.getElementById("content");
|
||||
|
||||
if (!userId) {
|
||||
content.innerHTML =
|
||||
'<div class="error-box">No user id provided.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let profile;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/discovery/profile/${encodeURIComponent(userId)}`
|
||||
);
|
||||
if (!res.ok) throw new Error("Profile not found");
|
||||
profile = await res.json();
|
||||
} catch (err) {
|
||||
content.innerHTML = `<div class="error-box">Could not load your profile: ${escapeHtml(
|
||||
err.message
|
||||
)}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const conf = profile.confidence || {};
|
||||
const triad = TRIAD_INFO[profile.triad] || TRIAD_INFO.gut;
|
||||
|
||||
const ikigaiCards = IKIGAI.map((item) => {
|
||||
return `
|
||||
<div class="card">
|
||||
<h3>${dot(conf.ikigai)} ${item.icon} ${item.title}</h3>
|
||||
<p>${escapeHtml(profile[item.key]) || "—"}</p>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
const locked = profile.locked;
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="profile-narrative">${escapeHtml(
|
||||
profile.overlap_narrative
|
||||
)}</div>
|
||||
|
||||
<p class="section-label">Where your four circles meet</p>
|
||||
<div class="ikigai-grid">${ikigaiCards}</div>
|
||||
|
||||
<div class="triad-block">
|
||||
<h2>${dot(conf.triad)} ${triad.label}</h2>
|
||||
<p>${triad.text}</p>
|
||||
</div>
|
||||
|
||||
<div class="confirm-row">
|
||||
<button class="btn-primary ${locked ? "confirmed" : ""}" id="confirmBtn" ${
|
||||
locked ? "disabled" : ""
|
||||
}>
|
||||
${locked ? "Profile confirmed ✓" : "This is me"}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const btn = document.getElementById("confirmBtn");
|
||||
if (btn && !locked) {
|
||||
btn.addEventListener("click", async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/discovery/profile/${encodeURIComponent(userId)}/confirm`,
|
||||
{ method: "PUT" }
|
||||
);
|
||||
if (!res.ok) throw new Error("confirm failed");
|
||||
btn.textContent = "Profile confirmed ✓";
|
||||
btn.classList.add("confirmed");
|
||||
} catch (err) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Try again";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,295 @@
|
||||
/* ImpactFlow Self-Discovery — shared styles
|
||||
Palette: navy #0d1b2a, gold #c9a84c, cream #f8f5ef */
|
||||
|
||||
:root {
|
||||
--navy: #0d1b2a;
|
||||
--gold: #c9a84c;
|
||||
--cream: #f8f5ef;
|
||||
--navy-soft: #1c3049;
|
||||
--shadow: rgba(13, 27, 42, 0.12);
|
||||
--green: #2e7d32;
|
||||
--yellow: #c9a84c;
|
||||
--red: #c0392b;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--cream);
|
||||
color: var(--navy);
|
||||
font-family: "DM Sans", system-ui, sans-serif;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px 80px;
|
||||
}
|
||||
|
||||
/* ---------- Discovery flow ---------- */
|
||||
|
||||
.brand {
|
||||
font-family: "Playfair Display", "DM Sans", serif;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--gold);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.progress {
|
||||
color: var(--gold);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.prompt-title {
|
||||
font-family: "Playfair Display", "DM Sans", serif;
|
||||
font-size: 1.35rem;
|
||||
color: var(--gold);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.prompt-text {
|
||||
font-family: "Playfair Display", "DM Sans", serif;
|
||||
font-size: 1.7rem;
|
||||
font-weight: 500;
|
||||
color: var(--navy);
|
||||
margin: 0 0 28px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 240px;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(13, 27, 42, 0.18);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
color: var(--navy);
|
||||
font-family: "DM Sans", system-ui, sans-serif;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--gold);
|
||||
box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.25);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 28px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: "DM Sans", system-ui, sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
padding: 13px 30px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: transform 0.06s ease, opacity 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--gold);
|
||||
color: var(--navy);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #d8b95f;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--navy);
|
||||
border: 1px solid rgba(13, 27, 42, 0.25);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: rgba(13, 27, 42, 0.05);
|
||||
}
|
||||
|
||||
/* ---------- Loading state ---------- */
|
||||
|
||||
.loading {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
min-height: 60vh;
|
||||
background: var(--cream);
|
||||
color: var(--navy);
|
||||
}
|
||||
|
||||
.loading.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.loading p {
|
||||
font-family: "Playfair Display", "DM Sans", serif;
|
||||
font-size: 1.6rem;
|
||||
color: var(--navy);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border: 4px solid rgba(201, 168, 76, 0.3);
|
||||
border-top-color: var(--gold);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.9s linear infinite;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Profile page ---------- */
|
||||
|
||||
.profile-narrative {
|
||||
background: var(--navy);
|
||||
color: var(--cream);
|
||||
padding: 36px 34px;
|
||||
border-radius: 16px;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.7;
|
||||
box-shadow: 0 10px 30px var(--shadow);
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-family: "Playfair Display", "DM Sans", serif;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gold);
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
|
||||
.ikigai-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 18px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(13, 27, 42, 0.1);
|
||||
border-radius: 14px;
|
||||
padding: 22px 24px;
|
||||
box-shadow: 0 4px 14px rgba(13, 27, 42, 0.05);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin: 0 0 10px;
|
||||
font-size: 1.05rem;
|
||||
color: var(--navy);
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0;
|
||||
color: var(--navy-soft);
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.dot.high {
|
||||
background: var(--green);
|
||||
}
|
||||
.dot.medium {
|
||||
background: var(--yellow);
|
||||
}
|
||||
.dot.low {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.triad-block {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(13, 27, 42, 0.1);
|
||||
border-left: 5px solid var(--gold);
|
||||
border-radius: 14px;
|
||||
padding: 26px 28px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.triad-block h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 0 12px;
|
||||
font-family: "Playfair Display", "DM Sans", serif;
|
||||
font-size: 1.3rem;
|
||||
color: var(--navy);
|
||||
}
|
||||
|
||||
.triad-block p {
|
||||
margin: 0;
|
||||
color: var(--navy-soft);
|
||||
}
|
||||
|
||||
.confirm-row {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.confirmed {
|
||||
background: var(--navy) !important;
|
||||
color: var(--cream) !important;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
background: #fdecea;
|
||||
border: 1px solid var(--red);
|
||||
color: #7a231b;
|
||||
padding: 18px 22px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.ikigai-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.prompt-text {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
"""Activity-log helpers + middleware.
|
||||
|
||||
Middleware fires after every request and writes one ActivityLog row when
|
||||
the request was authenticated and succeeded (status < 400). We classify the
|
||||
verb (GET -> view / POST -> create / etc.) and bucket the URL path into a
|
||||
coarse resource name so the dashboard can group "everything about
|
||||
conversations" without parsing URLs at query time.
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import ActivityLog
|
||||
|
||||
_METHOD_ACTION = {
|
||||
"GET": "view",
|
||||
"POST": "create",
|
||||
"PUT": "update",
|
||||
"PATCH": "update",
|
||||
"DELETE": "delete",
|
||||
}
|
||||
|
||||
# Paths whose activity is not worth recording (noisy / no user signal).
|
||||
_SKIP_PATH_PREFIXES = (
|
||||
"/health",
|
||||
"/static",
|
||||
"/favicon.ico",
|
||||
"/openapi.json",
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/api/auth/login",
|
||||
"/api/auth/callback",
|
||||
)
|
||||
|
||||
|
||||
def _method_to_action(method: str) -> str:
|
||||
return _METHOD_ACTION.get(method.upper(), "other")
|
||||
|
||||
|
||||
def _path_to_resource(path: str) -> str:
|
||||
parts = [p for p in path.strip("/").split("/") if p]
|
||||
if not parts:
|
||||
return "root"
|
||||
if parts[0] == "api" and len(parts) > 1:
|
||||
return parts[1]
|
||||
return parts[0]
|
||||
|
||||
|
||||
async def log_activity(
|
||||
*,
|
||||
user_id: str,
|
||||
action: str,
|
||||
resource: str,
|
||||
resource_id: Optional[str] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
source: str = "web",
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Write one row. Uses its own session because the request's session is
|
||||
already closed by the time tracking middleware sees the response."""
|
||||
row = ActivityLog(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
resource=resource,
|
||||
resource_id=resource_id,
|
||||
metadata_json=json.dumps(metadata) if metadata else None,
|
||||
source=source,
|
||||
ip_address=ip_address,
|
||||
user_agent=(user_agent or "")[:1000] or None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
async with AsyncSessionLocal() as db:
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
|
||||
|
||||
class ActivityTrackingMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
|
||||
# Only log authenticated, successful, non-noisy requests.
|
||||
if response.status_code >= 400:
|
||||
return response
|
||||
path = request.url.path
|
||||
if any(path.startswith(p) for p in _SKIP_PATH_PREFIXES):
|
||||
return response
|
||||
user = getattr(request.state, "user", None)
|
||||
if user is None:
|
||||
return response
|
||||
|
||||
source = "mcp" if request.headers.get("x-api-key") else "web"
|
||||
try:
|
||||
await log_activity(
|
||||
user_id=user.id,
|
||||
action=_method_to_action(request.method),
|
||||
resource=_path_to_resource(path),
|
||||
source=source,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
except Exception:
|
||||
# Tracking failures must never break the underlying request.
|
||||
pass
|
||||
return response
|
||||
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
discovery:
|
||||
build: .
|
||||
ports:
|
||||
- "${HOST_BIND_IP:-0.0.0.0}:${HOST_PORT:-8011}:8011"
|
||||
volumes:
|
||||
- ./data:/app/data # SQLite file persists here
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# Push the README-refresh thought to Open Brain via the public relay.
|
||||
# Requires a fresh write key from start-handoff.sh. Replace WRITE_KEY below.
|
||||
set -euo pipefail
|
||||
|
||||
WRITE_KEY="${OPENBRAIN_WRITE_KEY:-PASTE_WRITE_KEY_HERE}"
|
||||
ENDPOINT="https://openbrain.teamci.org/thought"
|
||||
|
||||
if [[ "$WRITE_KEY" == "PASTE_WRITE_KEY_HERE" ]]; then
|
||||
echo "Set OPENBRAIN_WRITE_KEY or edit this file before running." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl -sS -X POST "$ENDPOINT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Write-Key: $WRITE_KEY" \
|
||||
--data @- <<'JSON'
|
||||
{
|
||||
"title": "ImpactFlow Discovery README refreshed to match current code (2026-05-27)",
|
||||
"type": "observation",
|
||||
"topics": ["impactflow-discovery", "documentation", "alembic", "migration-bootstrap"],
|
||||
"people": ["jsalmon"],
|
||||
"body": "Refreshed README.md for impactflow-discovery on 2026-05-27. Drift fixed in four places: (1) Important files table now lists app/migration_bootstrap.py, tests/test_migration_bootstrap.py, tests/test_static_discovery.py, and tests/fixtures/{gut,head,heart}_type_responses.json. (2) App Startup section replaced the vague Docker line with the real three-step CMD: python -m app.migration_bootstrap && alembic upgrade head && uvicorn ..., and notes that the Dockerfile also runs alembic upgrade head at build time as a sanity check. (3) Frontend Behavior section documents the createUserId() helper and its crypto.getRandomValues() fallback for insecure (non-HTTPS) contexts. (4) Nothing else removed; every prior claim still checks out against code. The load-bearing detail is app/migration_bootstrap.py: it stamps pre-Alembic local SQLite DBs as revision 001 so alembic upgrade head doesn't crash recreating existing tables — that behavior was previously invisible to new readers."
|
||||
}
|
||||
JSON
|
||||
echo
|
||||
echo "Pushed."
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: ImpactFlow Discovery README refreshed to match current code (2026-05-27)
|
||||
type: observation
|
||||
topics: [impactflow-discovery, documentation, alembic, migration-bootstrap]
|
||||
people: [jsalmon]
|
||||
---
|
||||
|
||||
Refreshed `README.md` for `impactflow-discovery` so it matches the code as of
|
||||
2026-05-27. Repo state, routes, schemas, env vars, and dependency pins were
|
||||
already accurate; the doc had drifted in four specific places.
|
||||
|
||||
## What changed
|
||||
|
||||
1. **Important files table** — added rows for `app/migration_bootstrap.py`,
|
||||
`tests/test_migration_bootstrap.py`, `tests/test_static_discovery.py`,
|
||||
and `tests/fixtures/{gut,head,heart}_type_responses.json`.
|
||||
2. **App Startup section** — replaced the one-line "Docker startup also runs
|
||||
Alembic migrations" with the actual three-step container CMD:
|
||||
`python -m app.migration_bootstrap && alembic upgrade head && uvicorn ...`,
|
||||
plus a note that the Dockerfile also runs `alembic upgrade head` at build
|
||||
time as a sanity check against a throwaway in-image DB.
|
||||
3. **Frontend Behavior — discovery.html** — documented the `createUserId()`
|
||||
helper and its `crypto.getRandomValues()` fallback so the flow keeps
|
||||
working in insecure (non-HTTPS) contexts like LAN/Tailscale.
|
||||
4. No content removed; every existing claim still checks out against code.
|
||||
|
||||
## Why this matters
|
||||
|
||||
`app/migration_bootstrap.py` is the load-bearing piece for anyone whose local
|
||||
`data/discovery.db` predates Alembic — it stamps the existing schema as
|
||||
revision `001` so the next `alembic upgrade head` doesn't crash trying to
|
||||
recreate tables that already exist. That behavior was previously invisible
|
||||
to new readers of the repo.
|
||||
|
||||
## Files touched
|
||||
|
||||
- `C:\SyncData\impactflow-discovery\README.md`
|
||||
|
||||
## Verification
|
||||
|
||||
- Every backtick-quoted file path in the README maps to a real file on disk.
|
||||
- Every `/discovery/*` route in the README matches a handler in
|
||||
`app/routers/discovery.py`.
|
||||
- `createUserId()` and `getRandomValues` both confirmed present in
|
||||
`app/static/discovery.html`.
|
||||
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
asyncio_default_fixture_loop_scope = function
|
||||
testpaths = tests
|
||||
@@ -0,0 +1,15 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy==2.0.36
|
||||
greenlet==3.1.1
|
||||
aiosqlite==0.20.0
|
||||
alembic==1.14.0
|
||||
anthropic==0.42.0
|
||||
python-dotenv==1.0.1
|
||||
authlib==1.6.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
itsdangerous==2.2.0
|
||||
pydantic==2.10.4
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.25.0
|
||||
httpx==0.28.1
|
||||
@@ -0,0 +1,67 @@
|
||||
param(
|
||||
[string]$ListenAddress = "0.0.0.0",
|
||||
[string]$ConnectAddress = "wsl",
|
||||
[int]$Port = 8011,
|
||||
[int]$ConnectPort = 8011,
|
||||
[string]$Distro = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$principal = [Security.Principal.WindowsPrincipal]::new(
|
||||
[Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
)
|
||||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
throw "Run this script from an elevated PowerShell prompt."
|
||||
}
|
||||
|
||||
if ($ConnectAddress -eq "wsl") {
|
||||
$wslArgs = @()
|
||||
if ($Distro) {
|
||||
$wslArgs += @("-d", $Distro)
|
||||
}
|
||||
$wslArgs += @("sh", "-lc", "hostname -I")
|
||||
|
||||
$wslIps = (& wsl.exe @wslArgs) -split "\s+"
|
||||
$ConnectAddress = $wslIps |
|
||||
Where-Object { $_ -match "^\d{1,3}(\.\d{1,3}){3}$" } |
|
||||
Select-Object -First 1
|
||||
|
||||
if (-not $ConnectAddress) {
|
||||
throw "Could not detect the WSL IP address. Make sure your WSL distro is running."
|
||||
}
|
||||
}
|
||||
|
||||
# Remove stale entries for this app. The old Tailscale-only entry can prevent
|
||||
# this service from behaving like the other WSL-published services.
|
||||
foreach ($entry in @(
|
||||
@{ Address = $ListenAddress; Port = $Port },
|
||||
@{ Address = "100.103.206.4"; Port = $Port },
|
||||
@{ Address = "0.0.0.0"; Port = 8001 },
|
||||
@{ Address = "100.103.206.4"; Port = 8001 }
|
||||
)) {
|
||||
netsh interface portproxy delete v4tov4 listenaddress=$entry.Address listenport=$entry.Port | Out-Null
|
||||
}
|
||||
|
||||
netsh interface portproxy add v4tov4 listenaddress=$ListenAddress listenport=$Port connectaddress=$ConnectAddress connectport=$ConnectPort
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to add the new portproxy entry."
|
||||
}
|
||||
|
||||
$ruleName = "ImpactFlow Discovery $ListenAddress`:$Port"
|
||||
$existingRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
|
||||
if (-not $existingRule) {
|
||||
$firewallArgs = @{
|
||||
DisplayName = $ruleName
|
||||
Direction = "Inbound"
|
||||
Action = "Allow"
|
||||
Protocol = "TCP"
|
||||
LocalPort = $Port
|
||||
}
|
||||
if ($ListenAddress -ne "0.0.0.0") {
|
||||
$firewallArgs.LocalAddress = $ListenAddress
|
||||
}
|
||||
New-NetFirewallRule @firewallArgs | Out-Null
|
||||
}
|
||||
|
||||
Write-Output "Forwarding http://$ListenAddress`:$Port -> http://$ConnectAddress`:$ConnectPort"
|
||||
@@ -0,0 +1,47 @@
|
||||
param(
|
||||
[string]$ListenAddress = "100.103.206.4",
|
||||
[int]$ListenPort = 8011,
|
||||
[string]$ConnectAddress = "127.0.0.1",
|
||||
[int]$ConnectPort = 8011
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$listener = [System.Net.Sockets.TcpListener]::new(
|
||||
[System.Net.IPAddress]::Parse($ListenAddress),
|
||||
$ListenPort
|
||||
)
|
||||
$listener.Start()
|
||||
Write-Output "Forwarding tcp://$ListenAddress`:$ListenPort -> tcp://$ConnectAddress`:$ConnectPort"
|
||||
|
||||
try {
|
||||
while ($true) {
|
||||
$client = $listener.AcceptTcpClient()
|
||||
$null = [System.Threading.ThreadPool]::QueueUserWorkItem({
|
||||
param($state)
|
||||
|
||||
$source = $state.Source
|
||||
$target = [System.Net.Sockets.TcpClient]::new()
|
||||
try {
|
||||
$target.Connect($state.ConnectAddress, $state.ConnectPort)
|
||||
$sourceStream = $source.GetStream()
|
||||
$targetStream = $target.GetStream()
|
||||
|
||||
$copyToTarget = $sourceStream.CopyToAsync($targetStream)
|
||||
$copyToSource = $targetStream.CopyToAsync($sourceStream)
|
||||
[System.Threading.Tasks.Task]::WaitAny($copyToTarget, $copyToSource) | Out-Null
|
||||
} catch {
|
||||
# Drop failed bridge attempts; the caller will see the connection fail.
|
||||
} finally {
|
||||
$source.Close()
|
||||
$target.Close()
|
||||
}
|
||||
}, @{
|
||||
Source = $client
|
||||
ConnectAddress = $ConnectAddress
|
||||
ConnectPort = $ConnectPort
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
$listener.Stop()
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
"""In-process smoke test of the full discovery flow.
|
||||
|
||||
Uses httpx ASGITransport to drive the FastAPI app without a network server,
|
||||
and patches DiscoveryExtractor so no real Anthropic call is made.
|
||||
Run: .venv/Scripts/python.exe smoke_test.py
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///./data/smoke.db"
|
||||
os.environ.setdefault("JWT_SECRET", "smoke-jwt-secret")
|
||||
os.environ.setdefault("IMPACTFLOW_API_KEY", "smoke-api-key")
|
||||
|
||||
from httpx import ASGITransport, AsyncClient # noqa: E402
|
||||
|
||||
from app import models # noqa: E402,F401
|
||||
from app.database import Base, engine # noqa: E402
|
||||
import app.routers.discovery as disc # noqa: E402
|
||||
|
||||
|
||||
class FakeExtractor:
|
||||
def __init__(self, api_key, model="fake"):
|
||||
pass
|
||||
|
||||
async def extract(self, responses):
|
||||
assert "friction" in responses
|
||||
return {
|
||||
"triad": "gut",
|
||||
"probable_type": 8,
|
||||
"wing": 9,
|
||||
"instinctual_variant": "sp",
|
||||
"instinctual_stack": "sp/so/sx",
|
||||
"love_summary": "You love hands-on, high-stakes work.",
|
||||
"strength_summary": "You take charge and see the whole board.",
|
||||
"mission_summary": "People need someone who will act and protect.",
|
||||
"vocation_summary": "You can be paid to lead and build.",
|
||||
"overlap_narrative": "You come alive where action meets protection.",
|
||||
"confidence": {
|
||||
"triad": "high",
|
||||
"type": "medium",
|
||||
"variant": "medium",
|
||||
"ikigai": "high",
|
||||
},
|
||||
"extraction_notes": "",
|
||||
}
|
||||
|
||||
|
||||
disc.DiscoveryExtractor = FakeExtractor
|
||||
|
||||
from app.main import app # noqa: E402
|
||||
|
||||
|
||||
AUTH = {"X-API-Key": os.environ["IMPACTFLOW_API_KEY"]}
|
||||
|
||||
|
||||
async def main():
|
||||
# Fresh DB every run so this is deterministic.
|
||||
db_path = "./data/smoke.db"
|
||||
if os.path.exists(db_path):
|
||||
os.remove(db_path)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test"
|
||||
) as c:
|
||||
r = await c.get("/health")
|
||||
assert r.status_code == 200 and r.json() == {"status": "ok"}, r.text
|
||||
print("health:", r.json())
|
||||
|
||||
# Auth: unauthenticated calls must 401.
|
||||
r = await c.get("/api/me")
|
||||
assert r.status_code == 401, r.text
|
||||
print("unauth /api/me -> 401 OK")
|
||||
|
||||
r = await c.post("/discovery/start")
|
||||
assert r.status_code == 401, r.text
|
||||
print("unauth /discovery/start -> 401 OK")
|
||||
|
||||
# Auth: X-API-Key resolves to the synthetic admin user.
|
||||
r = await c.get("/api/me", headers=AUTH)
|
||||
assert r.status_code == 200, r.text
|
||||
me = r.json()
|
||||
assert me["role"] == "admin"
|
||||
assert me["email"] == "api-key@impactflow.local"
|
||||
print("authed /api/me OK ->", me["display_name"])
|
||||
|
||||
# Bogus JWT must 401.
|
||||
r = await c.get(
|
||||
"/api/me",
|
||||
headers={"Authorization": "Bearer not-a-real-jwt"},
|
||||
)
|
||||
assert r.status_code == 401, r.text
|
||||
print("bogus JWT -> 401 OK")
|
||||
|
||||
# /api/auth/login should redirect to Google.
|
||||
r = await c.get("/api/auth/login", follow_redirects=False)
|
||||
assert r.status_code in (302, 303, 307), r.text
|
||||
assert "accounts.google.com" in r.headers.get("location", "")
|
||||
print("OAuth login redirects to:", r.headers["location"][:60], "...")
|
||||
|
||||
# Discovery flow under the API-key admin user.
|
||||
r = await c.post("/discovery/start", headers=AUTH)
|
||||
assert r.status_code == 200, r.text
|
||||
conv_id = r.json()["conversation_id"]
|
||||
print("start -> conversation_id:", conv_id)
|
||||
|
||||
body = {
|
||||
"prompt_alive": "I felt alive leading a flood rescue.",
|
||||
"prompt_friction": "I confronted a manager cutting workers' hours.",
|
||||
"prompt_pull": "I'm always building and fixing things.",
|
||||
"prompt_recognition": "Recognized for standing up for my crew.",
|
||||
"prompt_future": "I'd build a trades school for written-off kids.",
|
||||
}
|
||||
r = await c.put(
|
||||
f"/discovery/{conv_id}/respond", json=body, headers=AUTH
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "responses_saved"
|
||||
print("respond:", r.json())
|
||||
|
||||
r = await c.get(
|
||||
f"/discovery/conversation/{conv_id}", headers=AUTH
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["prompt_friction"] == body["prompt_friction"]
|
||||
print("conversation fetched, friction stored OK")
|
||||
|
||||
r = await c.post(f"/discovery/{conv_id}/complete", headers=AUTH)
|
||||
assert r.status_code == 200, r.text
|
||||
profile = r.json()
|
||||
assert profile["triad"] == "gut"
|
||||
assert profile["confidence"]["ikigai"] == "high"
|
||||
assert profile["locked"] is False
|
||||
print(
|
||||
"complete -> profile triad:",
|
||||
profile["triad"],
|
||||
"locked:",
|
||||
profile["locked"],
|
||||
)
|
||||
|
||||
r = await c.get("/discovery/profile/me", headers=AUTH)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["overlap_narrative"]
|
||||
print("get my profile OK")
|
||||
|
||||
r = await c.put("/discovery/profile/me/confirm", headers=AUTH)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "locked"
|
||||
print("confirm:", r.json())
|
||||
|
||||
r = await c.get("/discovery/profile/me", headers=AUTH)
|
||||
assert r.json()["locked"] is True
|
||||
print("profile now locked:", r.json()["locked"])
|
||||
|
||||
# 404 paths
|
||||
r = await c.post("/discovery/does-not-exist/complete", headers=AUTH)
|
||||
assert r.status_code == 404
|
||||
print("404 handling OK")
|
||||
|
||||
# Activity tracking — at this point we've made several authed,
|
||||
# 2xx requests, so the activity log should have entries.
|
||||
r = await c.get("/api/activity?limit=50", headers=AUTH)
|
||||
assert r.status_code == 200, r.text
|
||||
events = r.json()
|
||||
assert len(events) > 0, "expected activity log entries"
|
||||
assert all(e["source"] == "mcp" for e in events), (
|
||||
"API key requests must be tagged source=mcp"
|
||||
)
|
||||
print(f"activity feed has {len(events)} entries, all source=mcp OK")
|
||||
|
||||
r = await c.get("/api/activity/summary?days=7", headers=AUTH)
|
||||
assert r.status_code == 200, r.text
|
||||
summary = r.json()
|
||||
assert summary["total"] > 0
|
||||
assert summary["mcp_count"] > 0
|
||||
print("activity summary:", summary["total"], "events")
|
||||
|
||||
r = await c.get("/api/me/stats", headers=AUTH)
|
||||
assert r.status_code == 200, r.text
|
||||
stats = r.json()
|
||||
assert stats["conversations"] == 1
|
||||
assert stats["profiles"] == 1
|
||||
assert stats["locked_profiles"] == 1
|
||||
print("me/stats:", stats)
|
||||
|
||||
print("\nALL SMOKE CHECKS PASSED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"alive": "The flood hit our street in March and the city basically forgot we existed for four days. I stopped waiting for someone to tell me what to do — I grabbed my truck, started knocking on doors, and got the older folks on the block moved up to the church on the hill. By the second day I'd organized a rotation: who had a generator, who could cook, who needed insulin runs. People kept asking who put me in charge and the honest answer is nobody did. It just needed doing and I could see the whole picture, so I moved. I slept maybe six hours that whole stretch and I have never in my life felt more like myself. When you can actually protect people instead of just feeling bad for them, that's the realest thing there is.",
|
||||
"friction": "A regional manager at my old job started quietly cutting hours for the warehouse crew right before the holidays so the numbers would look good for his bonus — guys with kids, guys who'd been there fifteen years. Everyone in the office knew and everyone kept their heads down. I couldn't do it. I pulled the timesheets, documented the pattern, and walked into the district director's office without an appointment and laid it on his desk. It got messy. The manager came after me, tried to make me look like the problem. I didn't back down once. The hours got restored. I'd do it again tomorrow. I can stomach a lot of things but I cannot stomach someone with power stepping on people who can't fight back.",
|
||||
"pull": "I'm always fixing something or building something with my hands. Right now it's rebuilding the deck, before that it was getting an old motorcycle running. If I sit still too long I get restless and irritable, like there's energy I have to burn off. I also can't stop strategizing — I'll be doing dishes and find myself working out how I'd reorganize my buddy's failing business, who he needs to fire, what's actually broken. People come to me when stuff is falling apart because I don't freeze, I just start moving and figuring it out.",
|
||||
"recognition": "After the warehouse thing, one of the older guys, Reuben, pulled me aside in the parking lot. He didn't say much — he's not a talker — but he shook my hand hard and said, 'You're the only one who actually did something. The rest of them just talked.' That stuck with me more than any award would have. I've been called reliable, called a leader, but what I really care about being known for is that when it counted, I stood between my people and the thing coming at them and I didn't move.",
|
||||
"future": "I'd build a trades training outfit for kids who got written off — the ones who aren't going to college and got treated like they're stupid their whole lives. Teach them welding, electrical, plumbing, real skills nobody can take from them, and teach them they don't have to take garbage from anybody once they're good at something. Give them backbone and a paycheck. I'd want it to grow into something across the whole state. Not a charity that pats people on the head — a place that makes them strong enough to never need rescuing."
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"alive": "Our research group had been stuck for months on why a sensor array kept drifting, and everyone had moved on to blaming the hardware. I couldn't let it go. I spent two weeks quietly pulling every log we had, building a model of the failure on my own time, testing one hypothesis at a time until the pattern finally resolved — it was a thermal feedback loop nobody had considered. The moment the data lined up and I understood the whole mechanism, I felt this deep, quiet electricity. I didn't even want to tell anyone right away; I just wanted to sit with how elegant the answer was. Understanding something that had defeated everyone else is the most alive I ever feel.",
|
||||
"friction": "My old team kept making the same expensive mistake — shipping features without any real understanding of why the last three had flopped. It bothered me at a level that's hard to describe: it felt almost reckless, like flying blind on purpose. I didn't storm anyone's office. Instead I went away and built a careful analysis — pulled two years of data, mapped the actual causes, modeled what a disciplined process would have caught. Then I wrote it up and laid out the options. I'd rather understand a problem completely and present the evidence than react in the moment. Watching people act confidently on bad assumptions is the thing that unsettles me most.",
|
||||
"pull": "Left to my own devices I read constantly and go down rabbit holes — lately it's been information theory and how power grids actually balance load second to second. I take systems apart to see how they really work. I keep a sprawling notes file of questions I want to chase down. I'll tell myself I'm going to relax and then look up three hours later having taught myself something completely unrelated to my job. I need a certain amount of solitude to think, and I guard it carefully.",
|
||||
"recognition": "The moment I think about is when a senior engineer I deeply respected — someone famously stingy with praise — read an analysis I'd done and just said, 'This is exactly right, and nobody else here would have caught it.' Being recognized specifically for the depth and rigor of my thinking, for seeing what others missed, meant more to me than any general 'good job.' I don't need to be liked by everyone. I want a few people whose judgment I trust to know that I actually understand things at a level most people don't bother to reach.",
|
||||
"future": "I'd build an independent research and tooling lab focused on making complex systems legible — taking things that are genuinely hard to understand, like energy markets or supply chains, and creating models and tools that let people actually see how they work and reason about them clearly. No hype, no pressure to ship before the thinking is done. Just a small group of sharp people with the time and resources to understand important things properly and hand that understanding to the people who need it."
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"alive": "It was the night before my friend Dana's mom passed. Dana was falling apart and the rest of the family had sort of scattered, so I just stayed. I made tea nobody drank, I sat on the bathroom floor with her at 3am, I quietly handled the calls to the hospice and the cousins so she wouldn't have to. Nobody asked me to and I didn't make a thing of it. The next morning she looked at me and said, 'I don't know how I would have gotten through that without you,' and something in me just lit up. That's the feeling I chase, honestly — being the person who shows up so completely for someone that they feel held. When I'm doing that, I feel like the truest version of me.",
|
||||
"friction": "At my last company I poured myself into a launch — late nights, smoothing over every conflict, basically holding the team together emotionally. When it went well, my manager stood up in the all-hands and credited two other people by name and never mentioned me once. I smiled and clapped and then went to the stairwell and cried, which I'm a little embarrassed to admit. It wasn't about the title. It was that I had given so much of myself and it was like I'd been invisible the whole time. I didn't make a scene. I just started, carefully, making sure the right people privately knew what I'd actually done. Being overlooked after I've given everything is the thing that wounds me most.",
|
||||
"pull": "In my free time I'm almost always thinking about people — who's drifting and needs a check-in text, who I could introduce to who, how to make my next dinner feel warm so nobody sits there feeling like an outsider. I curate playlists for specific friends based on what they're going through. I remember everyone's hard anniversaries. My partner teases me that I run a one-woman social infrastructure. Honestly I'd rather plan a gathering that makes ten people feel loved than do almost anything else.",
|
||||
"recognition": "A few years ago the people I'd mentored at work secretly got together and wrote me letters — like a whole bound little book of them — for my birthday. One of them wrote that I was 'the first person who ever made her feel like she belonged somewhere.' I still have it in my nightstand. Being recognized for being warm or generous means infinitely more to me than being recognized for being smart or productive. I want to be known as the person who made others feel they mattered.",
|
||||
"future": "I'd build a place — physical, not an app — where people who feel alone can come and actually be received. Newcomers to a city, people after a divorce, older folks whose friends have died. Part community center, part living room. I'd train hosts whose entire job is to notice the person standing awkwardly by the wall and go pull them in. I want to spend the rest of my life making belonging something people can count on instead of something they have to get lucky to find."
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Tests for the auth module: dual-auth dependency, JWT issue/decode,
|
||||
domain allow-list. Uses a temp SQLite DB so it doesn't touch the real one.
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def app_client(tmp_path, monkeypatch):
|
||||
"""Spin up a fresh app with an isolated DB and known auth secrets."""
|
||||
db_path = tmp_path / "auth_test.db"
|
||||
monkeypatch.setenv(
|
||||
"DATABASE_URL", f"sqlite+aiosqlite:///{db_path}"
|
||||
)
|
||||
monkeypatch.setenv("JWT_SECRET", "test-jwt-secret")
|
||||
monkeypatch.setenv("IMPACTFLOW_API_KEY", "test-api-key")
|
||||
monkeypatch.setenv("GOOGLE_CLIENT_ID", "fake-client-id")
|
||||
monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "fake-client-secret")
|
||||
monkeypatch.setenv("ALLOWED_EMAIL_DOMAINS", "")
|
||||
|
||||
# Re-import in a way that picks up the patched env. The simplest way is
|
||||
# to clear modules that read env at import time.
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
for mod in list(sys.modules):
|
||||
if mod.startswith("app"):
|
||||
del sys.modules[mod]
|
||||
|
||||
from app import database
|
||||
importlib.reload(database)
|
||||
from app.database import Base, engine
|
||||
|
||||
from app.main import app
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test"
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
|
||||
async def test_unauthenticated_request_returns_401(app_client):
|
||||
r = await app_client.get("/api/me")
|
||||
assert r.status_code == 401
|
||||
assert r.json()["detail"] == "Not authenticated"
|
||||
|
||||
|
||||
async def test_api_key_resolves_to_admin(app_client):
|
||||
r = await app_client.get(
|
||||
"/api/me", headers={"X-API-Key": "test-api-key"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["role"] == "admin"
|
||||
assert body["id"] == "api-key-admin"
|
||||
|
||||
|
||||
async def test_wrong_api_key_is_rejected(app_client):
|
||||
r = await app_client.get(
|
||||
"/api/me", headers={"X-API-Key": "wrong-key"}
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
async def test_bogus_bearer_is_rejected(app_client):
|
||||
r = await app_client.get(
|
||||
"/api/me", headers={"Authorization": "Bearer not-a-jwt"}
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
async def test_valid_jwt_authenticates(app_client, tmp_path):
|
||||
"""Mint a JWT for a user we insert directly into the DB."""
|
||||
from datetime import datetime, timezone
|
||||
from app.auth import create_access_token
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import User
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
user = User(
|
||||
id="u-1",
|
||||
email="real@example.com",
|
||||
display_name="Real User",
|
||||
google_id="g-1",
|
||||
role="user",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
|
||||
token = create_access_token("u-1", "real@example.com")
|
||||
r = await app_client.get(
|
||||
"/api/me", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["email"] == "real@example.com"
|
||||
assert r.json()["role"] == "user"
|
||||
|
||||
|
||||
async def test_oauth_login_redirects_to_google(app_client):
|
||||
r = await app_client.get(
|
||||
"/api/auth/login", follow_redirects=False
|
||||
)
|
||||
assert r.status_code in (302, 303, 307)
|
||||
assert "accounts.google.com" in r.headers["location"]
|
||||
|
||||
|
||||
async def test_admin_only_endpoint_requires_admin(app_client):
|
||||
"""Regular users get 403 on /api/admin/activity."""
|
||||
from datetime import datetime, timezone
|
||||
from app.auth import create_access_token
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import User
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
db.add(User(
|
||||
id="u-regular",
|
||||
email="reg@example.com",
|
||||
display_name="Reg",
|
||||
google_id="g-reg",
|
||||
role="user",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
))
|
||||
await db.commit()
|
||||
token = create_access_token("u-regular", "reg@example.com")
|
||||
r = await app_client.get(
|
||||
"/api/admin/activity",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
# API-key admin should be allowed.
|
||||
r = await app_client.get(
|
||||
"/api/admin/activity",
|
||||
headers={"X-API-Key": "test-api-key"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_email_domain_allowlist(monkeypatch):
|
||||
from app.auth import email_domain_allowed
|
||||
|
||||
monkeypatch.setenv("ALLOWED_EMAIL_DOMAINS", "")
|
||||
assert email_domain_allowed("anyone@example.com")
|
||||
|
||||
monkeypatch.setenv("ALLOWED_EMAIL_DOMAINS", "computerim.com")
|
||||
assert email_domain_allowed("j@computerim.com")
|
||||
assert not email_domain_allowed("j@gmail.com")
|
||||
|
||||
monkeypatch.setenv("ALLOWED_EMAIL_DOMAINS", "a.com, b.com")
|
||||
assert email_domain_allowed("x@a.com")
|
||||
assert email_domain_allowed("y@B.COM")
|
||||
assert not email_domain_allowed("z@c.com")
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Tests for DiscoveryExtractor.
|
||||
|
||||
The Anthropic API is mocked: a FakeClient returns canned JSON so the suite is
|
||||
deterministic and runs without a network call or API key. The fixtures supply
|
||||
realistic narrative input, so these tests exercise the full extract() pipeline
|
||||
(message building, parsing, validation, and retry) end to end.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.extractor import (
|
||||
DiscoveryExtractionError,
|
||||
DiscoveryExtractor,
|
||||
)
|
||||
|
||||
FIXTURE_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
|
||||
|
||||
|
||||
def load_fixture(name: str) -> dict:
|
||||
with open(os.path.join(FIXTURE_DIR, name), encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
class FakeMessages:
|
||||
"""Stand-in for client.messages that returns queued responses."""
|
||||
|
||||
def __init__(self, responses):
|
||||
self._responses = list(responses)
|
||||
self.calls = []
|
||||
|
||||
async def create(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
if not self._responses:
|
||||
raise AssertionError("messages.create called more times than queued")
|
||||
text = self._responses.pop(0)
|
||||
return SimpleNamespace(content=[SimpleNamespace(text=text)])
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, responses):
|
||||
self.messages = FakeMessages(responses)
|
||||
|
||||
|
||||
def make_profile_json(
|
||||
triad: str,
|
||||
probable_type: int,
|
||||
wing: int,
|
||||
variant: str = "sp",
|
||||
stack: str = "sp/so/sx",
|
||||
) -> str:
|
||||
"""Build a well-formed profile JSON string for the given archetype."""
|
||||
return json.dumps(
|
||||
{
|
||||
"triad": triad,
|
||||
"probable_type": probable_type,
|
||||
"wing": wing,
|
||||
"instinctual_variant": variant,
|
||||
"instinctual_stack": stack,
|
||||
"love_summary": "You light up around hands-on, purposeful work.",
|
||||
"strength_summary": "You see the whole picture and act decisively.",
|
||||
"mission_summary": "People around you need protection and clarity.",
|
||||
"vocation_summary": "You can be paid to lead and build under pressure.",
|
||||
"overlap_narrative": (
|
||||
"You come most alive where your instinct to act, your eye for "
|
||||
"what's broken, and the world's need for someone steady all "
|
||||
"meet. The work that fits you lets you move first and bring "
|
||||
"others with you."
|
||||
),
|
||||
"confidence": {
|
||||
"triad": "high",
|
||||
"type": "medium",
|
||||
"variant": "medium",
|
||||
"ikigai": "high",
|
||||
},
|
||||
"extraction_notes": "",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def make_extractor(responses) -> DiscoveryExtractor:
|
||||
extractor = DiscoveryExtractor(api_key="test-key")
|
||||
extractor.client = FakeClient(responses)
|
||||
return extractor
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gut_type_extraction():
|
||||
responses = load_fixture("gut_type_responses.json")
|
||||
extractor = make_extractor([make_profile_json("gut", 8, 9)])
|
||||
|
||||
result = await extractor.extract(responses)
|
||||
|
||||
assert result["triad"] == "gut"
|
||||
assert result["probable_type"] in (8, 9, 1)
|
||||
# the model was actually called and the friction story was in the prompt
|
||||
sent = extractor.client.messages.calls[0]["messages"][0]["content"]
|
||||
assert "The Friction Moment" in sent
|
||||
assert "regional manager" in sent
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heart_type_extraction():
|
||||
responses = load_fixture("heart_type_responses.json")
|
||||
extractor = make_extractor([make_profile_json("heart", 2, 3)])
|
||||
|
||||
result = await extractor.extract(responses)
|
||||
|
||||
assert result["triad"] == "heart"
|
||||
assert result["probable_type"] in (2, 3, 4)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_head_type_extraction():
|
||||
responses = load_fixture("head_type_responses.json")
|
||||
extractor = make_extractor([make_profile_json("head", 5, 6)])
|
||||
|
||||
result = await extractor.extract(responses)
|
||||
|
||||
assert result["triad"] == "head"
|
||||
assert result["probable_type"] in (5, 6, 7)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_json_parse_failure_retry():
|
||||
"""An invalid first response triggers exactly one retry, then succeeds."""
|
||||
valid = make_profile_json("head", 5, 6)
|
||||
extractor = make_extractor(["here is your profile: not-json!!!", valid])
|
||||
responses = load_fixture("head_type_responses.json")
|
||||
|
||||
result = await extractor.extract(responses)
|
||||
|
||||
assert result["triad"] == "head"
|
||||
assert len(extractor.client.messages.calls) == 2
|
||||
# the retry message includes an explicit JSON-only reminder
|
||||
retry_content = extractor.client.messages.calls[1]["messages"][0]["content"]
|
||||
assert "JSON" in retry_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_exhausted_raises():
|
||||
"""Two unparseable responses surface a DiscoveryExtractionError."""
|
||||
extractor = make_extractor(["nope", "still not json"])
|
||||
with pytest.raises(DiscoveryExtractionError):
|
||||
await extractor.extract(load_fixture("gut_type_responses.json"))
|
||||
assert len(extractor.client.messages.calls) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overlap_narrative_present():
|
||||
cases = [
|
||||
("gut_type_responses.json", "gut", 8, 9),
|
||||
("heart_type_responses.json", "heart", 2, 3),
|
||||
("head_type_responses.json", "head", 5, 6),
|
||||
]
|
||||
for fixture, triad, ptype, wing in cases:
|
||||
extractor = make_extractor([make_profile_json(triad, ptype, wing)])
|
||||
result = await extractor.extract(load_fixture(fixture))
|
||||
assert isinstance(result["overlap_narrative"], str)
|
||||
assert result["overlap_narrative"].strip() != ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confidence_flags_present():
|
||||
extractor = make_extractor([make_profile_json("heart", 3, 2)])
|
||||
result = await extractor.extract(load_fixture("heart_type_responses.json"))
|
||||
|
||||
confidence = result["confidence"]
|
||||
for key in ("triad", "type", "variant", "ikigai"):
|
||||
assert key in confidence
|
||||
@@ -0,0 +1,53 @@
|
||||
import sqlite3
|
||||
|
||||
from app.migration_bootstrap import stamp_existing_sqlite_schema
|
||||
|
||||
|
||||
def test_stamps_existing_sqlite_schema_without_alembic_version(tmp_path):
|
||||
db_path = tmp_path / "discovery.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("CREATE TABLE discovery_conversation (id TEXT PRIMARY KEY)")
|
||||
conn.execute("CREATE TABLE discovery_profile (id TEXT PRIMARY KEY)")
|
||||
|
||||
stamped = stamp_existing_sqlite_schema(
|
||||
f"sqlite+aiosqlite:///{db_path}", revision="001"
|
||||
)
|
||||
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
version = conn.execute(
|
||||
"SELECT version_num FROM alembic_version"
|
||||
).fetchone()[0]
|
||||
|
||||
assert stamped is True
|
||||
assert version == "001"
|
||||
|
||||
|
||||
def test_does_not_stamp_empty_sqlite_database(tmp_path):
|
||||
db_path = tmp_path / "empty.db"
|
||||
|
||||
stamped = stamp_existing_sqlite_schema(
|
||||
f"sqlite+aiosqlite:///{db_path}", revision="001"
|
||||
)
|
||||
|
||||
assert stamped is False
|
||||
assert not db_path.exists()
|
||||
|
||||
|
||||
def test_stamps_existing_schema_with_empty_alembic_version(tmp_path):
|
||||
db_path = tmp_path / "discovery.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("CREATE TABLE discovery_conversation (id TEXT PRIMARY KEY)")
|
||||
conn.execute("CREATE TABLE discovery_profile (id TEXT PRIMARY KEY)")
|
||||
conn.execute("CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)")
|
||||
|
||||
stamped = stamp_existing_sqlite_schema(
|
||||
f"sqlite+aiosqlite:///{db_path}", revision="001"
|
||||
)
|
||||
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
versions = conn.execute(
|
||||
"SELECT version_num FROM alembic_version"
|
||||
).fetchall()
|
||||
|
||||
assert stamped is True
|
||||
assert versions == [("001",)]
|
||||
@@ -0,0 +1,9 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_discovery_page_has_insecure_context_uuid_fallback():
|
||||
html = Path("app/static/discovery.html").read_text(encoding="utf-8")
|
||||
|
||||
assert "function createUserId()" in html
|
||||
assert "crypto.randomUUID()" not in html
|
||||
assert "getRandomValues" in html
|
||||
Reference in New Issue
Block a user