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:
Joel Salmon
2026-05-27 10:59:41 -05:00
commit b8f176bb31
45 changed files with 4679 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
.env
.venv/
venv/
__pycache__/
*.pyc
.pytest_cache/
*.egg-info/
data/*.db
data/*.db-journal
data/*.log
data/*.err.log
+29
View File
@@ -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
View File
@@ -0,0 +1,11 @@
__pycache__/
*.pyc
.venv/
venv/
.env
data/*.db
data/*.db-journal
data/*.log
data/*.err.log
.pytest_cache/
*.egg-info/
+17
View File
@@ -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"]
+465
View File
@@ -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 &amp; 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">&#9654;</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> &rarr; APIs &amp; Services &rarr; 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 &amp; Multi-User Data</span>
<span class="phase-est">~0.5 day</span>
<span class="chevron">&#9654;</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">&#9654;</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 &amp; Polish</span>
<span class="phase-est">~1 day</span>
<span class="chevron">&#9654;</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> &mdash;
<span class="removed">Password hashing (bcrypt)</span> &mdash;
<span class="removed">Password reset flow</span> &mdash;
<span class="removed">Email verification</span> &mdash;
<span class="removed">Brute-force protection on login</span> &mdash;
<span class="removed">Account recovery</span> &mdash;
<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>
+723
View File
@@ -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
View File
@@ -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
+68
View File
@@ -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()
+26
View File
@@ -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"}
+80
View File
@@ -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")
+104
View File
@@ -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")
View File
+230
View File
@@ -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
+53
View File
@@ -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
View File
@@ -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")
+70
View File
@@ -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
View File
@@ -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
)
View File
+135
View File
@@ -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)
+288
View File
@@ -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"}
+230
View File
@@ -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
+67
View File
@@ -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}
View File
+214
View File
@@ -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
+223
View File
@@ -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>
+157
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
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>
+295
View File
@@ -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
View File
@@ -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
View File
+10
View File
@@ -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
+27
View File
@@ -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."
+45
View File
@@ -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`.
+4
View File
@@ -0,0 +1,4 @@
[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
testpaths = tests
+15
View File
@@ -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
+67
View File
@@ -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"
+47
View File
@@ -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
View File
@@ -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())
View File
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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."
}
+159
View File
@@ -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")
+172
View File
@@ -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
+53
View File
@@ -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",)]
+9
View File
@@ -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