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
+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;
}
}