Complete Phase 1: goals, cookie auth, profile editing

Close the remaining Phase 1 DoD gaps and reconcile the browser flow with
the auth layer.

Goals (5 -> 7 prompts):
- Add near-term (6-12mo) and long-term (3-5yr) goal prompts; collect raw
  text on the conversation and store AI-articulated goal summaries on the
  profile. Extractor articulates the person's own stated goals (mirror,
  not compass) and never fabricates. Alembic 003 adds the four columns.

Cookie-based browser sessions (fixes frontend<->auth desync):
- OAuth callback now sets httpOnly session cookies and redirects into the
  app instead of returning JSON. get_current_user gains a cookie fallback
  (X-API-Key -> Bearer -> cookie). refresh/logout read the refresh cookie
  and set/clear cookies. New shared auth.js (authedFetch) sends cookies and
  silently refreshes on 401. Static pages drop the bogus user_id and call
  the correct /me endpoints.

Profile editing (read/edit/affirm):
- PATCH /discovery/profile/me edits the prose (Ikigai summaries, overlap
  narrative, goals); owner-scoped, partial update, 409 when locked. Edit
  mode in profile.html with Save/Cancel.

Also: bump default model to claude-sonnet-4-6, align ports to 8011
(OAuth redirect, CORS), add COOKIE_SECURE/POST_LOGIN_REDIRECT config, and
refresh the README to match the shipped behavior.

