mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 06:20:36 +00:00
b8f176bb31
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>
133 lines
5.3 KiB
Python
133 lines
5.3 KiB
Python
"""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
|
|
)
|