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