Tests: 33 passing (added cookie-auth, profile-edit, goal-extraction cases;
factored a shared app_client fixture into conftest.py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joel Salmon
2026-06-15 18:26:08 -05:00
parent b8f176bb31
commit 33674f92f4
22 changed files with 958 additions and 244 deletions
+10 -3
View File
@@ -4,13 +4,13 @@ HOST_BIND_IP=0.0.0.0
HOST_PORT=8011
# Optional: override the Anthropic model used for extraction
ANTHROPIC_MODEL=claude-sonnet-4-5
ANTHROPIC_MODEL=claude-sonnet-4-6
# Google OAuth (web client type). Register the redirect URI below as an
# authorized redirect URI in the Google Cloud Console for this client.
GOOGLE_CLIENT_ID=xxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxx
OAUTH_REDIRECT_URI=http://localhost:8000/api/auth/callback
OAUTH_REDIRECT_URI=http://localhost:8011/api/auth/callback
# Random 64-char URL-safe strings. Generate with:
# python -c "import secrets; print(secrets.token_urlsafe(48))"
@@ -21,8 +21,15 @@ IMPACTFLOW_API_KEY=generate-another-random-64-char-string
JWT_ACCESS_MINUTES=15
JWT_REFRESH_DAYS=7
# Browser session cookies. COOKIE_SECURE must be true in production (HTTPS);
# set it false ONLY for local http://localhost dev, where Secure cookies are
# never sent. POST_LOGIN_REDIRECT is where the OAuth callback lands the
# browser after setting the session cookies.
COOKIE_SECURE=false
POST_LOGIN_REDIRECT=/static/discovery.html
# Comma-separated allow-list of browser origins for CORS.
CORS_ALLOWED_ORIGINS=http://localhost:8000,http://100.103.206.4:8000
CORS_ALLOWED_ORIGINS=http://localhost:8011,http://100.103.206.4:8011
# Optional comma-separated email-domain allow-list (e.g. "computerim.com").
# Empty means any verified Google email is accepted.
+134 -74
View File
@@ -1,7 +1,8 @@
# ImpactFlow Self-Discovery Module
ImpactFlow Self-Discovery is a standalone FastAPI service that turns five
short reflection stories into a structured Enneagram + Ikigai profile. It is
ImpactFlow Self-Discovery is a standalone FastAPI service that turns seven
short reflection responses into a structured Enneagram + Ikigai + goals
profile. It is
designed to run independently from the main ImpactFlow app on port `8011` and
can be integrated into the larger product later.
@@ -15,24 +16,27 @@ If you need to explain this app in detail, use this mental model:
1. A user opens `/static/discovery.html`.
2. The browser sends them through Google sign-in at `GET /api/auth/login`;
`GET /api/auth/callback` mints an access JWT + refresh token and the
browser stores the access JWT for subsequent requests.
3. With the JWT in the `Authorization: Bearer …` header, the browser starts
a discovery conversation with `POST /discovery/start` the backend
derives the `user_id` from the JWT, not from the request body.
4. The user answers five open-ended prompts.
5. The browser saves all five answers with
`GET /api/auth/callback` mints an access + refresh token, sets them as
httpOnly cookies, and redirects back into the app. The browser holds no
tokens itself — the cookies are sent automatically on later requests.
3. The browser starts a discovery conversation with `POST /discovery/start`
the backend derives the `user_id` from the session cookie, not the body.
4. The user answers seven open-ended prompts: five discovery prompts plus a
near-term (612 month) and a long-term (35 year) goal prompt.
5. The browser saves all answers with
`PUT /discovery/{conversation_id}/respond`.
6. The browser asks the backend to analyze the saved answers with
`POST /discovery/{conversation_id}/complete`.
7. The backend calls Anthropic through `DiscoveryExtractor`.
8. The extractor asks for JSON containing Enneagram, instinctual variant,
Ikigai summaries, confidence flags, and optional extraction notes.
Ikigai summaries, articulated short- and long-term goals, confidence flags,
and optional extraction notes.
9. The backend stores that JSON as a `DiscoveryProfile` row owned by the
authenticated user.
10. The browser redirects to `/static/profile.html`.
11. The profile page loads the newest profile with
`GET /discovery/profile/me` and lets the user lock it with
11. The profile page loads the newest profile with `GET /discovery/profile/me`.
While it is unlocked the user can revise their words with
`PATCH /discovery/profile/me`, then lock it with
`PUT /discovery/profile/me/confirm`.
Machine-to-machine callers (e.g. the MCP server) skip the OAuth dance and
@@ -42,12 +46,13 @@ valid and admin-only routes work without a real Google sign-in.
The app is intentionally small: static HTML/CSS for the UI, FastAPI for the
API, async SQLAlchemy for persistence, Alembic for migrations, SQLite for local
storage, Google OAuth + JWT for browser auth, and Anthropic for the analysis
storage, Google OAuth + httpOnly cookie sessions for browser auth, and Anthropic for the analysis
step.
## What The App Does
The module collects five narrative prompts:
The module collects seven narrative prompts — five discovery prompts plus two
goal prompts:
| Stored field | User-facing prompt | Purpose in extraction |
| --- | --- | --- |
@@ -56,6 +61,8 @@ The module collects five narrative prompts:
| `prompt_pull` | The Natural Pull | Helps infer instinctual variant and recurring interests |
| `prompt_recognition` | The Recognition Moment | Reveals what the person values being seen for |
| `prompt_future` | The Future Pull | Helps infer mission, vocation, and ideal future direction |
| `prompt_goals_short` | The Near Horizon | The person's own near-term (612 month) goals, articulated back |
| `prompt_goals_long` | The Long Horizon | The person's own long-term (35 year) goals, articulated back |
The generated profile includes:
@@ -71,6 +78,8 @@ The generated profile includes:
| `mission_summary` | Ikigai: what the world needs from the user |
| `vocation_summary` | Ikigai: what the user can be paid for |
| `overlap_narrative` | Plain-language convergence narrative |
| `short_term_goals` | The user's own near-term (612 month) goals, articulated back |
| `long_term_goals` | The user's own long-term (35 year) goals, articulated back |
| `confidence` | Confidence flags for triad, type, variant, and Ikigai |
| `extraction_notes` | Optional ambiguity or caveat from the model |
| `locked` | Whether the user has confirmed the profile |
@@ -87,7 +96,7 @@ Browser static UI MCP server / other machines
profile.html
| |
| Google OAuth + |
| Bearer JWT |
| session cookie |
v v
+--------------------------------------------+
| FastAPI app |
@@ -126,27 +135,33 @@ Important files:
| `app/database.py` | Async database engine, session factory, SQLite directory setup |
| `app/services/extractor.py` | Anthropic client wrapper, prompt, JSON parsing, retry logic |
| `app/migration_bootstrap.py` | Stamps pre-Alembic SQLite DBs as revision `001` so `alembic upgrade head` succeeds on older local databases |
| `app/static/discovery.html` | Browser-based five-prompt flow |
| `app/static/profile.html` | Browser-based profile display and confirm action |
| `app/static/discovery.html` | Browser-based seven-prompt flow |
| `app/static/profile.html` | Browser-based profile display, edit, and confirm actions |
| `app/static/auth.js` | Shared `authedFetch` helper: sends session cookies, silently refreshes on `401`, redirects to login |
| `app/static/style.css` | Shared UI styling |
| `alembic/versions/001_initial.py` | Initial database schema migration |
| `alembic/versions/002_add_auth.py` | Adds `users`, `refresh_tokens`, and `activity_log` tables |
| `tests/test_extractor.py` | Unit tests for extraction plumbing and retry behavior |
| `tests/test_auth.py` | Tests for the dual-auth dependency, JWT minting/decode, admin enforcement, and domain allow-list |
| `alembic/versions/003_add_goals.py` | Adds the goal columns to `discovery_conversation` and `discovery_profile` |
| `tests/conftest.py` | Shared `app_client` fixture (isolated app + temp DB) |
| `tests/test_extractor.py` | Unit tests for extraction plumbing, goals, and retry behavior |
| `tests/test_auth.py` | Tests for the dual-auth dependency (JWT + cookie + API key), token refresh/logout, admin enforcement, and domain allow-list |
| `tests/test_profile_edit.py` | Tests for `PATCH /discovery/profile/me` (edit, partial update, lock/`409`) |
| `tests/test_migration_bootstrap.py` | Unit tests for the pre-Alembic SQLite stamping helper |
| `tests/test_static_discovery.py` | Guard test for the insecure-context UUID fallback in `discovery.html` |
| `tests/fixtures/{gut,head,heart}_type_responses.json` | Synthetic five-prompt responses used by extractor tests |
| `tests/test_static_discovery.py` | Guard tests for the static pages' cookie-session and edit contract |
| `tests/fixtures/{gut,head,heart}_type_responses.json` | Synthetic seven-prompt responses used by extractor tests |
| `smoke_test.py` | In-process end-to-end API smoke test with fake extraction; also exercises the auth and activity tracking surface |
## Authentication
The API supports two ways to authenticate, both resolved by a single
`get_current_user` dependency in `app/auth.py`:
The `get_current_user` dependency in `app/auth.py` resolves a request from
three credential sources, tried in order: `X-API-Key`, then an
`Authorization: Bearer` access token, then the session cookie.
| Caller | Mechanism | Notes |
| --- | --- | --- |
| Browser users | Google OAuth + signed JWT in `Authorization: Bearer …` | Issued by `/api/auth/callback` after a successful Google sign-in |
| Machine-to-machine (MCP server, scripts) | `X-API-Key: $IMPACTFLOW_API_KEY` | Resolves to a synthetic admin user `api-key-admin` so FK constraints stay valid |
| Browser users | Google OAuth → httpOnly session cookies | Set by `/api/auth/callback`; the access cookie is refreshed silently via `/api/auth/refresh` |
| Scripts / API clients | `Authorization: Bearer <access token>` | The same access token presented manually instead of via cookie |
| Machine-to-machine (MCP server) | `X-API-Key: $IMPACTFLOW_API_KEY` | Resolves to a synthetic admin user `api-key-admin` so FK constraints stay valid |
The first real Google user to sign in is auto-promoted to `role=admin`;
every subsequent user defaults to `role=user`. Admin-only routes (e.g.
@@ -157,8 +172,8 @@ every subsequent user defaults to `role=user`. Admin-only routes (e.g.
1. In <https://console.cloud.google.com> open APIs & Services → Credentials.
2. Create an **OAuth 2.0 Client ID** of type **Web application**.
3. Add authorized redirect URIs that match `OAUTH_REDIRECT_URI` in `.env`:
- `http://localhost:8000/api/auth/callback` for local dev
- `http://<deploy-host>:8000/api/auth/callback` for the deployed instance
- `http://localhost:8011/api/auth/callback` for local dev
- `http://<deploy-host>:8011/api/auth/callback` for the deployed instance
4. Copy the client id and client secret into `.env` as `GOOGLE_CLIENT_ID` and
`GOOGLE_CLIENT_SECRET`.
5. Generate two random 64-character strings and put them in `.env` as
@@ -240,8 +255,9 @@ step above is what populates the persistent database.
### 2. Starting A Conversation
`POST /discovery/start` requires authentication (Bearer JWT or `X-API-Key`).
It takes no body — the `user_id` is derived from the authenticated user.
`POST /discovery/start` requires authentication (session cookie, Bearer token,
or `X-API-Key`). It takes no body — the `user_id` is derived from the
authenticated user.
It creates a `DiscoveryConversation` row with:
@@ -258,12 +274,13 @@ It returns:
}
```
The static UI starts this conversation after the browser has a JWT from the
OAuth callback, and retries on submit if the first start call failed.
The static UI starts this conversation once the browser has a session cookie
from the OAuth callback, and retries on submit if the first start call failed.
### 3. Saving Answers
`PUT /discovery/{conversation_id}/respond` accepts all five prompt responses:
`PUT /discovery/{conversation_id}/respond` accepts all seven prompt responses
(five discovery prompts plus the two goal prompts):
```json
{
@@ -271,10 +288,14 @@ OAuth callback, and retries on submit if the first start call failed.
"prompt_friction": "Something felt wrong when...",
"prompt_pull": "I naturally keep returning to...",
"prompt_recognition": "I felt seen when...",
"prompt_future": "If I could not fail..."
"prompt_future": "If I could not fail...",
"prompt_goals_short": "In the next 612 months I want to...",
"prompt_goals_long": "In the next 35 years I want to..."
}
```
All fields default to empty, so a partial save is accepted.
It stores the responses on the existing conversation and returns:
```json
@@ -290,12 +311,13 @@ If the conversation id does not exist, it returns `404`.
`POST /discovery/{conversation_id}/complete` loads the conversation, builds a
compact response dictionary with keys `alive`, `friction`, `pull`,
`recognition`, and `future`, then calls `DiscoveryExtractor.extract()`.
`recognition`, `future`, `goals_short`, and `goals_long`, then calls
`DiscoveryExtractor.extract()`.
The route rejects completion with:
- `404` if the conversation does not exist
- `400` if all five responses are blank
- `400` if all seven responses are blank
- `502` if Anthropic extraction fails or returns unusable output after retry
On success, it stores a new `DiscoveryProfile`, marks the conversation
@@ -309,28 +331,46 @@ user by descending `generated_at`.
The profile page uses this route after redirect. This means one user can have
multiple completed conversations, but the UI always displays the latest one.
### 6. Confirming The Profile
### 6. Editing The Profile
`PATCH /discovery/profile/me` edits the prose of the newest profile. The body
is a partial update — only the fields supplied are changed — over the seven
editable text fields: `love_summary`, `strength_summary`, `mission_summary`,
`vocation_summary`, `overlap_narrative`, `short_term_goals`, and
`long_term_goals`. The AI's structural read (`triad`, `probable_type`, `wing`,
`instinctual_variant`, `instinctual_stack`) and confidence are not editable
here.
It rejects with:
- `404` if the user has no profile
- `409` if the profile is locked (affirming makes it final)
- `400` if the body contains no editable fields
### 7. Confirming The Profile
`PUT /discovery/profile/me/confirm` locks the newest profile by setting
`locked = true`.
This is the current confirmation mechanism for "This is me" on the profile
page. It does not prevent future conversations from generating newer profiles.
`locked = true`. This is the "This is me" affirmation on the profile page;
once locked, the profile can no longer be edited (`PATCH` returns `409`). It
does not prevent future conversations from generating newer profiles.
## API Reference
All `/discovery/*`, `/api/me*`, `/api/activity*`, and `/api/admin/*` routes
require authentication (Bearer JWT or `X-API-Key`). `/api/auth/login`,
`/api/auth/callback`, `/health`, `/`, and `/static/*` are public.
require authentication. The browser is authenticated by the session cookie set
at the OAuth callback; machine callers send `X-API-Key`. (A raw
`Authorization: Bearer` access token is also still accepted, e.g. for scripted
clients.) `/api/auth/login`, `/api/auth/callback`, `/health`, `/`, and
`/static/*` are public.
| Method | Path | Auth | Purpose |
| --- | --- | --- | --- |
| `GET` | `/` | public | Redirects to `/static/discovery.html` |
| `GET` | `/health` | public | Liveness check, returns `{"status": "ok"}` |
| `GET` | `/api/auth/login` | public | 302 to Google consent screen |
| `GET` | `/api/auth/callback` | public | OAuth callback; issues `{access_token, refresh_token, user}` |
| `POST` | `/api/auth/refresh` | public (token in body) | Exchange refresh token for a new access token |
| `POST` | `/api/auth/logout` | public (token in body) | Revoke a refresh token |
| `GET` | `/api/auth/callback` | public | OAuth callback; sets session cookies and redirects into the app |
| `POST` | `/api/auth/refresh` | public (refresh cookie or body) | Mint a new access token; refreshes the access cookie |
| `POST` | `/api/auth/logout` | public (refresh cookie or body) | Revoke the refresh token and clear session cookies |
| `GET` | `/api/me`, `/api/auth/me` | yes | Current user profile |
| `PATCH` | `/api/me` | yes | Update `display_name` |
| `GET` | `/api/me/stats` | yes | Per-user usage stats |
@@ -340,9 +380,10 @@ require authentication (Bearer JWT or `X-API-Key`). `/api/auth/login`,
| `GET` | `/api/activity/summary` | yes | Aggregate activity stats |
| `GET` | `/api/admin/activity` | admin | All-users activity feed |
| `POST` | `/discovery/start` | yes | Begin a conversation (user derived from auth) |
| `PUT` | `/discovery/{conversation_id}/respond` | yes | Save all five responses |
| `PUT` | `/discovery/{conversation_id}/respond` | yes | Save all seven responses |
| `POST` | `/discovery/{conversation_id}/complete` | yes | Run extraction, store profile, return profile |
| `GET` | `/discovery/profile/me` | yes | Fetch newest profile for the authenticated user |
| `PATCH` | `/discovery/profile/me` | yes | Edit the newest profile's prose (`409` if locked) |
| `PUT` | `/discovery/profile/me/confirm` | yes | Lock newest profile for the authenticated user |
| `GET` | `/discovery/conversation/{conversation_id}` | yes | Fetch stored conversation responses (owner only) |
@@ -395,7 +436,7 @@ Append-only audit trail. Pruned to 90 days on app startup.
### `discovery_conversation`
Stores one five-prompt response set.
Stores one seven-prompt response set.
| Column | Type | Notes |
| --- | --- | --- |
@@ -408,6 +449,8 @@ Stores one five-prompt response set.
| `prompt_pull` | text nullable | Third narrative response |
| `prompt_recognition` | text nullable | Fourth narrative response |
| `prompt_future` | text nullable | Fifth narrative response |
| `prompt_goals_short` | text nullable | Near-term (612 month) goal response |
| `prompt_goals_long` | text nullable | Long-term (35 year) goal response |
### `discovery_profile`
@@ -429,6 +472,8 @@ Stores one extracted profile for one conversation.
| `mission_summary` | text nullable | Ikigai mission summary |
| `vocation_summary` | text nullable | Ikigai vocation summary |
| `overlap_narrative` | text nullable | Convergence narrative |
| `short_term_goals` | text nullable | Articulated near-term (612 month) goals |
| `long_term_goals` | text nullable | Articulated long-term (35 year) goals |
| `confidence_json` | text nullable | JSON string for confidence flags |
| `locked` | boolean | Defaults to false |
@@ -438,7 +483,7 @@ Stores one extracted profile for one conversation.
logic hidden elsewhere. It:
1. Validates that `ANTHROPIC_API_KEY` exists.
2. Builds one user message from the five responses.
2. Builds one user message from the seven responses.
3. Calls Anthropic with `SYSTEM_PROMPT`.
4. Parses the model response as JSON.
5. Strips Markdown code fences if present.
@@ -453,7 +498,7 @@ Default extraction settings:
| Setting | Value |
| --- | --- |
| Default model | `claude-sonnet-4-5` |
| Default model | `claude-sonnet-4-6` |
| Max tokens | `2000` |
| Required response type | Single JSON object |
| Retry count | One retry after invalid JSON or missing keys |
@@ -467,30 +512,44 @@ The system prompt instructs the model to infer:
## Frontend Behavior
The frontend is static HTML with embedded JavaScript.
The frontend is static HTML with embedded JavaScript, served from `/static`.
The browser session is a server-set httpOnly cookie (issued by the OAuth
callback), so the pages hold no tokens of their own.
`auth.js` (shared helper):
- `authedFetch()` sends every request with `credentials: "include"` so the
session cookies ride along
- on a `401` it makes a single silent `POST /api/auth/refresh` (the refresh
cookie is scoped to `/api/auth`) and retries the original request
- if the refresh also fails, it redirects to `GET /api/auth/login` — which is
also how an unauthenticated first visit gets bounced through Google sign-in
`discovery.html`:
- stores a generated `impactflow_user_id` in `localStorage`
- generates that id via `createUserId()`, which prefers `crypto.randomUUID()`
when available and falls back to `crypto.getRandomValues()` so the flow
still works in insecure contexts (e.g. plain `http://` over LAN)
- shows five prompts one at a time
- shows the seven prompts one at a time (five discovery prompts plus near-term
and long-term goal prompts)
- keeps answers in memory while navigating back and next
- starts a conversation on page load
- saves all responses on submit
- triggers extraction
- redirects to the profile page on success
- starts a conversation on page load via `authedFetch("/discovery/start")`
- saves all responses on submit (`PUT /discovery/{id}/respond`), then triggers
extraction (`POST /discovery/{id}/complete`)
- redirects to `/static/profile.html` on success — no user id in the URL, since
the backend derives the user from the session cookie
- shows an error box and reload button if submission fails
`profile.html`:
- reads `user_id` from the query string
- fetches the newest profile for that user
- fetches the authenticated user's newest profile via `GET /discovery/profile/me`
- escapes all model-generated text before rendering
- shows Ikigai cards and a triad description
- uses confidence dots for triad and Ikigai confidence
- sends the confirm request when the user clicks "This is me"
- shows the overlap narrative, Ikigai cards, a triad description, and the
person's near-term/long-term goals, with confidence dots for triad and Ikigai
- while the profile is unlocked, offers two actions: "Edit my words" and
"This is me"
- edit mode turns the narrative, the four Ikigai summaries, and both goal
fields into textareas and saves with `PATCH /discovery/profile/me`; the AI's
structural read (triad/type/wing/variant) is shown but not editable here
- "This is me" locks the profile with `PUT /discovery/profile/me/confirm`; a
locked profile shows the confirmed state and no longer offers edit
## Configuration
@@ -513,15 +572,17 @@ Environment variables:
| `DATABASE_URL` | `sqlite+aiosqlite:///./data/discovery.db` | Async SQLAlchemy database URL |
| `HOST_BIND_IP` | `0.0.0.0` | Docker host IP for publishing port `8011`; use this when Docker runs inside WSL |
| `HOST_PORT` | `8011` | WSL/Docker host port; Windows portproxy exposes the same port |
| `ANTHROPIC_MODEL` | `claude-sonnet-4-5` | Override extraction model |
| `ANTHROPIC_MODEL` | `claude-sonnet-4-6` | Override extraction model |
| `GOOGLE_CLIENT_ID` | required for browser auth | OAuth 2.0 client id from Google Cloud Console |
| `GOOGLE_CLIENT_SECRET` | required for browser auth | OAuth 2.0 client secret |
| `OAUTH_REDIRECT_URI` | derived from request | Override the callback URL Google redirects to; must be registered in the Cloud Console |
| `JWT_SECRET` | required | HS256 signing key for access tokens; also used for the OAuth `state` session cookie |
| `JWT_ACCESS_MINUTES` | `15` | Access-token lifetime |
| `JWT_REFRESH_DAYS` | `7` | Refresh-token lifetime |
| `COOKIE_SECURE` | `true` | Set `false` for local `http://localhost` dev (Secure cookies aren't sent over plain http); must be `true` in production over HTTPS |
| `POST_LOGIN_REDIRECT` | `/static/discovery.html` | Path the OAuth callback redirects to after setting the session cookies |
| `IMPACTFLOW_API_KEY` | required for MCP/machine auth | Header value for `X-API-Key`; resolves to the synthetic admin user |
| `CORS_ALLOWED_ORIGINS` | `http://localhost:8000` | Comma-separated allow-list of browser origins |
| `CORS_ALLOWED_ORIGINS` | `http://localhost:8011` | Comma-separated allow-list of browser origins |
| `ALLOWED_EMAIL_DOMAINS` | empty (any) | Comma-separated allow-list of email domains; empty means accept any verified Google email |
`app/database.py` creates the SQLite directory automatically when the URL uses
@@ -648,20 +709,19 @@ reload, except for the browser's normal form restoration behavior.
## Integration Notes
This service is ready to be called from a larger ImpactFlow app. The
authenticated user identity now comes from Google OAuth on the browser side
and `X-API-Key` on the machine-to-machine side; the per-call `user_id` body
parameter is gone.
authenticated user identity comes from Google OAuth (httpOnly session cookies)
on the browser side and `X-API-Key` on the machine-to-machine side; the
per-call `user_id` body parameter is gone.
Likely integration points:
- route users into `/static/discovery.html` (or recreate the flow in the main
UI) and rely on the JWT issued by `/api/auth/callback` for subsequent calls
- the static frontend still needs to be updated to consume the new auth flow
(read the JWT from the callback response, store it, and send it as a
`Bearer` header on every `/discovery/*` call)
UI); the session cookie set by `/api/auth/callback` is sent automatically on
subsequent calls, and `auth.js` handles silent token refresh on `401`
- the MCP server should send `X-API-Key: $IMPACTFLOW_API_KEY` on every
request — no OAuth dance needed
- use `locked` as the user's confirmation signal
- use `locked` as the user's confirmation signal, and `PATCH
/discovery/profile/me` if the surrounding app wants its own edit affordance
- decide whether future profiles should supersede locked profiles or be
versioned in the main product experience
+45
View File
@@ -0,0 +1,45 @@
"""add goal-articulation columns to discovery_conversation and discovery_profile
Revision ID: 003
Revises: 002
Create Date: 2026-06-15
Phase 1 goal articulation: two new narrative prompts capture the person's own
near- and long-term goals (on the conversation), and two AI-articulated goal
summaries are stored alongside the Ikigai/enneagram profile.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "003"
down_revision: Union[str, None] = "002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"discovery_conversation",
sa.Column("prompt_goals_short", sa.Text(), nullable=True),
)
op.add_column(
"discovery_conversation",
sa.Column("prompt_goals_long", sa.Text(), nullable=True),
)
op.add_column(
"discovery_profile",
sa.Column("short_term_goals", sa.Text(), nullable=True),
)
op.add_column(
"discovery_profile",
sa.Column("long_term_goals", sa.Text(), nullable=True),
)
def downgrade() -> None:
op.drop_column("discovery_profile", "long_term_goals")
op.drop_column("discovery_profile", "short_term_goals")
op.drop_column("discovery_conversation", "prompt_goals_long")
op.drop_column("discovery_conversation", "prompt_goals_short")
+65
View File
@@ -28,6 +28,13 @@ from app.models import RefreshToken, User
API_KEY_ADMIN_ID = "api-key-admin"
JWT_ALGORITHM = "HS256"
# Cookie names for the browser session. The refresh cookie is scoped to the
# auth path so it is only ever sent to /api/auth/* (refresh, logout), not to
# every discovery request.
ACCESS_COOKIE = "access_token"
REFRESH_COOKIE = "refresh_token"
REFRESH_COOKIE_PATH = "/api/auth"
oauth = OAuth()
oauth.register(
name="google",
@@ -55,6 +62,48 @@ def _refresh_days() -> int:
return int(os.getenv("JWT_REFRESH_DAYS", "7"))
def _cookie_secure() -> bool:
"""Secure-by-default. Set COOKIE_SECURE=false for local http dev, where
Secure cookies would never be sent over plain http://localhost."""
return os.getenv("COOKIE_SECURE", "true").strip().lower() not in (
"false",
"0",
"no",
)
def set_auth_cookies(
response, access_token: str, refresh_token: Optional[str] = None
) -> None:
"""Write the session as httpOnly cookies. Pass refresh_token only when it
rotates (login); a plain access refresh leaves the refresh cookie intact."""
secure = _cookie_secure()
response.set_cookie(
ACCESS_COOKIE,
access_token,
max_age=_access_minutes() * 60,
httponly=True,
secure=secure,
samesite="lax",
path="/",
)
if refresh_token is not None:
response.set_cookie(
REFRESH_COOKIE,
refresh_token,
max_age=_refresh_days() * 86400,
httponly=True,
secure=secure,
samesite="lax",
path=REFRESH_COOKIE_PATH,
)
def clear_auth_cookies(response) -> None:
response.delete_cookie(ACCESS_COOKIE, path="/")
response.delete_cookie(REFRESH_COOKIE, path=REFRESH_COOKIE_PATH)
def create_access_token(user_id: str, email: str) -> str:
now = datetime.now(timezone.utc)
payload = {
@@ -160,6 +209,22 @@ async def get_current_user(
request.state.auth_source = "jwt"
return user
# Browser session: the access token rides in an httpOnly cookie. An
# expired cookie decodes to 401, which the frontend recovers from by
# calling /api/auth/refresh.
cookie_token = request.cookies.get(ACCESS_COOKIE)
if cookie_token:
payload = decode_access_token(cookie_token)
user = await db.get(User, payload["sub"])
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User no longer exists",
)
request.state.user = user
request.state.auth_source = "cookie"
return user
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
+1 -1
View File
@@ -49,7 +49,7 @@ app.add_middleware(SessionMiddleware, secret_key=_session_secret)
_cors_origins = [
o.strip()
for o in os.getenv(
"CORS_ALLOWED_ORIGINS", "http://localhost:8000"
"CORS_ALLOWED_ORIGINS", "http://localhost:8011"
).split(",")
if o.strip()
]
+16
View File
@@ -93,6 +93,15 @@ class DiscoveryConversation(Base):
)
prompt_future: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Phase 1 goal-articulation prompts: the person's own near- and long-term
# goals, in their own words.
prompt_goals_short: Mapped[Optional[str]] = mapped_column(
Text, nullable=True
)
prompt_goals_long: Mapped[Optional[str]] = mapped_column(
Text, nullable=True
)
class DiscoveryProfile(Base):
"""The extracted enneagram + Ikigai profile for a conversation."""
@@ -126,6 +135,13 @@ class DiscoveryProfile(Base):
Text, nullable=True
)
# AI-articulated goals: the person's own stated goals, clarified and
# connected to their Ikigai/enneagram pattern (mirror, never prescription).
short_term_goals: Mapped[Optional[str]] = mapped_column(
Text, nullable=True
)
long_term_goals: 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
+43 -19
View File
@@ -1,14 +1,18 @@
"""OAuth + JWT auth routes (mounted at /api/auth and /api/me)."""
import os
from datetime import datetime, timedelta, timezone
from typing import Optional
from authlib.integrations.base_client import OAuthError
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import (
REFRESH_COOKIE,
clear_auth_cookies,
create_access_token,
email_domain_allowed,
find_or_create_google_user,
@@ -16,6 +20,7 @@ from app.auth import (
hash_refresh_token,
issue_refresh_token,
oauth,
set_auth_cookies,
)
from app.database import get_db
from app.models import (
@@ -50,15 +55,10 @@ class UserOut(BaseModel):
)
class TokenBundle(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
user: UserOut
class RefreshIn(BaseModel):
refresh_token: str
# Optional: browser clients send the refresh token via httpOnly cookie and
# omit the body entirely; API clients may still post it explicitly.
refresh_token: Optional[str] = None
class AccessOut(BaseModel):
@@ -122,19 +122,32 @@ async def auth_callback(request: Request, db: AsyncSession = Depends(get_db)):
refresh = await issue_refresh_token(
db, user, request.headers.get("user-agent")
)
return TokenBundle(
access_token=access,
refresh_token=refresh,
user=UserOut.from_orm_user(user),
)
# The browser drove this redirect flow, so hand the session back as
# httpOnly cookies and bounce into the app rather than dumping JSON.
redirect_to = os.getenv("POST_LOGIN_REDIRECT", "/static/discovery.html")
response = RedirectResponse(url=redirect_to, status_code=status.HTTP_303_SEE_OTHER)
set_auth_cookies(response, access, refresh)
return response
@router.post("/auth/refresh", response_model=AccessOut)
async def refresh_access_token(
body: RefreshIn, db: AsyncSession = Depends(get_db)
request: Request,
response: Response,
body: Optional[RefreshIn] = None,
db: AsyncSession = Depends(get_db),
):
raw_refresh = (body.refresh_token if body else None) or request.cookies.get(
REFRESH_COOKIE
)
if not raw_refresh:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="No refresh token provided",
)
stmt = select(RefreshToken).where(
RefreshToken.token_hash == hash_refresh_token(body.refresh_token)
RefreshToken.token_hash == hash_refresh_token(raw_refresh)
)
row = (await db.execute(stmt)).scalar_one_or_none()
now = datetime.now(timezone.utc)
@@ -150,6 +163,8 @@ async def refresh_access_token(
detail="User no longer exists",
)
access = create_access_token(user.id, user.email)
# Refresh the access cookie in place; the refresh cookie is untouched.
set_auth_cookies(response, access)
return AccessOut(access_token=access)
@@ -162,16 +177,25 @@ def _expired(expires_at: datetime, now: datetime) -> bool:
@router.post("/auth/logout")
async def logout(
body: RefreshIn, db: AsyncSession = Depends(get_db)
request: Request,
response: Response,
body: Optional[RefreshIn] = None,
db: AsyncSession = Depends(get_db),
):
"""Revoke a single refresh token. Idempotent — unknown token returns 200."""
"""Revoke a single refresh token and clear the session cookies. Idempotent
— an unknown/missing token still returns 200 with cookies cleared."""
raw_refresh = (body.refresh_token if body else None) or request.cookies.get(
REFRESH_COOKIE
)
if raw_refresh:
stmt = select(RefreshToken).where(
RefreshToken.token_hash == hash_refresh_token(body.refresh_token)
RefreshToken.token_hash == hash_refresh_token(raw_refresh)
)
row = (await db.execute(stmt)).scalar_one_or_none()
if row is not None and row.revoked_at is None:
row.revoked_at = datetime.now(timezone.utc)
await db.commit()
clear_auth_cookies(response)
return {"status": "ok"}
+38 -1
View File
@@ -53,6 +53,8 @@ def _to_profile_response(
mission_summary=profile.mission_summary,
vocation_summary=profile.vocation_summary,
overlap_narrative=profile.overlap_narrative,
short_term_goals=profile.short_term_goals,
long_term_goals=profile.long_term_goals,
confidence=confidence,
locked=profile.locked,
extraction_notes=extraction_notes,
@@ -116,6 +118,8 @@ async def save_responses(
conversation.prompt_pull = payload.prompt_pull
conversation.prompt_recognition = payload.prompt_recognition
conversation.prompt_future = payload.prompt_future
conversation.prompt_goals_short = payload.prompt_goals_short
conversation.prompt_goals_long = payload.prompt_goals_long
await db.commit()
return schemas.RespondResponse(
@@ -139,6 +143,8 @@ async def complete_conversation(
"pull": conversation.prompt_pull or "",
"recognition": conversation.prompt_recognition or "",
"future": conversation.prompt_future or "",
"goals_short": conversation.prompt_goals_short or "",
"goals_long": conversation.prompt_goals_long or "",
}
if not any(text.strip() for text in responses.values()):
raise HTTPException(
@@ -146,7 +152,7 @@ async def complete_conversation(
)
api_key = os.getenv("ANTHROPIC_API_KEY")
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-5")
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")
try:
extractor = DiscoveryExtractor(api_key=api_key, model=model)
@@ -169,6 +175,8 @@ async def complete_conversation(
mission_summary=data.get("mission_summary"),
vocation_summary=data.get("vocation_summary"),
overlap_narrative=data.get("overlap_narrative"),
short_term_goals=data.get("short_term_goals"),
long_term_goals=data.get("long_term_goals"),
confidence_json=json.dumps(data.get("confidence", {})),
locked=False,
)
@@ -192,6 +200,35 @@ async def get_my_profile(
return _to_profile_response(profile)
@router.patch("/profile/me", response_model=schemas.ProfileResponse)
async def update_my_profile(
payload: schemas.ProfileUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Edit the prose of the latest profile. The person owns their words, so
they can revise any summary, the narrative, or their goals — but only
while the profile is unlocked. Affirming (locking) makes it final."""
profile = await _latest_profile(db, user.id)
if profile is None:
raise HTTPException(status_code=404, detail="No profile for this user")
if profile.locked:
raise HTTPException(
status_code=409,
detail="Profile is locked; it can no longer be edited.",
)
updates = payload.model_dump(exclude_unset=True)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
for field, value in updates.items():
setattr(profile, field, value)
await db.commit()
await db.refresh(profile)
return _to_profile_response(profile)
@router.put(
"/profile/me/confirm", response_model=schemas.ConfirmResponse
)
+20
View File
@@ -15,6 +15,8 @@ class RespondRequest(BaseModel):
prompt_pull: str = ""
prompt_recognition: str = ""
prompt_future: str = ""
prompt_goals_short: str = ""
prompt_goals_long: str = ""
class RespondResponse(BaseModel):
@@ -44,11 +46,27 @@ class ProfileResponse(BaseModel):
mission_summary: Optional[str] = None
vocation_summary: Optional[str] = None
overlap_narrative: Optional[str] = None
short_term_goals: Optional[str] = None
long_term_goals: Optional[str] = None
confidence: Optional[Confidence] = None
locked: bool = False
extraction_notes: Optional[str] = None
class ProfileUpdate(BaseModel):
"""Partial edit of a profile's prose. Only fields explicitly provided are
updated (see exclude_unset in the router). The AI's structural inference
(triad/type/wing/variant) and confidence are not editable here."""
love_summary: Optional[str] = None
strength_summary: Optional[str] = None
mission_summary: Optional[str] = None
vocation_summary: Optional[str] = None
overlap_narrative: Optional[str] = None
short_term_goals: Optional[str] = None
long_term_goals: Optional[str] = None
class ConfirmResponse(BaseModel):
status: str
@@ -63,5 +81,7 @@ class ConversationResponse(BaseModel):
prompt_pull: Optional[str] = None
prompt_recognition: Optional[str] = None
prompt_future: Optional[str] = None
prompt_goals_short: Optional[str] = None
prompt_goals_long: Optional[str] = None
model_config = {"from_attributes": True}
+14 -1
View File
@@ -11,7 +11,7 @@ from typing import Any, Dict
from anthropic import AsyncAnthropic
DEFAULT_MODEL = "claude-sonnet-4-5"
DEFAULT_MODEL = "claude-sonnet-4-6"
MAX_TOKENS = 2000
# Ordered mapping of response keys -> the human-facing prompt label, used to
@@ -22,6 +22,8 @@ PROMPT_LABELS = {
"pull": "The Natural Pull",
"recognition": "The Recognition Moment",
"future": "The Future Pull",
"goals_short": "Near-Term Goals (612 months)",
"goals_long": "Long-Term Goals (35 years)",
}
# Keys the model must return for a profile to be considered well-formed.
@@ -36,6 +38,8 @@ REQUIRED_KEYS = (
"mission_summary",
"vocation_summary",
"overlap_narrative",
"short_term_goals",
"long_term_goals",
"confidence",
)
REQUIRED_CONFIDENCE_KEYS = ("triad", "type", "variant", "ikigai")
@@ -60,6 +64,13 @@ IKIGAI EXTRACTION RULES:
- Mission: what problem or need in the world their stories orbit around
- Vocation: where their strength and the world's need intersect with economic potential
GOAL ARTICULATION RULES:
- The last two responses are the person's own near-term (6-12 month) and long-term (3-5 year) goals.
- You are a MIRROR, not a compass. Articulate the goals THEY stated — clarify and sharpen their own words, connecting each goal to the Ikigai and enneagram pattern you found. Never invent goals, never prescribe a direction, never substitute your judgment for theirs.
- If a goal response is vague, reflect back the direction you can hear in it and note in extraction_notes that it is still forming — do not fill the gap with goals of your own.
- If a goal response is empty, return an empty string for that field. Do not fabricate.
- Write each goal summary directly to the person in second person (you/your), 2-4 sentences.
CONFIDENCE RULES:
- high: strong consistent signal across 2+ responses
- medium: signal present but only in one response or partially contradicted
@@ -79,6 +90,8 @@ Respond ONLY with valid JSON. No preamble, no explanation, no markdown fences.
"mission_summary": "2-3 sentence summary of what the world needs from them",
"vocation_summary": "2-3 sentence summary of what they can be paid for",
"overlap_narrative": "One paragraph (4-6 sentences) describing where their four Ikigai circles converge and how their enneagram type shapes that intersection. Write directly to the person in second person (you/your). Do not mention enneagram type numbers — describe the pattern in plain language.",
"short_term_goals": "2-4 sentences articulating the person's OWN stated near-term (6-12 month) goals, clarified and connected to their pattern. Empty string if they gave no goal.",
"long_term_goals": "2-4 sentences articulating the person's OWN stated long-term (3-5 year) goals, clarified and connected to their pattern. Empty string if they gave no goal.",
"confidence": {
"triad": "high | medium | low",
"type": "high | medium | low",
+33
View File
@@ -0,0 +1,33 @@
// Shared client-side auth helpers.
//
// The session lives entirely in httpOnly cookies that the server sets at
// /api/auth/callback, so there are no tokens for the page to store or read.
// We just send the cookies with every request and recover from access-token
// expiry by silently refreshing once before giving up and sending the user
// back through Google.
function redirectToLogin() {
window.location.href = "/api/auth/login";
}
// fetch() wrapper that always sends the session cookies. On a 401 it attempts
// a single silent refresh (the refresh cookie is scoped to /api/auth) and
// retries the original request; if that still fails, it bounces to login.
async function authedFetch(url, options = {}) {
const opts = { credentials: "include", ...options };
let res = await fetch(url, opts);
if (res.status !== 401) return res;
const refreshed = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
});
if (refreshed.ok) {
res = await fetch(url, opts);
if (res.status !== 401) return res;
}
redirectToLogin();
throw new Error("Not authenticated");
}
+19 -46
View File
@@ -11,6 +11,7 @@
rel="stylesheet"
/>
<link rel="stylesheet" href="/static/style.css" />
<script src="/static/auth.js"></script>
</head>
<body>
<!-- Question flow -->
@@ -67,44 +68,22 @@
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?",
},
{
key: "prompt_goals_short",
title: "The Near Horizon",
text:
"Looking at the next 6 to 12 months, what do you most want to make progress on or accomplish? Say it in your own words.",
},
{
key: "prompt_goals_long",
title: "The Long Horizon",
text:
"Now stretch out 3 to 5 years. What do you want to have built, become, or changed by then?",
},
];
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();
// The signed-in user is resolved server-side from the session cookie, so
// the page no longer mints or tracks a user id of its own.
const answers = new Array(PROMPTS.length).fill("");
let index = 0;
let conversationId = null;
@@ -155,11 +134,7 @@
});
async function startConversation() {
const res = await fetch("/discovery/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: userId }),
});
const res = await authedFetch("/discovery/start", { method: "POST" });
if (!res.ok) throw new Error("Could not start conversation");
const data = await res.json();
conversationId = data.conversation_id;
@@ -177,7 +152,7 @@
body[p.key] = answers[i];
});
const respondRes = await fetch(
const respondRes = await authedFetch(
`/discovery/${conversationId}/respond`,
{
method: "PUT",
@@ -187,7 +162,7 @@
);
if (!respondRes.ok) throw new Error("Could not save responses");
const completeRes = await fetch(
const completeRes = await authedFetch(
`/discovery/${conversationId}/complete`,
{ method: "POST" }
);
@@ -198,9 +173,7 @@
throw new Error(detail.detail || "Extraction failed");
}
window.location.href = `/static/profile.html?user_id=${encodeURIComponent(
userId
)}`;
window.location.href = "/static/profile.html";
} catch (err) {
el.loading.classList.remove("active");
el.flow.style.display = "block";
+147 -35
View File
@@ -11,6 +11,7 @@
rel="stylesheet"
/>
<link rel="stylesheet" href="/static/style.css" />
<script src="/static/auth.js"></script>
</head>
<body>
<div class="wrap">
@@ -48,10 +49,6 @@
{ 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";
@@ -72,43 +69,76 @@
.replace(/>/g, "&gt;");
}
async function load() {
const userId = getParam("user_id");
// The editable prose. Order/labels mirror the read view. The AI's
// structural read (triad/type/wing/variant) is shown but not edited here.
const GOAL_FIELDS = [
{ key: "short_term_goals", title: "Next 612 months" },
{ key: "long_term_goals", title: "Next 35 years" },
];
const content = document.getElementById("content");
let currentProfile = null;
if (!userId) {
content.innerHTML =
'<div class="error-box">No user id provided.</div>';
return;
}
let profile;
async function load() {
try {
const res = await fetch(
`/discovery/profile/${encodeURIComponent(userId)}`
);
const res = await authedFetch("/discovery/profile/me");
if (!res.ok) throw new Error("Profile not found");
profile = await res.json();
currentProfile = await res.json();
} catch (err) {
content.innerHTML = `<div class="error-box">Could not load your profile: ${escapeHtml(
err.message
)}</div>`;
return;
}
renderRead();
}
function renderRead() {
const profile = currentProfile;
const conf = profile.confidence || {};
const triad = TRIAD_INFO[profile.triad] || TRIAD_INFO.gut;
const ikigaiCards = IKIGAI.map((item) => {
return `
const ikigaiCards = IKIGAI.map(
(item) => `
<div class="card">
<h3>${dot(conf.ikigai)} ${item.icon} ${item.title}</h3>
<p>${escapeHtml(profile[item.key]) || "—"}</p>
</div>`;
}).join("");
</div>`
).join("");
const locked = profile.locked;
// Goals are the person's own articulated direction. Only show the
// section if at least one horizon has content.
const goals = GOAL_FIELDS.filter(
(g) => profile[g.key] && profile[g.key].trim()
);
const goalsBlock = goals.length
? `
<p class="section-label">Where you're headed</p>
<div class="ikigai-grid">${goals
.map(
(g) => `
<div class="card">
<h3>→ ${g.title}</h3>
<p>${escapeHtml(profile[g.key])}</p>
</div>`
)
.join("")}</div>
`
: "";
// Unlocked profiles can be edited and then affirmed; locked profiles are
// final and show neither control as actionable.
const actions = locked
? `<div class="confirm-row">
<button class="btn-primary confirmed" disabled>Profile confirmed ✓</button>
</div>`
: `<div class="nav">
<button class="btn-ghost" id="editBtn">Edit my words</button>
<button class="btn-primary" id="confirmBtn">This is me</button>
</div>`;
content.innerHTML = `
<div class="profile-narrative">${escapeHtml(
profile.overlap_narrative
@@ -122,33 +152,115 @@
<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>
${goalsBlock}
${actions}
`;
if (locked) return;
document
.getElementById("editBtn")
.addEventListener("click", renderEdit);
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" }
);
const res = await authedFetch("/discovery/profile/me/confirm", {
method: "PUT",
});
if (!res.ok) throw new Error("confirm failed");
btn.textContent = "Profile confirmed ✓";
btn.classList.add("confirmed");
currentProfile.locked = true;
renderRead();
} catch (err) {
btn.disabled = false;
btn.textContent = "Try again";
}
});
}
function editField(key, label, value) {
return `
<div class="edit-field">
<label for="edit_${key}">${label}</label>
<textarea class="edit" id="edit_${key}">${escapeHtml(
value
)}</textarea>
</div>`;
}
function renderEdit() {
const profile = currentProfile;
const ikigaiFields = IKIGAI.map((item) =>
editField(item.key, `${item.icon} ${item.title}`, profile[item.key])
).join("");
const goalFields = GOAL_FIELDS.map((g) =>
editField(g.key, `${g.title}`, profile[g.key])
).join("");
content.innerHTML = `
<p class="section-label">Edit your words</p>
<p class="edit-help">These are your words to own. Revise anything that
doesn't sound like you, then save.</p>
${editField(
"overlap_narrative",
"Where it all comes together",
profile.overlap_narrative
)}
<div class="ikigai-grid">${ikigaiFields}</div>
<div class="ikigai-grid">${goalFields}</div>
<div id="editError"></div>
<div class="nav">
<button class="btn-ghost" id="cancelBtn">Cancel</button>
<button class="btn-primary" id="saveBtn">Save changes</button>
</div>
`;
document.getElementById("cancelBtn").addEventListener("click", renderRead);
document.getElementById("saveBtn").addEventListener("click", saveEdits);
}
const EDITABLE_KEYS = [
"overlap_narrative",
...IKIGAI.map((i) => i.key),
...GOAL_FIELDS.map((g) => g.key),
];
async function saveEdits() {
const saveBtn = document.getElementById("saveBtn");
saveBtn.disabled = true;
const payload = {};
EDITABLE_KEYS.forEach((key) => {
payload[key] = document.getElementById(`edit_${key}`).value;
});
try {
const res = await authedFetch("/discovery/profile/me", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const detail = await res
.json()
.catch(() => ({ detail: "Could not save changes" }));
throw new Error(detail.detail || "Could not save changes");
}
currentProfile = await res.json();
renderRead();
} catch (err) {
saveBtn.disabled = false;
document.getElementById(
"editError"
).innerHTML = `<div class="error-box">${escapeHtml(
err.message
)}</div>`;
}
}
load();
+25
View File
@@ -277,6 +277,31 @@ button:disabled {
cursor: default;
}
/* ---------- Profile edit mode ---------- */
.edit-help {
color: var(--navy-soft);
margin: 0 0 24px;
}
.edit-field {
margin-bottom: 18px;
}
.edit-field label {
display: block;
font-weight: 600;
font-size: 0.95rem;
color: var(--navy);
margin-bottom: 8px;
}
textarea.edit {
min-height: 120px;
padding: 14px 16px;
font-size: 0.98rem;
}
.error-box {
background: #fdecea;
border: 1px solid var(--red);
+45
View File
@@ -0,0 +1,45 @@
"""Shared test fixtures.
`app_client` spins up the FastAPI app against an isolated temp SQLite DB with
known auth secrets, so any test can exercise the real routes over httpx
without touching the developer's database.
"""
import pytest
from httpx import ASGITransport, AsyncClient
@pytest.fixture
async def app_client(tmp_path, monkeypatch):
db_path = tmp_path / "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", "")
# Plain http test transport: non-Secure cookies so the jar replays them.
monkeypatch.setenv("COOKIE_SECURE", "false")
# Clear app modules so they re-read the patched 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
+3 -1
View File
@@ -3,5 +3,7 @@
"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."
"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.",
"goals_short": "In the next year I want to run a pilot — get a real workshop space, line up the tools and a couple of working tradesmen to teach, and put a first cohort of eight or ten kids through a welding track from start to certification. I want proof it works before I talk to anybody about money.",
"goals_long": "In three to five years I want this to be a real outfit with its own building, multiple trades, and graduates actually placed in jobs across the state. I want it self-sustaining, not living grant to grant, and known well enough that the kids everybody else wrote off come looking for us."
}
+3 -1
View File
@@ -3,5 +3,7 @@
"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."
"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.",
"goals_short": "In the next 6 to 12 months I want to pick one genuinely hard system — probably grid load balancing — and build a working model and a small tool that makes it legible, rigorous enough that someone who actually works in the field tells me it's right.",
"goals_long": "Over three to five years I want that to grow into a small independent lab with a few sharp people and enough funding that we can take the time to understand important systems properly instead of shipping before the thinking is done."
}
+3 -1
View File
@@ -3,5 +3,7 @@
"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."
"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.",
"goals_short": "Over the next several months I want to start small and real — host a monthly gathering for people new to the city, get maybe a dozen regulars who feel like they belong, and learn what actually makes someone walk back through the door a second time.",
"goals_long": "In a few years I want a real gathering space of my own and a handful of trained hosts who know how to notice the person standing alone. I want it to be the place people in this city think of first when they feel adrift."
}
+91 -43
View File
@@ -1,48 +1,7 @@
"""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.
domain allow-list. The `app_client` fixture (tests/conftest.py) provides an
isolated app + temp DB so these never touch the real database.
"""
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):
@@ -103,6 +62,95 @@ async def test_valid_jwt_authenticates(app_client, tmp_path):
assert r.json()["role"] == "user"
async def test_cookie_access_token_authenticates(app_client):
"""A valid access token in the httpOnly cookie authenticates the browser."""
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-cookie",
email="cookie@example.com",
display_name="Cookie User",
google_id="g-cookie",
role="user",
created_at=datetime.now(timezone.utc),
))
await db.commit()
token = create_access_token("u-cookie", "cookie@example.com")
r = await app_client.get("/api/me", cookies={"access_token": token})
assert r.status_code == 200
assert r.json()["email"] == "cookie@example.com"
async def test_refresh_via_cookie_sets_new_access_cookie(app_client):
"""POST /api/auth/refresh with only the refresh cookie mints a new access
token and writes it back as a cookie (no JSON body required)."""
from datetime import datetime, timezone
from app.auth import issue_refresh_token
from app.database import AsyncSessionLocal
from app.models import User
async with AsyncSessionLocal() as db:
user = User(
id="u-refresh",
email="refresh@example.com",
display_name="Refresh User",
google_id="g-refresh",
role="user",
created_at=datetime.now(timezone.utc),
)
db.add(user)
await db.commit()
raw_refresh = await issue_refresh_token(db, user, "pytest")
r = await app_client.post(
"/api/auth/refresh", cookies={"refresh_token": raw_refresh}
)
assert r.status_code == 200
assert r.json()["access_token"]
# The response re-sets the access cookie, and it actually authenticates.
new_access = r.cookies.get("access_token")
assert new_access
me = await app_client.get("/api/me", cookies={"access_token": new_access})
assert me.status_code == 200
assert me.json()["email"] == "refresh@example.com"
async def test_logout_revokes_refresh_token(app_client):
"""Logout via the refresh cookie revokes it, so a later refresh fails."""
from datetime import datetime, timezone
from app.auth import issue_refresh_token
from app.database import AsyncSessionLocal
from app.models import User
async with AsyncSessionLocal() as db:
user = User(
id="u-logout",
email="logout@example.com",
display_name="Logout User",
google_id="g-logout",
role="user",
created_at=datetime.now(timezone.utc),
)
db.add(user)
await db.commit()
raw_refresh = await issue_refresh_token(db, user, "pytest")
out = await app_client.post(
"/api/auth/logout", cookies={"refresh_token": raw_refresh}
)
assert out.status_code == 200
again = await app_client.post(
"/api/auth/refresh", cookies={"refresh_token": raw_refresh}
)
assert again.status_code == 401
async def test_oauth_login_redirects_to_google(app_client):
r = await app_client.get(
"/api/auth/login", follow_redirects=False
+41
View File
@@ -69,6 +69,15 @@ def make_profile_json(
"meet. The work that fits you lets you move first and bring "
"others with you."
),
"short_term_goals": (
"Over the next year you want to get the training program off "
"the ground and prove it works with a first cohort."
),
"long_term_goals": (
"Within five years you see this grown into something "
"statewide that makes people strong enough to never need "
"rescuing."
),
"confidence": {
"triad": "high",
"type": "medium",
@@ -162,6 +171,38 @@ async def test_overlap_narrative_present():
assert result["overlap_narrative"].strip() != ""
@pytest.mark.asyncio
async def test_goal_fields_extracted():
"""Both goal horizons are returned and the goal prompts reach the model."""
responses = load_fixture("gut_type_responses.json")
extractor = make_extractor([make_profile_json("gut", 8, 9)])
result = await extractor.extract(responses)
assert result["short_term_goals"].strip() != ""
assert result["long_term_goals"].strip() != ""
# the goal responses were labelled and included in the prompt
sent = extractor.client.messages.calls[0]["messages"][0]["content"]
assert "Near-Term Goals (612 months)" in sent
assert "Long-Term Goals (35 years)" in sent
assert "first cohort" in sent # short-term goal text from the fixture
@pytest.mark.asyncio
async def test_missing_goal_keys_trigger_retry():
"""A profile lacking the goal fields is treated as malformed (one retry)."""
incomplete = json.loads(make_profile_json("head", 5, 6))
del incomplete["short_term_goals"]
del incomplete["long_term_goals"]
valid = make_profile_json("head", 5, 6)
extractor = make_extractor([json.dumps(incomplete), valid])
result = await extractor.extract(load_fixture("head_type_responses.json"))
assert result["short_term_goals"].strip() != ""
assert len(extractor.client.messages.calls) == 2
@pytest.mark.asyncio
async def test_confidence_flags_present():
extractor = make_extractor([make_profile_json("heart", 3, 2)])
+112
View File
@@ -0,0 +1,112 @@
"""Tests for PATCH /discovery/profile/me (profile editing).
These run under the X-API-Key admin identity, editing a profile seeded for
that user, so they don't depend on the Google OAuth flow.
"""
import uuid
from datetime import datetime, timezone
API_KEY = {"X-API-Key": "test-api-key"}
async def _seed_profile(locked: bool = False) -> str:
"""Insert a conversation + profile for the API-key admin user."""
from app.auth import API_KEY_ADMIN_ID, ensure_api_key_admin
from app.database import AsyncSessionLocal
from app.models import DiscoveryConversation, DiscoveryProfile
async with AsyncSessionLocal() as db:
await ensure_api_key_admin(db)
conv = DiscoveryConversation(
id=str(uuid.uuid4()),
user_id=API_KEY_ADMIN_ID,
started_at=datetime.now(timezone.utc),
)
db.add(conv)
await db.commit()
profile = DiscoveryProfile(
id=str(uuid.uuid4()),
user_id=API_KEY_ADMIN_ID,
conversation_id=conv.id,
generated_at=datetime.now(timezone.utc),
triad="gut",
probable_type=8,
wing=9,
instinctual_variant="sp",
love_summary="orig love",
strength_summary="orig strength",
mission_summary="orig mission",
vocation_summary="orig vocation",
overlap_narrative="orig narrative",
short_term_goals="orig short",
long_term_goals="orig long",
locked=locked,
)
db.add(profile)
await db.commit()
return profile.id
async def test_patch_updates_prose(app_client):
await _seed_profile()
r = await app_client.patch(
"/discovery/profile/me",
headers=API_KEY,
json={"love_summary": "new love", "overlap_narrative": "new narrative"},
)
assert r.status_code == 200
body = r.json()
assert body["love_summary"] == "new love"
assert body["overlap_narrative"] == "new narrative"
# Untouched prose preserved; structural inference is never editable here.
assert body["strength_summary"] == "orig strength"
assert body["triad"] == "gut"
assert body["probable_type"] == 8
async def test_patch_partial_does_not_clear_other_fields(app_client):
await _seed_profile()
r = await app_client.patch(
"/discovery/profile/me",
headers=API_KEY,
json={"short_term_goals": "updated goal"},
)
assert r.status_code == 200
body = r.json()
assert body["short_term_goals"] == "updated goal"
assert body["long_term_goals"] == "orig long"
async def test_patch_locked_profile_returns_409(app_client):
await _seed_profile(locked=True)
r = await app_client.patch(
"/discovery/profile/me",
headers=API_KEY,
json={"love_summary": "x"},
)
assert r.status_code == 409
async def test_patch_without_profile_returns_404(app_client):
r = await app_client.patch(
"/discovery/profile/me",
headers=API_KEY,
json={"love_summary": "x"},
)
assert r.status_code == 404
async def test_patch_empty_payload_returns_400(app_client):
await _seed_profile()
r = await app_client.patch(
"/discovery/profile/me", headers=API_KEY, json={}
)
assert r.status_code == 400
async def test_patch_requires_auth(app_client):
r = await app_client.patch(
"/discovery/profile/me", json={"love_summary": "x"}
)
assert r.status_code == 401
+36 -4
View File
@@ -1,9 +1,41 @@
from pathlib import Path
def test_discovery_page_has_insecure_context_uuid_fallback():
def test_discovery_page_uses_cookie_session_not_user_id():
"""The browser session is a server-set cookie, so the page must not mint
or carry a client-side user id, and all calls go through authedFetch."""
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
assert "impactflow_user_id" not in html
assert "createUserId" not in html
assert "user_id" not in html
assert "authedFetch" in html
assert '/static/auth.js' in html
def test_profile_page_uses_me_endpoints_and_authed_fetch():
"""Profile reads/confirms via the user-scoped /me routes, authenticated by
the session cookie through authedFetch — not the old user_id-in-URL paths."""
html = Path("app/static/profile.html").read_text(encoding="utf-8")
assert "/discovery/profile/me" in html
assert "/discovery/profile/me/confirm" in html
assert "user_id" not in html
assert "authedFetch" in html
def test_profile_page_has_edit_affordance():
"""The read/edit/affirm DoD: an Edit control that PATCHes the profile."""
html = Path("app/static/profile.html").read_text(encoding="utf-8")
assert "renderEdit" in html
assert 'method: "PATCH"' in html
assert "Edit my words" in html
def test_auth_helper_sends_credentials_and_refreshes():
js = Path("app/static/auth.js").read_text(encoding="utf-8")
assert 'credentials: "include"' in js
assert "/api/auth/refresh" in js
assert "/api/auth/login" in js