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
+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")