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