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
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