22 Commits

Author SHA1 Message Date
b1ee46656e Merge pull request 'docs(handoff): record PR #166/#168 merges + issues #171/#172' (#173) from docs/handoff-pr-168-merge into main
All checks were successful
CI / frontend (push) Successful in 7m8s
Mirror to GitHub / mirror (push) Successful in 5s
CI / backend (push) Successful in 11m23s
CI / e2e (push) Successful in 9m52s
2026-05-14 05:02:08 +00:00
3cea0f23ee docs(handoff): record PR #166/#168 merges, dashboard CTA + welcome step-2 fixes, issues #171/#172
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 6m44s
CI / e2e (pull_request) Successful in 10m25s
CI / backend (pull_request) Successful in 11m25s
- HANDOFF.md: refreshed for 2026-05-14. PR #166 + #168 merged. Bug-pending-capture
  item from 2026-05-12 likely resolved by PR #168 (dashboard CTA dead-link +
  welcome step-2 PSA confusion); confirm with user next session. Stripe/EIN
  blocker carried forward. Issues #171 (WelcomeStep2 connect-now test coverage)
  and #172 (gitignore core dumps + agent .remember/ state) noted.
- CURRENT_TASK.md: added entries for PR #166, #167, #168 to "Recently shipped"
  with full narrative of the three bundled threads on #168 (session expiration,
  dashboard CTA fix, welcome step-2 reshape).
- SESSION_LOG.md: appended detailed 2026-05-14 entry covering the bug-fix design
  conversation, the FOCUS_START_SESSION_EVENT pattern, the welcome step-2
  Connect-now-bug catch (link never persisted primary_psa), CI gating on PR #168,
  and the two filed issues.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:57:46 -04:00
3a35121578 Merge pull request 'feat(auth): session expiration policy (3d idle / 14d absolute) + per-account override + bulk revoke' (#168) from feat/session-expiration-policy into main
All checks were successful
CI / frontend (push) Successful in 6m46s
Mirror to GitHub / mirror (push) Successful in 5s
CI / e2e (push) Successful in 10m6s
CI / backend (push) Successful in 10m53s
2026-05-14 04:33:49 +00:00
fe0e6923d5 Merge pull request 'docs(handoff): record PR #164/#165 merges; flag Stripe activation as current blocker' (#166) from docs/handoff-pr-165-merge into main
All checks were successful
CI / backend (push) Successful in 10m33s
Mirror to GitHub / mirror (push) Successful in 4s
CI / e2e (push) Successful in 9m29s
CI / frontend (push) Successful in 21m24s
2026-05-14 03:59:59 +00:00
e5b26245ca docs: add architecture reports, public-landing routing plan, build-a-page tutorial, self-serve signup phase-2 design
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 6m45s
CI / e2e (pull_request) Successful in 10m13s
CI / backend (pull_request) Successful in 11m27s
- docs/architecture/: god-node map + report (2026-05-06), workflows.json/html + analysis snapshot
- docs/plans/2026-05-13-public-landing-routing-refactor.md
- docs/tutorials/build-a-page.md
- abc-feat-self-serve-signup-phase-2-design-20260507-112020.md (root)

Core dumps (core.144926, core.145678, docs/architecture/core.1392564) and
agent .remember/ state are intentionally left untracked.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:59:29 -04:00
dc88797469 feat(welcome): two-button PSA CTA in step-2 — Connect now / Connect later
Picking a real PSA in /welcome/step-2 now swaps the primary action from a
single "Continue" + a tiny "Connect now →" link into an explicit choice:
"Connect <PSA> now" (saves primary_psa and routes to /account/integrations)
or "Connect later" (saves primary_psa and continues to step 3). The old
link never actually persisted primary_psa before navigating — that's now
fixed. "No PSA yet" and no-selection states keep the original single
Continue button. Skip-this-step and Skip-the-rest are unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:59:18 -04:00
cbb4b25671 fix(ui): drop setState-in-effect in useAuthSessionExpiry
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 6m42s
CI / e2e (pull_request) Successful in 10m11s
CI / backend (pull_request) Successful in 10m43s
CI surfaced react-hooks/set-state-in-effect on the synchronous
setState(computeState(token)) inside the useEffect body. The earlier
shape mirrored token -> state via an effect, which is exactly the
"you might not need an effect" pattern React 19's eslint rule now
flags.

Switch to derived state: compute during render, use a useReducer
tick to force re-render on the 30s cadence (so relative timestamps
stay current even when token props don't change). Same observable
behavior, no cascading renders.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 20:15:11 -04:00
8d79dd93b8 feat(dashboard): focus same-page Start Session input from NextStep CTA and checklist
Some checks failed
Mirror to GitHub / mirror (push) Successful in 6s
CI / frontend (pull_request) Failing after 1m26s
CI / e2e (pull_request) Successful in 10m3s
CI / backend (pull_request) Successful in 10m10s
The "Start a session" CTAs on the NextStepCard and SetupChecklist used to
Link-navigate, which left the user on the same page (the Start Session
input lives on the dashboard) without any visible response. Replace those
CTAs with a custom window-event dispatch (FOCUS_START_SESSION_EVENT) that
the StartSessionInput listens for: scroll the input into view, focus the
textarea, and pulse a ring for 900ms so the click feels intentional. The
NextStepCard also locally hides itself after firing so the user isn't
double-prompted while typing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 17:56:30 -04:00
1106f79611 docs: add session-expiration-policy decision entry + CURRENT-STATE summary
Ninth and final commit in the session-expiration-policy series.

- .ai/DECISIONS.md: new entry documenting the two-window model
  (3d idle / 14d absolute defaults), per-account override design,
  grandfather strategy, error-detail taxonomy on the wire, and the
  rejected alternatives (idle-only / absolute-only / hard SECRET_KEY
  cutover / Loose preset / reveal-on-Custom UI / modal-stays-open
  for scope=all). Includes consequences and follow-up tickets.
- CURRENT-STATE.md: 'Recently shipped' entry summarizing the 8-commit
  series across backend (migration, claims, enforcement, two
  endpoints) and frontend (page, hook, toast, banner, modal),
  referencing the plan + design-review file.

Pending after this commit: open PR, merge, file the per-user
device-list + super-admin global-ceiling follow-up issues per plan §9.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 17:09:09 -04:00
c7cd711859 feat: AccountSecuritySettingsPage + active-users list + toast + login banner
Eighth commit in the session-expiration-policy series. Surfaces all
the owner controls and user-facing expiry UX that the prior commits
plumbed through, designed end-to-end via /plan-design-review (initial
4/10 -> final 9/10; 7 decisions locked in the plan).

Backend additions:
- accounts/me/security GET response gains active_users: list of
  {user_id, name, email, last_login_at} for users in this account
  with at least one un-revoked refresh token. Joined query on
  refresh_tokens + users, distinct, ordered by last_login desc.
  Drives the Active Sessions section.

Frontend additions:
- api/accountSecurity.ts: typed client for GET/PATCH/revoke-sessions.
- hooks/useAuthSessionExpiry.ts: reads idle/absolute expiry from the
  auth store, returns warning ('none'|'soon'|'now') + reason
  ('idle'|'absolute') so consumers can pick the right UX for the
  closer window. Re-evaluates every 30s.
- components/common/SessionExpiryToast.tsx: top-of-app notice that
  fires at T-5min. Idle case: warning-amber tone, [Stay signed in]
  button hits authApi.refresh() and updates the store on success.
  Absolute case: info-cyan tone, [Sign in now] link to /login (no
  recoverable action). Dismissable, doesn't re-fire after dismissal.
- components/account/RevokeSessionsModal.tsx: confirmation modal for
  the two bulk-revoke scopes. Title, body, and confirm-label vary by
  scope; danger-styled confirm button.
- pages/account/AccountSecuritySettingsPage.tsx: the main page.
  Header (Shield icon), intro, Policy card with Strict/Standard/Custom
  radios + always-visible-disabled Custom inputs (idle/absolute
  minutes) with inline validation, Save button + emerald success ping,
  info note about 'applies at next login'. Active sessions card with
  count-aware copy, list of {name, email, last-login-ago} rows
  (caller tagged '(you)'), two buttons — 'except me' hidden when
  count=1, 'sign me out and everyone else' uses danger-tinted styling.
- pages/AccountSettingsPage.tsx: 'Session security' row added to the
  owner-only settings list.
- router.tsx: /account/security route, owner-gated via ProtectedRoute.
- pages/LoginPage.tsx: cyan info-tone banner above form when
  ?reason=session_expired is in the URL.
- components/layout/AppLayout.tsx: mounts <SessionExpiryToast />.

Scope=all bulk-revoke UX (the most jarring moment): on success,
toast.success(N sessions), 1.5s delay, then clear localStorage +
useAuthStore.logout() + window.location='/login' (no banner — the
owner just did this).

Backend tests: existing 22/22 still green plus the GET test now
asserts active_users is present + non-empty after login. Frontend:
tsc clean, authStore test 2/2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 17:07:14 -04:00
aad554bb9c feat(ui): handle session_expired_{idle,absolute} in axios interceptor
Seventh commit in the session-expiration-policy series. Wires the
backend taxonomy from commit 2 through to the frontend so users see
the right page (calm banner vs plain logout) when the refresh path
fails for different reasons.

- types/auth.ts: Token gains idle_expires_at + absolute_expires_at
  (Optional ISO 8601 strings). The next commit adds the
  useAuthSessionExpiry hook that reads these.
- api/auth.ts: OAuthCallbackResponse mirrors the same two fields.
- api/client.ts: refresh-failure handler now branches on the response
  detail. session_expired_idle and session_expired_absolute both
  redirect to /login?reason=session_expired (commit 8 adds the
  banner that reads the query param); any other detail (most
  commonly invalid_refresh_token) goes to plain /login. The bare
  redirect is guarded against re-firing when the user is already on
  /login. The refresh-success path now forwards the two new fields
  into setTokens so the store stays current as the session ages.
- pages/OAuthCallbackPage.tsx: setTokens({...}) spreads
  idle_expires_at + absolute_expires_at from the OAuth response.

No new tests — authStore.test still 2/2, tsc clean. The
useAuthSessionExpiry hook and the SessionExpiryToast that consume
the new fields land in commit 8 alongside the AccountSecurity page.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 16:33:56 -04:00
cabd745a2b feat(api): add POST /accounts/me/security/revoke-sessions
Sixth commit in the session-expiration-policy series. The kill-all-
sessions endpoint folded into scope after the §4.11 design pass.

- POST /accounts/me/security/revoke-sessions, owner-only.
- Body: {"scope": "all" | "others"}. Default "all" includes the caller's
  own refresh token. "others" preserves the caller's sessions so an
  owner can sign everyone else out without logging themselves out.
- Single SQL UPDATE through users.account_id -> refresh_tokens, with
  revoked_at IS NULL preserved as the gate so already-revoked rows
  don't get double-stamped (the idempotency property).
- Caller's access token is not touched — it dies on its 5-minute timer.
  Frontend handles "scope=all" UX by clearing localStorage and
  redirecting after the response (commit 8).
- Affected users' next /auth/refresh hits the existing atomic-revoke
  zero-rows path -> invalid_refresh_token (plain logout, no banner).
- Writes one account.sessions_revoked_bulk audit event with
  {scope, revoked_count}.

Tests added in test_session_policy.py (6 cases):
- #17 scope=all kills caller's own session; their refresh -> 401
  invalid_refresh_token.
- #18 scope=others preserves caller's session; their refresh succeeds,
  member's refresh -> 401 invalid_refresh_token.
- #19 account-scoped: test_admin in a different account is unaffected
  when test_user's owner runs revoke-all (revoked_count=1, not 2).
- #20 engineer-role member -> 403.
- #21 emits exactly one audit row with the expected payload.
- #22 idempotent: second immediate POST returns revoked_count=0.

22/22 in test_session_policy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 16:31:10 -04:00
8cfaef6a9d feat(api): add GET/PATCH /accounts/me/security endpoint
Fifth commit in the session-expiration-policy series. Surfaces the
session-policy override controls to account owners.

- schemas/account_security.py: NEW. SessionPolicyResponse returns both
  the override (Optional[int]) and the effective value (always present)
  plus the system min/max bounds, so the frontend can render the
  Custom-preset form without re-implementing the defaults logic.
  SessionPolicyUpdateRequest accepts NULL to clear an override.
- endpoints/account_security.py: NEW. GET and PATCH on /me/security.
  Owner-only via require_account_owner. PATCH validates per-field
  bounds, then validates the effective idle <= absolute invariant
  (catching the partial-override case the DB CHECK can't see), then
  writes the row + an account.session_policy_update audit event with
  old/new/effective_old/effective_new payload.
- router.py: registers the new router under _tenant_deps next to
  accounts.router.

Tests added in test_session_policy.py (8 cases):
- GET returns NULL overrides + Strict defaults + system bounds.
- PATCH persists override; next login JWT reflects new values
  (60min/240min -> idle_max=3600, abs_max=14400 seconds).
- PATCH rejects idle < min (422).
- PATCH rejects absolute > max (422).
- PATCH rejects idle > absolute when both are set (422).
- PATCH rejects partial override that produces effective idle >
  effective absolute (idle=43200, absolute=NULL with default 20160).
- Engineer-role user gets 403.
- PATCH writes exactly one audit row with the expected payload shape.

16/16 in test_session_policy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 16:28:51 -04:00
b21d2fc234 feat(auth): enforce absolute session cap in /auth/refresh
Fourth commit in the session-expiration-policy series. The gate that
ends "logged in forever" — refresh now rejects tokens whose original
login (auth_time) is older than abs_max seconds.

Algorithm (plan §4.5):
1. Decode JWT (dep already handles idle expiry).
2. Load user; reject inactive/missing as invalid_refresh_token.
3. Resolve effective auth_time/idle_max/abs_max, grandfathering
   pre-PR tokens by snapshotting current account policy.
4. Atomically revoke the JTI regardless of outcome — this consumes
   the token whether or not the absolute check passes, so an
   absolute-expired token cannot be replayed forever.
5. If the atomic UPDATE matched zero rows -> invalid_refresh_token.
6. If now >= auth_time + abs_max -> commit the revoke explicitly
   (so it survives the rollback hook in get_admin_db) and 401
   session_expired_absolute.
7. Otherwise mint via _mint_with_claims, carrying claims forward.

Boundary check uses `>=`, not `>` — a deadline equal to now is
expired. _refresh_session_tokens (commit 3) replaced by two narrower
helpers: _resolve_refresh_claims (grandfather logic, no mint) and
_mint_with_claims (mint with explicit claims, no grandfather). Makes
the endpoint's algorithm read top-down without indirection.

Tests added in test_session_policy.py:
- #8: backdate auth_time by exactly abs_max -> session_expired_absolute
  at the deadline boundary.
- #9: same token tried twice; first returns session_expired_absolute
  AND consumes the row; second returns invalid_refresh_token.
- #12: legacy token without auth_time/idle_max/abs_max gets one
  successful rotation; new JWT carries fresh policy snapshot from
  the account (3d/14d defaults under Strict).

25/25 across test_session_policy + test_auth + test_oauth_callbacks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 16:26:00 -04:00
d6a02ee8da feat(auth): embed auth_time/idle_max/abs_max in refresh tokens at every login
Third commit in the session-expiration-policy series. Every refresh token
issued from now on carries the policy snapshot in its JWT (in seconds,
for direct Unix math), and every login/OAuth response surfaces both
expiry windows as ISO timestamps. /auth/refresh carries the claims
forward unchanged — including auth_time, which never resets on rotation.

Does NOT yet enforce the absolute cap — that's commit 4, sequenced so
the gate can be reverted independently if pilots hit an edge case.
But the wire is fully populated, and a grandfather path is already in
_refresh_session_tokens for tokens issued before this PR.

Key changes:
- core/security.py: create_refresh_token signature changes to
  (user_id, *, auth_time, idle_max_seconds, abs_max_seconds). Adds
  resolve_session_policy(account) -> (idle_minutes, absolute_minutes)
  applying defaults for NULL overrides.
- schemas/token.py + schemas/oauth.py: Token and OAuthCallbackResponse
  gain idle_expires_at + absolute_expires_at (Optional[datetime],
  Pydantic emits ISO 8601 UTC strings).
- endpoints/auth.py: new _mint_session_tokens(user, db) and
  _refresh_session_tokens(payload, user, db) helpers. /auth/login,
  /auth/login/json, and /auth/refresh now route through them. The
  refresh endpoint's pre-existing "Refresh token has been revoked"
  error normalized to the taxonomy detail "invalid_refresh_token".
- endpoints/oauth.py: both Google and Microsoft callbacks call
  _mint_session_tokens; OAuthCallbackResponse carries the expiry
  fields through.
- tests: two new cases in test_session_policy.py — login_json embeds
  the claims with strict defaults (3d/14d -> 259200/1209600 sec) and
  surfaces matching ISO expiry fields; refresh carries auth_time,
  idle_max, abs_max forward unchanged across rotation.

35/35 across test_session_policy + test_auth + test_oauth_callbacks +
test_account_invite_lookup + test_account_management.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 16:22:53 -04:00
2375948b7a feat(auth): distinguish idle expiry from invalid refresh tokens
Second commit in the session-expiration-policy series. Lands the
error-detail taxonomy from §4.10 of the plan; no UI-visible change yet
because the frontend interceptor (commit 7) doesn't read the new detail
strings, but the wire is now ready for it.

Today every /auth/refresh failure returns 401 "Invalid refresh token"
regardless of cause, so the frontend has no way to distinguish "your
session ended for security" from "we don't recognize this token at
all." This commit introduces:

- decode_refresh_token_strict(): wraps jose.jwt.decode and raises a new
  IdleTokenExpired exception (from ExpiredSignatureError) so callers
  can branch on idle expiry. All other jose failures still propagate
  as JWTError. The legacy decode_token() is preserved for access-token,
  password-reset, and email-verification paths that don't need the
  distinction.
- get_refresh_token_payload(): now maps IdleTokenExpired ->
  "session_expired_idle", JWTError and wrong-type tokens ->
  "invalid_refresh_token".
- test_session_policy.py: new test file (will accumulate cases across
  the series). Three tests for the taxonomy: idle-expired returns
  session_expired_idle; wrong type returns invalid_refresh_token; bad
  signature returns invalid_refresh_token.

20/20 across test_session_policy + test_auth + test_oauth_callbacks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 16:11:01 -04:00
92fa3bc6ab feat(auth): add session policy settings + account columns + migration
First commit in the session-expiration-policy series (see
docs/plans/2026-05-13-session-expiration-policy.md). No behavior change
yet — this lays the schema + settings groundwork only.

- Settings: SESSION_IDLE_MINUTES_DEFAULT=4320 (3d),
  SESSION_ABSOLUTE_MINUTES_DEFAULT=20160 (14d), plus MIN/MAX bounds
  so account overrides have envelopes (15min..30d idle, 1h..90d
  absolute).
- accounts table: nullable session_idle_minutes and
  session_absolute_minutes columns (NULL = use system default), plus
  a CHECK constraint that rejects idle > absolute when both are set.
  Partial-override validation lives at the app layer because the DB
  cannot read Settings.

Subsequent commits will: distinguish idle vs invalid-token expiry on
the wire, embed auth_time/idle_max/abs_max in refresh JWTs, enforce
the absolute cap in /auth/refresh, add the owner-only policy +
bulk-revoke endpoints, and surface everything in an AccountSecurity
settings page with a session-expiry toast.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 15:52:21 -04:00
dc22aa0ff0 docs(handoff): record PR #164/#165/#167 merges, EIN blocker, pending bug
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 6m42s
CI / e2e (pull_request) Successful in 10m8s
CI / backend (pull_request) Successful in 10m31s
PR #164 (taxonomy + Stripe sync + allowlist) merged as 3f04911.
PR #165 (legal/contact pages + MarketingFooter) merged as ba45cfe.
PR #167 (create_site_admin.py bootstrap script) merged as e50a215.

All code blockers for self-serve cutover are now on main. Site-admin
bootstrap script verified end-to-end against prod via railway ssh
(first prod super-admin row now exists).

Stripe live-mode activation blocked on EIN — user applying via
IRS.gov on 2026-05-13. Mailing-address decision: home address into
Stripe's private business profile temporarily; public-facing
ContactPage/PoliciesPage stays "available on request" until the
P.O. Box arrives.

Records a pending bug: user reported finding one but did not share
details — planning to send a screenshot via the VS Code extension
GUI in the next session. Next-session-first-action is updated to
capture and triage that screenshot before resuming Phase O.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 11:17:30 -04:00
e50a2150d5 Merge pull request 'feat(admin): add create_site_admin.py for bootstrapping a super_admin' (#167) from feat/site-admin-script into main
All checks were successful
CI / frontend (push) Successful in 6m43s
Mirror to GitHub / mirror (push) Successful in 6s
CI / e2e (push) Successful in 10m4s
CI / backend (push) Successful in 10m34s
Reviewed-on: #167

by: Michael Chihlas
2026-05-12 06:17:31 +00:00
3a3844b68e feat(admin): add create_site_admin.py for bootstrapping a super_admin
All checks were successful
CI / frontend (pull_request) Successful in 6m23s
Mirror to GitHub / mirror (push) Successful in 5s
CI / backend (pull_request) Successful in 10m10s
CI / e2e (pull_request) Successful in 9m14s
Idempotent CLI script that creates or promotes a site-wide super_admin
on any environment. Solves the prod bootstrap case where no admin
exists yet — dev's seed_test_users.py only runs in dev, self-serve
signup is still gated, and even when enabled, signup creates owner
roles, not super_admins.

The script:

- Reads --email (required), normalizes to lowercase.
- If user does not exist: creates an Account + super_admin User as
  the account owner, with email_verified_at stamped at creation and
  password_hash=NULL (forces the reset flow on first login).
- If user exists: promotes is_super_admin=true and backfills
  email_verified_at if null. Idempotent — re-running is safe.
- Mints a password-reset JWT, stores the token hash in
  password_reset_tokens, and either emails the link
  (--send-reset) or prints it to stdout (--print-reset). Email
  send is best-effort with a fallback URL on stdout so a
  misconfigured EmailService never blocks login.
- --promote-only flag: skips creation, only promotes an existing
  user. Useful for promoting an already-self-served user without
  triggering an unnecessary reset.

Uses ADMIN_DATABASE_URL when set (BYPASSRLS — required because users
is RLS-enabled and the script has no tenant context at bootstrap).

Smoke-tested in dev against all three paths: fresh create, re-run
idempotency on the same email, --promote-only on an existing user
with no password.

Intended invocation on prod, once Stripe/EIN unblocks:

  railway run python -m scripts.create_site_admin \
    --email michael@resolutionflow.com \
    --send-reset

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 01:58:53 -04:00
ba45cfeec1 feat(legal): add /policies, /contact, /promotions pages + MarketingFooter (#165)
All checks were successful
CI / frontend (push) Successful in 6m47s
Mirror to GitHub / mirror (push) Successful in 6s
CI / e2e (push) Successful in 10m16s
CI / backend (push) Successful in 11m13s
Adds the three legal/contact pages needed for Stripe live-mode site review: /policies (consolidated customer policies — refunds, cancellation, legal restrictions, promotions), /contact (phone (470) 949-4131 + support/sales/billing/security inboxes), /promotions (stub satisfying §6.2 cross-ref).

Extracts the existing landing footer into components/common/MarketingFooter.tsx and mounts it on /pricing and /contact-sales so all four legal links are reachable from every marketing surface.

Privacy and Terms closing sections updated to point at /contact + /policies; stale hello@ mailto removed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Michael Chihlas <michael@resolutionflow.com>
Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
2026-05-12 05:23:43 +00:00
3f04911070 feat(billing): plan taxonomy reconciliation + Stripe sync + internal-tester allowlist (#164)
All checks were successful
CI / frontend (push) Successful in 6m40s
Mirror to GitHub / mirror (push) Successful in 7s
CI / e2e (push) Successful in 10m7s
CI / backend (push) Successful in 10m34s
Co-authored-by: Michael Chihlas <michael@resolutionflow.com>
Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
2026-05-11 05:07:07 +00:00
115 changed files with 11536 additions and 264 deletions

View File

@@ -1,10 +1,19 @@
# CURRENT_TASK.md
**Active task:** Self-serve signup Phase 2 — PR #162 is open on `feat/self-serve-signup-phase-2`. Current focus is resolving its failing Gitea checks. Phase O manual ops (Stripe live setup, internal validation, flag flip) remain pending after review/merge. See `.ai/HANDOFF.md` for the resume point.
**Active task:** Phase O cutover for self-serve signup. All code blockers are now closed on `main`. Only user-side manual ops remain: apex DNS fix at Namecheap, Stripe Dashboard live-mode config (with the new `/contact` and `/policies` URLs surfaced in the business profile), Railway prod env vars, internal validation pass, public flag flip. See `.ai/HANDOFF.md` for the resume point.
## Recently shipped
- **2026-05-06`feat/self-serve-signup-phase-2`** Phase 2 frontend cutover code (Tasks 2744 of the plan, 18 commits). Backend remainders + frontend billing foundation + auth surfaces (OAuth + accept-invite + verify-email) + welcome wizard + dashboard redesign (TrialPill, NextStepCard, unified checklist) + public surfaces (`/pricing`, `/contact-sales`) + beta-signup deprecation. Phase O (Stripe live setup, internal validation, flag flip) is operational and pending. Single alembic head `c6cbfc534fad` (no new migrations).
- **2026-05-14PR #168** Session expiration policy + dashboard onboarding-CTA fix + welcome step-2 PSA CTA reshape. Merge-committed into main as `3a35121`. Three threads bundled on one branch (`feat/session-expiration-policy`):
- **Session expiration policy** (original branch scope): 3d idle / 14d absolute, per-account override, bulk revoke. New `AccountSecuritySettingsPage`, `RevokeSessionsModal`, `SessionExpiryToast`, `useAuthSessionExpiry` hook; backend dependencies in `accountSecurity.ts`.
- **Dashboard onboarding CTA fix** (`8d79dd9`): The "Start a session" CTAs on `NextStepCard` and `SetupChecklist` used to `<Link to="/">` while themselves rendered on `/`, so clicks were silent no-ops. Replaced with a `FOCUS_START_SESSION_EVENT` window event that `StartSessionInput` listens for — scrolls itself into view (top of viewport), focuses the textarea, pulses a blue ring 900ms. `NextStepCard` hides itself locally on click so the prompt doesn't linger while the user types.
- **Welcome step-2 PSA CTA reshape** (`dc88797`): Selecting a real PSA now swaps `[Continue] [Skip]` for `[Connect <PSA> now] [Connect later] [Skip this step]`. Primary blue button saves `primary_psa` and routes to `/account/integrations`; "Connect later" saves and continues to step 3. **Pre-existing bug fixed**: the old subtle "Connect now →" link never persisted `primary_psa` before navigating. Now it does. "No PSA yet" / no-selection states still show the original single Continue.
- **2026-05-14 — PR #166** Docs/handoff doc updates carrying forward PR #164/#165 state and EIN blocker. Squash-merged into main as `fe0e692`.
- **2026-05-12 — PR #167** `backend/scripts/create_site_admin.py` site-wide super-admin bootstrap script. Squash-merged into main as `e50a215`. Idempotent CLI, three modes (`--send-reset`, `--print-reset`, `--promote-only`). Uses `ADMIN_DATABASE_URL` (BYPASSRLS). User confirmed end-to-end success against prod via `railway ssh` 2026-05-12 evening.
- **2026-05-12 — PR #165** Legal/contact pages for Stripe site review. Squash-merged into main as `ba45cfe`. Three new SPA pages: `/policies` (consolidated Customer Policies — refunds, cancellation, U.S. legal/export restrictions, promotional terms; anchor IDs per subsection), `/contact` (phone (470) 949-4131, support/sales/billing/security inboxes, response-time SLAs), `/promotions` (stub satisfying Policies §6.2). New `MarketingFooter` component (`components/common/MarketingFooter.tsx`) extracted from inline landing footer; mounted on `/landing`, `/pricing`, `/contact-sales` so all four legal links (Privacy/Terms/Policies/Contact) are reachable from every marketing surface. Component reuses existing `landing-footer*` CSS — must be inside a `.landing-page` wrapper (documented in JSX comment). Privacy and Terms closing sections updated to point at `/contact` + `/policies` with correct per-area inboxes; stale `hello@` mailto removed everywhere. Mailing address left as TODO comments in both `ContactPage.tsx` and `PoliciesPage.tsx`, rendered publicly as "available on request" until P.O. Box is purchased. tsc + eslint clean.
- **2026-05-08 — PR #164** Plan taxonomy reconciliation + `INTERNAL_TESTER_EMAILS` allowlist + Stripe sync script + page-title fix + frontend taxonomy followups + doc refresh. 5 commits on `feat/billing-plan-taxonomy` from main (`dad5e1f`); HEAD `2c9f5e9`. Migration `4ce3e594cb87` renames `plan_limits.plan='team'``'enterprise'` and adds `starter` row (caps interpolated between free and pro: `max_trees=10`, `sessions=75`, `ai=15/mo`). Resource visibility (`Tree.visibility='team'`, `StepLibrary.visibility='team'`) is a separate domain and intentionally untouched. New `backend/scripts/sync_stripe_plan_ids.py` upserts `plan_billing` rows from Stripe products by exact name match — annual fields stay NULL by design (user explicitly skipping annual pricing for exit flexibility). `Settings.is_internal_tester` + `is_self_serve_active_for` centralize the allowlist + global-flag check; new `get_current_user_optional` dep; `/config/public` honors allowlist for authenticated callers; `/auth/register` allows allowlisted emails without invite code. LandingPage page-title bug — `—` inside JSX attribute strings was rendering as 6 literal characters in browser tabs; replaced with literal em dash. PageMeta default tagline updated from "Decision Tree Platform" to "AI-Powered Troubleshooting for MSPs". 86/86 passing across subscription/billing/plan/invite/admin sweep; tsc + lint clean. See `.ai/DECISIONS.md` for the two architectural entries (taxonomy reconciliation, allowlist).
- **2026-05-06 — PR #163** Seed test users marked email-verified. Squash-merged into main as `dad5e1f`.
- **2026-05-06 — PR #162** Self-serve signup Phase 2 (frontend cutover). 18 commits across Tasks 2744 of the plan. Backend remainders + frontend billing foundation + auth surfaces (OAuth + accept-invite + verify-email) + welcome wizard + dashboard redesign (TrialPill, NextStepCard, unified checklist) + public surfaces (`/pricing`, `/contact-sales`) + beta-signup deprecation. Squash-merged into main as `f1be3ab`. Single alembic head was `c6cbfc534fad` (no new migrations in Phase 2; PR #164 adds `4ce3e594cb87`).
- **2026-05-02 — PR #159** In-product User Guides rewrite. Merged into `main`. Replaced 15 feature-dump guides with 43 problem-oriented Diátaxis how-tos grouped under 10 categories. Dropped Maintenance Flows / AI Assistant / Flow Assist Sparkles (UI no longer exists). Renamed Step Library → Solutions Library. Authored 14 net-new how-tos for FlowPilot-era surfaces (tasklane keyboard flow, what-we-know, resolve, escalate, record-fix-outcome, post-docs-to-ticket, share-update, pause-and-leave, build-script-from-scratch, open-suggested-flow, pin-a-flow, invite-teammate, etc.). Schema additions: `category`, optional `relatedSlugs`; hub renders category sections; detail page renders related-guides footer. Fixed rendering bug where `**bold**` in `step.tip` rendered literally. Killed misleading "N sections" subtitle on guide cards. Browser-verified against engineer + owner login (sidebar labels, account sub-pages, pilot-screen header buttons, Tasks panel, integration form). Two unverified items intentionally deferred: change-teammate-role (requires non-owner test member to inspect role-change control) and detailed Resolve / Escalate modal contents (Resolve gated by 6 pending tasks in test data). tsc and Vite build clean.
- **2026-05-01 — PR #158** Session-screen UX impeccable pass + tasklane keyboard flow. Merged into `main` as `5e10005`.
- **Impeccable pass** (5 sub-passes — distill / quieter / layout / typeset / polish): score 24/40 → 33/40. Removed the duplicate "Suggested checks" chip strip; added an inline `Next steps · N pending in Tasks` cue above the latest action-bearing AI bubble; consolidated the desktop session header to Resolve + Escalate + ⋯ kebab (Context / New Ticket / Update Ticket / Pause now under the kebab, mobile kebab gained Context + New Ticket parity); centered the messages column to `max-w-3xl` to match the composer; bubbles dropped to `rounded-xl`. Decoration sweep: dropped 3px side stripes (TaskLane done states, all 6 ProposalBanner modes, WhatWeKnowItem rows), gradient backgrounds (WhatWeKnow + every banner), accent borderTop on TaskLane header, backdrop-blur on handoff overlay, animate-pulse-amber ring in VerifyingBanner, bordered avatar boxes in banners. Type sweep: 14 distinct sizes → 5-step scale (10/11/12/13/14px). Icon disambiguation: `MessageCircleQuestion` split into `Pencil` (Answer CTA) + `HelpCircle` (per-check explainer). Dead `font-sans` audit (12 sites) and double `text-xs` cleanups.

View File

@@ -13,6 +13,82 @@
---
## 2026-05-13 — Session expiration policy: 3d idle / 14d absolute defaults + per-account override
**Context:** User report: "I login to ResolutionFlow and never have to log back in." Investigation found refresh tokens at `REFRESH_TOKEN_EXPIRE_DAYS=7` with JTI rotation (`security.py:36`) — every `/auth/refresh` minted a fresh 7-day window. Net effect: a sliding 7-day session with no absolute cap. Visit once a week, logged in forever. Acceptable for pilot but not for MSP buyers whose SOC2 / cyber-insurance auditors require enforced session timeouts. Required for the same Phase O launch readiness as the other gates already in flight.
**Decision:** Two-window model snapshotted into the refresh JWT at login. Defaults to Strict (3-day idle, 14-day absolute), bounded by env-var system min/max. Per-account override via two new `accounts` columns (NULL = use system default). Owner-only `GET/PATCH /accounts/me/security` endpoint with effective-value validation (partial-override case caught at the app layer because the DB CHECK can't see Settings). Sibling `POST /accounts/me/security/revoke-sessions` for `all|others`-scoped bulk revocation. Frontend: Strict/Standard/Custom presets, active-users list (name + email + last-login-ago), differentiated SessionExpiryToast (idle = warning amber with "Stay signed in" → `/auth/refresh`; absolute = info cyan, informational only), cyan info-tone banner on `/login?reason=session_expired`, auto-redirect after scope=all bulk-revoke. Error-detail taxonomy on the wire: `session_expired_idle`, `session_expired_absolute`, `invalid_refresh_token`. Grandfather path: legacy refresh tokens (no `auth_time` claim) get one free rotation under the new policy. Atomic-revoke-then-check on `/auth/refresh` so absolute-expired tokens can't be replayed.
8 commits on `feat/session-expiration-policy` branch (`92fa3bc``c7cd711`), ~1300 LoC backend + frontend including 28 backend tests. Plan + design review at `docs/plans/2026-05-13-session-expiration-policy.md` (initial design score 4/10 → final 9/10 via `/plan-design-review`; 7 design decisions locked).
**Rejected:**
- **Idle-only or absolute-only enforcement.** Idle without absolute is the current broken state (sliding forever). Absolute without idle is too strict — kicks users out daily.
- **Hard cutover on deploy (SECRET_KEY rotation).** Forces every pilot to log in again immediately; high support cost. Grandfather path is friendlier and adds ~50 lines of code.
- **Distinguish `session_revoked_by_admin` from `invalid_refresh_token` on the wire** for users whose sessions were killed via bulk-revoke. Requires tracking revocation reason per `refresh_tokens` row. Not worth the complexity for v1 — affected users see they're logged out, same as any other revoke.
- **Per-user device list with per-device revoke.** Refresh tokens don't carry device/user-agent metadata today. Account-wide bulk revoke covers the breach-response use case; per-device is a follow-up if pilots ask.
- **"Loose" preset (90d).** Strict default suggests we shouldn't ship a one-click loose option. Owners who want a loose policy can use Custom and own the choice explicitly.
- **Always-required `idle_minutes`+`absolute_minutes` (XOR-NULL invariant).** Forces owners who only want to override idle to also re-declare the absolute window, leaking the system default into account data. Partial overrides allowed; validated at the app layer against current defaults.
- **Reveal-on-Custom UI for the minute inputs.** Hidden-by-default-reveal-on-radio shifts page layout when Custom is selected. Always-visible-but-disabled is more stable and previews the Custom interaction.
- **Modal-stays-open-success-state for scope=all bulk-revoke.** User preferred auto-redirect-with-toast (more standard SaaS pattern); the toast acts as the success acknowledgment before /login loads.
**Consequences:**
- "Logged in forever" is fixed. Every user sees a hard 14-day re-auth at minimum (3-day idle in practice for typical usage).
- Account owners get a complete self-service surface for policy + bulk session control. New `/account/security` route, owner-gated.
- Audit-log entries on both mutations: `account.session_policy_update` and `account.sessions_revoked_bulk`. SOC2-ready.
- Frontend `idle_expires_at` + `absolute_expires_at` flow through the entire auth surface (`Token`, `OAuthCallbackResponse`, `authStore`, persistence). `useAuthSessionExpiry` hook is the single source for "is the session about to end."
- Future improvements (filed as follow-ups in plan §9): per-user device list (requires `refresh_tokens.last_used_at` column), super-admin global ceiling UI, per-user policy. None block current shipping.
- Cyan info-tone banner on `/login` is the first of its kind in the app; sets precedent for future neutral system messages.
---
## 2026-05-07 — Per-email allowlist (`INTERNAL_TESTER_EMAILS`) for self-serve soft cutover
**Context:** Phase O Task 46 ("internal validation pass") needed a way to exercise the full self-serve flow against the prod backend before flipping `SELF_SERVE_ENABLED=true` for everyone. The plan doc described the mechanism but the backend support was never built — flagged in `SESSION_LOG.md` as a code blocker. Stripe live-mode setup is also gated on having a working internal-tester path in prod test mode.
**Decision:** Comma-separated allowlist `INTERNAL_TESTER_EMAILS` parsed by a Pydantic field_validator into a normalized lowercase list. Two helpers on `Settings`: `is_internal_tester(email)` (case-insensitive membership check) and `is_self_serve_active_for(email)` (returns `SELF_SERVE_ENABLED OR is_internal_tester(email)`). Both endpoints that gate on the global flag now call the helper:
- `/config/public` accepts optional auth via new `get_current_user_optional` dep; returns `self_serve_enabled=true` for allowlisted authenticated callers; anonymous calls always see the global flag.
- `/auth/register` allows allowlisted emails to register without an invite code.
**Rejected:**
- **Custom header `X-Internal-Tester-Email` for anonymous flows.** Spoofable. The auth/register-payload checks are sufficient because the user has to OWN the email to register or log in.
- **Separate allowlists per surface (`INTERNAL_PRICING_TESTERS`, `INTERNAL_OAUTH_TESTERS`).** Premature splitting. The Phase O use case is "this small set of people can see the new flow"; one variable handles it. If finer granularity emerges, split then.
- **Database table for the allowlist.** Env var matches the spec from the plan doc and fits the soft-cutover lifecycle — list is small, changes infrequently, lives alongside other deployment-time config.
**Consequences:**
- Stripe internal validation can run end-to-end in prod test mode without flipping the global flag.
- Anonymous callers always see the global flag — the allowlist never leaks via unauthenticated request content. Three regression tests in `test_config_public.py` enforce this.
- `INTERNAL_TESTER_EMAILS` plumbed through `docker-compose.dev.yml` and documented in `backend/.env.example`. Railway prod env will need the same var set during Phase O cutover.
---
## 2026-05-07 — Reconcile plan tier taxonomy (rename `team` → `enterprise`, add `starter`)
**Context:** PR #162 left a real architectural gap. Marketing surface (PricingPage, Stripe products) was wired for `Starter / Pro / Enterprise` while backend was on `free / pro / team`. `plan_billing.plan` FK referenced `plan_limits.plan` so the `BillingPlan` schema's `Literal["pro", "starter", "team", "enterprise"]` could accept values that violated the FK. `plan_billing` was unseeded in dev, so no checkout could complete. `Subscription.plan.in_(["pro", "team"])` paid-plan checks wouldn't recognize `enterprise`. Self-serve cutover was blocked at the data layer.
**Decision:** Reconcile to a single taxonomy — backend slugs become `free / pro / starter / enterprise`, matching the marketing surface and Stripe products. Migration `4ce3e594cb87`:
1. Defensive `UPDATE subscriptions SET plan='enterprise' WHERE plan='team'` (dev had zero such rows; safety for any prod stragglers).
2. Rename the `plan_limits.plan='team'` row to `'enterprise'`.
3. Insert a `starter` row with caps interpolated between free and pro: `max_trees=10`, `max_sessions=75`, `max_users=1`, `max_ai_builds_per_month=15`, no KB Accelerator, no custom branding, no priority support.
Code rename across schemas, `Subscription` paid-plan/`has_pro_entitlement` checks, admin endpoints, frontend `useSubscription.isPaidPlan`. Resource visibility (`Tree.visibility='team'`, `StepLibrary.visibility='team'`) is a separate domain and intentionally untouched — that string means "shared with my account" and has nothing to do with the subscription tier.
New `backend/scripts/sync_stripe_plan_ids.py` — idempotent upsert of `plan_billing` rows from Stripe products by exact name match (`ResolutionFlow Starter / Pro / Enterprise`). Picks the active monthly recurring price for tiers that have one. Annual fields stay NULL by design — annual pricing is intentionally out of scope for the soft cutover ("want to be able to exit if necessary without breaching any terms").
**Rejected:**
- **Map marketing names to existing slugs (Option A from the discussion).** Smallest diff but means PricingPage cards have to translate `enterprise``team` at render time, and "Starter" can't exist as a real backend tier — it'd have to be hidden or dropped. Kicks the can.
- **Add `starter` only, keep `team` slug as cosmetic enterprise (Option C).** Mixed taxonomy across layers — slug-vs-display-name divergence guarantees confusion in 6 months. Compromise that's worse than either pure choice.
- **Annual pricing in this iteration.** User's explicit constraint: skip annual to keep exit-flexibility. Schema columns (`annual_price_cents`, `stripe_annual_price_id`) preserved as nullable for future re-enable.
- **Auto-archive the existing Enterprise `$500/mo` test-mode price.** Done manually via Stripe MCP after un-setting the product's `default_price` first. Spec says Enterprise is sales-led with no catalog price.
**Consequences:**
- `plan_billing` table is now seedable and seeded. Test-mode `plan_billing` populated for all 3 tiers via `sync_stripe_plan_ids.py`. Live mode runs the same script after manual Dashboard setup of products + prices.
- New consumers of `Subscription.plan` literal must use `("free", "pro", "starter", "enterprise")`. Three call sites already updated. Backend-wide grep is the safety net for new ones.
- `Subscription.is_paid` and `has_pro_entitlement` now include `starter` — Starter is a paid tier with a real $19.99/mo price.
- 86/86 passing across the subscription/billing/plan/invite/admin sweep after the rename.
- Test fixtures: `conftest.py` plan_limits seed updated to the new taxonomy. `_seed_plan_limits` helper in `test_plans_public.py` is now a true upsert so tests can override `max_users` even when conftest seeded the canonical value.
---
## 2026-05-07 — Standardize backend Python on 3.12
**Context:** Runtime facts had drifted from docs. The backend Dockerfiles and running dev container were already on Python 3.12, GitHub CI had just been updated to 3.12, but project docs still said Python 3.11 and Gitea CI relied on the runner's ambient Python.

View File

@@ -2,56 +2,67 @@
# HANDOFF.md
**Last updated:** 2026-05-07 (PR #162 CI investigation/fixes)
**Last updated:** 2026-05-14
**Active task:** PR #162 (`feat/self-serve-signup-phase-2`) is open in Gitea. Current session is resolving its failing checks.
**Active task:** Phase O cutover for self-serve signup. All code blockers remain closed on `main`. **Still blocked on Stripe live-mode activation — root cause is EIN, not code.** User does not yet have an EIN for ResolutionFlow, LLC; Stripe requires a tax ID for live-mode activation. EIN application via IRS.gov was scheduled for 2026-05-13 — confirm status at next session start. Mailing-address decision (carried forward from 2026-05-12): user enters home address into Stripe's **private** business profile temporarily so live-mode isn't blocked on the P.O. Box; public `ContactPage`/`PoliciesPage` mailing-address TODOs stay "available on request" until the P.O. Box is purchased. Stripe accepts an address update later without re-verification. Apex DNS at Namecheap is still missing (separate user-side issue, only matters once Stripe runs site-verification). Nothing on the code side blocks live-mode flip.
**Bug-pending-capture item (2026-05-12) — likely resolved:** Prior session noted "user reported finding a bug, will send screenshot next session." This session surfaced two concrete UX bugs that were fixed and merged (PR #168): the dashboard "Start a session" CTA was a dead link, and welcome step-2's PSA setup had a near-invisible "Connect now →" link that didn't even persist `primary_psa`. **Confirm with user at next session start whether the screenshot bug was one of these or something else still pending.**
## Where this session ended
PR #162 originally failed quickly in Gitea CI. Public Gitea status metadata was available, but job logs redirected to login and no `GITEA_TOKEN` was present. The branch was pushed over SSH.
Two PRs merged into main:
Fixed environment drift first:
- **PR #166** (`fe0e692`) — docs/handoff doc updates from prior session. Squash-merged 2026-05-14.
- **PR #168** (`3a35121`) — session expiration policy + dashboard NextStep CTA fix + welcome step-2 PSA CTA reshape. Merge-committed 2026-05-14. Three notable additions:
- `feat(dashboard)` `8d79dd9` — The "Start a session" CTAs on NextStepCard and SetupChecklist used to `Link`-navigate to `/`, leaving the user on the same page (the StartSessionInput lives on the dashboard) with no visible response. Replaced with a `FOCUS_START_SESSION_EVENT` window event the StartSessionInput listens for: scrolls input to viewport top (`scrollIntoView({block:'start'})`), focuses the textarea (with `preventScroll:true` so it doesn't fight the smooth scroll), pulses a `rgba(96,165,250,…)` ring for 900ms. NextStepCard hides itself via local `locallyHidden` state on click so the user isn't double-prompted while typing. SetupChecklist gets the same event-dispatch treatment for its `ran_session` row.
- `feat(welcome)` `dc88797` — Welcome step-2 PSA CTA reshaped. Selecting a real PSA now swaps the single Continue + tiny "Connect now →" link for an explicit two-button choice: `Connect <PSA> now` (primary, blue — saves `primary_psa` then routes to `/account/integrations`) and `Connect later` (secondary outlined — saves `primary_psa` then continues to step 3). **Important pre-existing bug fixed**: the old subtle Link never actually persisted `primary_psa` before navigating away. Both new buttons do. "No PSA yet" and no-selection states still show the original single Continue. Skip-this-step and Skip-the-rest unchanged. Existing tests pass without edits (testids `welcome-step-2-connect-now` and `welcome-step-2-continue` reused).
- `docs:` `e5b2624` — added `docs/plans/2026-05-13-public-landing-routing-refactor.md`, `docs/architecture/` reports (god-node map + report 2026-05-06, workflows.json/html, workflows-analysis.html), `docs/tutorials/build-a-page.md`, and `abc-feat-self-serve-signup-phase-2-design-20260507-112020.md` at repo root.
- Standardized backend native/dev/CI Python on 3.12.13 to match Docker.
- Added `.python-version`.
- Rebuilt `backend/venv` from pyenv Python 3.12.13 and verified native `pytest --version` / `alembic --version` with explicit local env.
- Updated Gitea CI backend/e2e Python setup to 3.12.
`tsc --project tsconfig.app.json --noEmit` clean across all changes. Local vitest blocked by root-owned `node_modules/.vite-temp` (same env issue noted in prior handoffs); CI ran the suite green.
Fixed Gitea runner assumptions next:
**Two issues filed for session leftovers:**
- Added `actions/setup-node@v4` with Node 20 to Gitea frontend and e2e jobs.
- Pushed `fix(ci): set up node in gitea workflow`.
- **Issue #171** — Test coverage for the new welcome step-2 "Connect now" path (existing tests still pass but don't exercise the new button's save + redirect-to-integrations behavior).
- **Issue #172** — Repo hygiene: gitignore `core.[0-9]*` + `**/.remember/`, and delete the existing 20MB core dumps (`core.144926`, `core.145678`, `docs/architecture/core.1392564`) and `docs/architecture/.remember/`. Carried forward across multiple sessions.
Local frontend validation then exposed real lint failures in Phase 2 React code under the current lint stack. The current WIP fixes:
Working tree clean except those persistent untracked items (intentionally left for issue #172).
- `react-refresh/only-export-components` for exported pure helpers used by tests/shared invite OAuth code.
- `react-hooks/set-state-in-effect` warnings where local state intentionally mirrors route/config/cache state.
- `react-hooks/purity` warnings from `Date.now()` during render.
- Redundant loading-state write in pricing page.
Validation after those frontend changes:
- `docker exec -w /app resolutionflow_frontend npm run lint` passed.
- `docker exec -w /app resolutionflow_frontend npm run test:coverage` passed (`198` tests).
- `docker exec -w /app -e NODE_OPTIONS=--max-old-space-size=4096 resolutionflow_frontend npm run build` passed.
Known local noise:
- React `act(...)` warnings appeared in existing tests during coverage but did not fail the suite.
- Vite emitted large chunk warnings during build.
- Unrelated dirty/untracked files remain and should not be staged unless explicitly requested: `docker-compose.dev.yml`, `.env.example`, `abc-feat-self-serve-signup-phase-2-design-20260507-112020.md`, `core.*`, `docs/architecture/`, `docs/tutorials/`.
Single alembic head: `4ce3e594cb87` (no schema changes this session).
## Resume point
1. Commit the frontend lint fixes and `.ai/` handoff updates with the required Codex trailer.
2. Push `feat/self-serve-signup-phase-2`.
3. Poll Gitea PR #162 statuses for the new head SHA:
`curl -fsSL https://gitea.resolutionflow.com/api/v1/repos/chihlasm/resolutionflow/statuses/<sha> | python -m json.tool`
4. If statuses are still pending, report that local frontend CI is green and Gitea runner work is queued/running. If a check fails, public statuses may show only the context/description; logs require authenticated Gitea access.
**First thing next session:**
1. Confirm with user whether the "bug-pending-capture" screenshot bug from 2026-05-12 was one of the two PR #168 fixes or something else still pending.
2. Check EIN application status (filed 2026-05-13 via IRS.gov). If granted, unblocks the Phase O Stripe live-mode setup chain.
After that — **Phase O manual ops, all user-side, all gated on EIN landing first:**
1. **EIN application status check** (user, applied 2026-05-13).
2. **Stripe Dashboard live-mode** (once EIN is in hand):
- 3 Products (Starter, Pro, Enterprise). Monthly Prices for Starter ($19.99) + Pro ($29.99). No Prices on Enterprise (sales-led).
- Customer Portal with plan-switching disabled.
- Webhook at `https://api.resolutionflow.com/api/v1/webhooks/stripe` with 5 events. Save live signing secret.
- **Business profile fields**: Customer service URL `https://resolutionflow.com/contact`. Refund/cancellation policy URL `https://resolutionflow.com/policies`. Terms `https://resolutionflow.com/terms`. Privacy `https://resolutionflow.com/privacy`. Phone `(470) 949-4131`. Mailing address = user's home address temporarily (private Stripe field; swap to P.O. Box later without re-verification). EIN = the newly-issued tax ID.
3. **Apex DNS fix at Namecheap** (re-add `@` ALIAS → `c9g7uku8.up.railway.app`, or re-add apex as a Railway custom domain). Becomes the next blocker once Stripe runs site-verification.
4. **Railway prod env**: `STRIPE_SECRET_KEY=sk_live_...`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PUBLISHABLE_KEY` + `VITE_STRIPE_PUBLISHABLE_KEY` (frontend redeploy required — Vite bake-at-build, Lesson 60), `OAUTH_REDIRECT_BASE=https://resolutionflow.com`, `SELF_SERVE_ENABLED=false` (still false at this point), `INTERNAL_TESTER_EMAILS=<allowlist>`, prod Google + Microsoft OAuth credentials.
5. **Bootstrap prod super-admin** via `create_site_admin.py` (PR #167) — already done end-to-end on prod per 2026-05-12 user confirmation. Re-runnable if needed.
6. **Sync Stripe → DB**: `railway run python -m scripts.sync_stripe_plan_ids` (or via `railway ssh`). Verify `plan_billing` rows have `sk_live_*` price IDs.
7. **Internal validation (Phase O Task 46)**: 9 scenarios with internal testers whose emails match `INTERNAL_TESTER_EMAILS`.
8. **Flag flip (Task 47)**: email pilots, set `SELF_SERVE_ENABLED=true` + `VITE_SELF_SERVE_ENABLED=true` (frontend redeploy). PostHog signup-funnel dashboard + Sentry alert at >1/hour Stripe webhook errors.
## Open issues from prior session (non-code, user-side)
- **Apex DNS missing.** `resolutionflow.com` (apex) returns no A/CNAME at the authoritative DNS (Namecheap). When `www` was reconfigured in Railway, the apex record got dropped from the zone. `www` works (cert provisioned 2026-05-08 01:40 UTC). User to re-add apex record at Namecheap (ALIAS `@``c9g7uku8.up.railway.app`) or re-add the apex as a Railway custom domain. Railway path is more durable.
- **Edge HSTS sticky state on user's machine.** Browser remembers the earlier broken-cert visit. Fix: `edge://net-internals/#hsts` (delete `resolutionflow.com` and `www.resolutionflow.com`) + `#dns` clear host cache + `#sockets` flush.
## Carry-forward
- Phase O manual ops remain pending after PR review/merge: Stripe live setup, internal validation, feature-flag flip.
- Backend env: `SALES_LEAD_RECIPIENT_EMAIL`.
- Frontend env: `VITE_SELF_SERVE_ENABLED`, `VITE_GOOGLE_CLIENT_ID`, `VITE_MS_CLIENT_ID`, `VITE_OAUTH_REDIRECT_BASE`, `VITE_CALENDLY_URL`.
- Single alembic head remains `c6cbfc534fad`; Phase 2 added no migrations.
- Annual pricing intentionally NOT implemented — user wants exit flexibility. Schema columns preserved as nullable. `sync_stripe_plan_ids.py` leaves annual fields NULL.
- `INTERNAL_TESTER_EMAILS` parsed comma-separated → normalized lowercase list. Anonymous callers always see the global flag — allowlist never leaks via unauthenticated request content (regression test enforces).
- Office-hours design doc now at `docs/` root (`abc-feat-self-serve-signup-phase-2-design-20260507-112020.md`) as of this session. NOT yet adopted as roadmap — gated on 3 cold calls with external Directors of Onboarding.
- Mailing address fill-in: search for `TODO: replace with full mailing address` in `frontend/src/pages/ContactPage.tsx` and `frontend/src/pages/PoliciesPage.tsx` (one each) once P.O. Box is purchased.
- `backend/scripts/create_site_admin.py` is the durable site-admin bootstrap tool — idempotent. Three modes: `--send-reset`, `--print-reset`, `--promote-only`. Run from inside the deployed backend container via `railway ssh`.
- Bot-crawlability of legal pages: still SPA-rendered. Stripe didn't enforce content scraping last time (issue was DNS). If a future vendor review flags it, pre-render with `vite-plugin-prerender-spa` (~half day).
- Frontend env additions for cutover: `VITE_SELF_SERVE_ENABLED`, `VITE_GOOGLE_CLIENT_ID`, `VITE_MS_CLIENT_ID`, `VITE_OAUTH_REDIRECT_BASE`, `VITE_CALENDLY_URL`, `VITE_STRIPE_PUBLISHABLE_KEY`.
- **Branch hygiene note (process learning):** PR #168 ended up bundling unrelated work — session expiration policy (the original scope of `feat/session-expiration-policy`) plus dashboard CTA fixes plus welcome step-2 reshape. The mixed scope was deliberate (user wanted it on the same PR), but worth flagging for future PRs: if onboarding-UX work continues, branch it separately from auth/session work.

View File

@@ -12,6 +12,125 @@
---
## 2026-05-14 ~04:00 UTC — Claude — PR #166 + #168 merged; dashboard CTA bug fixed; welcome step-2 PSA CTA reshaped
**Accomplished:**
- User reported the "Start a session" CTA on the dashboard onboarding card doing nothing after completing the welcome wizard. Root cause: `NextStepCard.tsx:80-82` had `ctaPath: '/'` and the card itself only renders on the dashboard at `/`. Clicking `<Link to="/">` while already on `/` is a react-router no-op. Same dead-link in `SetupChecklist.tsx` for the `ran_session` row.
- Designed and built the fix collaboratively (user wanted scroll-to-input + visual pulse rather than auto-navigate to `/pilot` or just hiding the card):
- Added `FOCUS_START_SESSION_EVENT = 'rf:focus-start-session'` window event exported from `StartSessionInput.tsx`. The component listens via `useEffect`, on dispatch calls `wrapperRef.current?.scrollIntoView({behavior:'smooth', block:'start'})`, focuses the textarea with `preventScroll:true` (so it doesn't fight the smooth scroll), and sets a 900ms `nudge` state that swaps the inner wrapper's `focus-within:` ring classes for a louder `ring-2 ring-[rgba(96,165,250,0.35)] shadow-[0_0_0_6px_rgba(96,165,250,0.12)]`. Added `scroll-mt-6` to the outer ref'd div so the input doesn't hug the very top edge.
- `NextStepCard.tsx` — branched on `next.key === 'ran_session'`. Render a `<button>` that dispatches the event AND sets a new `locallyHidden` useState so the card disappears immediately on click (without calling the persisting `dismissOnboarding` API — that would kill all future onboarding nudges). All other CTAs keep the original `Link` element. Tests pass without changes (assertions only check text + testid).
- `SetupChecklist.tsx` — same `ran_session` branch (the checklist had the same dead-link bug if the user expanded "Show all setup steps").
- User then asked about the welcome wizard PSA flow — "is it supposed to take me to set up ConnectWise if I keep clicking next after picking it?" Read `WelcomeStep2.tsx`: the spec was intentionally "just pick what you use, we'll wire it up later" with a `text-xs text-muted-foreground` "Connect now →" link as the only credential-setup entry. The link was visually near-invisible AND had a bug: it was a `<Link to="/account/integrations">` that navigated WITHOUT calling `onboardingApi.updateStep`, so `primary_psa` was never persisted if the user clicked it.
- Proposed three fix options; user picked option 2 (explicit two-button branch). Implemented in `WelcomeStep2.tsx`:
- New `handleConnectNow` handler that calls `onboardingApi.updateStep({step:2, action:'complete', data:{primary_psa}})` then `navigate('/account/integrations')`. New `submitting === 'connect-now'` state value.
- When `showConnectNow` (real PSA selected): action row renders `[Connect <PSA> now (primary)] [Connect later (secondary)] [Skip this step (tertiary)]`. Reused the old `welcome-step-2-connect-now` testid on the new primary button. "Connect later" reuses the `welcome-step-2-continue` testid + handleContinue. PSA label derived dynamically from `PSA_OPTIONS`.
- When 'none' or no selection: original `[Continue] [Skip this step]` preserved.
- Removed the import of `Link` from `react-router-dom` and the entire `showConnectNow && <Link>` block.
- All existing tests pass unchanged (`tsc --noEmit` clean, locally; vitest blocked by root-owned `node_modules/.vite-temp` — same env issue noted previously; CI ran the suite green on the PR).
- Committed in two logical commits onto current branch (`feat/session-expiration-policy`): `feat(welcome): two-button PSA CTA in step-2` (`dc88797`) and `docs: add architecture reports, public-landing routing plan, build-a-page tutorial, self-serve signup phase-2 design` (`e5b2624`). Pushed. PR #168 CI ran green across `CI/backend`, `CI/frontend`, `CI/e2e`. PR #166 merged first (HTTP 200), then PR #168 once CI cleared (HTTP 200). `main` now at `3a35121`.
- Filed two issues for session leftovers:
- **#171** — Test coverage for the new `welcome-step-2-connect-now` path (existing tests still pass but don't exercise the new save + redirect behavior).
- **#172** — Repo hygiene: add `core.[0-9]*` and `**/.remember/` to `.gitignore`, delete the three 20MB core dumps + `docs/architecture/.remember/`.
**Left for next session:**
- Confirm with user whether the "bug-pending-capture" item from 2026-05-12 HANDOFF was one of the two fixes above (dashboard CTA dead-click, welcome step-2 ConnectWise confusion) or a third bug still pending. Likely covered, but worth asking.
- Phase O cutover remains gated on EIN — check status of 2026-05-13 IRS.gov application.
- Issues #171 and #172 sitting in the backlog when there's time.
**Files touched (all merged to main via PR #168 `3a35121` and PR #166 `fe0e692`):**
- `frontend/src/components/dashboard/StartSessionInput.tsx` (event listener, scroll/focus/nudge ring)
- `frontend/src/components/dashboard/NextStepCard.tsx` (event-dispatch button branch, `locallyHidden` state)
- `frontend/src/components/dashboard/SetupChecklist.tsx` (event-dispatch button branch for `ran_session` row)
- `frontend/src/pages/welcome/WelcomeStep2.tsx` (two-button PSA CTA + `handleConnectNow`)
- `docs/plans/2026-05-13-public-landing-routing-refactor.md` (new, untouched by Claude this session — user-authored)
- `docs/architecture/{god-node-map-2026-05-06.canvas, god-node-report-2026-05-06.md, workflows-analysis.html, workflows.html, workflows.json}` (new, generated reports)
- `docs/tutorials/build-a-page.md` (new, user-authored)
- `abc-feat-self-serve-signup-phase-2-design-20260507-112020.md` (root, office-hours design doc — committed as-is from prior local state)
- `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/SESSION_LOG.md` (this update)
---
## 2026-05-12 ~06:30 UTC — Claude — PR #167 (site-admin bootstrap script) merged; bug pending capture
**Accomplished:**
- User reported being unable to log into prod with `admin@resolutionflow.example.com` — that's the dev seed email (`.example.com` is a documentation TLD), only present in dev. Prod has no admin user at all because `seed_test_users.py` doesn't run in prod, self-serve is still gated, and even when it flips on signup creates `owner` roles not `super_admin`.
- Designed and built `backend/scripts/create_site_admin.py` — idempotent CLI script for creating or promoting a site-wide super-admin on any environment. Three modes: `--send-reset` (mails reset link), `--print-reset` (stdout reset link), `--promote-only` (promote existing user without creating). Creates an `Account` first, then a `User` with `is_super_admin=true`, `account_role='owner'`, `email_verified_at` stamped at creation, `password_hash=NULL` (forces the reset flow on first login). Uses `ADMIN_DATABASE_URL` (BYPASSRLS) — required because `users` is RLS-enabled and the script has no tenant context at bootstrap. Reset token mints via existing `create_password_reset_token` helper, hashes JTI into `password_reset_tokens` row matching the `/auth/password/forgot` shape.
- Smoke-tested all three paths in the dev container before pushing: fresh create on a new email (Account + User + reset URL emitted), idempotent re-run on same email (SKIP message + new reset URL), `--promote-only` on a user with `password_hash=NULL` (promotes + issues reset). Cleaned up the dev test row + account afterwards.
- Initial bug: had `used: false` in the `password_reset_tokens` INSERT — actual column is `used_at` (nullable timestamp, NULL means "not used"). Fixed before pushing.
- PR #167 opened, CI green, squash-merged into main as `e50a215`. Remote branch `feat/site-admin-script` auto-deleted.
- User confirmed end-to-end success on prod via `railway ssh --service=<backend>` then `python -m scripts.create_site_admin ...` ("we're good now"). Specific service name not captured. First prod super-admin row now exists in the prod DB.
- Stripe live-mode activation block traced to EIN, not code (user does not yet have an EIN for ResolutionFlow, LLC). Applying via IRS.gov 2026-05-13. Mailing-address decision: home address into Stripe's **private** business profile temporarily so live-mode isn't blocked on the P.O. Box; public `ContactPage`/`PoliciesPage` stays "available on request". Stripe accepts address update later without re-verification.
- PR #166 (docs handoff for PR #164/#165 merges + EIN decision) still open from earlier in this same session — was never merged. This entry rebases the docs branch onto current main (which now includes PR #167) and adds the PR #167 narrative + bug-pending state so a fresh session has the full picture in one merge.
- User reported finding a bug in a UI surface but did not provide details — planning to send a screenshot via the VS Code extension GUI in the next session (CLI is unreliable for them). Next session: ask for the screenshot at session start, then triage.
**Left for next session:**
- Get the bug screenshot from the user, triage, fix or scope.
- Otherwise everything that was on the prior entry's left-for-next-session still stands: EIN application Tuesday 2026-05-13, then Stripe live-mode setup, apex DNS at Namecheap, Railway prod env vars, internal validation, flag flip.
**Files touched (all merged to main via PR #167 squash `e50a215`):** `backend/scripts/create_site_admin.py` (new, ~270 lines including docstring). Plus `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md` on `docs/handoff-pr-165-merge` (PR #166, awaiting merge).
---
## 2026-05-12 05:30 UTC — Claude — PR #164 + #165 merged; Stripe activation reported blocked
**Accomplished:**
- Resumed from compacted context. Confirmed PR #164 (`feat/billing-plan-taxonomy`, head `2c9f5e9`) was already CI-green at session start and squash-merged into main as `3f04911` earlier in the session (occurred pre-compaction; reflected in the prior HANDOFF revision). Branch auto-deleted on remote.
- User raised the legal/contact pages question in conversation. Verified existing state of `frontend/src/pages/{PrivacyPage,TermsPage}.tsx` — both already contain real, dated content (last updated 2026-03-21) but are SPA-rendered. Discussed Stripe's site-review needs with the user and agreed to build a consolidated Customer Policies page plus a Contact page (now that the user has a business phone number) plus a Promotions stub to satisfy Policies §6.2 cross-reference. User authorized the work.
- Built PR #165 (`feat/stripe-legal-pages`, head `545b2ad`):
- **`/policies``frontend/src/pages/PoliciesPage.tsx`** (new). Consolidated Customer Policies doc, 8 sections with anchor IDs per subsection so Stripe (or a support email) can deep-link: customer service contact (with phone (470) 949-4131), return policy (n/a — SaaS), refund / dispute policy, cancellation policy, U.S. legal and export restrictions (Georgia governing law, OFAC / BIS compliance, sanctioned-jurisdiction exclusion), promotional terms (general + cross-ref to `/promotions`), changes-to-policies, relationship-to-other-agreements. Mailing address left as in-source `TODO` comment, rendered publicly as "available on request — email support@" until P.O. Box is purchased.
- **`/contact``frontend/src/pages/ContactPage.tsx`** (new). Phone **(470) 949-4131**, all four inboxes (`support@`, `sales@`, `billing@`, `security@`), response-time SLAs, mailing-address placeholder, link to `/contact-sales` for the lead-gen Calendly flow (distinct surface — kept both routes intentionally).
- **`/promotions``frontend/src/pages/PromotionsPage.tsx`** (new). One-paragraph stub stating no promotions currently active. Will be appended to when offers run; satisfies Policies §6.2's cross-reference.
- Routes wired in `frontend/src/router.tsx` as 3 new public lazy-loaded routes alongside existing `/privacy`, `/terms`, `/pricing`, `/contact-sales`.
- **`MarketingFooter``frontend/src/components/common/MarketingFooter.tsx`** (new, second commit). Extracted from the inline landing footer (26 lines → 1 line at the call site). Mounted on `/landing`, `/pricing`, `/contact-sales` so all four legal links (Privacy / Terms / Policies / Contact) are reachable from every marketing surface — including the page Stripe's reviewer spends the most time on (`/pricing`). Reuses existing `landing-footer*` CSS in `frontend/src/styles/landing.css` — must be rendered inside a `.landing-page` wrapper because `--lp-*` vars are scoped there (documented in a JSX comment). All three current call sites already wrap in `.landing-page`, so landing renders pixel-identically and the two new mount sites match.
- **Privacy and Terms closing sections** updated to point at `/contact` + `/policies` with correct per-area inboxes (`security@` for Privacy, `support@` for Terms). Stale `hello@resolutionflow.com` mailto removed everywhere.
- `tsc --project tsconfig.app.json --noEmit` clean, `eslint` clean. Local `vite build` and `tsc -b` blocked by root-owned `node_modules/.tmp` and `node_modules/.vite-temp` cache directories — CI rebuilds from a clean env and was green.
- PR #165 opened at `gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/165`, CI passed, squash-merged into main as `ba45cfe`. Remote branch `feat/stripe-legal-pages` auto-deleted.
- User reports continued trouble activating Stripe live mode. After follow-up: the real blocker is the EIN — ResolutionFlow, LLC does not have one yet, and Stripe requires a tax ID before it will activate live mode. User is applying via IRS.gov on 2026-05-13. Updated HANDOFF.md to remove the earlier speculation list and record EIN as the named blocker, with the P.O. Box / mailing address called out as the likely-next blocker (Stripe live-mode also requires a business mailing address). Apex DNS at Namecheap is still pending but only matters after the business profile is accepted (site verification is a downstream step).
- Mailing-address decision: user is going with the home-address-temporarily approach for Stripe so live-mode isn't blocked on the P.O. Box. Home address goes into Stripe's **private** business profile only — the **public** `TODO: replace with full mailing address` in `ContactPage.tsx` and `PoliciesPage.tsx` stays as "available on request" until the P.O. Box is purchased. Stripe accepts updating the address later without re-verification, so swapping in the P.O. Box when it arrives is non-disruptive.
**Left for next session:**
- Check in on whether the EIN application went through and whether the P.O. Box / mailing address is sorted. Both are pure user-side ops; no code work to do until Stripe accepts the business profile.
- Once Stripe is activated: Stripe Dashboard live-mode product/price/webhook setup, Railway prod env vars, `railway run python -m scripts.sync_stripe_plan_ids` against prod, 9-scenario internal validation, flag flip.
- Apex DNS at Namecheap (still missing; only matters once Stripe runs its site-verification step).
- Mailing address TODO in `ContactPage.tsx` and `PoliciesPage.tsx` (one each) — fill in when P.O. Box is purchased.
**Files touched (all merged to main via PR #165 squash `ba45cfe`):** `frontend/src/pages/ContactPage.tsx` (new), `frontend/src/pages/PoliciesPage.tsx` (new), `frontend/src/pages/PromotionsPage.tsx` (new), `frontend/src/components/common/MarketingFooter.tsx` (new), `frontend/src/router.tsx`, `frontend/src/pages/LandingPage.tsx`, `frontend/src/pages/PricingPage.tsx`, `frontend/src/pages/ContactSalesPage.tsx`, `frontend/src/pages/PrivacyPage.tsx`, `frontend/src/pages/TermsPage.tsx`. Plus `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/SESSION_LOG.md` on the `docs/handoff-pr-165-merge` branch (this entry).
---
## 2026-05-08 03:30 UTC — Claude — PR #164 self-serve cutover code blockers, doc refresh, page-title bug, DNS triage
**Accomplished:**
- Merged PR #162 (self-serve Phase 2 frontend) and PR #163 (seed users email-verified) into main via Gitea API squash merge. Created branch `feat/billing-plan-taxonomy` off the new main; pushed 5 commits closing the last code blockers for Phase O cutover. PR #164 opened at gitea pulls/164.
- Plan taxonomy reconciliation. Discovered the marketing surface (PricingPage, Stripe products) was wired for `Starter / Pro / Enterprise` while backend was on `free / pro / team`; `BillingPlan` schema's `Literal["pro","starter","team","enterprise"]` could accept FK-violating values; `plan_billing` was unseeded. Migration `4ce3e594cb87` renames `plan_limits.plan='team'``'enterprise'` (defensive update of any subscriptions on the old slug; dev had zero), adds `starter` row with caps interpolated between free and pro (`max_trees=10`, `sessions=75`, `users=1`, `ai=15/mo`, no KB Accelerator, no custom branding, no priority support). Code rename across schemas (`invite_code`, `billing`, `admin`, `subscription`), `Subscription` paid-plan/`has_pro_entitlement` checks, `admin_dashboard.py`, `admin.py`, frontend `useSubscription.isPaidPlan`. Resource visibility (`Tree.visibility='team'`, `StepLibrary.visibility='team'`) is a separate domain (means "shared with my account") and intentionally untouched. 86/86 passing across subscription/billing/plan/invite/admin sweep after the rename. Conftest plan_limits seed + `_seed_plan_limits` helper made a true upsert.
- New `backend/scripts/sync_stripe_plan_ids.py` — idempotent upsert from Stripe products by exact name match (`ResolutionFlow Starter / Pro / Enterprise`), picks active monthly recurring price, leaves annual fields NULL by design. Works against test or live keys via `STRIPE_SECRET_KEY`. Run against test mode populated `plan_billing` for all 3 tiers in dev DB. Annual pricing intentionally skipped per user's exit-flexibility constraint.
- Stripe MCP work (test mode, `livemode=false`): archived leftover Enterprise `$500/mo` test price (had to clear the product's `default_price` first — Stripe blocks archive otherwise). Verified test-mode product set: Starter $19.99/mo, Pro $29.99/mo, Enterprise no price (sales-led).
- `INTERNAL_TESTER_EMAILS` allowlist. Phase O Task 46 needed it as a code blocker (flagged in prior SESSION_LOG as "backend support is NOT yet built"). `Settings.is_internal_tester` (case-insensitive membership) + `is_self_serve_active_for(email)` (returns global flag OR allowlist hit) centralize the check. New `get_current_user_optional` dep — best-effort auth that returns `None` instead of 401, used by `/config/public` so the same endpoint serves anonymous and authed. `/config/public` returns `self_serve_enabled=true` for authenticated allowlist members; `/auth/register` allows allowlisted emails without invite code. 5 regression tests including "anonymous callers always see the global flag" (prevents leak via unauthenticated request content).
- Stripe env passthrough: `docker-compose.dev.yml` now wires `STRIPE_*` + `SELF_SERVE_ENABLED` + `INTERNAL_TESTER_EMAILS` into the backend container. New repo-root `.env.example`. `backend/.env.example` updated with the self-serve cutover vars.
- Page-title bug fix on `LandingPage.tsx`. Two JSX attribute strings (`title="..."`, `description="..."`) had `—` (six literal characters) — JSX attribute strings don't process JS escape sequences, so the browser tab and OG description rendered the literal text instead of an em dash. Replaced with the literal em dash character. Verified by grep — every other `\u...` in the codebase is inside a real JS string (`'...'` literal or `{...}` JSX expression) where escapes resolve at compile time. PageMeta default tagline updated from stale "Decision Tree Platform" to "AI-Powered Troubleshooting for MSPs" (matches index.html and brand positioning).
- Frontend taxonomy followups (caught by tsc -b after rebuild). The earlier taxonomy commit didn't propagate through frontend types: `types/account.ts`, `types/admin.ts`, `types/billing.ts`, `admin/AccountsPage.tsx` (state type, select onChange cast, `<option value="team">` rendered UI), `admin/InviteCodesPage.tsx` (PLAN_OPTIONS array, state type, onChange cast), `AccountSettingsPage.tsx` (`plan !== 'team'` check + CheckoutButton prop), `subscription/CheckoutButton.tsx` (prop type + planLabels). All updated to `'free' | 'pro' | 'starter' | 'enterprise'`. tsc clean. Lint clean (3 warnings only in auto-generated `coverage/`).
- Doc refresh commit (`docs: refresh CURRENT-STATE, ROADMAP, README, DECISIONS for self-serve cutover`). CURRENT-STATE bumped to 2026-05-07; added entries for PR #159164; refreshed What's In Progress / What's Next around Phase O. ROADMAP got a "Status as of 2026-05-07" preamble (months-stale historical content kept underneath as record); In Progress and What's Next sections updated. README fixed legacy `patherly_postgres` Docker command, project-tree path, `UI-DESIGN-SYSTEM.md` reference; added `AGENTS.md`, `PROJECT_CONTEXT.md`, `PRODUCT.md` to docs table. DECISIONS appended two entries (taxonomy reconciliation, allowlist).
- Office-hours session ran via `/office-hours` skill earlier in this session. Design doc saved at `~/.gstack/projects/chihlasm-resolutionflow/abc-feat-self-serve-signup-phase-2-design-20260507-112020.md`. Captured the "documentation builder" thesis — cut branching Flows from pilot UI, focus product around FlowPilot + Day 1 onboarding checklist as navigational frame + 3 deep-capture procedures (M365 tenant build, Windows server build, credential vault) + Hudu/IT Glue/ConnectWise output. Founder is a Director-of-Onboarding at his own MSP (Andrea Henry); pre-build assignment is 3 cold calls with external Directors of Onboarding before scoping. NOT yet adopted as roadmap.
- DNS / cert triage: `www.resolutionflow.com` was unreachable (Railway "train hasn't arrived" page) — user added it as a custom domain in Railway, cert provisioned at 2026-05-08 01:40 UTC, `www` now serves 200 with valid Let's Encrypt SAN. Apex `resolutionflow.com` separately discovered to have NO A/CNAME at authoritative DNS (Namecheap per SOA `dns1.registrar-servers.com.`). When user reconfigured `www`, the apex record dropped from the zone. From Railway-edge IP both names work fine when DNS is forced (proven by `curl --resolve` returning 200 OK from user's box) — so the apex cert is also valid; the failure mode is purely DNS-level absence. User asked for HSTS clearance steps in Edge — provided `edge://net-internals/#hsts`, `#dns`, `#sockets` walkthrough plus Linux DNS flush options.
**Left for next session:**
- Verify PR #164 CI green, then squash-merge.
- Phase O manual ops sequence (Stripe Dashboard live-mode setup, Railway prod env vars including `INTERNAL_TESTER_EMAILS`, run `sync_stripe_plan_ids.py` against prod, internal validation Task 46, flag flip Task 47, PostHog dashboards, Sentry alert).
- User-side: re-add apex DNS record at Namecheap (ALIAS `@``c9g7uku8.up.railway.app`, or re-add apex in Railway), clear Edge HSTS state.
**Files touched (all on `feat/billing-plan-taxonomy`, all pushed):** `backend/alembic/versions/4ce3e594cb87_add_starter_rename_team_to_enterprise.py` (new), `backend/scripts/sync_stripe_plan_ids.py` (new), `backend/app/{schemas/{billing,invite_code,admin,subscription}.py, models/subscription.py, api/{deps.py, endpoints/{auth.py, admin.py, admin_dashboard.py, config.py}}, core/config.py}`, `frontend/src/{components/{common/PageMeta.tsx, subscription/CheckoutButton.tsx}, hooks/useSubscription.ts, pages/{LandingPage.tsx, AccountSettingsPage.tsx, admin/{AccountsPage.tsx, InviteCodesPage.tsx}}, types/{account.ts, admin.ts, billing.ts}}`, `backend/tests/{conftest.py, test_admin_plan_limits.py, test_invite_plan.py, test_plans_public.py, test_config_public.py}`, `docker-compose.dev.yml`, `.env.example` (new), `backend/.env.example`, `CURRENT-STATE.md`, `03-DEVELOPMENT-ROADMAP.md`, `README.md`, `.ai/{DECISIONS.md, HANDOFF.md, CURRENT_TASK.md, SESSION_LOG.md}`.
---
## 2026-05-07 11:45 EDT — Codex — Push PR #162 CI runner setup fixes
- Inspected Gitea PR #162 via public API. PR head was `380fcf7` and all CI jobs failed quickly; pushed local commits through `4a37a47`, including Python 3.12 setup for Gitea backend/e2e jobs.

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
REPO_ROOT=/opt/docker/code-server/workspace/resolutionflow
POSTGRES_PORT=5433
SECRET_KEY=
ANTHROPIC_API_KEY=
GOOGLE_AI_API_KEY=
STRIPE_SECRET_KEY=sk_test_
STRIPE_PUBLISHABLE_KEY=pk_test_
STRIPE_WEBHOOK_SECRET=whsec_
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_
INTERNAL_TESTER_EMAILS=internaltest@resolutionflow.com

View File

@@ -1,11 +1,25 @@
# Development Roadmap
> **Last Updated:** March 18, 2026
> **Product:** ResolutionFlow (repo: patherly)
> **Last Updated:** May 7, 2026
> **Product:** ResolutionFlow (repo path: `resolutionflow/`; `patherly` is the legacy internal name)
> **Target Market:** MSP companies — IT service providers managing infrastructure and support for multiple clients
---
## Status as of 2026-05-07
The historical phase content below (Phase 1 through Phase 5) is preserved as a factual record. **This section is the live status overlay — read it first.**
**Where we are:** Pre-PMF, Go-to-Market Validation. Backend feature-complete (50+ endpoints, 100+ tests). FlowPilot session UX is the daily-driver surface and recently went through PR #155 (escalation wedge), #156 (`applied_pending` non-terminal status), #158 (impeccable pass + tasklane keyboard flow), #159 (Diátaxis User Guides), #160 (sidebar IA + account redesign).
**Currently in flight:** Self-serve signup cutover. Phase 1 backend (#161) and Phase 2 frontend (#162) merged. PR #164 (open) closes the last code blockers — plan taxonomy reconciliation (`team``enterprise`, add `starter`) and `INTERNAL_TESTER_EMAILS` allowlist for the soft cutover. After merge, remaining work is **manual operations only**: Stripe Dashboard live-mode setup, Railway prod env vars, internal validation pass, public flag flip. See `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md` Phase O for the checklist.
**Product thesis being tested:** "We're not a documentation app. We are the documentation builders." Captured in `~/.gstack/projects/chihlasm-resolutionflow/abc-feat-self-serve-signup-phase-2-design-20260507-112020.md` (office-hours design doc). Pre-build assignment: 3 calls with external Directors of Onboarding (cold, no friendly contacts) to validate the framing before adopting it as the public positioning.
**What's not yet decided:** Whether to formally cut branching Flows from the pilot UI surface in favor of a Project (linear procedure) + FlowPilot + Documentation-Builder positioning. Discussed in /office-hours but no implementation work scheduled — gated on the 3 external validation calls.
---
## Completed Work
### Phase 1: MVP
@@ -72,13 +86,26 @@
| Task | Status | Notes |
|------|--------|-------|
| ConnectWise PSA Integration (Advanced) | In Progress | Core done — ticket linking, note posting, member mapping. Remaining: callback webhooks, deeper ticket context in sessions |
| PR #114 Merge | In Progress | Empty states, onboarding, PDF exports, branding, supporting data — ready for review |
| Self-serve signup cutover (Phase O) | In Progress | PR #164 merge → Stripe live-mode Dashboard setup → Railway prod env vars → internal validation → public flag flip. Code blockers cleared by #164 (taxonomy + `INTERNAL_TESTER_EMAILS` allowlist). |
| External validation of documentation-builder thesis | Not started | 3 calls with external Directors of Onboarding (cold). Decision gate before scoping a "Day 1 onboarding checklist" build. |
| ConnectWise PSA Integration (Advanced) | Deferred | Core complete — ticket linking, note posting, member mapping, ticket context retrieval. Callback webhooks deferred until pilot signal demands them. |
---
## What's Next
### Phase O Cutover (Weeks 0-1)
| Step | Status |
|---|---|
| Merge PR #164 (taxonomy reconciliation + allowlist) | Open, CI green |
| Stripe Dashboard live-mode setup (Products + Prices for Starter/Pro, no Prices on Enterprise, Customer Portal config, webhook endpoint with 5 events) | Manual op |
| Railway prod env vars (`sk_live_*`, `whsec_*`, `INTERNAL_TESTER_EMAILS`, prod Google + Microsoft OAuth credentials, `OAUTH_REDIRECT_BASE`, `STRIPE_PUBLISHABLE_KEY`, `VITE_STRIPE_PUBLISHABLE_KEY` for frontend redeploy) | Manual op |
| Run `python -m scripts.sync_stripe_plan_ids` against prod backend; verify `plan_billing` has `sk_live_*` price IDs | Manual op |
| Internal validation pass (9 scenarios from Phase O Task 46) | Manual op |
| Email pilots about complimentary status, flip `SELF_SERVE_ENABLED=true` (frontend redeploy required for `VITE_SELF_SERVE_ENABLED`) | Manual op |
| PostHog signup-funnel dashboard + Sentry alert at >1/hour Stripe webhook errors | Manual op |
### Near-Term Priorities (from Stack Priorities Plan)
| Feature | Status | Description |
@@ -86,7 +113,7 @@
| Coverage gates in CI | ✅ Complete | Backend enforced at 80%, frontend coverage reporting enabled |
| Security headers | ✅ Complete | HSTS, CSP (report-only), X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy |
| Web Vitals / performance budgets | ✅ Complete | LCP, INP, CLS, FCP, TTFB reported to PostHog via web-vitals |
| Search and recall improvements | ⬜ Not started | Search sessions by flow, tag, client, ticket context |
| Search and recall improvements | ✅ Complete | Structured filters + FTS + Voyage AI semantic search shipped (see CURRENT-STATE.md "Search & Recall" section) |
### 3A: Quick Wins & UX (Priority: Medium)

View File

@@ -2,16 +2,32 @@
> **Purpose:** Quick-reference file showing exactly where the project stands.
> **For Claude Code:** Read this first to understand what's done and what's next.
> **Last Updated:** May 1, 2026
> **Last Updated:** May 7, 2026
---
## Active Phase: Go-to-Market Validation (Pre-PMF)
## Active Phase: Go-to-Market Validation (Pre-PMF) — Self-serve cutover (Phase O) in flight
Self-serve signup backend (Phase 1) and frontend (Phase 2) are merged. Cutover (Phase O) is gated on manual ops: live-mode Stripe Dashboard config, Railway prod env vars, internal validation pass against prod test mode, then the public flag flip. Plan: `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md`.
---
## Recently shipped (post-0.1.0.0)
- **2026-05-13 — `feat/session-expiration-policy` (open)** Session expiration policy series — 8 commits, fixes the "logged in forever" bug and adds owner-side controls. Migration `b269a1add160` adds `accounts.session_idle_minutes` + `session_absolute_minutes` (NULL = use system default, defaults Strict 3d/14d via `Settings.SESSION_*_MINUTES_DEFAULT`). Refresh-token JWT carries `auth_time` + `idle_max` + `abs_max` claims (seconds) snapshotted at every login entry point (`/auth/login`, `/auth/login/json`, both OAuth callbacks). `/auth/refresh` enforces absolute cap (`now >= auth_time + abs_max` → 401 `session_expired_absolute`), atomic-revoke-then-check prevents replay. Error-detail taxonomy on the wire distinguishes `session_expired_idle` / `session_expired_absolute` / `invalid_refresh_token`. New owner-only `GET/PATCH /accounts/me/security` returns `{idle_minutes, absolute_minutes, effective_*, *_min/max, active_users}` with audit logging on PATCH. `POST /accounts/me/security/revoke-sessions` bulk-revokes refresh tokens for the account (`scope: "all" | "others"`), audited. Frontend: new `/account/security` page (Strict/Standard/Custom presets, active-users list with name + email + last-login-ago, count-aware revoke buttons + confirmation modal), `useAuthSessionExpiry` hook + top-of-app `SessionExpiryToast` (differentiated by idle vs absolute), cyan info-tone banner on `/login?reason=session_expired`. Plan + design review in `docs/plans/2026-05-13-session-expiration-policy.md` (initial 4/10 → 9/10 via `/plan-design-review`). 28 backend tests; tsc clean. Pending: open PR, merge, document follow-up issues (per-user device list, super-admin global ceiling UI).
- **2026-05-07 — PR #164 (open)** Plan taxonomy reconciliation + `INTERNAL_TESTER_EMAILS` allowlist. Marketing surface (PricingPage, Stripe products) used `Starter / Pro / Enterprise` while backend was on `free / pro / team`, leaving `plan_billing` unseeded and `BillingPlan` schema accepting a literal that violated the FK. Migration `4ce3e594cb87`: rename `team``enterprise` in `plan_limits`, add `starter` row (caps interpolated between free and pro: `max_trees=10`, `sessions=75`, `ai=15/mo`), defensive update of any subscriptions on the `team` slug. Code rename across schemas, `Subscription` paid-plan checks, admin endpoints, and frontend `useSubscription`. Resource visibility (`Tree.visibility='team'`, `StepLibrary.visibility='team'`) is a separate domain and intentionally untouched. New `backend/scripts/sync_stripe_plan_ids.py` — idempotent upsert of `plan_billing` rows from Stripe products by exact name match, picks active monthly recurring price, leaves annual fields NULL by design. Test-mode `plan_billing` populated for all 3 tiers in dev. Phase O Task 46 allowlist: `INTERNAL_TESTER_EMAILS` env var (comma-separated) bypasses `SELF_SERVE_ENABLED=false` for specific authenticated users — `Settings.is_self_serve_active_for(email)` centralizes the check; `/config/public` returns `self_serve_enabled=true` for allowlisted authenticated callers; `/auth/register` allows allowlisted emails to register without invite code. New `get_current_user_optional` dep for endpoints that work both anonymous and authed.
- **2026-05-06 — PR #163** Seed test users marked email-verified. Fixed seeded users showing the email verification banner in dev/test, blocking flows that gate on `email_verified=True`. Squash-merged into main as `dad5e1f`.
- **2026-05-06 — PR #162** Self-serve signup Phase 2 (frontend cutover). 18 commits across Tasks 2744 of the Phase 2 plan: backend remainders + frontend billing foundation + auth surfaces (OAuth + accept-invite + verify-email) + welcome wizard + dashboard redesign (TrialPill, NextStepCard, unified checklist) + public surfaces (`/pricing`, `/contact-sales`) + beta-signup deprecation. Single alembic head `c6cbfc534fad` (no new migrations in Phase 2). Squash-merged as `f1be3ab`.
- **2026-05-?? — PR #161** Self-serve signup backend (Phase 1). `plan_billing` sibling table for Stripe + catalog metadata, `sales_leads` and `stripe_events` tables, `complimentary` status with `has_pro_entitlement`, `BillingService.start_trial` wired into `/auth/register`, `/billing/checkout-session`, Stripe webhook handler with idempotency via `stripe_events`, Google + Microsoft OAuth callbacks with `oauth_identities` linking, `require_verified_email_after_grace` + `require_active_subscription` guards, bulk-create + soft-revoke invite endpoints, account-invite email-match enforcement, pilot complimentary backfill, `accounts.team_size_bucket` + `primary_psa` for wizard. Squash-merged as `f918b76`.
- **2026-05-02 — PR #159** In-product User Guides rewrite to Diátaxis how-tos. Replaced 15 feature-dump guides with 43 problem-oriented how-tos grouped under 10 categories. Dropped Maintenance Flows / AI Assistant / Flow Assist Sparkles guides (UI no longer exists). Renamed Step Library → Solutions Library. Authored 14 net-new how-tos for FlowPilot-era surfaces (tasklane keyboard flow, what-we-know, resolve, escalate, record-fix-outcome, post-docs-to-ticket, share-update, pause-and-leave, build-script-from-scratch, open-suggested-flow, pin-a-flow, invite-teammate, etc.). Schema additions: `category`, optional `relatedSlugs`. Browser-verified against engineer + owner login.
- **2026-05-?? — PR #160** Post-PR-159 UI cleanup — sidebar IA + account redesign. Squash-merged as `a8b22cf`.
- **2026-05-01 — PR #158** Session-screen UX impeccable pass + tasklane keyboard flow. Heuristic score 24/40 → 33/40 across five sub-passes (distill, quieter, layout, typeset, polish). Removed duplicate "Suggested checks" chip strip → TaskLane is the single source of truth; added inline `Next steps · N pending` cue on the latest action-bearing AI bubble; consolidated session header to Resolve + Escalate + ⋯ kebab; centered messages column to match composer; dropped all banned decorations (side stripes, gradient surfaces, backdrop blur, accent borderTop) for a single decoration channel per surface; unified 14 text sizes into a 5-step scale. TaskLane keyboard flow: Enter submits + auto-advances, Shift+Enter newline, Esc cancel, focus jumps to Send after the last task. Banner ↔ script-panel are now linked (collapse hides both, any outcome closes both). WhatWeKnow section is collapsible with `sessionStorage` memory + auto-collapse-at-5-facts. Side fix: ParameterizationPreview no longer over-highlights short parameter values (word-boundary check). Two backlog entries logged in `.ai/TODO.md`: ConcludeSessionModal multi-select and `bg-card-hover` Tailwind drift in CommandPalette.
- **2026-05-01 — PR #156** Suggested-fix "Awaiting verification" outcome. Engineers can now park a fix in `applied_pending` (waiting on client power-cycle, AD replication, license sync, etc.) instead of forcing a synchronous worked/didn't/partial verdict. PendingBanner with worked / didn't / update reason / dismiss; nudge "Still checking" records pending with a reason; page-level Resolve auto-patches pending → success before the resolution flow opens; page-level Escalate intercepts pending. Migration `c0f3a4b7e91d` (`pending_reason` column + status CHECK constraint).
- **2026-04-30 — PR #155** Escalation Mode wedge. Magic-moment handoff-context screen for senior pickup, live SSE escalation arrivals, post-claim time-to-first-action metric (`GET /analytics/flowpilot/escalations`), atomic role-gated claim with conflict resolution, queue self-exclusion, chat ownership extended to claimed sessions. The wedge for the first paying-customer push.
@@ -215,17 +231,30 @@
## What's In Progress
- **GTM Validation:** Shadow & Ship — founder uses product for 2 weeks, then hands logins to 5 colleagues
- **Solutions Library spec:** Written at `docs/plans/2026-03-23-solutions-library-design.md`, implementation deferred to post-pilot
- **Self-serve cutover (Phase O):** PR #164 (open) closes the last code blockers — taxonomy reconciliation + `INTERNAL_TESTER_EMAILS` allowlist. After merge, remaining work is purely manual ops: live-mode Stripe Dashboard config, Railway prod env vars, internal validation pass with Andrea Henry + 2-3 external Directors of Onboarding, then `SELF_SERVE_ENABLED=true` flip with frontend redeploy.
- **Stripe live-mode setup:** Test-mode is fully wired (3 products, monthly prices for Starter/Pro, Enterprise sales-led, `plan_billing` seeded via `sync_stripe_plan_ids.py`). Live mode requires manual Dashboard config — same script handles seeding live IDs.
- **GTM Validation:** Shadow & Ship — founder uses product for real MSP tickets daily, then hands logins to 5 colleagues.
- **Solutions Library spec:** Written at `docs/plans/2026-03-23-solutions-library-design.md`, implementation deferred to post-pilot.
---
## What's Next (Priority Order)
### Phase O Cutover (Weeks 0-1)
- Merge PR #164
- Stripe Dashboard live-mode setup (Products + Prices for Starter/Pro, no Prices on Enterprise, Customer Portal config, webhook endpoint with 5 events)
- Railway prod env vars (`sk_live_*`, `whsec_*`, `INTERNAL_TESTER_EMAILS`, prod Google + Microsoft OAuth credentials, `OAUTH_REDIRECT_BASE`)
- Run `sync_stripe_plan_ids.py` against prod backend; verify `plan_billing` has `sk_live_*` price IDs
- Internal validation pass (9 scenarios from Phase O Task 46 plan)
- Email pilots about complimentary status, flip `SELF_SERVE_ENABLED=true` (frontend redeploy required for `VITE_SELF_SERVE_ENABLED`)
- PostHog dashboards + Sentry alert at >1/hour Stripe webhook errors
### Pilot Phase (Weeks 1-2)
- Founder dogfooding: use ResolutionFlow for real MSP tickets daily
- Collect feedback on copilot-first experience
- 3 calls with external Directors of Onboarding to validate the documentation-builder thesis (cold pitch, no friendly contacts)
- Collect feedback on copilot-first experience and self-serve onboarding flow
- Fix issues discovered during real usage
### Post-Pilot (Weeks 3-4)

View File

@@ -13,8 +13,8 @@
```bash
# Prerequisites: Docker, Python 3.12, Node.js 20+
# Start PostgreSQL
docker start patherly_postgres
# Start PostgreSQL (and the rest of the dev stack)
docker compose -f docker-compose.dev.yml up -d
# Backend
cd backend
@@ -105,16 +105,17 @@ Every session generates timestamped, detailed notes formatted for your PSA. Engi
## Project Structure
```
patherly/
resolutionflow/
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI entry point
│ │ ├── api/endpoints/ # Route handlers (35+ endpoints)
│ │ ├── api/endpoints/ # Route handlers (50+ endpoints)
│ │ ├── core/ # Config, database, permissions, security
│ │ ├── models/ # SQLAlchemy models
│ │ ├── schemas/ # Pydantic schemas
│ │ └── services/psa/ # PSA provider abstraction layer
│ ├── alembic/ # Database migrations
│ ├── scripts/ # Seed + sync scripts (incl. sync_stripe_plan_ids.py)
│ └── tests/ # Integration tests (100+)
├── frontend/
│ ├── src/
@@ -122,13 +123,19 @@ patherly/
│ │ ├── pages/ # Page components
│ │ ├── store/ # Zustand stores
│ │ └── types/ # TypeScript interfaces
├── .ai/ # Dual-agent handoff system (PROJECT_CONTEXT, HANDOFF, etc.)
├── docs/ # Design docs, plans, ConnectWise reference
├── brand-assets/ # SVGs, brand guide
├── CLAUDE.md # AI assistant project context
├── CLAUDE.md # AI assistant project context (Claude Code)
├── AGENTS.md # AI assistant project context (Codex; shared protocol with CLAUDE.md)
├── CURRENT-STATE.md # Detailed feature status
├── DESIGN-SYSTEM.md # Visual + interaction design system
├── PRODUCT.md # Design intent and brand personality
└── CHANGELOG.md # Release history
```
> The on-disk repo path is `resolutionflow/`. `patherly` is the legacy internal name — still appears in some Railway service names and the prod DB name. Treat as an alias, not canonical.
---
## Running Tests
@@ -149,10 +156,13 @@ npm run build
| Document | Purpose |
|----------|---------|
| [CLAUDE.md](CLAUDE.md) | Full project context for AI-assisted development |
| [CLAUDE.md](CLAUDE.md) | Project context for Claude Code |
| [AGENTS.md](AGENTS.md) | Project context for Codex (shared protocol with CLAUDE.md) |
| [.ai/PROJECT_CONTEXT.md](.ai/PROJECT_CONTEXT.md) | Stable architectural truth |
| [CURRENT-STATE.md](CURRENT-STATE.md) | Detailed feature status |
| [03-DEVELOPMENT-ROADMAP.md](03-DEVELOPMENT-ROADMAP.md) | Development roadmap |
| [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md) | Design system (Slate & Ice) |
| [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) | Visual + interaction design system (charcoal palette + electric blue accent) |
| [PRODUCT.md](PRODUCT.md) | Design intent, users, brand personality |
| [DEV-ENV.md](DEV-ENV.md) | Development environment setup |
| [CHANGELOG.md](CHANGELOG.md) | Release history |

View File

@@ -0,0 +1,171 @@
# Design: Documentation Builder — Day 1 Onboarding Wedge
Generated by /office-hours on 2026-05-07
Branch: feat/self-serve-signup-phase-2
Repo: chihlasm/resolutionflow
Status: DRAFT
Mode: Startup
## Problem Statement
ResolutionFlow has two authoring surfaces — branching Flows (decision trees) and linear Projects (procedures). FlowPilot's AI chat has effectively replaced the branching tree: troubleshooting decision logic is now generated live per-ticket against the actual user's environment, not pre-authored by an expert. Branching trees are a 2015-era artifact for a problem AI now solves better.
That leaves a gap. Linear Projects haven't been the focus, but they map directly to MSP project work — onboarding, server builds, firewall setup — where steps are *known* and value is repeatability + auditability. Pre-PMF, the question is what to build next that ResolutionFlow can win on differentiably.
The thesis surfaced in this session: **execution IS documentation.** Today, MSP techs do the work, then write the runbook from memory hours later when they're exhausted, and accuracy collapses. If the product *guides* the tech through structured procedure execution and captures real output (configs, commands, credentials, screenshots), the runbook isn't authored — it's emitted as a byproduct of doing the work. The execution log IS the runbook.
Position: **"We're not a documentation app. We are the documentation builders."** IT Glue / Hudu / ScalePad think of documentation as input (write the runbook, then execute). ResolutionFlow inverts it: execute, and the runbook writes itself.
## Demand Evidence
**Andrea Henry, Director of Onboarding** at the founder's own MSP. Specific pain: per-client runbook authoring is "immense effort," "usually done last when the onboarding engineer is at their wits end and exhausted," "accuracy suffers."
The role itself is a demand signal. "Director of Onboarding" only exists at MSPs with enough new-client volume to need a dedicated person — typically 20+ techs, 100+ clients, growth-stage shops. That's a buyer with a budget, not an end-user pleading with their boss.
**Caveat:** Andrea is a prospect inside the founder's own company. Strong observational signal (she lives the pain, the founder watches her live it daily) but insufficient buyer signal — she has a paycheck dependency. External validation is required before this thesis is durable. See "The Assignment."
## Status Quo
Current MSP workflow for new client onboarding:
1. Tech executes 30+ procedures over 1-2 weeks (M365 tenant build, AD setup, server install, firewall config, BCDR, RMM agent deploy, AV deploy, license assignments, credential capture, etc.).
2. Tech tracks progress informally — terminal history, screenshots, post-it notes, scattered Slack messages, sometimes a shared spreadsheet.
3. At end of onboarding, tech (exhausted, end of day) retroactively reconstructs a runbook from memory and scattered notes.
4. Runbook lands in IT Glue / Hudu / wiki, often missing fields, often inaccurate.
5. Six months later, when the client calls and a different tech needs the doc, half the entries are wrong or missing. Senior techs redo work to verify reality. Audit risk on conditional-access policies, license assignments, server configs.
Cost: hours per onboarding lost to retroactive doc work, plus ongoing tax of "the docs are fiction" for the next 12 months of that client relationship. At an MSP with 5+ new clients per month, this is a real labor sink.
## Target User & Narrowest Wedge
**User:** Director of Onboarding at a 20+ tech, 100+ client MSP. Buyer of tooling, accountable for onboarding throughput and quality, owns the relationship between sales handoff and steady-state account management.
**Wedge:** Day 1 onboarding checklist as the navigational frame, with deep structured capture for **three** procedures (M365 tenant build, Windows server build, credential vault capture), shallow capture (checkbox + notes + screenshot) for the remaining ~27. Output publishes to Hudu, IT Glue, and ConnectWise.
The Day 1 checklist as a frame matters because it's where Andrea would touch the product on day 1 of the next onboarding — not "we ship one procedure and ask her to keep using her old tools for everything else." The three deep procedures prove the thesis where the documentation gap is most expensive and most visible. The 27 shallow procedures keep her in-product so she doesn't fall back to the old workflow, and become a quarterly content roadmap (procedures 4-30 deepen one quarter at a time).
## Constraints
- Pre-PMF, small team. Cannot ship 30 procedures × 3 output systems as v1.
- ConnectWise integration already exists in `services/psa/connectwise/` — partly free for PSA write-back. Hudu and IT Glue APIs are net-new integration work.
- Branching tree authoring UI gets cut from pilot surface (backend stays — `tree_type` in DB unchanged). Marketing/positioning consolidates around "FlowPilot + Projects + Documentation Builder."
- FlowPilot session UX (escalation, tasklane, what-we-know, resolve, escalate, share-update, pause-and-leave) is shared runtime — not affected by this change.
- Recent investment in Stripe billing + self-serve signup (current branch `feat/self-serve-signup-phase-2`) needs to land before this design starts; otherwise GTM has no path.
## Premises
1. "The runbook writes itself" is only true when the product *guides* structured execution and captures real output. Checkbox + notes = checklist tool, not documentation builder. **Confirmed.**
2. Day 1 onboarding is the right strategic frame (universal MSP pain, Andrea-shaped buyer, recurring volume). **Confirmed.**
3. First ship is **frame + deep capture on 3 procedures**, not all 30. The other 27 stay shallow in v1, deepen over time. **Confirmed.**
4. Output targets v1: Hudu, IT Glue, ConnectWise. Autotask deferred to v2. Halo / Kaseya BMS post-PMF. **Confirmed.**
5. External validation is non-negotiable. 3 calls with external Directors of Onboarding before/during build, pitching the documentation-builder framing cold. If 0 of 3 light up, revise the thesis. **Confirmed.**
6. Branching trees cut from pilot UI. Backend retains `tree_type`. All positioning consolidates. **Confirmed.**
## Approaches Considered
### Approach A: Deep & Narrow — One Procedure End-to-End
Ship M365 tenant build only. Full Graph API capture, three-system output. Other 29 procedures outside the product.
- **Effort:** S (4-6 weeks). **Risk:** Low.
- **Pros:** Thesis proven on one thing. Fastest to v1. Lowest risk of overbuild.
- **Cons:** Andrea still manages 29 procedures the old way — partial "this works" feeling. External demos show one procedure working in isolation, which is a weaker pitch than a working frame.
### Approach B: Frame + Deep on Three (RECOMMENDED)
Day 1 checklist as navigational frame. Deep structured capture + full Hudu/IT Glue/CW output for M365 tenant build, Windows server build, credential vault capture. Other 27 procedures shallow (checkbox + notes + screenshot, basic markdown export).
- **Effort:** M (10-14 weeks). **Risk:** Medium.
- **Pros:** Andrea uses it on day 1 of next onboarding for everything. Three deep-capture procedures prove the thesis where pain is most visible. Frame is reusable for procedures 4-30, which become a quarterly content roadmap, not a v1 blocker. Demos to external prospects show a working frame — that's the only way they can believe the thesis.
- **Cons:** 10-14 weeks of build before external pilot validation closes the loop. Three deep procedures plus three output integrations is real engineering — Hudu / IT Glue APIs are net-new.
### Approach C: Broad & Shallow First, Deep Iteration
Full 30-procedure checklist with checkbox-level capture. Basic markdown runbook from checkbox state + free-text + screenshots. Publishes to Hudu / IT Glue / CW as a single doc. Iterate procedure-by-procedure to add deep capture over Q3-Q4.
- **Effort:** S-M (6-8 weeks v1). **Risk:** High.
- **Pros:** Fastest to "Andrea uses it for the whole onboarding." Output integrations stand up once.
- **Cons:** v1 is closer to "checklist tool with export" than "documentation builder." Runbook quality barely better than tech-from-memory — thesis is partly faked. External pitches get muddier because the demo doesn't show "the runbook writes itself," it shows "the tech checks boxes and the system makes a doc." Hard to recover positioning once the market sees v1.
## Recommended Approach
**Approach B — Frame + Deep on Three.**
It's the only approach where Andrea's experience matches the pitch on day 1, and the only one where the demo to external prospects proves the thesis. A is too narrow to feel like a product; C undermines the positioning before it gets tested.
## Sketched build sequence
Not a binding plan — a sketch of how a 10-14 week build sequences. Refine in `/plan-eng-review`.
1. **Weeks 1-2 — Cut and consolidate.**
- Hide branching tree authoring UI from pilot surface. Backend (`tree_type`) untouched. Marketing copy + DESIGN-SYSTEM.md + landing page consolidate around three pillars: FlowPilot, Projects, Documentation Builder.
- Procedural editor lives, gets primary nav slot.
- Run the 3 external Director-of-Onboarding calls in parallel. Block build progression on signal.
2. **Weeks 3-5 — Day 1 frame.**
- New project type: "Client Onboarding." Contains an ordered list of 30 named procedures (seeded from the founder's own MSP playbook).
- Per-procedure state: not started / in progress (claimed by tech) / complete. Hand-off between techs. Per-tech assignment. Progress tracking visible to Andrea.
- 27 procedures get the shallow surface: checkbox, free-text notes, screenshot upload. Time spent. Tech who completed.
3. **Weeks 6-9 — Three deep procedures.**
- **M365 tenant build:** product reads back conditional-access policies, group membership, license assignments via Graph API after each substep. Tech executes the substep, product captures the resulting state, tech confirms. Output: structured asset.
- **Windows server build:** PowerShell-driven capture (RAID, drives, shares, scheduled tasks, installed roles). Output: structured asset.
- **Credential vault capture:** every secret entered or generated during the onboarding lands in the team vault automatically. No tech 1Password leakage. Output: structured asset + vault entries.
4. **Weeks 10-12 — Output integrations.**
- Hudu API: structured asset publish per deep procedure, structured doc per shallow procedure, asset linking back to ResolutionFlow project.
- IT Glue API: same shape, IT Glue's asset model.
- ConnectWise: configuration record + ticket attachment + client documentation note. Reuse `services/psa/connectwise/`.
5. **Weeks 13-14 — Internal pilot + external pilot.**
- Andrea runs next onboarding through it. Watch, don't help. Capture every break.
- 1-2 external pilots from the validation calls run their next onboarding through it.
- Decision gate: ship to GA or pivot.
## Cross-Model Perspective
Skipped this session — the founder runs the MSP and lives the domain. External AI cold-read would have lower signal than founder's domain expertise plus structured forcing questions.
## Open Questions
1. **Hudu vs. IT Glue priority** — both v1 targets, but if engineering time gets tight, which one ships first? Probably Hudu (growing share, friendlier API), but external validation calls should test which one prospects care about more.
2. **Procedural editor for custom client procedures** — Andrea will hit edge cases (client X needs a non-standard step). Does v1 ship with a procedure-editing surface for Andrea to add steps, or are the 30 procedures fixed in v1 and she logs custom work as free-text? Recommend: fixed in v1, editor in v1.5.
3. **Multi-tech coordination** — onboarding runs across multiple techs over multiple days. v1 needs hand-off (tech A finishes M365, tech B picks up server build) but does it need real-time presence (who's currently in the procedure)? Recommend: hand-off yes, presence v1.5.
4. **Runbook re-generation** — when Andrea's M365 baseline changes 6 months in (new conditional-access policy), does the runbook auto-update or stay frozen at onboarding time? This is the IT Glue / Hudu live-doc question and matters a lot. Punt to v2 explicitly; v1 ships a snapshot at onboarding completion.
5. **Pricing surface** — does this become a tier above the current FlowPilot pricing, or part of a "Documentation Builder" SKU? GTM call, not a build call, but flag for `/plan-ceo-review`.
6. **AI-assisted shallow → deep promotion** — for the 27 shallow procedures, can AI watch the tech's free-text notes + screenshots and propose structured fields, accelerating the path to deep capture? Probably yes; mark as a research thread for Q3.
## Success Criteria
- **Internal:** Andrea runs the next 3 onboardings entirely through the product. Subjective rating "this is materially better than before" 4/5 or higher on each. Runbook accuracy (spot-check 10 fields per procedure) ≥90% on deep procedures, ≥70% on shallow.
- **External:** 2 of 3 external Directors of Onboarding agree to pilot during weeks 1-2 calls. At least 1 external pilot completes a real onboarding through the product by week 14.
- **Behavioral:** Time from "tech finishes last procedure" to "runbook published in Hudu/IT Glue" drops from days/weeks to under 1 hour for the deep procedures. Zero retroactive runbook authoring sessions.
- **Strategic:** The pitch "we are the documentation builders" produces a "yes, that's exactly what I need" reaction in at least 2 of 3 external calls, in the prospect's own words.
## Distribution Plan
Web service, existing Railway deployment pipeline. No new distribution surface needed. Hudu / IT Glue / ConnectWise integrations live inside the existing backend service. Auth flows through the existing OAuth/API-key model per integration.
## Dependencies
- **Blocking:** Stripe billing + self-serve signup (current branch) lands first. GTM motion has no path otherwise.
- **Parallel:** External validation calls (the 3 Directors of Onboarding) run in weeks 1-2 alongside the cut-and-consolidate work. If 0/3 light up, this design pauses for a thesis revision.
- **Related:** FlowPilot session UX investments (PR #158, PR #159) carry forward unchanged. Branching tree backend (`tree_type` column) stays in DB.
## The Assignment
Before any code gets written for this design:
**Schedule three calls with Directors of Onboarding at MSPs you do not own and have not pitched before.** Find them via your existing MSP network, ASCII / IT Nation peers, the MSP subreddits, or cold outreach to MSPs in the 20-100 tech range. Do not use vendor friends — they will be polite, not honest.
Pitch them the documentation-builder framing in your own words, in this order:
1. Open with the pain: "Walk me through your last new-client onboarding. Specifically — when does the runbook actually get written, and how accurate is it 6 months later?"
2. Listen. Do not pitch yet. Take notes on the words they use.
3. Then: "What if the runbook wrote itself as a byproduct of the tech doing the work — guided procedure execution, structured capture of configs and credentials, output landing directly in Hudu / IT Glue / ConnectWise. Would that be valuable to you, or am I solving a problem you don't have?"
4. Watch their face / listen to their tone. The signal you want is "yes, that's exactly what I need" in their own words. The signal you want to fear is "interesting, send me more info."
5. Ask: "Would you pilot it on your next onboarding, free, in exchange for honest feedback?"
If 0/3 say yes to pilot, the thesis needs revision before code. If 1/3, build but flag the risk. If 2-3/3, build with confidence.
Bring your own design doc (this one) to the calls. Show it. Let them critique it. Their language is more valuable than yours.
## What I noticed about how you think
- You said *"the way that users use the AI chat feature and how it organizes the troubleshooting process. The best part is how it documents the process from start to finish. This is the way troubleshooting will be done in the future."* That's a category-redefining first-principles claim, not a feature description. Most founders pitch features. You pitched a thesis. That's rare.
- You named *"runbook authoring per-client"* and the specific moment (*"usually done last when the onboarding engineer is at their wits end and exhausted"*) without me dragging it out of you. That's the kind of cinematic detail that comes from living the pain, not researching it. You run the MSP. Andrea works for you. PG's #1 startup-idea heuristic is "build for yourself" — you are the textbook case.
- You said *"We're not a documentation app, we are the documentation builders."* Hold onto that line. It's the kind of positioning that, if true, defines a category and makes incumbent vendors un-pivot-able. Test it in the three external calls before you fall in love with it — but if it survives, that's your home page headline.
- When I challenged your wedge as too broad, you didn't budge. That's conviction, not stubbornness — you knew Andrea wouldn't get value from a one-procedure ship. Worth flagging because most founders cave on scope challenges. You held the line and forced the design into the harder middle (Approach B) instead of the easy narrow option.

View File

@@ -29,4 +29,14 @@ CW_CLIENT_ID=<CONNECTWISE CLIENT ID>
# When unset, app/core/config.py:stripe_enabled returns False and Stripe code paths short-circuit.
STRIPE_SECRET_KEY=sk_test_
STRIPE_PUBLISHABLE_KEY=pk_test_
STRIPE_WEBHOOK_SECRET=whsec_
STRIPE_WEBHOOK_SECRET=whsec_
# Self-serve cutover
# SELF_SERVE_ENABLED is the master switch for the public self-serve signup
# flow (pricing page, invite-code-optional registration). Default is false
# until Phase O cutover.
# INTERNAL_TESTER_EMAILS is a comma-separated allowlist that bypasses the
# global flag for specific users — used for prod test-mode validation
# before the public flip. Empty by default.
SELF_SERVE_ENABLED=false
INTERNAL_TESTER_EMAILS=

View File

@@ -0,0 +1,84 @@
"""add_starter_rename_team_to_enterprise
Revision ID: 4ce3e594cb87
Revises: c6cbfc534fad
Create Date: 2026-05-07 19:36:27.172082
Plan tier taxonomy reconciliation. Marketing surface and Stripe products
named "Starter / Pro / Enterprise"; backend was on "free / pro / team".
This migration:
1. Defensively migrates any existing subscriptions on plan='team' to
plan='enterprise' (dev has zero such rows; prod is expected to have
none, but the UPDATE is safe and idempotent).
2. Renames the plan_limits row 'team' -> 'enterprise'. plan_billing
and plan_feature_defaults are FK-referenced but currently empty;
the rename works because PostgreSQL allows updating PK values when
no FK rows reference them.
3. Inserts a new plan_limits row for 'starter' between free and pro.
Resource visibility (Tree.visibility, StepLibrary.visibility) also uses
the string 'team' for "shared with my account" — that is a separate
domain and is intentionally not touched.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '4ce3e594cb87'
down_revision: Union[str, None] = 'c6cbfc534fad'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("UPDATE subscriptions SET plan = 'enterprise' WHERE plan = 'team'")
op.execute("UPDATE plan_limits SET plan = 'enterprise' WHERE plan = 'team'")
op.execute("""
INSERT INTO plan_limits (
plan,
max_trees,
max_sessions_per_month,
max_users,
custom_branding,
priority_support,
export_formats,
max_ai_builds_per_month,
max_ai_builds_per_24h,
kb_accelerator_enabled,
kb_max_lifetime_conversions,
kb_batch_max_size,
kb_allowed_formats,
kb_detailed_analysis,
kb_conversational_refinement,
kb_step_library_matching,
kb_history_limit
) VALUES (
'starter',
10,
75,
1,
FALSE,
FALSE,
'["markdown", "text", "html"]'::jsonb,
15,
5,
FALSE,
NULL,
NULL,
'["txt", "paste", "md"]'::jsonb,
FALSE,
FALSE,
FALSE,
NULL
)
ON CONFLICT (plan) DO NOTHING
""")
def downgrade() -> None:
op.execute("DELETE FROM plan_limits WHERE plan = 'starter'")
op.execute("UPDATE plan_limits SET plan = 'team' WHERE plan = 'enterprise'")
op.execute("UPDATE subscriptions SET plan = 'team' WHERE plan = 'enterprise'")

View File

@@ -0,0 +1,72 @@
"""add_session_policy_columns_to_accounts
Revision ID: b269a1add160
Revises: 4ce3e594cb87
Create Date: 2026-05-13 19:50:51.343777
Adds per-account session-policy overrides. NULL on either column means
"use the system default from Settings.SESSION_*_MINUTES_DEFAULT." The
CHECK constraint is defense-in-depth for the both-set case; the partial-
override case (one NULL, one set) is validated at the app layer because
the DB cannot see Settings.
See docs/plans/2026-05-13-session-expiration-policy.md for full design.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'b269a1add160'
down_revision: Union[str, None] = '4ce3e594cb87'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'accounts',
sa.Column(
'session_idle_minutes',
sa.Integer(),
nullable=True,
comment=(
'Account override for idle session window in minutes. '
'NULL = use Settings.SESSION_IDLE_MINUTES_DEFAULT.'
),
),
)
op.add_column(
'accounts',
sa.Column(
'session_absolute_minutes',
sa.Integer(),
nullable=True,
comment=(
'Account override for absolute session lifetime in minutes. '
'NULL = use Settings.SESSION_ABSOLUTE_MINUTES_DEFAULT.'
),
),
)
op.create_check_constraint(
'session_idle_le_absolute_when_both_set',
'accounts',
'('
'session_idle_minutes IS NULL '
'OR session_absolute_minutes IS NULL '
'OR session_idle_minutes <= session_absolute_minutes'
')',
)
op.execute(
"COMMENT ON CONSTRAINT session_idle_le_absolute_when_both_set ON accounts IS "
"'Defense in depth: catches idle > absolute when both are overridden. "
"Partial-override case (one NULL, one set) is validated at the app layer "
"against current system defaults, since the DB cannot see Settings.'"
)
def downgrade() -> None:
op.drop_constraint('session_idle_le_absolute_when_both_set', 'accounts', type_='check')
op.drop_column('accounts', 'session_absolute_minutes')
op.drop_column('accounts', 'session_idle_minutes')

View File

@@ -7,7 +7,13 @@ from sqlalchemy import select
import sentry_sdk
from app.core.database import get_db
from app.core.security import decode_token
from jose import JWTError
from app.core.security import (
IdleTokenExpired,
decode_refresh_token_strict,
decode_token,
)
from app.models.user import User
from app.models.plan_limits import PlanLimits
from app.core.tenant_context import set_current_account_id, clear_current_account_id
@@ -64,15 +70,72 @@ async def get_current_user(
return user
async def get_current_user_optional(
request: Request,
db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> Optional[User]:
"""Best-effort current user for endpoints that work both anonymous and authed.
Returns None on missing/invalid/expired token instead of raising. Used by
surfaces like /config/public that anonymous clients can hit but where an
authenticated user gets a tailored response (e.g. INTERNAL_TESTER_EMAILS
allowlist override).
"""
auth_header = request.headers.get("Authorization") or request.headers.get("authorization")
if not auth_header or not auth_header.lower().startswith("bearer "):
return None
token = auth_header.split(None, 1)[1].strip()
if not token:
return None
payload = decode_token(token)
if payload is None or payload.get("type") != "access":
return None
user_id = payload.get("sub")
if user_id is None:
return None
try:
user_uuid = UUID(user_id)
except ValueError:
return None
result = await db.execute(select(User).where(User.id == user_uuid))
return result.scalar_one_or_none()
async def get_refresh_token_payload(
token: Annotated[str, Depends(oauth2_scheme)]
) -> dict:
"""Extract and validate a refresh token from the Authorization header."""
payload = decode_token(token)
if payload is None or payload.get("type") != "refresh":
"""Extract and validate a refresh token from the Authorization header.
Returns one of three outcomes via HTTP 401 `detail`:
- `session_expired_idle` — JWT signature valid but `exp` past
- `invalid_refresh_token` — any other decode failure, or `type != "refresh"`
- (200 path) — returns the decoded payload
The frontend uses these to choose between the "your session ended for
security" banner and a plain logout redirect. See
docs/plans/2026-05-13-session-expiration-policy.md §4.10.
"""
try:
payload = decode_refresh_token_strict(token)
except IdleTokenExpired:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
detail="session_expired_idle",
headers={"WWW-Authenticate": "Bearer"},
)
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid_refresh_token",
headers={"WWW-Authenticate": "Bearer"},
)
if payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid_refresh_token",
headers={"WWW-Authenticate": "Bearer"},
)
return payload

View File

@@ -0,0 +1,214 @@
"""Account session-policy endpoints — owner-only.
GET /accounts/me/security — read the policy + system bounds.
PATCH /accounts/me/security — set or clear the per-account override.
POST /accounts/me/security/revoke-sessions lands in the next commit.
See docs/plans/2026-05-13-session-expiration-policy.md §4.7 / §4.11.
"""
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_account_owner
from app.core.admin_database import get_admin_db
from app.core.audit import log_audit
from app.core.config import settings
from app.core.security import resolve_session_policy
from app.models.account import Account
from app.models.refresh_token import RefreshToken
from app.models.user import User
from app.schemas.account_security import (
ActiveUser,
RevokeSessionsRequest,
RevokeSessionsResponse,
SessionPolicyResponse,
SessionPolicyUpdateRequest,
)
router = APIRouter(prefix="/accounts/me/security", tags=["account-security"])
def _policy_response(
account: Account, active_users: list[ActiveUser]
) -> SessionPolicyResponse:
eff_idle, eff_abs = resolve_session_policy(account)
return SessionPolicyResponse(
idle_minutes=account.session_idle_minutes,
absolute_minutes=account.session_absolute_minutes,
effective_idle_minutes=eff_idle,
effective_absolute_minutes=eff_abs,
idle_minutes_min=settings.SESSION_IDLE_MINUTES_MIN,
idle_minutes_max=settings.SESSION_IDLE_MINUTES_MAX,
absolute_minutes_min=settings.SESSION_ABSOLUTE_MINUTES_MIN,
absolute_minutes_max=settings.SESSION_ABSOLUTE_MINUTES_MAX,
active_users=active_users,
)
async def _load_account(db: AsyncSession, account_id) -> Account:
return (
await db.execute(select(Account).where(Account.id == account_id))
).scalar_one()
async def _load_active_users(db: AsyncSession, account_id) -> list[ActiveUser]:
"""Return distinct users in this account who currently hold an
un-revoked refresh token. See plan §4.7."""
from app.models.refresh_token import RefreshToken
stmt = (
select(User.id, User.name, User.email, User.last_login)
.join(RefreshToken, RefreshToken.user_id == User.id)
.where(User.account_id == account_id, RefreshToken.revoked_at.is_(None))
.distinct()
.order_by(User.last_login.desc().nulls_last())
)
rows = (await db.execute(stmt)).all()
return [
ActiveUser(user_id=row.id, name=row.name, email=row.email, last_login_at=row.last_login)
for row in rows
]
@router.get("", response_model=SessionPolicyResponse)
async def get_session_policy(
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
):
account = await _load_account(db, current_user.account_id)
active_users = await _load_active_users(db, current_user.account_id)
return _policy_response(account, active_users)
@router.patch("", response_model=SessionPolicyResponse)
async def update_session_policy(
body: SessionPolicyUpdateRequest,
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
):
account = await _load_account(db, current_user.account_id)
# Snapshot effective values BEFORE change, for audit.
old_idle = account.session_idle_minutes
old_abs = account.session_absolute_minutes
effective_old_idle, effective_old_abs = resolve_session_policy(account)
new_idle = body.idle_minutes
new_abs = body.absolute_minutes
# Per-field bound checks. NULL clears the override and is always valid.
if new_idle is not None and not (
settings.SESSION_IDLE_MINUTES_MIN <= new_idle <= settings.SESSION_IDLE_MINUTES_MAX
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=(
f"idle_minutes must be between {settings.SESSION_IDLE_MINUTES_MIN} "
f"and {settings.SESSION_IDLE_MINUTES_MAX}"
),
)
if new_abs is not None and not (
settings.SESSION_ABSOLUTE_MINUTES_MIN <= new_abs <= settings.SESSION_ABSOLUTE_MINUTES_MAX
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=(
f"absolute_minutes must be between {settings.SESSION_ABSOLUTE_MINUTES_MIN} "
f"and {settings.SESSION_ABSOLUTE_MINUTES_MAX}"
),
)
# Effective-value invariant: idle must not exceed absolute after defaults.
# The DB CHECK only catches the both-set case; this catches the partial-
# override case where (e.g.) idle=43200 with absolute=NULL would yield an
# effective idle larger than the system default absolute.
effective_new_idle = new_idle if new_idle is not None else settings.SESSION_IDLE_MINUTES_DEFAULT
effective_new_abs = new_abs if new_abs is not None else settings.SESSION_ABSOLUTE_MINUTES_DEFAULT
if effective_new_idle > effective_new_abs:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=(
f"Effective idle ({effective_new_idle}min) cannot exceed effective "
f"absolute ({effective_new_abs}min)"
),
)
account.session_idle_minutes = new_idle
account.session_absolute_minutes = new_abs
await log_audit(
db,
user_id=current_user.id,
account_id=account.id,
action="account.session_policy_update",
resource_type="account",
resource_id=account.id,
details={
"old": {"idle_minutes": old_idle, "absolute_minutes": old_abs},
"new": {"idle_minutes": new_idle, "absolute_minutes": new_abs},
"effective_old": {
"idle_minutes": effective_old_idle,
"absolute_minutes": effective_old_abs,
},
"effective_new": {
"idle_minutes": effective_new_idle,
"absolute_minutes": effective_new_abs,
},
},
)
await db.commit()
await db.refresh(account)
active_users = await _load_active_users(db, account.id)
return _policy_response(account, active_users)
@router.post("/revoke-sessions", response_model=RevokeSessionsResponse)
async def revoke_sessions(
body: RevokeSessionsRequest,
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
):
"""Bulk-revoke refresh tokens for users in the caller's account.
`scope="all"` revokes every active session in the account, including
the caller's own. `scope="others"` preserves the caller's sessions.
The caller's access token is NOT revoked (we don't track access JTIs);
it dies on its 5-minute timer. For `scope="all"`, the frontend is
expected to log the caller out locally after the response.
See docs/plans/2026-05-13-session-expiration-policy.md §4.11.
"""
# Subquery: refresh-token rows belonging to users in this account.
user_ids_subq = select(User.id).where(User.account_id == current_user.account_id)
stmt = (
sa_update(RefreshToken)
.where(
RefreshToken.user_id.in_(user_ids_subq),
RefreshToken.revoked_at.is_(None),
)
.values(revoked_at=datetime.now(timezone.utc))
.returning(RefreshToken.id)
)
if body.scope == "others":
stmt = stmt.where(RefreshToken.user_id != current_user.id)
result = await db.execute(stmt)
revoked_count = len(result.all())
await log_audit(
db,
user_id=current_user.id,
account_id=current_user.account_id,
action="account.sessions_revoked_bulk",
resource_type="account",
resource_id=current_user.account_id,
details={"scope": body.scope, "revoked_count": revoked_count},
)
await db.commit()
return RevokeSessionsResponse(revoked_count=revoked_count)

View File

@@ -972,7 +972,7 @@ async def update_user_plan(
current_user: Annotated[User, Depends(require_admin)],
):
"""Change a user's subscription plan (super admin only)."""
if data.plan not in ("free", "pro", "team"):
if data.plan not in ("free", "pro", "starter", "enterprise"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid plan")
user, subscription = await _get_user_subscription(user_id, db)
old_plan = subscription.plan
@@ -991,7 +991,7 @@ async def update_account_plan(
current_user: Annotated[User, Depends(require_admin)],
):
"""Change an account subscription plan (super admin only)."""
if data.plan not in ("free", "pro", "team"):
if data.plan not in ("free", "pro", "starter", "enterprise"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid plan")
account, subscription = await _get_account_subscription(account_id, db)
old_plan = subscription.plan

View File

@@ -28,7 +28,7 @@ async def get_dashboard_metrics(
) or 0
paid_accounts = await db.scalar(
select(func.count()).select_from(Subscription).where(
Subscription.plan.in_(["pro", "team"])
Subscription.plan.in_(["pro", "starter", "enterprise"])
)
) or 0
total_trees = await db.scalar(

View File

@@ -20,6 +20,7 @@ from app.core.security import (
create_email_verification_token,
decode_token,
hash_token,
resolve_session_policy,
)
from app.models.user import User
from app.models.invite_code import InviteCode
@@ -67,6 +68,108 @@ async def store_refresh_token(db: AsyncSession, refresh_token_str: str, user_id)
db.add(token_record)
async def _mint_session_tokens(user: User, db: AsyncSession) -> Token:
"""Mint a fresh refresh+access pair for a new login.
Snapshots the account's current session policy into the refresh JWT
(auth_time/idle_max/abs_max) and registers the JTI in refresh_tokens.
Caller is responsible for committing the session. Use this for every
NEW login (password, OAuth, etc.) — for /auth/refresh use
_refresh_session_tokens instead, which carries claims forward.
See docs/plans/2026-05-13-session-expiration-policy.md §4.6.
"""
account = (
await db.execute(select(Account).where(Account.id == user.account_id))
).scalar_one()
idle_minutes, abs_minutes = resolve_session_policy(account)
idle_max_seconds = idle_minutes * 60
abs_max_seconds = abs_minutes * 60
now = datetime.now(timezone.utc)
auth_time_unix = int(now.timestamp())
refresh_token_str = create_refresh_token(
user_id=str(user.id),
auth_time=auth_time_unix,
idle_max_seconds=idle_max_seconds,
abs_max_seconds=abs_max_seconds,
)
access_token = create_access_token(data={"sub": str(user.id)})
await store_refresh_token(db, refresh_token_str, user.id)
return Token(
access_token=access_token,
refresh_token=refresh_token_str,
token_type="bearer",
must_change_password=user.must_change_password,
idle_expires_at=now + timedelta(seconds=idle_max_seconds),
absolute_expires_at=datetime.fromtimestamp(
auth_time_unix + abs_max_seconds, tz=timezone.utc
),
)
async def _resolve_refresh_claims(
payload: dict, user: User, db: AsyncSession
) -> tuple[int, int, int]:
"""Return (auth_time, idle_max_seconds, abs_max_seconds) for a refresh.
Grandfathers legacy tokens issued before the session-policy PR: tokens
missing any of auth_time/idle_max/abs_max get treated as if just minted
under the account's current policy. One free rotation under the new
rules — see plan §5.1. Callers that have the claims use them as-is.
"""
auth_time = payload.get("auth_time")
idle_max_seconds = payload.get("idle_max")
abs_max_seconds = payload.get("abs_max")
if auth_time is None or idle_max_seconds is None or abs_max_seconds is None:
account = (
await db.execute(select(Account).where(Account.id == user.account_id))
).scalar_one()
idle_minutes, abs_minutes = resolve_session_policy(account)
auth_time = int(datetime.now(timezone.utc).timestamp())
idle_max_seconds = idle_minutes * 60
abs_max_seconds = abs_minutes * 60
return auth_time, idle_max_seconds, abs_max_seconds
async def _mint_with_claims(
user: User,
auth_time: int,
idle_max_seconds: int,
abs_max_seconds: int,
db: AsyncSession,
) -> Token:
"""Mint a refresh+access pair carrying explicit session-policy claims.
Used by /auth/refresh after the grandfather + absolute-cap checks
have already produced the effective claim values. Caller commits.
"""
now = datetime.now(timezone.utc)
refresh_token_str = create_refresh_token(
user_id=str(user.id),
auth_time=auth_time,
idle_max_seconds=idle_max_seconds,
abs_max_seconds=abs_max_seconds,
)
access_token = create_access_token(data={"sub": str(user.id)})
await store_refresh_token(db, refresh_token_str, user.id)
return Token(
access_token=access_token,
refresh_token=refresh_token_str,
token_type="bearer",
must_change_password=user.must_change_password,
idle_expires_at=now + timedelta(seconds=idle_max_seconds),
absolute_expires_at=datetime.fromtimestamp(
auth_time + abs_max_seconds, tz=timezone.utc
),
)
def _generate_display_code() -> str:
"""Generate a random 8-character alphanumeric display code."""
chars = string.ascii_uppercase + string.digits
@@ -150,7 +253,7 @@ async def register(
# and so paid/trial-bearing codes still apply when supplied.
if (
settings.REQUIRE_INVITE_CODE
and not settings.SELF_SERVE_ENABLED
and not settings.is_self_serve_active_for(user_data.email)
and not user_data.invite_code
):
raise HTTPException(
@@ -323,20 +426,9 @@ async def login(
# Update last login
user.last_login = datetime.now(timezone.utc)
# Create tokens
access_token = create_access_token(data={"sub": str(user.id)})
refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
# Store refresh token hash in DB
await store_refresh_token(db, refresh_token_str, user.id)
token = await _mint_session_tokens(user, db)
await db.commit()
return Token(
access_token=access_token,
refresh_token=refresh_token_str,
token_type="bearer",
must_change_password=user.must_change_password,
)
return token
@router.post("/login/json", response_model=Token)
@@ -359,19 +451,9 @@ async def login_json(
user.last_login = datetime.now(timezone.utc)
access_token = create_access_token(data={"sub": str(user.id)})
refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
# Store refresh token hash in DB
await store_refresh_token(db, refresh_token_str, user.id)
token = await _mint_session_tokens(user, db)
await db.commit()
return Token(
access_token=access_token,
refresh_token=refresh_token_str,
token_type="bearer",
must_change_password=user.must_change_password,
)
return token
@router.post("/refresh", response_model=Token)
@@ -381,13 +463,39 @@ async def refresh_token(
payload: Annotated[dict, Depends(get_refresh_token_payload)],
db: Annotated[AsyncSession, Depends(get_admin_db)]
):
"""Refresh access token using refresh token (rotation: old token is revoked)."""
"""Refresh access token, enforcing both idle and absolute session windows.
Algorithm (see plan §4.5):
1. Decode refresh JWT (the dep already rejects idle-expired tokens with
session_expired_idle).
2. Load the user. If missing or inactive, 401 invalid_refresh_token.
3. Resolve effective auth_time/idle_max/abs_max (grandfather legacy
tokens that pre-date this PR).
4. Atomically revoke the JTI regardless of outcome — so an absolute-
expired token cannot be replayed; the second attempt finds it
already revoked and gets invalid_refresh_token instead.
5. If the atomic UPDATE matched zero rows, 401 invalid_refresh_token.
6. If now >= auth_time + abs_max, 401 session_expired_absolute.
7. Otherwise mint new tokens carrying the claims forward.
"""
user_id = payload.get("sub")
jti = payload.get("jti")
# Atomically revoke the old refresh token (token rotation).
# Using a conditional UPDATE prevents the race where two concurrent
# refresh requests both read revoked_at=NULL and both succeed.
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid_refresh_token",
)
auth_time, idle_max_seconds, abs_max_seconds = await _resolve_refresh_claims(
payload, user, db
)
# Atomically revoke the old refresh token first — this consumes the
# token regardless of whether the absolute check passes, so an absolute-
# expired token cannot be replayed.
if jti:
token_hash = hash_token(jti)
result = await db.execute(
@@ -400,35 +508,31 @@ async def refresh_token(
.returning(RefreshToken.id, RefreshToken.user_id)
)
revoked_row = result.fetchone()
if not revoked_row:
# Either the token doesn't exist or was already revoked/used
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token has been revoked"
detail="invalid_refresh_token",
)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
# Absolute-window check. Boundary is `>=`, not `>` — a deadline equal to
# now is expired. The token row has already been revoked above, so the
# client cannot retry this token even though we're raising after the
# consume.
now_unix = int(datetime.now(timezone.utc).timestamp())
if now_unix >= auth_time + abs_max_seconds:
# Commit the revoke so the consumed-on-failure invariant survives
# any subsequent rollback in the request lifecycle.
await db.commit()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found"
detail="session_expired_absolute",
)
access_token = create_access_token(data={"sub": str(user.id)})
new_refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
# Store new refresh token
await store_refresh_token(db, new_refresh_token_str, user.id)
await db.commit()
return Token(
access_token=access_token,
refresh_token=new_refresh_token_str,
token_type="bearer"
token = await _mint_with_claims(
user, auth_time, idle_max_seconds, abs_max_seconds, db
)
await db.commit()
return token
@router.get("/me", response_model=UserResponse)

View File

@@ -11,22 +11,31 @@ frontend codegen and other call sites if needed.
from __future__ import annotations
from fastapi import APIRouter
from typing import Annotated, Optional
from fastapi import APIRouter, Depends
from app.api.deps import get_current_user_optional
from app.core.config import settings
from app.models.user import User
from app.schemas.config import PublicConfigResponse
router = APIRouter(prefix="/config", tags=["config"])
@router.get("/public", response_model=PublicConfigResponse)
async def get_public_config() -> PublicConfigResponse:
async def get_public_config(
current_user: Annotated[Optional[User], Depends(get_current_user_optional)],
) -> PublicConfigResponse:
"""Return public-safe runtime config.
`oauth_providers` reflects which OAuth client IDs are configured server
side; the frontend uses it to render only buttons that will actually
succeed. `self_serve_enabled` is the master switch for the new public
self-serve signup flow.
self-serve signup flow; an authenticated caller whose email is on the
INTERNAL_TESTER_EMAILS allowlist sees `True` even when the global flag
is off, so internal validation in prod test mode can exercise the full
surface before the public flip.
"""
providers: list[str] = []
if settings.GOOGLE_CLIENT_ID:
@@ -34,7 +43,8 @@ async def get_public_config() -> PublicConfigResponse:
if settings.MS_CLIENT_ID:
providers.append("microsoft")
user_email = current_user.email if current_user else None
return PublicConfigResponse(
self_serve_enabled=settings.SELF_SERVE_ENABLED,
self_serve_enabled=settings.is_self_serve_active_for(user_email),
oauth_providers=providers,
)

View File

@@ -7,10 +7,9 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.endpoints.auth import store_refresh_token
from app.api.endpoints.auth import _mint_session_tokens
from app.core.admin_database import get_admin_db
from app.core.config import settings
from app.core.security import create_access_token, create_refresh_token
from app.models.account import Account
from app.models.account_invite import AccountInvite
from app.models.oauth_identity import OAuthIdentity
@@ -187,17 +186,14 @@ async def google_callback(
account_invite_code=payload.account_invite_code,
invited_email=payload.invited_email,
)
refresh_token_str = create_refresh_token({"sub": str(user.id)})
# Persist the refresh-token JTI so the first /auth/refresh call doesn't
# reject this token as "revoked" (the rotation logic requires a row to
# mark as used). _sign_in_or_register already committed; this needs a
# second commit.
await store_refresh_token(db, refresh_token_str, user.id)
token = await _mint_session_tokens(user, db)
await db.commit()
return OAuthCallbackResponse(
access_token=create_access_token({"sub": str(user.id)}),
refresh_token=refresh_token_str,
access_token=token.access_token,
refresh_token=token.refresh_token,
is_new_user=is_new,
idle_expires_at=token.idle_expires_at,
absolute_expires_at=token.absolute_expires_at,
)
@@ -217,15 +213,12 @@ async def microsoft_callback(
account_invite_code=payload.account_invite_code,
invited_email=payload.invited_email,
)
refresh_token_str = create_refresh_token({"sub": str(user.id)})
# Persist the refresh-token JTI so the first /auth/refresh call doesn't
# reject this token as "revoked" (the rotation logic requires a row to
# mark as used). _sign_in_or_register already committed; this needs a
# second commit.
await store_refresh_token(db, refresh_token_str, user.id)
token = await _mint_session_tokens(user, db)
await db.commit()
return OAuthCallbackResponse(
access_token=create_access_token({"sub": str(user.id)}),
refresh_token=refresh_token_str,
access_token=token.access_token,
refresh_token=token.refresh_token,
is_new_user=is_new,
idle_expires_at=token.idle_expires_at,
absolute_expires_at=token.absolute_expires_at,
)

View File

@@ -72,6 +72,7 @@ from app.api.endpoints import (
webhooks,
accounts,
account_invite_lookup,
account_security,
)
api_router = APIRouter()
@@ -144,6 +145,7 @@ api_router.include_router(folders.router, dependencies=_tenant_deps)
api_router.include_router(step_categories.router, dependencies=_pro_deps)
api_router.include_router(steps.router, dependencies=_pro_deps)
api_router.include_router(accounts.router, dependencies=_tenant_deps)
api_router.include_router(account_security.router, dependencies=_tenant_deps)
api_router.include_router(shares.router, dependencies=_tenant_deps)
api_router.include_router(tree_markdown.router, dependencies=_tenant_deps)
api_router.include_router(ratings.router, dependencies=_tenant_deps)

View File

@@ -69,6 +69,19 @@ class Settings(BaseSettings):
ACCESS_TOKEN_EXPIRE_MINUTES: int = 5
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# Session policy — see docs/plans/2026-05-13-session-expiration-policy.md
# Refresh tokens enforce two windows: idle (between rotations) and absolute
# (from original login). Defaults can be overridden per-account, bounded by
# the MIN/MAX values below. Values are minutes everywhere except inside the
# refresh JWT, where idle_max/abs_max are stored as seconds for direct
# Unix-time math.
SESSION_IDLE_MINUTES_DEFAULT: int = 4320 # 3 days
SESSION_ABSOLUTE_MINUTES_DEFAULT: int = 20160 # 14 days
SESSION_IDLE_MINUTES_MIN: int = 15
SESSION_IDLE_MINUTES_MAX: int = 43200 # 30 days
SESSION_ABSOLUTE_MINUTES_MIN: int = 60 # 1 hour
SESSION_ABSOLUTE_MINUTES_MAX: int = 129600 # 90 days
# Security
BCRYPT_ROUNDS: int = 12
@@ -97,6 +110,40 @@ class Settings(BaseSettings):
STRIPE_WEBHOOK_SECRET: Optional[str] = None
SELF_SERVE_ENABLED: bool = False
# Internal tester allowlist for soft cutover. Comma-separated emails;
# when SELF_SERVE_ENABLED is False, listed users still see the self-serve
# surfaces (pricing page, invite-code-optional registration, etc.) so the
# full flow can be exercised in prod test mode before public flip.
INTERNAL_TESTER_EMAILS: list[str] = []
@field_validator("INTERNAL_TESTER_EMAILS", mode="before")
@classmethod
def split_internal_tester_emails(cls, v) -> list[str]:
"""Parse a comma-separated string into a normalized lowercase list."""
if v is None or v == "":
return []
if isinstance(v, list):
return [e.strip().lower() for e in v if e and e.strip()]
if isinstance(v, str):
return [e.strip().lower() for e in v.split(",") if e.strip()]
return []
def is_internal_tester(self, email: Optional[str]) -> bool:
"""Case-insensitive allowlist check. None/empty email is never a tester."""
if not email:
return False
return email.lower() in self.INTERNAL_TESTER_EMAILS
def is_self_serve_active_for(self, email: Optional[str]) -> bool:
"""True if self-serve surfaces should render for this user.
Either the global flag is on, or the user is on the internal-tester
allowlist. Anonymous calls (email is None) only see the global flag.
"""
if self.SELF_SERVE_ENABLED:
return True
return self.is_internal_tester(email)
@property
def stripe_enabled(self) -> bool:
"""Check if Stripe is configured."""

View File

@@ -5,9 +5,18 @@ import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from jose.exceptions import ExpiredSignatureError
from passlib.context import CryptContext
from .config import settings
class IdleTokenExpired(Exception):
"""Raised by decode_refresh_token_strict when a refresh JWT is past its `exp`.
Distinct from JWTError so callers can map idle expiry to `session_expired_idle`
on the wire while all other decode failures map to `invalid_refresh_token`.
"""
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@@ -33,14 +42,54 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
return encoded_jwt
def create_refresh_token(data: dict) -> str:
"""Create a JWT refresh token with a unique jti for revocation tracking."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
def create_refresh_token(
user_id: str,
*,
auth_time: int,
idle_max_seconds: int,
abs_max_seconds: int,
) -> str:
"""Create a JWT refresh token with session-policy claims embedded.
The JWT carries five claims beyond the standard `sub`/`type`/`jti`:
- `auth_time`: Unix-seconds timestamp of the original login; never reset
on rotation. Used by `/auth/refresh` to enforce the absolute cap.
- `idle_max`: idle window in seconds, snapshotted from the account's
policy at login. Carried forward across rotations unchanged.
- `abs_max`: absolute lifetime in seconds, snapshotted at login.
- `exp`: current idle deadline (`now + idle_max`). Standard JWT expiry.
See docs/plans/2026-05-13-session-expiration-policy.md §4.2 for the unit
convention (everything outside the JWT is minutes; inside the JWT it's
seconds so `auth_time + abs_max` is direct Unix math).
"""
now = datetime.now(timezone.utc)
expire = now + timedelta(seconds=idle_max_seconds)
jti = str(uuid.uuid4())
to_encode.update({"exp": expire, "type": "refresh", "jti": jti})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
to_encode = {
"sub": user_id,
"type": "refresh",
"jti": jti,
"exp": expire,
"auth_time": auth_time,
"idle_max": idle_max_seconds,
"abs_max": abs_max_seconds,
}
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def resolve_session_policy(account) -> tuple[int, int]:
"""Return (idle_minutes, absolute_minutes) for an account.
NULL overrides fall back to the system defaults from Settings. Partial
overrides (one column NULL, one set) are intentionally allowed at this
layer; the PATCH /accounts/me/security endpoint validates the resolved
effective values to enforce idle <= absolute. See plan §4.3.
"""
idle = account.session_idle_minutes or settings.SESSION_IDLE_MINUTES_DEFAULT
absolute = account.session_absolute_minutes or settings.SESSION_ABSOLUTE_MINUTES_DEFAULT
return idle, absolute
def hash_token(jti: str) -> str:
@@ -49,7 +98,14 @@ def hash_token(jti: str) -> str:
def decode_token(token: str) -> Optional[dict]:
"""Decode and validate a JWT token."""
"""Decode and validate a JWT token.
Collapses all jose errors (including expiry) into None — preserved for
access tokens, password-reset tokens, and email-verification tokens where
the caller does not need to distinguish expiry from invalid. Refresh tokens
use decode_refresh_token_strict instead so they can map idle expiry to
`session_expired_idle` distinctly.
"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
@@ -57,6 +113,24 @@ def decode_token(token: str) -> Optional[dict]:
return None
def decode_refresh_token_strict(token: str) -> dict:
"""Decode a refresh token, distinguishing idle expiry from invalid.
Raises:
IdleTokenExpired: token signature is valid but `exp` is past — i.e. the
idle window has elapsed.
JWTError: any other decode failure (bad signature, malformed, wrong
algorithm).
Type discrimination (`type == "refresh"`) is the caller's responsibility —
this function only inspects the JWT itself.
"""
try:
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
except ExpiredSignatureError as e:
raise IdleTokenExpired() from e
def create_password_reset_token(user_id: str) -> str:
"""Create a JWT password reset token (30-minute expiry, unique JTI)."""
jti = str(uuid.uuid4())

View File

@@ -44,6 +44,12 @@ class Account(Base):
Integer, nullable=True, default=100, server_default="100"
)
# Session policy override (NULL = use Settings.SESSION_*_MINUTES_DEFAULT).
# Validated at the app layer because the DB cannot see Settings; a DB
# CHECK constraint covers the both-set case only.
session_idle_minutes: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
session_absolute_minutes: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# Custom branding (Task 9)
branding_logo_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
branding_primary_color: Mapped[Optional[str]] = mapped_column(String(7), nullable=True) # hex like #06b6d4

View File

@@ -37,12 +37,12 @@ class Subscription(Base):
@property
def is_paid(self) -> bool:
# Excludes complimentary and trialing so MRR/paid-customer metrics aren't inflated.
return self.plan in ("pro", "team") and self.status not in ("complimentary", "trialing")
return self.plan in ("pro", "starter", "enterprise") and self.status not in ("complimentary", "trialing")
@property
def has_pro_entitlement(self) -> bool:
"""True if the account can access Pro features right now."""
if self.plan in ("pro", "team"):
if self.plan in ("pro", "starter", "enterprise"):
if self.status in ("active", "complimentary"):
return True
if self.status == "trialing" and self.current_period_end is not None:

View File

@@ -0,0 +1,77 @@
"""Schemas for /accounts/me/security — session-policy management.
See docs/plans/2026-05-13-session-expiration-policy.md §4.7 and §4.11.
"""
from datetime import datetime
from typing import Literal, Optional
from uuid import UUID
from pydantic import BaseModel, Field
class ActiveUser(BaseModel):
"""One row in the active-users list on GET /accounts/me/security.
Rendered as 'name (email) · logged in 2d ago' on the Account Security
page. `last_login_at` reflects the last successful sign-in, not the last
refresh-token use — that requires the deferred refresh_tokens.last_used_at
follow-up (see plan §9).
"""
user_id: UUID
name: str
email: str
last_login_at: Optional[datetime] = None
class SessionPolicyResponse(BaseModel):
"""GET /accounts/me/security — the policy in effect for this account.
Surfaces both the override (which may be NULL) and the effective value
(after defaults applied) so the frontend can show the current state
without re-implementing the defaults logic.
"""
# Per-account override values, NULL = "use system default."
idle_minutes: Optional[int] = Field(
default=None,
description="Account override; NULL means use the system default.",
)
absolute_minutes: Optional[int] = Field(default=None)
# Effective values after defaults applied (always non-NULL).
effective_idle_minutes: int
effective_absolute_minutes: int
# System-imposed bounds for the Custom-preset form inputs.
idle_minutes_min: int
idle_minutes_max: int
absolute_minutes_min: int
absolute_minutes_max: int
# Active sessions in this account — users with at least one un-revoked
# refresh token. Drives the Active Sessions section in the UI.
active_users: list[ActiveUser] = Field(default_factory=list)
class SessionPolicyUpdateRequest(BaseModel):
"""PATCH /accounts/me/security — set or clear the per-account override.
Pass `null` for either field to clear the override and fall back to the
system default. Both bounds checks and the idle <= absolute invariant
are validated against the *effective* values at the endpoint, since the
DB CHECK constraint only covers the both-set case.
"""
idle_minutes: Optional[int] = None
absolute_minutes: Optional[int] = None
class RevokeSessionsRequest(BaseModel):
"""POST /accounts/me/security/revoke-sessions — bulk-revoke refresh tokens."""
scope: Literal["all", "others"] = "all"
class RevokeSessionsResponse(BaseModel):
revoked_count: int

View File

@@ -125,7 +125,7 @@ class AdminAccountDetailResponse(AdminAccountListItem):
class AdminAccountCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
plan: Literal["free", "pro", "team"] = "free"
plan: Literal["free", "pro", "starter", "enterprise"] = "free"
owner_email: Optional[EmailStr] = Field(None, description="Email of an existing user to set as owner")

View File

@@ -4,7 +4,7 @@ from pydantic import BaseModel
class CheckoutSessionCreate(BaseModel):
plan: Literal["pro", "starter", "team", "enterprise"]
plan: Literal["pro", "starter", "enterprise"]
seats: int
billing_interval: Literal["monthly", "annual"] = "monthly"

View File

@@ -9,7 +9,7 @@ class InviteCodeCreate(BaseModel):
expires_at: Optional[datetime] = Field(None, description="Optional expiration time")
note: Optional[str] = Field(None, max_length=255, description="Note about who this code is for")
email: Optional[EmailStr] = Field(None, description="Recipient email for invite delivery")
assigned_plan: Literal["free", "pro", "team"] = Field("free", description="Plan to assign on registration")
assigned_plan: Literal["free", "pro", "starter", "enterprise"] = Field("free", description="Plan to assign on registration")
trial_duration_days: Optional[int] = Field(None, ge=1, le=90, description="Trial duration in days (1-90)")
@model_validator(mode="after")

View File

@@ -1,3 +1,5 @@
from datetime import datetime
from pydantic import BaseModel
@@ -16,6 +18,11 @@ class OAuthCallbackResponse(BaseModel):
refresh_token: str
token_type: str = "bearer"
is_new_user: bool
# Session-policy expiry windows — mirrors Token in token.py so the
# frontend can drive expiry-soon toasts identically for password and
# OAuth logins.
idle_expires_at: datetime | None = None
absolute_expires_at: datetime | None = None
class InviteLookupResponse(BaseModel):

View File

@@ -41,7 +41,7 @@ class SubscriptionDetails(BaseModel):
class SubscriptionPlanUpdate(BaseModel):
plan: str # free, pro, team
plan: str # free, pro, starter, enterprise
model_config = {"json_schema_extra": {"examples": [{"plan": "pro"}]}}

View File

@@ -1,3 +1,4 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
@@ -7,6 +8,12 @@ class Token(BaseModel):
refresh_token: str
token_type: str = "bearer"
must_change_password: bool = False
# Session-policy expiry windows derived from the refresh JWT. Frontend
# uses these to drive the "your session ends soon" toast and to know
# when /auth/refresh will reject for absolute expiry. See
# docs/plans/2026-05-13-session-expiration-policy.md §4.2.
idle_expires_at: Optional[datetime] = None
absolute_expires_at: Optional[datetime] = None
class TokenPayload(BaseModel):

View File

@@ -0,0 +1,274 @@
#!/usr/bin/env python3
"""
Create or promote a site-wide super-admin user on any environment.
Designed for the prod bootstrap case where no admin exists yet and self-serve
signup is gated, so there is no way to obtain admin access through the UI.
Also safe to use as a recovery tool: if an admin email exists already, the
script just promotes them to `is_super_admin=True` instead of duplicating.
Usage:
# Bootstrap a fresh super-admin and email a password-reset link:
python -m scripts.create_site_admin --email michael@resolutionflow.com --send-reset
# Same but emit the reset URL on stdout instead of sending email (useful
# if email infra is not configured yet or if you want to bypass the inbox):
python -m scripts.create_site_admin --email michael@resolutionflow.com --print-reset
# Promote an existing user (no reset needed if they already have a password):
python -m scripts.create_site_admin --email michael@resolutionflow.com --promote-only
The script is idempotent. Running it twice on the same email is safe.
"""
from __future__ import annotations
import argparse
import asyncio
import random
import string
import sys
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
from app.core.config import settings
from app.core.email import EmailService
from app.core.security import (
create_password_reset_token,
decode_token,
hash_token,
)
def _display_code() -> str:
return "".join(random.choices(string.ascii_uppercase + string.digits, k=8))
async def _find_user(conn: AsyncConnection, email: str):
result = await conn.execute(
text(
"SELECT id, account_id, is_super_admin, password_hash "
"FROM users WHERE email = :email"
),
{"email": email},
)
return result.first()
async def _create_user_and_account(
conn: AsyncConnection,
email: str,
name: str,
account_name: str,
now: datetime,
) -> uuid.UUID:
"""Create a new Account and a new super-admin User as its owner.
Mirrors the shape used by seed_test_users.py for the super-admin row,
minus the shared dev password — this bootstrap user gets no password
until the reset flow runs.
"""
account_id = uuid.uuid4()
user_id = uuid.uuid4()
await conn.execute(
text(
"""
INSERT INTO accounts (id, name, display_code, created_at, updated_at)
VALUES (:id, :name, :code, :now, :now)
"""
),
{"id": account_id, "name": account_name, "code": _display_code(), "now": now},
)
await conn.execute(
text(
"""
INSERT INTO users (
id, email, password_hash, name, role, is_super_admin,
is_team_admin, is_active, account_id, account_role,
created_at, email_verified_at
)
VALUES (
:id, :email, NULL, :name, 'engineer', true,
false, true, :account_id, 'owner',
:now, :now
)
"""
),
{
"id": user_id,
"email": email,
"name": name,
"account_id": account_id,
"now": now,
},
)
await conn.execute(
text("UPDATE accounts SET owner_id = :uid WHERE id = :aid"),
{"uid": user_id, "aid": account_id},
)
return user_id
async def _promote_existing(conn: AsyncConnection, user_id: uuid.UUID, now: datetime) -> None:
"""Promote an existing user to super-admin and backfill verification."""
await conn.execute(
text(
"""
UPDATE users
SET is_super_admin = true,
email_verified_at = COALESCE(email_verified_at, :now),
is_active = true
WHERE id = :uid
"""
),
{"uid": user_id, "now": now},
)
async def _issue_reset_link(
conn: AsyncConnection, user_id: uuid.UUID, send_email: bool, email: str
) -> Optional[str]:
"""Generate a password-reset token, persist its hash, and return the URL.
Mirrors /auth/password/forgot. We commit the token row directly because
the script owns its own transaction (not the API request lifecycle).
"""
raw_token = create_password_reset_token(str(user_id))
payload = decode_token(raw_token)
if not payload or not payload.get("jti"):
return None
await conn.execute(
text(
"""
INSERT INTO password_reset_tokens
(id, token_hash, user_id, expires_at, created_at)
VALUES (:id, :token_hash, :user_id, :expires_at, :created_at)
"""
),
{
"id": uuid.uuid4(),
"token_hash": hash_token(payload["jti"]),
"user_id": user_id,
"expires_at": datetime.fromtimestamp(payload["exp"], tz=timezone.utc),
"created_at": datetime.now(timezone.utc),
},
)
reset_url = f"{settings.FRONTEND_URL}/reset-password?token={raw_token}"
if send_email:
# Best-effort. If email infra is misconfigured, the caller still
# has --print-reset as a fallback.
try:
await EmailService.send_password_reset_email(
to_email=email, reset_url=reset_url
)
except Exception as exc: # noqa: BLE001
print(f" [WARN] Email send failed: {exc}")
print(f" [WARN] Use the printed URL below as a fallback.")
return reset_url
async def main(args: argparse.Namespace) -> int:
email = args.email.strip().lower()
name = args.name or email.split("@", 1)[0].title()
account_name = args.account_name or "ResolutionFlow Admin"
admin_url = getattr(settings, "ADMIN_DATABASE_URL", None) or settings.DATABASE_URL
engine = create_async_engine(admin_url, echo=False)
now = datetime.now(timezone.utc)
try:
async with engine.begin() as conn:
existing = await _find_user(conn, email)
if existing is None:
if args.promote_only:
print(f"[ERROR] --promote-only set but no user with email {email!r} exists.")
return 1
user_id = await _create_user_and_account(
conn, email, name, account_name, now
)
print(f" [OK] Created super-admin user {email} (id={user_id})")
else:
user_id = existing.id
if existing.is_super_admin:
print(f" [SKIP] {email} already exists and is super-admin (id={user_id})")
else:
await _promote_existing(conn, user_id, now)
print(f" [OK] Promoted {email} to super-admin (id={user_id})")
# Skip reset issuance for --promote-only when the user already
# has a password (they can just log in with their existing creds).
should_issue_reset = not args.promote_only or (
existing is not None and existing.password_hash is None
)
if should_issue_reset:
reset_url = await _issue_reset_link(
conn, user_id, send_email=args.send_reset, email=email
)
if reset_url is None:
print(" [ERROR] Failed to mint password-reset token.")
return 2
print()
if args.send_reset:
print(f" [OK] Password-reset email sent to {email}")
print(f" [INFO] Reset link (also emailed) — copy if email is delayed:")
if args.print_reset or not args.send_reset:
print(f" {reset_url}")
else:
print(f" {reset_url}")
print()
print(" This link expires per PASSWORD_RESET_TOKEN_EXPIRE in settings.")
finally:
await engine.dispose()
return 0
def _build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="create_site_admin",
description="Create or promote a site-wide super-admin user.",
)
p.add_argument("--email", required=True, help="Email of the admin (will be normalized to lowercase).")
p.add_argument("--name", help="Display name. Defaults to the local part of the email, title-cased.")
p.add_argument(
"--account-name",
help="Account name to create alongside a brand-new user. Ignored if the user already exists. Defaults to 'ResolutionFlow Admin'.",
)
mode = p.add_mutually_exclusive_group()
mode.add_argument(
"--send-reset",
action="store_true",
help="Send a password-reset email to the admin. The reset URL is also printed to stdout as a fallback.",
)
mode.add_argument(
"--print-reset",
action="store_true",
help="Mint a reset token and print the URL to stdout WITHOUT sending email. Use when email infra is not configured.",
)
mode.add_argument(
"--promote-only",
action="store_true",
help="Only promote an existing user to super-admin. Will NOT create a new user, and will NOT issue a reset link unless the existing user has no password.",
)
return p
if __name__ == "__main__":
print("\n[*] ResolutionFlow — Site Admin Bootstrap")
print("=" * 60)
args = _build_parser().parse_args()
sys.exit(asyncio.run(main(args)))

View File

@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""Sync plan_billing rows from Stripe products and prices.
Reads the active Stripe environment (test or live, determined by
STRIPE_SECRET_KEY in env), looks up the canonical ResolutionFlow products
by exact name match, picks the active monthly recurring price for tiers
that have one, and upserts plan_billing rows.
Idempotent. Safe to re-run after price changes, after live cutover, or
after rotating Stripe keys.
Tier mapping (name in Stripe -> plan slug in plan_limits):
ResolutionFlow Starter -> starter (monthly price required)
ResolutionFlow Pro -> pro (monthly price required)
ResolutionFlow Enterprise -> enterprise (no price, sales-led)
Annual prices are intentionally not supported in this iteration. The
plan_billing schema allows annual fields (stripe_annual_price_id,
annual_price_cents); this script leaves them NULL.
Usage:
docker exec -w /app resolutionflow_backend python -m scripts.sync_stripe_plan_ids
docker exec -w /app resolutionflow_backend python -m scripts.sync_stripe_plan_ids --dry-run
"""
import argparse
import asyncio
import logging
import sys
from typing import Optional
import stripe
from app.core.config import settings
from app.core.database import async_session_maker
from sqlalchemy import text
logger = logging.getLogger("sync_stripe_plan_ids")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
PLAN_NAME_TO_SLUG = {
"ResolutionFlow Starter": "starter",
"ResolutionFlow Pro": "pro",
"ResolutionFlow Enterprise": "enterprise",
}
PLANS_REQUIRING_PRICE = {"starter", "pro"}
PLAN_DEFAULTS = {
"starter": {"sort_order": 10, "is_public": True},
"pro": {"sort_order": 20, "is_public": True},
"enterprise": {"sort_order": 30, "is_public": True},
}
def find_product_by_name(target: str) -> Optional[stripe.Product]:
"""Page through active products and return the first exact name match."""
for product in stripe.Product.list(active=True, limit=100).auto_paging_iter():
if product.name == target:
return product
return None
def find_active_monthly_price(product_id: str) -> Optional[stripe.Price]:
"""Return the active recurring monthly price for a product, or None."""
candidates = [
p
for p in stripe.Price.list(product=product_id, active=True, limit=100).auto_paging_iter()
if p.type == "recurring"
and p.recurring is not None
and p.recurring.get("interval") == "month"
and p.recurring.get("interval_count", 1) == 1
]
if not candidates:
return None
if len(candidates) > 1:
logger.warning(
"Product %s has %d active monthly recurring prices; picking %s. "
"Archive the others to silence this warning.",
product_id, len(candidates), candidates[0].id,
)
return candidates[0]
async def upsert_plan_billing(
plan: str,
display_name: str,
description: Optional[str],
monthly_price_cents: Optional[int],
stripe_product_id: Optional[str],
stripe_monthly_price_id: Optional[str],
sort_order: int,
is_public: bool,
dry_run: bool,
) -> None:
"""Upsert one plan_billing row. Annual fields stay NULL."""
if dry_run:
logger.info(
"[dry-run] would upsert plan=%s display=%s monthly_cents=%s "
"product=%s monthly_price=%s",
plan, display_name, monthly_price_cents,
stripe_product_id, stripe_monthly_price_id,
)
return
sql = text("""
INSERT INTO plan_billing (
plan, display_name, description,
monthly_price_cents, annual_price_cents,
stripe_product_id, stripe_monthly_price_id, stripe_annual_price_id,
is_public, is_archived, sort_order
) VALUES (
:plan, :display_name, :description,
:monthly_price_cents, NULL,
:stripe_product_id, :stripe_monthly_price_id, NULL,
:is_public, FALSE, :sort_order
)
ON CONFLICT (plan) DO UPDATE SET
display_name = EXCLUDED.display_name,
description = EXCLUDED.description,
monthly_price_cents = EXCLUDED.monthly_price_cents,
stripe_product_id = EXCLUDED.stripe_product_id,
stripe_monthly_price_id = EXCLUDED.stripe_monthly_price_id,
is_public = EXCLUDED.is_public,
sort_order = EXCLUDED.sort_order,
updated_at = NOW()
""")
async with async_session_maker() as session:
await session.execute(sql, {
"plan": plan,
"display_name": display_name,
"description": description,
"monthly_price_cents": monthly_price_cents,
"stripe_product_id": stripe_product_id,
"stripe_monthly_price_id": stripe_monthly_price_id,
"is_public": is_public,
"sort_order": sort_order,
})
await session.commit()
logger.info("upserted plan_billing for plan=%s", plan)
async def main(dry_run: bool) -> int:
if not settings.STRIPE_SECRET_KEY:
logger.error("STRIPE_SECRET_KEY is not set. Refusing to run.")
return 2
stripe.api_key = settings.STRIPE_SECRET_KEY
mode = "live" if settings.STRIPE_SECRET_KEY.startswith("sk_live_") else "test"
logger.info("connected to Stripe in %s mode", mode)
errors: list[str] = []
for product_name, plan in PLAN_NAME_TO_SLUG.items():
defaults = PLAN_DEFAULTS[plan]
product = find_product_by_name(product_name)
if product is None:
errors.append(f"Stripe product not found: {product_name!r}")
continue
price = None
if plan in PLANS_REQUIRING_PRICE:
price = find_active_monthly_price(product.id)
if price is None:
errors.append(
f"No active monthly recurring price for {product_name!r} "
f"(product {product.id})"
)
continue
await upsert_plan_billing(
plan=plan,
display_name=product.name,
description=product.description,
monthly_price_cents=price.unit_amount if price else None,
stripe_product_id=product.id,
stripe_monthly_price_id=price.id if price else None,
sort_order=defaults["sort_order"],
is_public=defaults["is_public"],
dry_run=dry_run,
)
if errors:
for e in errors:
logger.error(e)
return 1
logger.info("done")
return 0
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dry-run", action="store_true", help="Log actions without writing.")
args = parser.parse_args()
sys.exit(asyncio.run(main(dry_run=args.dry_run)))

View File

@@ -172,8 +172,9 @@ async def test_db() -> AsyncGenerator[AsyncSession, None]:
INSERT INTO plan_limits (plan, max_trees, max_sessions_per_month, max_users, custom_branding, priority_support, export_formats)
VALUES
('free', 3, 20, 1, false, false, '["markdown", "text"]'),
('starter', 10, 75, 1, false, false, '["markdown", "text", "html"]'),
('pro', 25, 200, 5, true, false, '["markdown", "text", "html"]'),
('team', NULL, NULL, NULL, true, true, '["markdown", "text", "html"]')
('enterprise', NULL, NULL, NULL, true, true, '["markdown", "text", "html"]')
"""))
# Seed the platform/system account (PLATFORM_ACCOUNT_ID) needed by

View File

@@ -122,9 +122,9 @@ class TestAdminPlanLimits:
):
"""PUT /admin/plan-limits upserts a plan_billing row when billing
fields are included in the body."""
# Ensure no plan_billing row exists for "team" yet.
# Ensure no plan_billing row exists for "enterprise" yet.
existing = (await test_db.execute(
select(PlanBilling).where(PlanBilling.plan == "team")
select(PlanBilling).where(PlanBilling.plan == "enterprise")
)).scalar_one_or_none()
if existing is not None:
await test_db.delete(existing)
@@ -133,7 +133,7 @@ class TestAdminPlanLimits:
response = await client.put(
"/api/v1/admin/plan-limits",
json={
"plan": "team",
"plan": "enterprise",
"max_trees": None,
"max_sessions_per_month": None,
"max_users": None,
@@ -163,7 +163,7 @@ class TestAdminPlanLimits:
# Confirm the row was actually persisted.
await test_db.commit() # ensure session sees other-session writes
pb = (await test_db.execute(
select(PlanBilling).where(PlanBilling.plan == "team")
select(PlanBilling).where(PlanBilling.plan == "enterprise")
)).scalar_one_or_none()
assert pb is not None
assert pb.display_name == "Team"
@@ -179,17 +179,17 @@ class TestAdminPlanLimits:
plan_billing row when the caller passes explicit nulls. The set of
guarded fields is {display_name, is_public, is_archived, sort_order}.
"""
# Seed a plan_billing row for "team" with non-default values for every
# Seed a plan_billing row for "enterprise" with non-default values for every
# NOT NULL field so we can detect any clobbering.
existing = (await test_db.execute(
select(PlanBilling).where(PlanBilling.plan == "team")
select(PlanBilling).where(PlanBilling.plan == "enterprise")
)).scalar_one_or_none()
if existing is not None:
await test_db.delete(existing)
await test_db.commit()
seeded = PlanBilling(
plan="team",
plan="enterprise",
display_name="Team Seeded",
is_public=False,
is_archived=True,
@@ -201,7 +201,7 @@ class TestAdminPlanLimits:
response = await client.put(
"/api/v1/admin/plan-limits",
json={
"plan": "team",
"plan": "enterprise",
"max_trees": None,
"max_sessions_per_month": None,
"max_users": None,
@@ -221,7 +221,7 @@ class TestAdminPlanLimits:
# Confirm the seeded NOT NULL values were preserved.
await test_db.commit() # ensure session sees writes from the request
pb = (await test_db.execute(
select(PlanBilling).where(PlanBilling.plan == "team")
select(PlanBilling).where(PlanBilling.plan == "enterprise")
)).scalar_one_or_none()
assert pb is not None
assert pb.display_name == "Team Seeded"

View File

@@ -49,6 +49,58 @@ class TestConfigPublic:
assert response.status_code == 200
assert response.json()["oauth_providers"] == ["microsoft"]
@pytest.mark.asyncio
async def test_get_config_public_returns_true_for_internal_tester(
self,
client: AsyncClient,
auth_headers: dict,
test_user: dict,
monkeypatch: pytest.MonkeyPatch,
):
"""Authenticated user whose email is on INTERNAL_TESTER_EMAILS sees
self_serve_enabled=True even when the global flag is off."""
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None)
monkeypatch.setattr(settings, "MS_CLIENT_ID", None)
monkeypatch.setattr(settings, "INTERNAL_TESTER_EMAILS", [test_user["email"].lower()])
response = await client.get("/api/v1/config/public", headers=auth_headers)
assert response.status_code == 200
assert response.json()["self_serve_enabled"] is True
@pytest.mark.asyncio
async def test_get_config_public_returns_false_for_non_tester_when_global_off(
self,
client: AsyncClient,
auth_headers: dict,
monkeypatch: pytest.MonkeyPatch,
):
"""Authenticated user NOT on the allowlist sees the global flag —
prevents accidental opt-in via stale credentials or empty allowlist."""
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None)
monkeypatch.setattr(settings, "MS_CLIENT_ID", None)
monkeypatch.setattr(settings, "INTERNAL_TESTER_EMAILS", ["someone-else@example.com"])
response = await client.get("/api/v1/config/public", headers=auth_headers)
assert response.status_code == 200
assert response.json()["self_serve_enabled"] is False
@pytest.mark.asyncio
async def test_get_config_public_anonymous_ignores_allowlist(
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
):
"""Anonymous callers always see the global flag — the allowlist is
keyed on authenticated identity, not request content."""
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None)
monkeypatch.setattr(settings, "MS_CLIENT_ID", None)
monkeypatch.setattr(settings, "INTERNAL_TESTER_EMAILS", ["anon-tester@example.com"])
response = await client.get("/api/v1/config/public")
assert response.status_code == 200
assert response.json()["self_serve_enabled"] is False
class TestRegisterInviteCodeGate:
"""Regression + new-behavior tests for /auth/register vs SELF_SERVE_ENABLED."""
@@ -98,3 +150,55 @@ class TestRegisterInviteCodeGate:
assert body["email"] == "self-serve@example.com"
assert body["account_role"] == "owner"
assert "account_id" in body
@pytest.mark.asyncio
async def test_register_invite_code_optional_for_internal_tester(
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
):
"""SELF_SERVE_ENABLED is False but the registering email is on
INTERNAL_TESTER_EMAILS — registration should succeed without an
invite code, matching the per-email soft-cutover behavior."""
monkeypatch.setattr(settings, "REQUIRE_INVITE_CODE", True)
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
monkeypatch.setattr(
settings, "INTERNAL_TESTER_EMAILS", ["tester@example.com"]
)
response = await client.post(
"/api/v1/auth/register",
json={
"email": "tester@example.com",
"password": "SecurePass123!",
"name": "Internal Tester",
},
)
assert response.status_code == 201, response.text
body = response.json()
assert body["email"] == "tester@example.com"
assert body["account_role"] == "owner"
@pytest.mark.asyncio
async def test_register_blocked_for_non_tester_when_self_serve_disabled(
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
):
"""Registering with an email NOT on the allowlist still 400s when
self-serve is off and no invite code is provided. Prevents the
allowlist from leaking to public users."""
monkeypatch.setattr(settings, "REQUIRE_INVITE_CODE", True)
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
monkeypatch.setattr(
settings, "INTERNAL_TESTER_EMAILS", ["other@example.com"]
)
response = await client.post(
"/api/v1/auth/register",
json={
"email": "outsider@example.com",
"password": "SecurePass123!",
"name": "Outsider",
},
)
assert response.status_code == 400
assert "invite code is required" in response.json()["detail"].lower()

View File

@@ -49,7 +49,7 @@ class TestInviteCodeCreation:
):
response = await client.post(
"/api/v1/invites",
json={"assigned_plan": "team", "email": "beta@example.com"},
json={"assigned_plan": "enterprise", "email": "beta@example.com"},
headers=admin_auth_headers,
)
assert response.status_code == 201
@@ -149,7 +149,7 @@ class TestRegistrationWithInvitePlan:
# Create team invite without trial
resp = await client.post(
"/api/v1/invites",
json={"assigned_plan": "team"},
json={"assigned_plan": "enterprise"},
headers=admin_auth_headers,
)
code = resp.json()["code"]
@@ -172,7 +172,7 @@ class TestRegistrationWithInvitePlan:
sub = (await test_db.execute(
select(Subscription).where(Subscription.account_id == user.account_id)
)).scalar_one()
assert sub.plan == "team"
assert sub.plan == "enterprise"
assert sub.status == "active"

View File

@@ -14,7 +14,12 @@ from app.models.plan_limits import PlanLimits
async def _seed_plan_limits(test_db, plan: str, max_users: int | None) -> None:
"""Ensure a plan_limits row exists for the given plan name."""
"""Ensure a plan_limits row exists with the given max_users.
Upserts: conftest seeds the canonical plans (free/starter/pro/enterprise)
so this helper has to overwrite max_users when a test wants different
values for fixture-driven assertions.
"""
existing = await test_db.get(PlanLimits, plan)
if existing is None:
test_db.add(
@@ -28,7 +33,9 @@ async def _seed_plan_limits(test_db, plan: str, max_users: int | None) -> None:
export_formats=["markdown", "text"],
)
)
await test_db.commit()
else:
existing.max_users = max_users
await test_db.commit()
class TestGetPlansPublic:

View File

@@ -0,0 +1,782 @@
"""Tests for the session-expiration-policy series.
See docs/plans/2026-05-13-session-expiration-policy.md.
Test numbers below correspond to the cases listed in §6 of the plan.
This file grows across commits:
- Commit 2: error-detail taxonomy (#11 + wrong-type + bad-signature)
- Commit 3: claims embedded at login + response fields surfaced (#1, #14)
- Commit 4: absolute-cap enforcement + grandfather path (#8, #9, #12)
- Commit 5: GET/PATCH /accounts/me/security (#2, #3, #4, #5, #7, #16)
- Commit 6: POST /accounts/me/security/revoke-sessions (#17-#22)
"""
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from httpx import AsyncClient
from jose import jwt
from app.core.config import settings
def _encode_refresh_token(
*,
sub: str,
exp: datetime,
token_type: str = "refresh",
secret: str | None = None,
) -> str:
"""Build a refresh JWT with arbitrary `exp` for testing.
Bypasses create_refresh_token so tests can produce already-expired
tokens, wrong-type tokens, or wrong-signature tokens.
"""
return jwt.encode(
{
"sub": sub,
"type": token_type,
"jti": str(uuid.uuid4()),
"exp": exp,
},
secret or settings.SECRET_KEY,
algorithm=settings.ALGORITHM,
)
class TestRefreshTokenErrorTaxonomy:
"""§6 test #11 — refresh-token error-detail taxonomy.
`/auth/refresh` distinguishes idle expiry from generic invalid-token
failures via `detail`, so the frontend can choose between the "session
ended for security" banner and a plain logout redirect.
"""
@pytest.mark.asyncio
async def test_idle_expired_refresh_returns_session_expired_idle(
self, client: AsyncClient, test_user: dict
):
token = _encode_refresh_token(
sub=test_user["user_data"]["id"],
exp=datetime.now(timezone.utc) - timedelta(seconds=1),
)
response = await client.post(
"/api/v1/auth/refresh",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 401
assert response.json()["detail"] == "session_expired_idle"
@pytest.mark.asyncio
async def test_wrong_type_token_returns_invalid_refresh_token(
self, client: AsyncClient, test_user: dict
):
token = _encode_refresh_token(
sub=test_user["user_data"]["id"],
exp=datetime.now(timezone.utc) + timedelta(minutes=5),
token_type="access",
)
response = await client.post(
"/api/v1/auth/refresh",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 401
assert response.json()["detail"] == "invalid_refresh_token"
@pytest.mark.asyncio
async def test_bad_signature_returns_invalid_refresh_token(
self, client: AsyncClient, test_user: dict
):
token = _encode_refresh_token(
sub=test_user["user_data"]["id"],
exp=datetime.now(timezone.utc) + timedelta(minutes=5),
secret="not-the-real-secret-key",
)
response = await client.post(
"/api/v1/auth/refresh",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 401
assert response.json()["detail"] == "invalid_refresh_token"
class TestSessionPolicyClaims:
"""§6 tests #1 and #14 — session-policy claims stamped at login.
Every token-issuing endpoint embeds auth_time/idle_max/abs_max in
the refresh JWT and surfaces idle_expires_at/absolute_expires_at on
the response.
"""
@pytest.mark.asyncio
async def test_login_json_embeds_session_claims_with_defaults(
self, client: AsyncClient, test_user: dict
):
before = datetime.now(timezone.utc)
response = await client.post(
"/api/v1/auth/login/json",
json={
"email": test_user["email"],
"password": test_user["password"],
},
)
assert response.status_code == 200, response.json()
body = response.json()
after = datetime.now(timezone.utc)
# Response surfaces both expiry windows as ISO strings.
assert body["idle_expires_at"] is not None
assert body["absolute_expires_at"] is not None
idle_at = datetime.fromisoformat(body["idle_expires_at"])
abs_at = datetime.fromisoformat(body["absolute_expires_at"])
# Strict default: 3 days idle, 14 days absolute.
assert timedelta(days=3) - timedelta(seconds=10) <= idle_at - before <= timedelta(days=3) + timedelta(seconds=10)
assert timedelta(days=14) - timedelta(seconds=10) <= abs_at - before <= timedelta(days=14) + timedelta(seconds=10)
# JWT carries the claims in seconds, plus auth_time as Unix seconds.
decoded = jwt.decode(
body["refresh_token"], settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
assert decoded["idle_max"] == 3 * 24 * 60 * 60 # 259200
assert decoded["abs_max"] == 14 * 24 * 60 * 60 # 1209600
assert int(before.timestamp()) <= decoded["auth_time"] <= int(after.timestamp())
@pytest.mark.asyncio
async def test_refresh_carries_claims_forward_unchanged(
self, client: AsyncClient, test_user: dict
):
# Login produces the original session.
login_resp = await client.post(
"/api/v1/auth/login/json",
json={"email": test_user["email"], "password": test_user["password"]},
)
original_refresh = login_resp.json()["refresh_token"]
original_payload = jwt.decode(
original_refresh, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
# Refresh rotates the token but must carry auth_time/idle_max/abs_max
# forward unchanged so the absolute window doesn't slide.
refresh_resp = await client.post(
"/api/v1/auth/refresh",
headers={"Authorization": f"Bearer {original_refresh}"},
)
assert refresh_resp.status_code == 200, refresh_resp.json()
new_refresh = refresh_resp.json()["refresh_token"]
new_payload = jwt.decode(
new_refresh, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
assert new_payload["auth_time"] == original_payload["auth_time"]
assert new_payload["idle_max"] == original_payload["idle_max"]
assert new_payload["abs_max"] == original_payload["abs_max"]
# Idle deadline does slide because exp = now + idle_max.
assert new_payload["exp"] >= original_payload["exp"]
# JTI rotates.
assert new_payload["jti"] != original_payload["jti"]
def _backdate_auth_time(refresh_token: str, *, seconds_back: int) -> str:
"""Re-sign a refresh JWT with an earlier auth_time, preserving JTI.
The DB row in refresh_tokens is keyed on hash(jti), so preserving jti
lets the atomic revoke step still find the row. Used to simulate
"this session is past its absolute cap" without waiting two weeks.
"""
payload = jwt.decode(
refresh_token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
payload["auth_time"] = payload["auth_time"] - seconds_back
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
class TestSessionPolicyEndpoint:
"""§6 tests #2, #3, #4, #5, #7, #16 — GET/PATCH /accounts/me/security."""
@pytest.mark.asyncio
async def test_get_returns_defaults_and_bounds(
self, client: AsyncClient, auth_headers: dict, test_user: dict
):
response = await client.get(
"/api/v1/accounts/me/security", headers=auth_headers
)
assert response.status_code == 200, response.json()
body = response.json()
# No override yet -> effective values are the system defaults.
assert body["idle_minutes"] is None
assert body["absolute_minutes"] is None
assert body["effective_idle_minutes"] == 4320 # 3d Strict default
assert body["effective_absolute_minutes"] == 20160 # 14d
assert body["idle_minutes_min"] == 15
assert body["idle_minutes_max"] == 43200
assert body["absolute_minutes_min"] == 60
assert body["absolute_minutes_max"] == 129600
# active_users reflects users with un-revoked refresh tokens.
# auth_headers logged the owner in once, so they should appear.
assert isinstance(body["active_users"], list)
assert len(body["active_users"]) >= 1
emails = [u["email"] for u in body["active_users"]]
assert test_user["email"] in emails
# Schema check on one row.
first = body["active_users"][0]
assert "user_id" in first
assert "name" in first
assert "email" in first
assert "last_login_at" in first
@pytest.mark.asyncio
async def test_patch_persists_override_and_returns_new_state(
self, client: AsyncClient, auth_headers: dict
):
response = await client.patch(
"/api/v1/accounts/me/security",
headers=auth_headers,
json={"idle_minutes": 60, "absolute_minutes": 240},
)
assert response.status_code == 200, response.json()
body = response.json()
assert body["idle_minutes"] == 60
assert body["absolute_minutes"] == 240
assert body["effective_idle_minutes"] == 60
assert body["effective_absolute_minutes"] == 240
# Next login picks up the new policy.
login_resp = await client.post(
"/api/v1/auth/login/json",
json={"email": "test@example.com", "password": "TestPassword123!"},
)
new_payload = jwt.decode(
login_resp.json()["refresh_token"],
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
)
assert new_payload["idle_max"] == 60 * 60 # 3600 seconds
assert new_payload["abs_max"] == 240 * 60 # 14400 seconds
@pytest.mark.asyncio
async def test_patch_rejects_idle_below_min(
self, client: AsyncClient, auth_headers: dict
):
response = await client.patch(
"/api/v1/accounts/me/security",
headers=auth_headers,
json={"idle_minutes": 5, "absolute_minutes": 60},
)
assert response.status_code == 422
assert "idle_minutes" in response.json()["detail"]
@pytest.mark.asyncio
async def test_patch_rejects_absolute_above_max(
self, client: AsyncClient, auth_headers: dict
):
response = await client.patch(
"/api/v1/accounts/me/security",
headers=auth_headers,
json={"absolute_minutes": 200000},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_patch_rejects_idle_greater_than_absolute_both_set(
self, client: AsyncClient, auth_headers: dict
):
response = await client.patch(
"/api/v1/accounts/me/security",
headers=auth_headers,
json={"idle_minutes": 300, "absolute_minutes": 120},
)
assert response.status_code == 422
assert "exceed" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_patch_rejects_partial_override_when_effective_invalid(
self, client: AsyncClient, auth_headers: dict
):
"""§6 test #5 — partial override: idle=43200, absolute=NULL ->
effective idle (43200) > effective absolute (20160 default) -> 422.
"""
response = await client.patch(
"/api/v1/accounts/me/security",
headers=auth_headers,
json={"idle_minutes": 43200, "absolute_minutes": None},
)
assert response.status_code == 422
assert "exceed" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_non_owner_cannot_patch(
self, client: AsyncClient, test_user: dict, test_db
):
"""§6 test #7 — engineer role is forbidden."""
from app.models.user import User
from sqlalchemy import select
# Add a second user in the same account with account_role=engineer.
result = await test_db.execute(
select(User).where(User.email == test_user["email"])
)
owner = result.scalar_one()
engineer = User(
email="engineer-policy@example.com",
password_hash=owner.password_hash, # reuse the bcrypt hash
name="Engineer",
role="engineer",
is_super_admin=False,
is_active=True,
account_id=owner.account_id,
account_role="engineer",
email_verified_at=datetime.now(timezone.utc),
)
test_db.add(engineer)
await test_db.commit()
login_resp = await client.post(
"/api/v1/auth/login/json",
json={
"email": "engineer-policy@example.com",
"password": test_user["password"],
},
)
assert login_resp.status_code == 200
engineer_headers = {
"Authorization": f"Bearer {login_resp.json()['access_token']}"
}
response = await client.patch(
"/api/v1/accounts/me/security",
headers=engineer_headers,
json={"idle_minutes": 60, "absolute_minutes": 240},
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_patch_writes_audit_row(
self, client: AsyncClient, auth_headers: dict, test_db
):
"""§6 test #16 — PATCH emits one account.session_policy_update
audit event with old/new + effective_old/new payload.
"""
from app.models.audit_log import AuditLog
from sqlalchemy import select
response = await client.patch(
"/api/v1/accounts/me/security",
headers=auth_headers,
json={"idle_minutes": 120, "absolute_minutes": 480},
)
assert response.status_code == 200
result = await test_db.execute(
select(AuditLog).where(AuditLog.action == "account.session_policy_update")
)
rows = result.scalars().all()
assert len(rows) == 1
entry = rows[0]
assert entry.resource_type == "account"
assert entry.details["new"] == {"idle_minutes": 120, "absolute_minutes": 480}
assert entry.details["effective_new"] == {
"idle_minutes": 120,
"absolute_minutes": 480,
}
assert entry.details["effective_old"]["idle_minutes"] == 4320 # default
assert entry.details["effective_old"]["absolute_minutes"] == 20160
async def _seed_extra_account_user(
test_db, *, email: str, account_id, password_hash: str, role: str = "engineer"
):
"""Add a second user under an existing account for revoke-scope tests."""
from app.models.user import User
user = User(
email=email,
password_hash=password_hash,
name=email,
role="engineer",
is_super_admin=False,
is_active=True,
account_id=account_id,
account_role=role,
email_verified_at=datetime.now(timezone.utc),
)
test_db.add(user)
await test_db.commit()
return user
class TestBulkRevoke:
"""§6 tests #17-#22 — POST /accounts/me/security/revoke-sessions."""
@pytest.mark.asyncio
async def test_revoke_all_kills_callers_own_session(
self, client: AsyncClient, test_user: dict, test_db
):
"""§6 test #17 — scope=all includes the caller's own token. After
the response, the caller's refresh_token gets invalid_refresh_token
on next /auth/refresh.
"""
from app.models.user import User
from sqlalchemy import select
owner = (
await test_db.execute(
select(User).where(User.email == test_user["email"])
)
).scalar_one()
await _seed_extra_account_user(
test_db,
email="member-revoke-all@example.com",
account_id=owner.account_id,
password_hash=owner.password_hash,
)
# Owner logs in (also seeds owner's refresh-token row).
owner_login = await client.post(
"/api/v1/auth/login/json",
json={"email": test_user["email"], "password": test_user["password"]},
)
owner_refresh = owner_login.json()["refresh_token"]
owner_access = owner_login.json()["access_token"]
# Member also logs in so there's another active refresh-token row.
member_login = await client.post(
"/api/v1/auth/login/json",
json={
"email": "member-revoke-all@example.com",
"password": test_user["password"],
},
)
assert member_login.status_code == 200
response = await client.post(
"/api/v1/accounts/me/security/revoke-sessions",
headers={"Authorization": f"Bearer {owner_access}"},
json={"scope": "all"},
)
assert response.status_code == 200, response.json()
assert response.json()["revoked_count"] == 2
# Owner's own refresh now returns invalid_refresh_token.
retry = await client.post(
"/api/v1/auth/refresh",
headers={"Authorization": f"Bearer {owner_refresh}"},
)
assert retry.status_code == 401
assert retry.json()["detail"] == "invalid_refresh_token"
@pytest.mark.asyncio
async def test_revoke_others_preserves_callers_session(
self, client: AsyncClient, test_user: dict, test_db
):
"""§6 test #18 — scope=others excludes the caller's user_id from
the bulk update. Caller can still refresh; other users cannot.
"""
from app.models.user import User
from sqlalchemy import select
owner = (
await test_db.execute(
select(User).where(User.email == test_user["email"])
)
).scalar_one()
await _seed_extra_account_user(
test_db,
email="member-revoke-others@example.com",
account_id=owner.account_id,
password_hash=owner.password_hash,
)
owner_login = await client.post(
"/api/v1/auth/login/json",
json={"email": test_user["email"], "password": test_user["password"]},
)
owner_refresh = owner_login.json()["refresh_token"]
owner_access = owner_login.json()["access_token"]
member_login = await client.post(
"/api/v1/auth/login/json",
json={
"email": "member-revoke-others@example.com",
"password": test_user["password"],
},
)
member_refresh = member_login.json()["refresh_token"]
response = await client.post(
"/api/v1/accounts/me/security/revoke-sessions",
headers={"Authorization": f"Bearer {owner_access}"},
json={"scope": "others"},
)
assert response.status_code == 200
assert response.json()["revoked_count"] == 1
# Owner's refresh still works.
owner_retry = await client.post(
"/api/v1/auth/refresh",
headers={"Authorization": f"Bearer {owner_refresh}"},
)
assert owner_retry.status_code == 200
# Member's refresh is dead.
member_retry = await client.post(
"/api/v1/auth/refresh",
headers={"Authorization": f"Bearer {member_refresh}"},
)
assert member_retry.status_code == 401
assert member_retry.json()["detail"] == "invalid_refresh_token"
@pytest.mark.asyncio
async def test_revoke_is_account_scoped(
self, client: AsyncClient, test_user: dict, test_admin: dict
):
"""§6 test #19 — owner of account A cannot revoke tokens in account B.
test_admin lives in its own account. After test_user's owner runs
revoke-all, test_admin's session continues to work.
"""
owner_login = await client.post(
"/api/v1/auth/login/json",
json={"email": test_user["email"], "password": test_user["password"]},
)
owner_access = owner_login.json()["access_token"]
admin_login = await client.post(
"/api/v1/auth/login/json",
json={"email": test_admin["email"], "password": test_admin["password"]},
)
admin_refresh = admin_login.json()["refresh_token"]
response = await client.post(
"/api/v1/accounts/me/security/revoke-sessions",
headers={"Authorization": f"Bearer {owner_access}"},
json={"scope": "all"},
)
assert response.status_code == 200
# Only test_user's own session is revoked.
assert response.json()["revoked_count"] == 1
admin_retry = await client.post(
"/api/v1/auth/refresh",
headers={"Authorization": f"Bearer {admin_refresh}"},
)
assert admin_retry.status_code == 200
@pytest.mark.asyncio
async def test_revoke_engineer_forbidden(
self, client: AsyncClient, test_user: dict, test_db
):
"""§6 test #20 — engineer-role member gets 403."""
from app.models.user import User
from sqlalchemy import select
owner = (
await test_db.execute(
select(User).where(User.email == test_user["email"])
)
).scalar_one()
await _seed_extra_account_user(
test_db,
email="engineer-revoke@example.com",
account_id=owner.account_id,
password_hash=owner.password_hash,
)
engineer_login = await client.post(
"/api/v1/auth/login/json",
json={
"email": "engineer-revoke@example.com",
"password": test_user["password"],
},
)
engineer_access = engineer_login.json()["access_token"]
response = await client.post(
"/api/v1/accounts/me/security/revoke-sessions",
headers={"Authorization": f"Bearer {engineer_access}"},
json={"scope": "all"},
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_revoke_writes_audit_row(
self, client: AsyncClient, test_user: dict, test_db
):
"""§6 test #21 — emits one account.sessions_revoked_bulk event."""
from app.models.audit_log import AuditLog
from sqlalchemy import select
owner_login = await client.post(
"/api/v1/auth/login/json",
json={"email": test_user["email"], "password": test_user["password"]},
)
owner_access = owner_login.json()["access_token"]
response = await client.post(
"/api/v1/accounts/me/security/revoke-sessions",
headers={"Authorization": f"Bearer {owner_access}"},
json={"scope": "all"},
)
assert response.status_code == 200
result = await test_db.execute(
select(AuditLog).where(AuditLog.action == "account.sessions_revoked_bulk")
)
rows = result.scalars().all()
assert len(rows) == 1
entry = rows[0]
assert entry.details["scope"] == "all"
assert entry.details["revoked_count"] == 1
@pytest.mark.asyncio
async def test_revoke_is_idempotent(
self, client: AsyncClient, test_user: dict
):
"""§6 test #22 — second immediate POST returns revoked_count=0
(no already-revoked rows get double-stamped or counted again).
"""
owner_login = await client.post(
"/api/v1/auth/login/json",
json={"email": test_user["email"], "password": test_user["password"]},
)
owner_access = owner_login.json()["access_token"]
first = await client.post(
"/api/v1/accounts/me/security/revoke-sessions",
headers={"Authorization": f"Bearer {owner_access}"},
json={"scope": "others"}, # owner's own session preserved
)
assert first.status_code == 200
second = await client.post(
"/api/v1/accounts/me/security/revoke-sessions",
headers={"Authorization": f"Bearer {owner_access}"},
json={"scope": "others"},
)
assert second.status_code == 200
assert second.json()["revoked_count"] == 0
class TestAbsoluteCap:
"""§6 tests #8, #9, #12 — absolute-cap enforcement and grandfather path."""
@pytest.mark.asyncio
async def test_refresh_at_absolute_deadline_rejects(
self, client: AsyncClient, test_user: dict
):
"""§6 test #8 — boundary check uses `>=`, not `>`.
A token whose auth_time + abs_max equals now() is expired, not
valid. Backdate the original token's auth_time by exactly abs_max
seconds so now >= deadline.
"""
login_resp = await client.post(
"/api/v1/auth/login/json",
json={"email": test_user["email"], "password": test_user["password"]},
)
original = login_resp.json()["refresh_token"]
abs_max = jwt.decode(
original, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)["abs_max"]
expired = _backdate_auth_time(original, seconds_back=abs_max)
response = await client.post(
"/api/v1/auth/refresh",
headers={"Authorization": f"Bearer {expired}"},
)
assert response.status_code == 401
assert response.json()["detail"] == "session_expired_absolute"
@pytest.mark.asyncio
async def test_absolute_expired_token_is_consumed(
self, client: AsyncClient, test_user: dict
):
"""§6 test #9 — first attempt returns session_expired_absolute and
revokes the row; second attempt sees the revoked row and returns
invalid_refresh_token. Prevents replay of an absolute-expired token.
"""
login_resp = await client.post(
"/api/v1/auth/login/json",
json={"email": test_user["email"], "password": test_user["password"]},
)
original = login_resp.json()["refresh_token"]
abs_max = jwt.decode(
original, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)["abs_max"]
expired = _backdate_auth_time(original, seconds_back=abs_max + 1)
first = await client.post(
"/api/v1/auth/refresh",
headers={"Authorization": f"Bearer {expired}"},
)
assert first.status_code == 401
assert first.json()["detail"] == "session_expired_absolute"
second = await client.post(
"/api/v1/auth/refresh",
headers={"Authorization": f"Bearer {expired}"},
)
assert second.status_code == 401
assert second.json()["detail"] == "invalid_refresh_token"
@pytest.mark.asyncio
async def test_grandfather_path_for_legacy_token(
self, client: AsyncClient, test_user: dict, test_db
):
"""§6 test #12 — refresh token issued before this PR (no auth_time
claim) gets one successful rotation; the new token has fresh
auth_time/idle_max/abs_max claims snapshotted from current policy.
"""
from app.core.security import hash_token
from app.models.refresh_token import RefreshToken
login_resp = await client.post(
"/api/v1/auth/login/json",
json={"email": test_user["email"], "password": test_user["password"]},
)
original = login_resp.json()["refresh_token"]
original_payload = jwt.decode(
original, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
# Strip the new claims to simulate a token issued before this PR.
# JTI preserved so the DB-side revoke still finds the row.
legacy_payload = {
"sub": original_payload["sub"],
"type": "refresh",
"jti": original_payload["jti"],
"exp": original_payload["exp"],
}
legacy_token = jwt.encode(
legacy_payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM
)
response = await client.post(
"/api/v1/auth/refresh",
headers={"Authorization": f"Bearer {legacy_token}"},
)
assert response.status_code == 200, response.json()
new_payload = jwt.decode(
response.json()["refresh_token"],
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
)
assert new_payload.get("auth_time") is not None
assert new_payload.get("idle_max") == 3 * 24 * 60 * 60
assert new_payload.get("abs_max") == 14 * 24 * 60 * 60
# auth_time was set to ~now during grandfather, not preserved from
# the legacy token (since the legacy token didn't have one).
now_unix = int(datetime.now(timezone.utc).timestamp())
assert abs(new_payload["auth_time"] - now_unix) < 10

View File

@@ -40,11 +40,16 @@ services:
- ALGORITHM=HS256
- ACCESS_TOKEN_EXPIRE_MINUTES=15
- REFRESH_TOKEN_EXPIRE_DAYS=7
- REQUIRE_INVITE_CODE=true
- REQUIRE_INVITE_CODE=false
- FEEDBACK_EMAIL=feedback@resolutionflow.com
- AI_PROVIDER=anthropic
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- GOOGLE_AI_API_KEY=${GOOGLE_AI_API_KEY:-}
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
- STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY:-}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
- SELF_SERVE_ENABLED=${SELF_SERVE_ENABLED:-false}
- INTERNAL_TESTER_EMAILS=${INTERNAL_TESTER_EMAILS:-}
- ENABLE_MCP_MICROSOFT_LEARN=true
- FRONTEND_URL=http://docker-01:5173
- CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173","http://docker-01:5173","http://100.64.78.44:5173"]

View File

@@ -0,0 +1,336 @@
{
"nodes": [
{
"id": "title",
"type": "text",
"x": -860,
"y": -520,
"width": 1080,
"height": 150,
"color": "#2563eb",
"text": "# God Node Map\nResolutionFlow architecture hotspots, 2026-05-06\n\nRead left to right: behavioral risk -> expected infrastructure -> self-serve boundaries."
},
{
"id": "frontend_group",
"type": "group",
"x": -900,
"y": -300,
"width": 720,
"height": 760,
"color": "#fee2e2",
"label": "Frontend Behavioral Hubs"
},
{
"id": "assistant_page",
"type": "text",
"x": -860,
"y": -240,
"width": 300,
"height": 190,
"color": "#ef4444",
"text": "## AssistantChatPage.tsx\n\nHighest-risk frontend node.\n\n- 2,493 LOC\n- 39 outbound imports\n- 77 changes in 90 days\n- Owns many unrelated workflows"
},
{
"id": "tree_navigation_page",
"type": "text",
"x": -520,
"y": -240,
"width": 300,
"height": 160,
"color": "#f97316",
"text": "## TreeNavigationPage.tsx\n\nLarge page orchestrator.\n\n- 1,385 LOC\n- 31 outbound imports\n- 33 changes in 90 days"
},
{
"id": "procedural_navigation_page",
"type": "text",
"x": -860,
"y": 0,
"width": 300,
"height": 160,
"color": "#f97316",
"text": "## ProceduralNavigationPage.tsx\n\nLarge page orchestrator.\n\n- 1,021 LOC\n- 33 outbound imports\n- 22 changes in 90 days"
},
{
"id": "frontend_pages",
"type": "text",
"x": -520,
"y": 0,
"width": 300,
"height": 190,
"color": "#f59e0b",
"text": "## Other Page Hubs\n\n- TreeLibraryPage.tsx\n- TreeEditorPage.tsx\n- SessionDetailPage.tsx\n\nTreat as page shells. Extract workflow hooks when touched."
},
{
"id": "frontend_action",
"type": "text",
"x": -860,
"y": 250,
"width": 640,
"height": 150,
"color": "#16a34a",
"text": "## Frontend Rule\n\nDo not start a broad cleanup. For new self-serve work, keep billing in `useBillingStore`, keep onboarding state narrow, and prefer direct API module imports over the `@/api` barrel."
},
{
"id": "backend_group",
"type": "group",
"x": -80,
"y": -300,
"width": 740,
"height": 760,
"color": "#ffedd5",
"label": "Backend Behavioral Hubs"
},
{
"id": "flowpilot_engine",
"type": "text",
"x": -40,
"y": -240,
"width": 310,
"height": 190,
"color": "#ef4444",
"text": "## flowpilot_engine.py\n\nReal backend behavioral hub.\n\n- 1,793 LOC\n- prompts\n- structured parsing\n- session state transitions\n- model orchestration"
},
{
"id": "ai_sessions_endpoint",
"type": "text",
"x": 310,
"y": -240,
"width": 310,
"height": 180,
"color": "#f97316",
"text": "## ai_sessions.py\n\nController plus mapper.\n\n- 1,173 LOC\n- 15 outbound imports\n- 32 changes in 90 days\n\nKeep subscription/onboarding logic out."
},
{
"id": "sessions_trees_endpoints",
"type": "text",
"x": -40,
"y": 0,
"width": 310,
"height": 190,
"color": "#f59e0b",
"text": "## sessions.py / trees.py\n\nLarge endpoint hubs.\n\n- ownership\n- exports\n- sharing\n- limits\n- tree/session behavior\n\nUse guards and services instead of handler sprawl."
},
{
"id": "admin_endpoint",
"type": "text",
"x": 310,
"y": 0,
"width": 310,
"height": 150,
"color": "#f59e0b",
"text": "## admin.py\n\nLarge admin surface.\n\nHigh LOC, lower churn. Extend carefully, but not a self-serve blocker."
},
{
"id": "backend_action",
"type": "text",
"x": -40,
"y": 250,
"width": 660,
"height": 150,
"color": "#16a34a",
"text": "## Backend Rule\n\nMount subscription and email-verification checks at dependency/router boundaries. Keep billing behavior in BillingService and subscription models, not in AI/session/tree endpoints."
},
{
"id": "infra_group",
"type": "group",
"x": 820,
"y": -300,
"width": 640,
"height": 760,
"color": "#dbeafe",
"label": "Expected Infrastructure Hubs"
},
{
"id": "frontend_infra",
"type": "text",
"x": 860,
"y": -240,
"width": 260,
"height": 200,
"color": "#3b82f6",
"text": "## Frontend Infra\n\nExpected central nodes:\n\n- lib/utils.ts\n- lib/toast.ts\n- api/client.ts\n- types/index.ts\n- ui/Button.tsx"
},
{
"id": "backend_infra",
"type": "text",
"x": 1160,
"y": -240,
"width": 260,
"height": 200,
"color": "#3b82f6",
"text": "## Backend Infra\n\nExpected central nodes:\n\n- core/database.py\n- api/deps.py\n- core/config.py\n- ORM models"
},
{
"id": "barrel_cycles",
"type": "text",
"x": 860,
"y": 10,
"width": 260,
"height": 170,
"color": "#60a5fa",
"text": "## Barrel Cycles\n\n`frontend/src/api/*` has a large barrel/export cycle.\n\nLow urgency. Prefer direct imports in new code."
},
{
"id": "orm_cycles",
"type": "text",
"x": 1160,
"y": 10,
"width": 260,
"height": 170,
"color": "#60a5fa",
"text": "## ORM Cycles\n\nSQLAlchemy model cycles are expected.\n\nKeep behavior in services, not model methods."
},
{
"id": "infra_action",
"type": "text",
"x": 860,
"y": 250,
"width": 560,
"height": 150,
"color": "#16a34a",
"text": "## Infrastructure Rule\n\nDo not refactor a file just because it has high inbound count. Central utilities, clients, config, database, and model definitions are allowed to be central."
},
{
"id": "self_serve_group",
"type": "group",
"x": -900,
"y": 560,
"width": 2360,
"height": 300,
"color": "#dcfce7",
"label": "Self-Serve Signup Guidance"
},
{
"id": "no_blocker",
"type": "text",
"x": -860,
"y": 620,
"width": 360,
"height": 160,
"color": "#22c55e",
"text": "## Do Now\n\nNo large refactor is required before self-serve signup.\n\nUse this map to avoid accidental coupling while implementing the plans."
},
{
"id": "self_serve_boundaries",
"type": "text",
"x": -440,
"y": 620,
"width": 440,
"height": 170,
"color": "#22c55e",
"text": "## During Self-Serve\n\n- `useBillingStore`, not `authStore`\n- `BillingService`, not AI/session/tree endpoints\n- dependency guards, not repeated handler checks\n- direct API imports in new frontend code"
},
{
"id": "opportunistic_refactors",
"type": "text",
"x": 60,
"y": 620,
"width": 440,
"height": 170,
"color": "#84cc16",
"text": "## Opportunistic Refactors\n\n- Extract one Assistant workflow at a time\n- Extract FlowPilot prompt/validation pieces when touched\n- Move ai_sessions mapping helpers if touched again"
},
{
"id": "avoid_refactors",
"type": "text",
"x": 560,
"y": 620,
"width": 820,
"height": 170,
"color": "#a3e635",
"text": "## Avoid\n\n- Broad `AssistantChatPage` cleanup before product work\n- ORM cycle cleanup unless there is a runtime issue\n- Splitting utilities, toast, API client, or database just because they are central\n- Running self-serve behavior through AI/product endpoints"
}
],
"edges": [
{
"id": "edge_assistant_frontend_action",
"fromNode": "assistant_page",
"fromSide": "bottom",
"toNode": "frontend_action",
"toSide": "top",
"label": "extract one workflow at a time"
},
{
"id": "edge_tree_frontend_action",
"fromNode": "tree_navigation_page",
"fromSide": "bottom",
"toNode": "frontend_action",
"toSide": "top",
"label": "extract hooks when touched"
},
{
"id": "edge_proc_frontend_action",
"fromNode": "procedural_navigation_page",
"fromSide": "bottom",
"toNode": "frontend_action",
"toSide": "top"
},
{
"id": "edge_flowpilot_backend_action",
"fromNode": "flowpilot_engine",
"fromSide": "bottom",
"toNode": "backend_action",
"toSide": "top",
"label": "keep self-serve out"
},
{
"id": "edge_ai_backend_action",
"fromNode": "ai_sessions_endpoint",
"fromSide": "bottom",
"toNode": "backend_action",
"toSide": "top",
"label": "avoid billing logic here"
},
{
"id": "edge_sessions_backend_action",
"fromNode": "sessions_trees_endpoints",
"fromSide": "bottom",
"toNode": "backend_action",
"toSide": "top",
"label": "mount guards"
},
{
"id": "edge_frontend_selfserve",
"fromNode": "frontend_action",
"fromSide": "bottom",
"toNode": "self_serve_boundaries",
"toSide": "top"
},
{
"id": "edge_backend_selfserve",
"fromNode": "backend_action",
"fromSide": "bottom",
"toNode": "self_serve_boundaries",
"toSide": "top"
},
{
"id": "edge_infra_selfserve",
"fromNode": "infra_action",
"fromSide": "bottom",
"toNode": "avoid_refactors",
"toSide": "top",
"label": "do not refactor just because central"
},
{
"id": "edge_no_blocker_boundaries",
"fromNode": "no_blocker",
"fromSide": "right",
"toNode": "self_serve_boundaries",
"toSide": "left"
},
{
"id": "edge_boundaries_opportunistic",
"fromNode": "self_serve_boundaries",
"fromSide": "right",
"toNode": "opportunistic_refactors",
"toSide": "left"
},
{
"id": "edge_opportunistic_avoid",
"fromNode": "opportunistic_refactors",
"fromSide": "right",
"toNode": "avoid_refactors",
"toSide": "left"
}
]
}

View File

@@ -0,0 +1,458 @@
---
title: God Node Architecture Report
date: 2026-05-06
tags:
- architecture
- dependency-graph
- god-nodes
---
# God Node Architecture Report — 2026-05-06
## Summary
This is a static dependency and churn report for `backend/app` and `frontend/src`.
The main finding: ResolutionFlow has several expected infrastructure hubs, plus a smaller set of behavioral hubs that deserve care when touched. The highest-risk candidates are not the most-imported files; they are the files that combine high size, high churn, and many outbound dependencies.
Highest-risk behavioral hubs:
1. `frontend/src/pages/AssistantChatPage.tsx`
2. `frontend/src/pages/TreeNavigationPage.tsx`
3. `frontend/src/pages/ProceduralNavigationPage.tsx`
4. `backend/app/services/flowpilot_engine.py`
5. `backend/app/api/endpoints/ai_sessions.py`
6. `backend/app/api/endpoints/sessions.py`
7. `backend/app/api/endpoints/trees.py`
8. `backend/app/api/endpoints/admin.py`
Expected infrastructure hubs:
- `frontend/src/lib/utils.ts`
- `frontend/src/types/index.ts`
- `frontend/src/api/index.ts`
- `frontend/src/api/client.ts`
- `frontend/src/lib/toast.ts`
- `backend/app/core/database.py`
- `backend/app/api/deps.py`
- `backend/app/core/config.py`
- SQLAlchemy models such as `User`, `Tree`, `AISession`, and `Account`
Do not treat all high-degree nodes as bad. A utility, type barrel, API barrel, router, or ORM model can be central by design. The suspicious shape is: high outbound dependencies + high churn + large file + multiple unrelated reasons to change.
## Method
Inputs:
- Source files: `backend/app/**/*.py`, `frontend/src/**/*.ts`, `frontend/src/**/*.tsx`
- Excluded: tests, docs, migrations, build output, env files
- Static imports:
- Python: regex import extraction for `import ...` and `from ... import ...`
- TypeScript/TSX: static `import/export from` plus dynamic `import(...)`
- Churn: `git log --name-only --since='90 days ago'`
- Size: line count
Scoring used for triage, not truth:
```text
score = inbound_edges * 2
+ outbound_edges * 1.5
+ min(churn_90d, 30) * 1.2
+ min(lines_of_code / 100, 20)
```
Caveats:
- `backend/app/__init__.py` appears as a very high inbound node because static imports through `app.*` resolve through the package root in this simple parser. Ignore it as a parser artifact.
- Barrel files (`frontend/src/api/index.ts`, `frontend/src/types/index.ts`) intentionally create cycles with the modules they export. This is a known TypeScript graph artifact, not automatically a design flaw.
- Static graphs do not show runtime call volume. This report answers “where is the code structurally central?” not “what is hot in production?”
## Visual Map
Primary visualization:
- Open `docs/architecture/god-node-map-2026-05-06.canvas` in Obsidian.
- This uses Obsidian Canvas, so no community plugin is required.
- The Canvas groups nodes by interpretation instead of drawing every import edge.
The dense dependency graph is intentionally not the default view anymore. For architecture review, the useful split is:
1. Which nodes are high-risk behavioral hubs?
2. Which central nodes are expected infrastructure?
3. What should self-serve signup avoid touching?
### Risk Overview
```mermaid
flowchart LR
Work["Self-serve signup work"] --> Boundaries["Keep changes at boundaries"]
Boundaries --> BillingStore["useBillingStore"]
Boundaries --> Guards["router/dependency guards"]
Boundaries --> BillingService["BillingService"]
Assistant["AssistantChatPage.tsx\nfrontend god node"] -. avoid unrelated edits .-> Work
FlowPilot["flowpilot_engine.py\nbackend god node"] -. avoid unrelated edits .-> Work
AISessions["ai_sessions.py\ncontroller + mapper"] -. do not add billing logic .-> Work
SessionsTrees["sessions.py / trees.py\nlarge endpoint hubs"] -. mount guards, avoid handler sprawl .-> Work
Utils["utils / toast / api client / database\nexpected infrastructure"] -. do not refactor just because central .-> Work
```
### Frontend Hotspots
```mermaid
flowchart TB
Router["router.tsx\nroute hub"] --> Assistant["AssistantChatPage.tsx\nhighest risk"]
Router --> TreeNav["TreeNavigationPage.tsx"]
Router --> ProcNav["ProceduralNavigationPage.tsx"]
Router --> TreeLibrary["TreeLibraryPage.tsx"]
Router --> TreeEditor["TreeEditorPage.tsx"]
Router --> SessionDetail["SessionDetailPage.tsx"]
Assistant --> ExtractA["Extract one workflow at a time"]
TreeNav --> ExtractB["Extract orchestration hooks when touched"]
ProcNav --> ExtractB
Infra["utils.ts / toast.ts / api/client.ts / types/index.ts"]
Assistant --> Infra
TreeNav --> Infra
ProcNav --> Infra
```
### Backend Hotspots
```mermaid
flowchart TB
Deps["api/deps.py\nboundary hub"] --> DB["database + models\nexpected infrastructure"]
AISessions["api/endpoints/ai_sessions.py"] --> FlowPilot["services/flowpilot_engine.py"]
Sessions["api/endpoints/sessions.py"] --> Export["services/export_service.py"]
Trees["api/endpoints/trees.py"] --> DB
Admin["api/endpoints/admin.py"] --> DB
SelfServe["Self-serve backend"] --> Deps
SelfServe --> Billing["BillingService + subscriptions"]
SelfServe -. avoid .-> AISessions
SelfServe -. avoid .-> Sessions
SelfServe -. avoid .-> Trees
SelfServe -. avoid .-> FlowPilot
```
## Obsidian Visualization Options
Best default: use the generated Canvas file. Obsidian Canvas is a core plugin and stores diagrams as `.canvas` files, so it works without adding community plugin risk.
Optional plugins worth considering:
- Excalidraw: best if you want hand-edited architecture diagrams that feel like a whiteboard.
- Markmind: useful if you want this report as a mind map or outline-first view.
- Diagrams.net / draw.io plugin: useful for formal boxes-and-arrows diagrams, but heavier than Canvas for this use case.
Recommendation: start with Canvas. Add Excalidraw only if you want to manually sketch over the architecture map during planning sessions.
## Top Centrality Candidates
| Rank | File | In | Out | 90d churn | LOC | Classification | Read |
|---:|---|---:|---:|---:|---:|---|---|
| 1 | `frontend/src/lib/utils.ts` | 225 | 0 | 1 | 32 | Infrastructure hub | Good |
| 2 | `frontend/src/types/index.ts` | 137 | 32 | 22 | 103 | Barrel hub | Watch |
| 3 | `backend/app/core/database.py` | 110 | 2 | 2 | 47 | Infrastructure hub | Good |
| 4 | `backend/app/models/user.py` | 90 | 7 | 13 | 130 | Domain model hub | Watch |
| 5 | `frontend/src/api/index.ts` | 38 | 40 | 26 | 41 | API barrel hub | Watch |
| 6 | `frontend/src/lib/toast.ts` | 79 | 0 | 1 | 72 | Infrastructure hub | Good |
| 7 | `frontend/src/router.tsx` | 1 | 72 | 48 | 308 | Router hub | Watch |
| 8 | `backend/app/api/deps.py` | 56 | 9 | 13 | 292 | Auth/dependency hub | Watch |
| 9 | `backend/app/core/config.py` | 44 | 1 | 27 | 232 | Config hub | Good, but churny |
| 10 | `frontend/src/pages/AssistantChatPage.tsx` | 2 | 39 | 77 | 2493 | Behavioral hub | High risk |
| 11 | `backend/app/models/tree.py` | 43 | 10 | 11 | 233 | Domain model hub | Watch |
| 12 | `frontend/src/api/client.ts` | 51 | 2 | 5 | 173 | API client hub | Good |
| 13 | `frontend/src/pages/TreeNavigationPage.tsx` | 2 | 31 | 33 | 1385 | Behavioral hub | High risk |
| 14 | `frontend/src/components/ui/Button.tsx` | 43 | 2 | 6 | 65 | UI primitive | Good |
| 15 | `backend/app/models/ai_session.py` | 32 | 11 | 11 | 314 | Domain model hub | Watch |
| 16 | `frontend/src/pages/ProceduralNavigationPage.tsx` | 1 | 33 | 22 | 1021 | Behavioral hub | High risk |
| 17 | `frontend/src/pages/TreeLibraryPage.tsx` | 3 | 27 | 38 | 546 | Behavioral hub | Medium risk |
| 18 | `backend/app/models/account.py` | 29 | 11 | 8 | 70 | Domain model hub | Watch |
| 19 | `backend/app/api/endpoints/sessions.py` | 0 | 24 | 26 | 1186 | Endpoint hub | High risk |
| 20 | `frontend/src/pages/TreeEditorPage.tsx` | 2 | 20 | 28 | 928 | Behavioral hub | Medium risk |
| 21 | `frontend/src/pages/SessionDetailPage.tsx` | 2 | 21 | 28 | 623 | Behavioral hub | Medium risk |
| 22 | `backend/app/api/endpoints/trees.py` | 0 | 20 | 23 | 1332 | Endpoint hub | High risk |
| 23 | `backend/app/api/endpoints/ai_sessions.py` | 0 | 15 | 32 | 1173 | Endpoint hub | High risk |
| 24 | `backend/app/services/flowpilot_engine.py` | 1 | 17 | 20 | 1793 | Behavioral service hub | High risk |
## Findings
### 1. `AssistantChatPage.tsx` Is The Clearest Frontend God Node
Evidence:
- 2,493 LOC
- 39 outbound dependencies
- 77 changes in 90 days
- Owns routing, chat selection, magic-moment pickup state, task-lane state, upload state, facts, suggested fixes, preview state, script-builder surfaces, modals, keyboard shortcuts, local/session storage, and message rendering orchestration.
Classification: behavioral god node.
This file has too many reasons to change. It is not dangerous because many files import it; it is dangerous because it imports many things, owns many workflows, and changes constantly.
Recommended response:
- Do not do a broad refactor in isolation.
- When touching it, extract one workflow at a time behind a hook or controller:
- `useTaskLaneState`
- `usePilotPickup`
- `useSuggestedFixPreview`
- `useSessionFacts`
- `useScriptBuilderPanelState`
- Keep the page as an orchestrator, but move state machines and async effects out.
- Before major changes, add narrow regression tests around task-lane ownership and session switching.
Priority: high, opportunistic refactor.
### 2. `flowpilot_engine.py` Is A Real Backend Behavioral Hub
Evidence:
- 1,793 LOC
- 17 outbound dependencies
- 20 changes in 90 days
- Owns prompts, structured output parsing, session start, step generation, confidence, close/resolve/escalate behaviors, and likely several persistence transitions.
Classification: behavioral service hub.
This is not surprising: FlowPilot is core product logic. The risk is that prompt text, model call orchestration, persistence, and business rules live close together.
Recommended response:
- Keep this file stable during unrelated work.
- Extract only when a change naturally creates a seam:
- prompt construction
- structured output validation
- session state transition persistence
- documentation/status update generation
- Avoid routing new self-serve billing or account logic through this service.
Priority: high, but avoid speculative refactor.
### 3. AI Session Endpoint Is Acting As A Controller Plus Mapper
File: `backend/app/api/endpoints/ai_sessions.py`
Evidence:
- 1,173 LOC
- 15 outbound dependencies
- 32 changes in 90 days
- Contains endpoint handlers, quota checks, response mapping, ownership behavior, chat wiring, and PSA retry integration.
Classification: endpoint god node.
The endpoint does more than route HTTP to services. Some helper logic is fine, but the mapper and ownership rules should stay stable and test-backed.
Recommended response:
- Keep endpoint handlers thin when adding new features.
- Move reusable mapping logic such as `_build_session_detail` to a schema/service helper if it is touched again.
- Do not add subscription or onboarding behavior directly here; mount dependencies at router level where possible.
Priority: high for change discipline, medium for refactor.
### 4. Classic Session And Tree Endpoints Are Large, But Mostly Expected
Files:
- `backend/app/api/endpoints/sessions.py`
- `backend/app/api/endpoints/trees.py`
Evidence:
- `sessions.py`: 1,186 LOC, 24 outbound dependencies, 26 changes
- `trees.py`: 1,332 LOC, 20 outbound dependencies, 23 changes
Classification: endpoint hubs.
These files are not surprising in a CRUD-heavy FastAPI app, but they are large enough that behavioral additions should be routed through services or focused helpers.
Recommended response:
- For new subscription guards, mount dependencies instead of inserting repeated checks inside handlers.
- For new tree/session behavior, prefer service functions over adding more endpoint-local logic.
- Add regression tests before modifying export, sharing, ownership, or limit-check paths.
Priority: medium-high.
### 5. Frontend Page-Level Hubs Are The Main UI Risk
Files:
- `frontend/src/pages/TreeNavigationPage.tsx`
- `frontend/src/pages/ProceduralNavigationPage.tsx`
- `frontend/src/pages/TreeLibraryPage.tsx`
- `frontend/src/pages/TreeEditorPage.tsx`
- `frontend/src/pages/SessionDetailPage.tsx`
Pattern:
- High outbound dependencies
- Meaningful churn
- Page components own orchestration plus rendering
Recommended response:
- Treat page components as shells where possible.
- Extract stable workflow hooks before adding another workflow.
- Keep design updates scoped to subcomponents.
- Avoid adding global state unless the state truly spans routes.
Priority: medium, with `TreeNavigationPage.tsx` and `ProceduralNavigationPage.tsx` highest.
### 6. Auth Store Is Central But Not Yet A Problem
File: `frontend/src/store/authStore.ts`
Evidence:
- 21 inbound dependencies
- 5 outbound dependencies
- 144 LOC
- 6 changes in 90 days
Classification: central state hub.
This is a normal app hub. It becomes risky if billing, onboarding, feature gates, and auth all accumulate here. The self-serve specs choice to create `useBillingStore` instead of embedding billing state in `/auth/me` is the right architectural direction.
Recommended response:
- Keep auth store focused on identity/session/account bootstrap.
- Put billing in `useBillingStore`.
- Put onboarding wizard state in a narrow API/hook, not in auth.
Priority: watch.
### 7. Barrels Are Creating A Large Frontend Cycle
Cycle:
- 42 files under `frontend/src/api/*`
- Driven by `frontend/src/api/index.ts` exporting modules while some modules import from the barrel or share `apiClient`.
Classification: barrel cycle / tooling artifact with some real coupling risk.
This is common and not urgent. It can confuse static tools and make imports less explicit.
Recommended response:
- Prefer direct imports from concrete API modules in new code:
- Good: `import { aiSessionsApi } from '@/api/aiSessions'`
- Avoid: `import { aiSessionsApi } from '@/api'`
- Keep `api/index.ts` only for broad convenience if it remains useful.
- Do not spend time untangling old imports unless dependency tooling starts enforcing boundaries.
Priority: low.
### 8. Backend ORM Model Cycles Are Expected
Cycle:
- 17 files across account/user/tree/session/subscription/category/share models
- 5 files across AI session branch/handoff/step models
Classification: SQLAlchemy relationship cycle.
This is expected in an ORM with bidirectional relationships. It does not mean the model layer is broken.
Recommended response:
- Keep imports guarded with `TYPE_CHECKING` where possible.
- Keep model methods thin.
- Put behavior in services, not model properties beyond simple derived flags.
Priority: low.
## Ranked Action List
### Do Now
No immediate large refactor is recommended before self-serve signup work. The report does not show a blocker.
### Do During Self-Serve Work
1. Keep `useBillingStore` separate from `authStore`.
2. Mount subscription and email verification guards at router/dependency boundaries, not inside individual endpoint handlers.
3. Keep new billing service behavior out of existing `ai_sessions.py`, `sessions.py`, and `trees.py` except for dependency wiring.
4. Prefer direct frontend API imports over `@/api` barrel imports in new code.
### Do Opportunistically
1. Extract one workflow at a time from `AssistantChatPage.tsx`.
2. Extract prompt construction or structured response validation from `flowpilot_engine.py` when touched.
3. Move response mapping helpers out of `ai_sessions.py` if those helpers change again.
4. Split page-level orchestration hooks out of `TreeNavigationPage.tsx` and `ProceduralNavigationPage.tsx` as features touch them.
### Avoid
1. Do not split `utils.ts`, `toast.ts`, `api/client.ts`, or `core/database.py` just because they are central.
2. Do not refactor ORM model cycles unless they cause import/runtime issues.
3. Do not start a broad barrel-file cleanup unless tooling or build performance requires it.
## Raw Metrics Snapshot
Total analyzed files: 783
Total static import edges: 2,946
Top inbound hubs:
| File | Inbound | Outbound | 90d churn | LOC | Note |
|---|---:|---:|---:|---:|---|
| `frontend/src/lib/utils.ts` | 225 | 0 | 1 | 32 | Healthy utility hub |
| `frontend/src/types/index.ts` | 137 | 32 | 22 | 103 | Barrel hub |
| `backend/app/core/database.py` | 110 | 2 | 2 | 47 | Healthy infrastructure hub |
| `backend/app/models/user.py` | 90 | 7 | 13 | 130 | Domain model hub |
| `frontend/src/lib/toast.ts` | 79 | 0 | 1 | 72 | Healthy utility hub |
| `backend/app/api/deps.py` | 56 | 9 | 13 | 292 | Auth/dependency hub |
| `frontend/src/api/client.ts` | 51 | 2 | 5 | 173 | API infrastructure hub |
| `backend/app/core/config.py` | 44 | 1 | 27 | 232 | Config hub, high churn |
| `backend/app/models/tree.py` | 43 | 10 | 11 | 233 | Domain model hub |
| `frontend/src/components/ui/Button.tsx` | 43 | 2 | 6 | 65 | UI primitive |
Top outbound hubs:
| File | Inbound | Outbound | 90d churn | LOC | Note |
|---|---:|---:|---:|---:|---|
| `frontend/src/router.tsx` | 1 | 72 | 48 | 308 | Router hub, acceptable |
| `frontend/src/api/index.ts` | 38 | 40 | 26 | 41 | Barrel hub |
| `frontend/src/pages/AssistantChatPage.tsx` | 2 | 39 | 77 | 2493 | High-risk behavioral hub |
| `frontend/src/pages/ProceduralNavigationPage.tsx` | 1 | 33 | 22 | 1021 | High-risk behavioral hub |
| `frontend/src/pages/TreeNavigationPage.tsx` | 2 | 31 | 33 | 1385 | High-risk behavioral hub |
| `frontend/src/pages/TreeLibraryPage.tsx` | 3 | 27 | 38 | 546 | Medium-risk page hub |
| `backend/app/api/endpoints/sessions.py` | 0 | 24 | 26 | 1186 | High-risk endpoint hub |
| `backend/app/api/endpoints/admin.py` | 0 | 22 | 10 | 1430 | Admin endpoint hub |
| `frontend/src/pages/SessionDetailPage.tsx` | 2 | 21 | 28 | 623 | Medium-risk page hub |
| `backend/app/api/endpoints/auth.py` | 0 | 20 | 9 | 721 | Auth endpoint hub |
| `backend/app/api/endpoints/trees.py` | 0 | 20 | 23 | 1332 | High-risk endpoint hub |
| `frontend/src/pages/ProceduralEditorPage.tsx` | 1 | 20 | 16 | 475 | Medium-risk page hub |
| `frontend/src/pages/TreeEditorPage.tsx` | 2 | 20 | 28 | 928 | Medium-risk page hub |
Detected cycles:
| Size | Area | Interpretation |
|---:|---|---|
| 42 | `frontend/src/api/*` | Barrel/export cycle. Low urgency. |
| 17 | backend ORM models | Expected SQLAlchemy relationship cycle. Low urgency. |
| 5 | backend AI session models | Expected relationship cycle. Low urgency. |
| 2 | tree preview components | Small component cycle; inspect only if these files become troublesome. |
## How To Re-run
The current environment does not have native Python, so this report was generated with Node-based static parsing plus shell/git commands. A future repeat can use a dedicated script if this becomes a regular architecture check.
Suggested future command shape:
```bash
node scripts/architecture/god-node-report.mjs
```
If this becomes a recurring check, add:
- `scripts/architecture/god-node-report.mjs`
- `docs/architecture/god-node-report-YYYY-MM-DD.md`
- optional `docs/architecture/god-node-graph-YYYY-MM-DD.mmd`

View File

@@ -0,0 +1,523 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ResolutionFlow — Workflow Analysis</title>
<style>
:root {
--bg-page: #0e1016;
--bg-card: #1e2028;
--bg-elev: #2a2d38;
--border: #2a2d38;
--border-hover: #3a3d48;
--text-heading: #f4f5f7;
--text-primary: #d6d8df;
--text-muted: #8b8e98;
--text-dim: #5a5d68;
--accent: #60a5fa;
--accent-dim: rgba(96, 165, 250, 0.15);
--warning: #fbbf24;
--warning-dim: rgba(251, 191, 36, 0.12);
--info: #67e8f9;
--success: #34d399;
--success-dim: rgba(52, 211, 153, 0.12);
--danger: #f87171;
--danger-dim: rgba(248, 113, 113, 0.12);
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--sans: "IBM Plex Sans", system-ui, -apple-system, sans-serif;
--heading: "Bricolage Grotesque", "IBM Plex Sans", sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg-page);
color: var(--text-primary);
font-family: var(--sans);
font-size: 14px;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
.container {
max-width: 880px;
margin: 0 auto;
padding: 48px 32px 80px;
}
header.page {
border-bottom: 1px solid var(--border);
padding-bottom: 24px;
margin-bottom: 32px;
}
header.page .eyebrow {
font-family: var(--mono);
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 8px;
}
header.page h1 {
font-family: var(--heading);
font-weight: 700;
font-size: 32px;
line-height: 1.15;
color: var(--text-heading);
margin: 0 0 12px;
letter-spacing: -0.015em;
}
header.page .meta {
color: var(--text-muted);
font-size: 13px;
}
header.page .meta a { color: var(--accent); text-decoration: none; }
header.page .meta a:hover { text-decoration: underline; }
h2 {
font-family: var(--heading);
font-weight: 700;
font-size: 22px;
color: var(--text-heading);
margin: 48px 0 14px;
letter-spacing: -0.01em;
}
h3 {
font-family: var(--heading);
font-weight: 600;
font-size: 17px;
color: var(--text-heading);
margin: 28px 0 8px;
letter-spacing: -0.005em;
}
p { margin: 0 0 12px; }
ul { margin: 0 0 16px; padding-left: 22px; }
ul li { margin-bottom: 6px; }
a { color: var(--accent); }
code, kbd {
font-family: var(--mono);
font-size: 12px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.04);
padding: 1px 6px;
border-radius: 3px;
color: var(--text-primary);
}
strong { color: var(--text-heading); font-weight: 600; }
em { color: var(--text-primary); font-style: italic; }
.tldr {
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: 6px;
padding: 20px 24px;
margin-bottom: 36px;
}
.tldr h2 { margin: 0 0 8px; font-size: 14px; font-family: var(--mono); text-transform: uppercase; letter-spacing: 0.1em; color: var(--accent); }
.tldr p { margin: 0 0 10px; font-size: 15px; line-height: 1.55; }
.tldr p:last-child { margin-bottom: 0; }
.metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin: 20px 0 8px;
}
.metric {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px 16px;
}
.metric .label {
font-family: var(--mono);
font-size: 10px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 6px;
}
.metric .value {
font-family: var(--heading);
font-weight: 700;
font-size: 26px;
color: var(--text-heading);
line-height: 1.1;
}
.metric .sub {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
}
table.data {
width: 100%;
border-collapse: collapse;
margin: 12px 0 20px;
font-size: 13px;
}
table.data th, table.data td {
text-align: left;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
table.data th {
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
font-weight: 700;
background: var(--bg-card);
}
table.data td.num { font-family: var(--mono); text-align: right; }
table.data td.kind { font-family: var(--mono); font-size: 12px; }
table.data td .pill {
display: inline-block;
padding: 1px 7px;
border-radius: 3px;
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
td .pill.yes { background: var(--warning-dim); color: var(--warning); border: 1px solid rgba(251,191,36,0.3); }
td .pill.no { background: rgba(255,255,255,0.04); color: var(--text-muted); border: 1px solid var(--border); }
td .pill.maybe { background: var(--accent-dim); color: var(--accent); border: 1px solid rgba(96,165,250,0.3); }
/* Heatmap */
.heatmap {
margin: 16px 0;
border-collapse: collapse;
font-family: var(--mono);
font-size: 11px;
}
.heatmap th, .heatmap td {
border: 1px solid var(--border);
padding: 8px 6px;
text-align: center;
min-width: 44px;
color: var(--text-muted);
}
.heatmap th {
background: var(--bg-card);
color: var(--text-muted);
text-transform: lowercase;
font-weight: 600;
letter-spacing: 0.04em;
}
.heatmap td.label {
text-align: right;
background: var(--bg-card);
color: var(--text-muted);
font-weight: 600;
padding-right: 10px;
}
.heatmap td.empty { color: var(--text-dim); }
.heatmap td.diag { color: var(--text-dim); background: rgba(255,255,255,0.015); }
.heatmap td.h1 { color: var(--text-heading); background: rgba(96,165,250,0.1); }
.heatmap td.h2 { color: var(--text-heading); background: rgba(96,165,250,0.22); font-weight: 700; }
.heatmap td.h3 { color: var(--bg-page); background: var(--accent); font-weight: 700; }
.heatmap-caption { color: var(--text-muted); font-size: 12px; margin: 8px 0 16px; }
.concern {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 18px 22px;
margin: 14px 0;
}
.concern.warning { border-left: 3px solid var(--warning); }
.concern.info { border-left: 3px solid var(--accent); }
.concern.success { border-left: 3px solid var(--success); }
.concern h3 { margin-top: 0; display: flex; align-items: baseline; gap: 10px; }
.concern h3 .tag {
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 2px 7px;
border-radius: 3px;
}
.concern.warning h3 .tag { background: var(--warning-dim); color: var(--warning); }
.concern.info h3 .tag { background: var(--accent-dim); color: var(--accent); }
.concern.success h3 .tag { background: var(--success-dim); color: var(--success); }
.concern p:last-child { margin-bottom: 0; }
.caveat {
background: rgba(255,255,255,0.025);
border-left: 2px solid var(--text-dim);
padding: 12px 18px;
margin: 16px 0;
font-size: 13px;
color: var(--text-muted);
}
.caveat strong { color: var(--text-primary); }
footer.page {
margin-top: 64px;
padding-top: 24px;
border-top: 1px solid var(--border);
font-size: 12px;
color: var(--text-muted);
}
@media (max-width: 720px) {
.container { padding: 32px 20px; }
.metrics { grid-template-columns: repeat(2, 1fr); }
header.page h1 { font-size: 26px; }
.heatmap { font-size: 10px; }
.heatmap th, .heatmap td { padding: 6px 4px; min-width: 32px; }
}
</style>
</head>
<body>
<div class="container">
<header class="page">
<div class="eyebrow">Architecture review · 2026-05-13</div>
<h1>ResolutionFlow workflow analysis</h1>
<div class="meta">
Based on <a href="workflows.html">workflows.html</a> · 28 user-facing flows · 297 traced steps · 120 unique files
</div>
</header>
<div class="tldr">
<h2>Bottom line</h2>
<p>You're <strong>not bloated</strong>, and most of the "circles" in the diagram are <strong>visualization artifact, not architecture problems</strong>. Each HTTP call shows up as two steps (request + response), so a normal round-trip <em>looks</em> like a circle even though it's one unit of work.</p>
<p>Three real items worth engineering attention: <code>ai_sessions.py</code> is becoming a god endpoint, the three chat services have a confusing boundary, and the auth token tables have no physical cleanup so they accrue rows forever. Everything else looks structurally healthy.</p>
</div>
<h2>Headline numbers</h2>
<div class="metrics">
<div class="metric">
<div class="label">Avg steps / flow</div>
<div class="value">10.6</div>
<div class="sub">healthy range for multi-tenant SaaS</div>
</div>
<div class="metric">
<div class="label">Avg files / flow</div>
<div class="value">7.5</div>
<div class="sub">one file per layer, roughly</div>
</div>
<div class="metric">
<div class="label">Revisit ratio</div>
<div class="value">1.39</div>
<div class="sub">1.0 = flat; 2.0+ = chat-shaped</div>
</div>
<div class="metric">
<div class="label">"Backward" edges</div>
<div class="value">15%</div>
<div class="sub">mostly HTTP response, not real circles</div>
</div>
</div>
<h2>Why the diagrams look circular</h2>
<p>Each HTTP request and its response are encoded as <strong>two separate steps</strong>. So an API call architecturally goes <em>one direction</em>, but visually looks like a loop. Breakdown of the 44 backward-flowing edges:</p>
<table class="data">
<thead>
<tr><th>Kind</th><th class="num">Count</th><th>Real circle?</th><th>Example</th></tr>
</thead>
<tbody>
<tr>
<td class="kind">http_post / http_get response</td>
<td class="num">20</td>
<td><span class="pill no">artifact</span></td>
<td>Server returns 200 to client. Not a circle.</td>
</tr>
<tr>
<td class="kind">function_call return value</td>
<td class="num">8</td>
<td><span class="pill no">artifact</span></td>
<td><code>oauth_providers</code> returns an <code>OAuthProfile</code> to the endpoint that called it.</td>
</tr>
<tr>
<td class="kind">state_update (hook → component/page)</td>
<td class="num">8</td>
<td><span class="pill maybe">idiomatic</span></td>
<td>Hook returns updated state, page re-renders. Pure React data flow.</td>
</tr>
<tr>
<td class="kind">redirect (OAuth provider → app)</td>
<td class="num">4</td>
<td><span class="pill yes">real</span></td>
<td>Google/Microsoft sends user back to <code>/oauth/callback</code>. Architecturally required.</td>
</tr>
<tr>
<td class="kind">webhook</td>
<td class="num">1</td>
<td><span class="pill yes">real</span></td>
<td>Stripe POSTs to <code>/webhooks/stripe</code>. External system re-enters us.</td>
</tr>
<tr>
<td class="kind">navigation / external_api / other</td>
<td class="num">3</td>
<td><span class="pill yes">real</span></td>
<td>Page-to-page nav, Anthropic returning a response.</td>
</tr>
</tbody>
</table>
<p>After subtracting the request/response duality, the <em>real</em> backward edges are about <strong>3% of steps</strong>, and every one of them is in a place where the architecture demands it (React state propagation, OAuth callbacks, webhooks).</p>
<h2>What's healthy</h2>
<div class="concern success">
<h3>Clean layer discipline <span class="tag">good</span></h3>
<p>The system mostly respects layer boundaries. <code>endpoint → service</code> (34x), <code>service → external</code> (37x), <code>api_client → endpoint</code> (30x) dominate the traffic. Things flow in the expected direction.</p>
</div>
<div class="concern success">
<h3><code>flowpilot_engine</code> is the right kind of shared service <span class="tag">good</span></h3>
<p>Touched by 5 flows (start, respond, resolve, pause, abandon). That's a coordination kernel doing its job — high fan-in is correct for orchestration code.</p>
</div>
<div class="concern success">
<h3>PostgreSQL in 25/28 flows <span class="tag">good</span></h3>
<p>Star topology, not a tangle. That's what a database is supposed to look like.</p>
</div>
<h2>Layer transition heatmap</h2>
<p>How many times each layer-pair appears across all steps. Bright cells = well-traveled paths. Empty cells = layer boundaries that aren't crossed (mostly a good sign).</p>
<table class="heatmap">
<thead>
<tr>
<th></th>
<th>page</th><th>comp</th><th>hook</th><th>store</th><th>api_c</th><th>http</th><th>endp</th><th>serv</th><th>core</th><th>model</th><th>ext</th>
</tr>
</thead>
<tbody>
<tr><td class="label">page</td> <td class="diag">13</td><td class="h1">5</td> <td class="h1">6</td><td class="h1">12</td><td class="h2">17</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td>2</td></tr>
<tr><td class="label">comp</td> <td>1</td> <td class="diag">5</td><td>2</td><td class="empty">·</td><td>1</td><td class="empty">·</td><td>1</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td></tr>
<tr><td class="label">hook</td> <td class="h1">7</td><td>1</td> <td class="diag empty">·</td><td class="empty">·</td><td class="h2">11</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td></tr>
<tr><td class="label">store</td> <td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="diag">4</td><td>2</td><td class="empty">·</td><td>1</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td>1</td></tr>
<tr><td class="label">api_client</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="diag empty">·</td><td>5</td><td class="h3">30</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td>1</td></tr>
<tr><td class="label">endpoint</td> <td>3</td><td class="empty">·</td><td class="h1">9</td><td>2</td><td>4</td><td class="empty">·</td><td class="diag">1</td><td class="h3">34</td><td class="h1">8</td><td>2</td><td class="h3">29</td></tr>
<tr><td class="label">service</td> <td>1</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td>2</td><td class="empty">·</td><td>3</td><td class="diag h1">9</td><td>5</td><td>4</td><td class="h3">37</td></tr>
<tr><td class="label">core</td> <td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="diag empty">·</td><td class="empty">·</td><td>4</td></tr>
<tr><td class="label">model</td> <td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="diag empty">·</td><td>1</td></tr>
<tr><td class="label">external</td> <td>4</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td>1</td><td>1</td><td class="empty">·</td><td class="empty">·</td><td class="diag empty">·</td></tr>
<tr><td class="label">http_client</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="diag empty">·</td><td>5</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td></tr>
</tbody>
</table>
<p class="heatmap-caption">Read row → column. Diagonal = same-layer transitions. Above-diagonal = "backward" (e.g. <code>endpoint → hook</code> = HTTP response). The strong upper-right concentration (<code>endpoint → service → external</code>) is the right shape.</p>
<h2>Top coupling hot-spots</h2>
<p>Files appearing in the most flows. The first two (PostgreSQL, Anthropic) are expected; everything else is worth a glance.</p>
<table class="data">
<thead>
<tr><th class="num">Flows</th><th>File</th><th>Layer</th><th>Read</th></tr>
</thead>
<tbody>
<tr><td class="num">25</td><td><code>external:postgres</code></td><td>external</td><td>Expected. The DB is the hub.</td></tr>
<tr><td class="num">10</td><td><code>external:anthropic_api</code></td><td>external</td><td>Expected for an AI product.</td></tr>
<tr><td class="num"><strong>7</strong></td><td><code>backend/app/api/endpoints/ai_sessions.py</code></td><td>endpoint</td><td><strong>God endpoint candidate.</strong> See concern below.</td></tr>
<tr><td class="num">6</td><td><code>frontend/src/api/aiSessions.ts</code></td><td>api_client</td><td>Mirrors the god endpoint. Splits naturally if backend splits.</td></tr>
<tr><td class="num">5</td><td><code>backend/app/services/flowpilot_engine.py</code></td><td>service</td><td>Healthy coordination kernel.</td></tr>
<tr><td class="num">5</td><td><code>backend/app/api/endpoints/auth.py</code></td><td>endpoint</td><td>5 auth flows, 5 endpoints. Reasonable.</td></tr>
<tr><td class="num">5</td><td><code>frontend/src/store/authStore.ts</code></td><td>store</td><td>Centralized auth state. Correct.</td></tr>
<tr><td class="num">5</td><td><code>frontend/src/pages/FlowPilotSessionPage.tsx</code></td><td>page</td><td>Worth checking — see OAuth concern.</td></tr>
<tr><td class="num">5</td><td><code>frontend/src/hooks/useFlowPilotSession.ts</code></td><td>hook</td><td>Always co-travels with the page. Right pattern.</td></tr>
</tbody>
</table>
<h2>Things worth examining</h2>
<div class="concern warning">
<h3>1. <code>ai_sessions.py</code> is a god endpoint <span class="tag">split candidate</span></h3>
<p>Appears in 7 flows. Houses ~12 route handlers in one file: <code>create</code>, <code>respond</code>, <code>chat</code>, <code>resolve</code>, <code>escalate</code>, <code>pause</code>, <code>abandon</code>, <code>pickup</code>, <code>list</code>, <code>get</code>, plus the <code>/chat</code> + <code>/respond</code> overload. It's the highest-coupled non-DB node.</p>
<p>Suggested seam:</p>
<ul>
<li><code>session_lifecycle.py</code> — create, resolve, escalate, pause, abandon, pickup</li>
<li><code>session_messaging.py</code> — chat, respond</li>
</ul>
<p>Frontend <code>aiSessions.ts</code> would split along the same line. Net change: clearer ownership, no functional impact.</p>
</div>
<div class="concern warning">
<h3>2. Three chat services with a confusing boundary <span class="tag">name vs reality</span></h3>
<p>Three files exist with overlapping responsibilities:</p>
<ul>
<li><code>backend/app/services/unified_chat_service.py</code> — chat session handling, marker parsing</li>
<li><code>backend/app/services/assistant_chat_service.py</code><code>_call_ai</code> infrastructure (Anthropic with caching, MCP, vision)</li>
<li><code>backend/app/core/ai_chat_service.py</code> — flow-builder chat for editors (separate domain)</li>
</ul>
<p>The <code>PROJECT_CONTEXT.md</code> note says <code>assistant_chat_service</code> was "removed except for retention settings," but the trace shows <code>unified_chat_service.send_chat_message</code> still calls into it for <code>_call_ai</code>. So the file is load-bearing infrastructure, not retention scaffolding.</p>
<p>Two paths forward:</p>
<ul>
<li>Rename <code>assistant_chat_service.py</code><code>ai_call_utils.py</code> (or fold the <code>_call_ai</code> function into <code>core/ai_provider.py</code> where the provider abstraction already lives).</li>
<li>Update <code>PROJECT_CONTEXT.md</code> to match reality.</li>
</ul>
<p>Either way the confusing seam goes away.</p>
</div>
<div class="concern warning">
<h3>3. OAuth login is the most "circular" real flow <span class="tag">overloaded callback</span></h3>
<p>19 steps, 4 backward edges, 3 self-loops — by far the most complex auth flow. Some complexity is unavoidable (provider redirect = 2 boundary crossings). But 3 self-loops on <code>OAuthCallbackPage</code> suggest the page is doing too much local state shuffling: CSRF state validation, code exchange, invite-code stash retrieval, JWT storage, navigation, welcome-banner logic.</p>
<p>Worth a look: move OAuth state handling into either <code>authStore</code> (which would centralize all auth state in one place) or a <code>useOAuthCallback</code> hook. The page itself should be mostly declarative.</p>
</div>
<div class="concern warning">
<h3>4. Three auth-token tables grow without bound <span class="tag">add cleanup</span></h3>
<p>Auth writes to <code>refresh_tokens</code>, <code>password_reset_tokens</code>, <code>email_verification_tokens</code>, and <code>oauth_identities</code>. Each table is individually justified (different lifecycles, different lookup patterns, JTI rotation for refresh) — <strong>this is not bloat in the code</strong>. But the cleanup story is missing.</p>
<p>Verified directly: <code>retention_cleanup.py</code> only sweeps <code>AssistantChat</code>. <code>scheduler.py</code> only has one other cleanup job, for <code>AIConversation</code>. The auth endpoint code in <code>auth.py</code> <em>revokes</em> tokens (<code>UPDATE … SET revoked_at = now()</code>) but never <em>deletes</em> them. So:</p>
<ul>
<li><code>refresh_tokens</code> — revoked rows stay forever. One row per login + one per refresh rotation.</li>
<li><code>password_reset_tokens</code> — one row per forgot-password request, no cleanup at all.</li>
<li><code>email_verification_tokens</code> — one row per signup (and per re-send), no cleanup.</li>
<li><code>oauth_identities</code> — correctly persistent; this is a permanent FK from user to provider, not a cleanup target.</li>
</ul>
<p>Suggested fix: add a daily APScheduler job in <code>retention_cleanup.py</code> (or a sibling) that hard-deletes rows where <code>revoked_at &lt; now() - INTERVAL '30 days'</code> for <code>refresh_tokens</code>, and <code>expires_at &lt; now() - INTERVAL '7 days'</code> for the two single-use token tables. Pattern matches the existing <code>cleanup_expired_chats</code> shape and the <code>_cleanup_expired_ai_conversations</code> job in <code>scheduler.py</code>.</p>
<p class="heatmap-caption">Earlier draft of this concern pointed to <code>retention_cleanup.py</code> as the place to <em>verify</em> existing cleanup. That was wrong — no such cleanup exists. Corrected after direct check.</p>
</div>
<h2>Things <em>not</em> to worry about</h2>
<div class="concern success">
<h3>Hook ↔ page state loops in session flows</h3>
<p>That's just React. <code>useFlowPilotSession</code> and <code>FlowPilotSessionPage</code> always travel together because the hook <em>is</em> that page's controller — they're maximally coupled by design, which is the right pattern.</p>
</div>
<div class="concern success">
<h3>Low "work percentage" on simple flows</h3>
<p>"Pause &amp; leave" comes out at 11% real work, 89% plumbing. That's correct — pause is structurally just <code>PATCH status='paused'</code>. There's no work to do beyond plumbing. The metric undersells simple flows.</p>
</div>
<div class="concern success">
<h3>The 25-flow PostgreSQL hub</h3>
<p>Star topology, not a tangle. A database serving every flow is the architectural ideal.</p>
</div>
<h2>Caveats on this analysis</h2>
<div class="caveat">
<strong>Work vs plumbing heuristic undersells reality.</strong> It counts <code>http_post</code> as plumbing even when it carries the actual payload. Work percentages should be read as <em>roughly 2x</em> the displayed value.
</div>
<div class="caveat">
<strong>Only user-facing flows are traced.</strong> Background work (knowledge flywheel scheduler, retention cleanup, PSA retry scheduler, MCP turn routing) isn't in here — and that's exactly where bloat tends to hide because nobody watches it. A follow-up trace of the background jobs would close the loop.
</div>
<div class="caveat">
<strong>~6 of 297 steps marked <code>unverified</code></strong> (mostly knowledge-flywheel-created proposals). They're included in the totals but the conclusions don't depend on them.
</div>
<div class="caveat">
<strong>"Backward edge" includes HTTP responses.</strong> An HTTP round-trip looks like one forward step (request) plus one backward step (response). That alone accounts for the majority of the 15% backward share. The interesting backward edges are the ~3% that aren't request/response duality.
</div>
<footer class="page">
Generated from <code>workflows.json</code> · 28 user-facing flows · 297 steps · 120 files · ResolutionFlow 2026-05-13
</footer>
</div>
</body>
</html>

View File

@@ -0,0 +1,807 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ResolutionFlow — Workflow Diagram</title>
<style>
:root {
--bg-sidebar: #0e1016;
--bg-page: #16181f;
--bg-card: #1e2028;
--bg-elev: #2a2d38;
--border: #2a2d38;
--border-hover: #3a3d48;
--text-heading: #f4f5f7;
--text-primary: #d6d8df;
--text-muted: #8b8e98;
--text-dim: #5a5d68;
--accent: #60a5fa;
--accent-dim: rgba(96, 165, 250, 0.15);
--warning: #fbbf24;
--info: #67e8f9;
--success: #34d399;
--danger: #f87171;
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--sans: "IBM Plex Sans", system-ui, -apple-system, sans-serif;
--heading: "Bricolage Grotesque", "IBM Plex Sans", sans-serif;
/* layer colors */
--layer-page: #a78bfa;
--layer-component: #60a5fa;
--layer-hook: #38bdf8;
--layer-store: #22d3ee;
--layer-api_client: #34d399;
--layer-http_client: #6ee7b7;
--layer-endpoint: #fbbf24;
--layer-service: #fb923c;
--layer-core: #f87171;
--layer-model: #ec4899;
--layer-db: #c084fc;
--layer-external: #94a3b8;
--layer-unknown: #6b7280;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
background: var(--bg-sidebar);
color: var(--text-primary);
font-family: var(--sans);
font-size: 13px;
line-height: 1.5;
overflow: hidden;
}
.app {
display: grid;
grid-template-columns: 280px 1fr 380px;
grid-template-rows: 52px 1fr;
height: 100vh;
}
header {
grid-column: 1 / -1;
background: var(--bg-sidebar);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 16px;
gap: 16px;
}
header h1 {
margin: 0;
font-family: var(--heading);
font-weight: 700;
font-size: 16px;
color: var(--text-heading);
letter-spacing: -0.01em;
}
header .badge {
font-family: var(--mono);
font-size: 11px;
color: var(--text-muted);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px 8px;
}
header .spacer { flex: 1; }
header .meta { font-size: 11px; color: var(--text-muted); }
header a { color: var(--accent); text-decoration: none; }
.sidebar {
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 12px 0;
}
.sidebar .group {
padding: 8px 16px 4px;
font-family: var(--heading);
font-size: 10px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-top: 8px;
}
.sidebar .group:first-child { margin-top: 0; }
.sidebar button.flow-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
background: transparent;
border: 0;
border-left: 2px solid transparent;
color: var(--text-primary);
font-family: var(--sans);
font-size: 13px;
text-align: left;
padding: 7px 14px 7px 14px;
cursor: pointer;
transition: background-color 0.12s, color 0.12s, border-color 0.12s;
}
.sidebar button.flow-item:hover {
background: var(--bg-card);
color: var(--text-heading);
}
.sidebar button.flow-item.active {
background: var(--accent-dim);
border-left-color: var(--accent);
color: var(--text-heading);
}
.sidebar button.flow-item .count {
font-family: var(--mono);
font-size: 10px;
color: var(--text-dim);
}
.sidebar button.flow-item.active .count { color: var(--accent); }
.canvas-wrap {
background: var(--bg-page);
overflow: auto;
position: relative;
}
.canvas-wrap .placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 14px;
text-align: center;
padding: 24px;
}
.canvas-wrap .placeholder h2 {
font-family: var(--heading);
color: var(--text-heading);
margin: 0 0 8px;
}
.canvas-wrap .placeholder p { max-width: 420px; margin: 4px 0; }
svg.graph { display: block; background: var(--bg-page); }
.layer-header {
fill: var(--text-muted);
font-family: var(--heading);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.column-band { fill: rgba(255,255,255,0.012); }
.node rect {
fill: var(--bg-card);
stroke: var(--border-hover);
stroke-width: 1;
rx: 6;
}
.node.in-flow rect { stroke-width: 1.5; }
.node text.label {
fill: var(--text-primary);
font-family: var(--mono);
font-size: 11px;
dominant-baseline: middle;
}
.node text.sublabel {
fill: var(--text-dim);
font-family: var(--sans);
font-size: 10px;
dominant-baseline: middle;
}
.node .layer-pill {
rx: 2;
height: 4;
}
.edge {
fill: none;
stroke: #4a5061;
color: #4a5061;
stroke-width: 1.25;
opacity: 0.55;
transition: opacity 0.18s, stroke-width 0.18s, stroke 0.18s, color 0.18s;
}
.edge.has-active { opacity: 0.15; }
.edge.highlight {
stroke-width: 2.5;
opacity: 1;
stroke: var(--warning);
color: var(--warning);
}
.edge-num {
cursor: pointer;
transition: opacity 0.18s;
}
.edge-num.dim { opacity: 0.35; }
.edge-num-bg {
fill: var(--bg-card);
stroke: var(--accent);
stroke-width: 1.25;
transition: fill 0.18s, stroke 0.18s, r 0.18s;
}
.edge-num.highlight .edge-num-bg {
fill: var(--warning);
stroke: var(--warning);
}
.edge-num-text {
fill: var(--accent);
font-family: var(--mono);
font-size: 10px;
font-weight: 700;
text-anchor: middle;
dominant-baseline: central;
pointer-events: none;
transition: fill 0.18s;
}
.edge-num.highlight .edge-num-text {
fill: var(--bg-sidebar);
}
.self-indicator {
fill: var(--text-dim);
font-family: var(--mono);
font-size: 9px;
}
.self-indicator.highlight { fill: var(--warning); }
.panel {
background: var(--bg-sidebar);
border-left: 1px solid var(--border);
overflow-y: auto;
padding: 16px;
}
.panel h2 {
font-family: var(--heading);
font-size: 15px;
margin: 0 0 4px;
color: var(--text-heading);
}
.panel .desc {
color: var(--text-muted);
font-size: 12px;
margin: 0 0 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.step {
display: grid;
grid-template-columns: 24px 1fr;
gap: 10px;
padding: 10px 8px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.12s;
margin-bottom: 2px;
}
.step:hover { background: var(--bg-card); }
.step.active { background: var(--accent-dim); }
.step .num {
background: var(--bg-card);
border: 1px solid var(--border-hover);
border-radius: 50%;
width: 22px; height: 22px;
display: flex; align-items: center; justify-content: center;
font-family: var(--mono);
font-size: 11px;
font-weight: 700;
color: var(--text-primary);
}
.step.active .num { background: var(--accent); color: var(--bg-sidebar); border-color: var(--accent); }
.step .body { min-width: 0; }
.step .from-to {
font-family: var(--mono);
font-size: 10px;
color: var(--text-muted);
margin-bottom: 3px;
word-break: break-all;
}
.step .from-to .arrow { color: var(--accent); }
.step .label {
color: var(--text-primary);
font-size: 12px;
font-weight: 500;
margin-bottom: 3px;
}
.step .passes {
color: var(--text-muted);
font-family: var(--mono);
font-size: 10px;
white-space: pre-wrap;
word-break: break-word;
}
.step .passes::before { content: "passes: "; color: var(--text-dim); }
.step .meta {
display: inline-flex; gap: 6px;
margin-top: 4px;
font-family: var(--mono);
font-size: 10px;
}
.step .via-tag {
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-muted);
padding: 1px 6px;
border-radius: 3px;
}
.step .unverified {
background: rgba(251, 191, 36, 0.1);
border: 1px solid rgba(251, 191, 36, 0.3);
color: var(--warning);
padding: 1px 6px;
border-radius: 3px;
}
/* Legend */
.legend {
position: absolute;
top: 12px;
right: 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font-size: 10px;
color: var(--text-muted);
display: flex;
flex-direction: column;
gap: 4px;
pointer-events: none;
}
.legend .row { display: flex; align-items: center; gap: 6px; }
.legend .dot { width: 8px; height: 8px; border-radius: 2px; }
/* Scrollbars */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: var(--bg-sidebar); }
::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 5px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
</style>
</head>
<body>
<div class="app">
<header>
<h1>ResolutionFlow Workflows</h1>
<span class="badge" id="counts">loading…</span>
<span class="spacer"></span>
<span class="meta">Click a flow to trace its path. Node-per-file granularity.</span>
</header>
<aside class="sidebar" id="sidebar"></aside>
<main class="canvas-wrap" id="canvas">
<div class="placeholder" id="placeholder">
<h2>Pick a flow</h2>
<p>Each flow is an ordered trace of how a user action moves through the codebase — page → component → API client → endpoint → service → DB/external.</p>
<p>Nodes are <strong>individual files</strong>. Numbered arrows show data flow direction; click a step in the right panel to highlight it.</p>
</div>
</main>
<aside class="panel" id="panel">
<h2>Steps</h2>
<p class="desc">Select a flow on the left.</p>
</aside>
</div>
<script>
const LAYER_ORDER = [
"page", "component", "hook", "store",
"api_client", "http_client", "endpoint",
"service", "core", "model", "external"
];
const LAYER_LABEL = {
page: "Page", component: "Component", hook: "Hook", store: "Store",
api_client: "API Client", http_client: "HTTP Client", endpoint: "Endpoint",
service: "Service", core: "Core", model: "Model", external: "External"
};
const LAYER_COLOR = (l) => getComputedStyle(document.documentElement).getPropertyValue(`--layer-${l}`).trim() || "#888";
const COLUMN_WIDTH = 260;
const COLUMN_PADDING = 28;
const NODE_HEIGHT = 44;
const NODE_WIDTH = 220;
const NODE_GAP = 12;
const HEADER_HEIGHT = 36;
const CANVAS_PADDING_TOP = 24;
const CANVAS_PADDING_BOTTOM = 40;
let DATA = null;
let nodeById = {};
let activeFlowId = null;
let activeStepIndex = null;
fetch("workflows.json").then(r => r.json()).then(d => { DATA = d; init(); }).catch(err => {
document.getElementById("placeholder").innerHTML = `<h2>Couldn't load workflows.json</h2><p>${err}</p><p>Serve this directory with a static server, then open workflows.html — opening directly via file:// may be blocked by CORS.</p>`;
});
function init() {
for (const n of DATA.nodes) nodeById[n.id] = n;
document.getElementById("counts").textContent = `${DATA.nodes.length} files · ${DATA.flows.length} flows`;
renderSidebar();
}
function renderSidebar() {
const sb = document.getElementById("sidebar");
const groups = {};
for (const f of DATA.flows) (groups[f.group] = groups[f.group] || []).push(f);
const groupOrder = ["Auth & Access", "Sessions & FlowPilot", "Flow Authoring", "Integrations", "Team & Billing", "Tools"];
const ordered = groupOrder.filter(g => groups[g]).concat(Object.keys(groups).filter(g => !groupOrder.includes(g)));
let html = "";
for (const g of ordered) {
html += `<div class="group">${escapeHtml(g)}</div>`;
for (const f of groups[g]) {
html += `<button class="flow-item" data-flow="${escapeHtml(f.id)}">
<span>${escapeHtml(f.label)}</span>
<span class="count">${f.steps.length}</span>
</button>`;
}
}
sb.innerHTML = html;
sb.querySelectorAll("button.flow-item").forEach(btn => {
btn.addEventListener("click", () => selectFlow(btn.dataset.flow));
});
}
function selectFlow(flowId) {
activeFlowId = flowId;
activeStepIndex = null;
document.querySelectorAll(".sidebar .flow-item").forEach(b => b.classList.toggle("active", b.dataset.flow === flowId));
const flow = DATA.flows.find(f => f.id === flowId);
renderGraph(flow);
renderPanel(flow);
}
// Compute layout: each flow's nodes positioned in layer columns.
function layoutFlow(flow) {
// Collect distinct nodes referenced by the flow (in step order)
const seen = new Set();
const nodes = [];
for (const s of flow.steps) {
for (const ep of [s.from, s.to]) {
if (!seen.has(ep) && nodeById[ep]) { seen.add(ep); nodes.push(nodeById[ep]); }
}
}
// Group by layer, preserve first-appearance order within layer
const byLayer = {};
for (const n of nodes) (byLayer[n.layer] = byLayer[n.layer] || []).push(n);
const activeLayers = LAYER_ORDER.filter(l => byLayer[l] && byLayer[l].length);
const positions = {};
let maxRows = 0;
activeLayers.forEach((layer, colIdx) => {
const col = byLayer[layer];
col.forEach((node, rowIdx) => {
const x = COLUMN_PADDING + colIdx * COLUMN_WIDTH;
const y = CANVAS_PADDING_TOP + HEADER_HEIGHT + rowIdx * (NODE_HEIGHT + NODE_GAP);
positions[node.id] = { x, y, w: NODE_WIDTH, h: NODE_HEIGHT, node, layer, col: colIdx, row: rowIdx };
});
maxRows = Math.max(maxRows, col.length);
});
const width = COLUMN_PADDING * 2 + activeLayers.length * COLUMN_WIDTH;
const height = CANVAS_PADDING_TOP + HEADER_HEIGHT + maxRows * (NODE_HEIGHT + NODE_GAP) + CANVAS_PADDING_BOTTOM;
return { positions, activeLayers, width, height };
}
function renderGraph(flow) {
const canvas = document.getElementById("canvas");
const placeholder = document.getElementById("placeholder");
if (placeholder) placeholder.style.display = "none";
const layout = layoutFlow(flow);
const svg = createSvg(layout.width, layout.height);
// Column band backgrounds
layout.activeLayers.forEach((layer, colIdx) => {
const x = COLUMN_PADDING + colIdx * COLUMN_WIDTH - 10;
svg.appendChild(rect(x, CANVAS_PADDING_TOP - 8, NODE_WIDTH + 20, layout.height - CANVAS_PADDING_TOP, "column-band"));
const headerEl = text(x + NODE_WIDTH / 2 + 10, CANVAS_PADDING_TOP + 12, LAYER_LABEL[layer] || layer, "layer-header");
headerEl.setAttribute("text-anchor", "middle");
svg.appendChild(headerEl);
});
// Dedupe: group steps by (from, to). One curve per unique pair.
// Track separate counts of mutual pairs (A→B and B→A both exist) so we offset their curves.
const edgeGroups = new Map();
const selfSteps = new Map(); // node id → [step indexes] for from===to (state updates)
flow.steps.forEach((step, idx) => {
if (step.from === step.to) {
const arr = selfSteps.get(step.from) || [];
arr.push(idx);
selfSteps.set(step.from, arr);
return;
}
const key = step.from + ">>" + step.to;
const grp = edgeGroups.get(key) || { from: step.from, to: step.to, steps: [] };
grp.steps.push(idx);
edgeGroups.set(key, grp);
});
// Detect mutual pairs (both A→B and B→A present) so we curve them apart
const mutualPairs = new Set();
for (const [key, grp] of edgeGroups) {
const reverseKey = grp.to + ">>" + grp.from;
if (edgeGroups.has(reverseKey)) mutualPairs.add(key);
}
const edgesGroup = group("edges");
svg.appendChild(edgesGroup);
const badgesGroup = group("badges");
// (badges added later — drawn over nodes)
for (const [key, grp] of edgeGroups) {
const fromPos = layout.positions[grp.from];
const toPos = layout.positions[grp.to];
if (!fromPos || !toPos) continue;
const isMutual = mutualPairs.has(key);
drawEdgeGroup(edgesGroup, badgesGroup, fromPos, toPos, grp, isMutual, flow.steps);
}
// Nodes
const nodeGroup = group("nodes");
svg.appendChild(nodeGroup);
for (const id in layout.positions) {
const pos = layout.positions[id];
drawNode(nodeGroup, pos, selfSteps.get(id) || []);
}
// Append badges last so they sit above nodes
svg.appendChild(badgesGroup);
canvas.innerHTML = "";
canvas.appendChild(svg);
svg.addEventListener("click", (e) => {
// Click on empty SVG background clears highlight
if (e.target === svg || e.target.classList.contains("column-band")) clearStepHighlight();
});
// Legend
const legend = document.createElement("div");
legend.className = "legend";
legend.innerHTML = `
<div class="row"><strong style="color:var(--text-primary)">Layers</strong></div>
${layout.activeLayers.map(l => `<div class="row"><span class="dot" style="background:${LAYER_COLOR(l)}"></span>${LAYER_LABEL[l]||l}</div>`).join("")}
`;
canvas.appendChild(legend);
}
function drawEdgeGroup(edgeLayer, badgeLayer, from, to, grp, isMutual, allSteps) {
const forward = to.col >= from.col;
// Anchor on the side of the node that fits the direction.
let fromX, fromY, toX, toY;
if (forward) {
fromX = from.x + from.w; toX = to.x;
} else {
// Backward: exit from left of source, enter right of target
fromX = from.x; toX = to.x + to.w;
}
fromY = from.y + from.h / 2;
toY = to.y + to.h / 2;
// Offset mutual pairs perpendicularly so the two arrows don't sit on top of each other.
// Forward edges get a small upward offset; backward gets downward (or vice versa) when mutual.
let perpOffset = 0;
if (isMutual) perpOffset = forward ? -18 : 18;
// Bezier control points
const dx = toX - fromX;
const dy = toY - fromY;
let c1x, c1y, c2x, c2y;
if (forward) {
const horiz = Math.max(50, Math.abs(dx) * 0.4);
c1x = fromX + horiz;
c2x = toX - horiz;
c1y = fromY + perpOffset;
c2y = toY + perpOffset;
} else {
// Backward — arc out to the side, then back. We use a wide horizontal sweep.
const sweep = 80 + Math.abs(dy) * 0.15;
c1x = fromX - sweep;
c2x = toX + sweep;
c1y = fromY + perpOffset;
c2y = toY + perpOffset;
}
const pathD = `M ${fromX} ${fromY} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${toX} ${toY}`;
const stepNums = grp.steps.map(i => i + 1).join(",");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", pathD);
path.setAttribute("class", "edge");
path.setAttribute("data-steps", stepNums);
path.setAttribute("marker-end", "url(#arrow)");
edgeLayer.appendChild(path);
// Step-number badges placed along the curve at evenly spaced t-values.
const k = grp.steps.length;
const SPREAD = Math.min(0.18, 0.55 / Math.max(1, k));
grp.steps.forEach((stepIdx, i) => {
// t centered around 0.5 — for k=1, t=0.5; for k=2 t=0.41, 0.59; etc.
const t = 0.5 + (i - (k - 1) / 2) * SPREAD;
const p = cubicAt(t, [fromX, fromY], [c1x, c1y], [c2x, c2y], [toX, toY]);
const numG = document.createElementNS("http://www.w3.org/2000/svg", "g");
numG.setAttribute("data-step", stepIdx + 1);
numG.setAttribute("class", "edge-num");
numG.addEventListener("click", (e) => { e.stopPropagation(); highlightStep(stepIdx); });
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("cx", p.x);
circle.setAttribute("cy", p.y);
circle.setAttribute("r", 9);
circle.setAttribute("class", "edge-num-bg");
numG.appendChild(circle);
const numText = document.createElementNS("http://www.w3.org/2000/svg", "text");
numText.setAttribute("x", p.x);
numText.setAttribute("y", p.y);
numText.setAttribute("class", "edge-num-text");
numText.textContent = stepIdx + 1;
numG.appendChild(numText);
const step = allSteps[stepIdx];
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
title.textContent = `${stepIdx + 1}. ${step.label}\n${step.via}\npasses: ${step.passes}`;
numG.appendChild(title);
badgeLayer.appendChild(numG);
});
}
function cubicAt(t, P0, P1, P2, P3) {
const mt = 1 - t;
const x = mt*mt*mt*P0[0] + 3*mt*mt*t*P1[0] + 3*mt*t*t*P2[0] + t*t*t*P3[0];
const y = mt*mt*mt*P0[1] + 3*mt*mt*t*P1[1] + 3*mt*t*t*P2[1] + t*t*t*P3[1];
return { x, y };
}
function drawNode(parent, pos, selfStepIdxs) {
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
g.setAttribute("class", "node in-flow");
g.setAttribute("transform", `translate(${pos.x}, ${pos.y})`);
const r = rect(0, 0, pos.w, pos.h);
g.appendChild(r);
// Layer-color top pill
const pill = rect(0, 0, pos.w, 4, "layer-pill");
pill.setAttribute("fill", LAYER_COLOR(pos.layer));
pill.setAttribute("rx", 6);
g.appendChild(pill);
const label = text(10, pos.h / 2 - 7, pos.node.label, "label");
g.appendChild(label);
const fp = pos.node.file;
let sub = fp;
if (sub.startsWith("external:")) sub = sub.replace("external:", "ext · ");
else if (sub.length > 32) {
const parts = sub.split("/");
sub = parts.slice(-3).join("/");
if (sub.length > 32) sub = "…/" + parts[parts.length - 1];
}
const subEl = text(10, pos.h / 2 + 8, sub, "sublabel");
g.appendChild(subEl);
// Self-loop steps (from === to, typically state_update) — render as a small "↻ N" indicator
// in the upper-right of the node, clickable to highlight that step.
if (selfStepIdxs.length) {
const xRight = pos.w - 8;
selfStepIdxs.slice(0, 3).forEach((stepIdx, i) => {
const sg = document.createElementNS("http://www.w3.org/2000/svg", "g");
sg.setAttribute("class", "edge-num self-step");
sg.setAttribute("data-step", stepIdx + 1);
sg.addEventListener("click", (e) => { e.stopPropagation(); highlightStep(stepIdx); });
const cx = xRight - i * 18;
const cy = 14;
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("cx", cx);
circle.setAttribute("cy", cy);
circle.setAttribute("r", 8);
circle.setAttribute("class", "edge-num-bg");
sg.appendChild(circle);
const t = document.createElementNS("http://www.w3.org/2000/svg", "text");
t.setAttribute("x", cx);
t.setAttribute("y", cy);
t.setAttribute("class", "edge-num-text");
t.textContent = stepIdx + 1;
sg.appendChild(t);
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
title.textContent = `Step ${stepIdx + 1} (self · stays on this node)`;
sg.appendChild(title);
g.appendChild(sg);
});
}
// Hover tooltip
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
title.textContent = `${pos.node.label}\n${pos.node.file}\n${pos.node.description || ""}`;
g.appendChild(title);
parent.appendChild(g);
}
function renderPanel(flow) {
const p = document.getElementById("panel");
let html = `<h2>${escapeHtml(flow.label)}</h2>`;
if (flow.description) html += `<p class="desc">${escapeHtml(flow.description)}</p>`;
flow.steps.forEach((step, idx) => {
const fromNode = nodeById[step.from], toNode = nodeById[step.to];
const fromLabel = fromNode ? fromNode.label : step.from;
const toLabel = toNode ? toNode.label : step.to;
html += `<div class="step" data-step="${idx}">
<div class="num">${idx + 1}</div>
<div class="body">
<div class="from-to">${escapeHtml(fromLabel)} <span class="arrow">→</span> ${escapeHtml(toLabel)}</div>
<div class="label">${escapeHtml(step.label || "(unlabeled)")}</div>
${step.passes ? `<div class="passes">${escapeHtml(step.passes)}</div>` : ""}
<div class="meta">
<span class="via-tag">${escapeHtml(step.via)}</span>
${step.unverified ? `<span class="unverified">unverified</span>` : ""}
</div>
</div>
</div>`;
});
p.innerHTML = html;
p.querySelectorAll(".step").forEach(el => {
el.addEventListener("click", () => highlightStep(+el.dataset.step));
});
p.scrollTop = 0;
}
function highlightStep(idx) {
activeStepIndex = idx;
const stepNum = idx + 1;
document.querySelectorAll(".panel .step").forEach((el, i) => el.classList.toggle("active", i === idx));
// Edges: a path can carry multiple steps (comma-separated). Highlight if this stepNum is in its list.
document.querySelectorAll("svg.graph .edge").forEach(e => {
const nums = (e.getAttribute("data-steps") || "").split(",").map(Number);
const isOn = nums.includes(stepNum);
e.classList.toggle("highlight", isOn);
e.classList.toggle("has-active", !isOn);
});
// Step-number badges: highlight the one matching this step; others stay visible but dimmed.
document.querySelectorAll("svg.graph .edge-num").forEach(g => {
const n = +g.getAttribute("data-step");
g.classList.toggle("highlight", n === stepNum);
g.classList.toggle("dim", n !== stepNum);
});
const active = document.querySelector(".panel .step.active");
if (active && typeof active.scrollIntoView === "function") {
active.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}
function clearStepHighlight() {
activeStepIndex = null;
document.querySelectorAll(".panel .step.active").forEach(el => el.classList.remove("active"));
document.querySelectorAll("svg.graph .edge").forEach(e => { e.classList.remove("highlight", "has-active"); });
document.querySelectorAll("svg.graph .edge-num").forEach(g => { g.classList.remove("highlight", "dim"); });
}
/* ---------- SVG helpers ---------- */
function createSvg(w, h) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("class", "graph");
svg.setAttribute("width", w);
svg.setAttribute("height", h);
svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
// Arrow marker
const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
defs.innerHTML = `
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor" />
</marker>`;
svg.appendChild(defs);
return svg;
}
function rect(x, y, w, h, cls) {
const r = document.createElementNS("http://www.w3.org/2000/svg", "rect");
r.setAttribute("x", x); r.setAttribute("y", y);
r.setAttribute("width", w); r.setAttribute("height", h);
if (cls) r.setAttribute("class", cls);
return r;
}
function text(x, y, str, cls) {
const t = document.createElementNS("http://www.w3.org/2000/svg", "text");
t.setAttribute("x", x); t.setAttribute("y", y);
if (cls) t.setAttribute("class", cls);
t.textContent = str;
return t;
}
function group(cls) {
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
if (cls) g.setAttribute("class", cls);
return g;
}
function escapeHtml(s) {
if (s == null) return "";
return String(s).replace(/[&<>"']/g, c => ({ "&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;" }[c]));
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,266 @@
# Public Landing Routing Refactor
**Date:** 2026-05-13
**Status:** Planned — pending execution
**Author:** session handoff
**Driver:** Stripe activation review — Stripe's compliance crawler cannot view `resolutionflow.com`
## Problem
The bare apex URL `https://resolutionflow.com/` serves a Vite SPA shell
(`<div id="root"></div>` and a module script — see [`frontend/index.html`](../../frontend/index.html))
and the React Router config in [`frontend/src/router.tsx`](../../frontend/src/router.tsx)
mounts `/` behind `<ProtectedRoute>`. The public marketing landing page lives
at `/landing`. For unauthenticated visitors, the flow is:
1. Browser fetches `/` → empty HTML shell.
2. JS executes, auth store hydrates as not-authenticated.
3. `ProtectedRoute` client-side `<Navigate to="/landing">`.
Stripe (and many automated compliance crawlers) fetch the apex without
executing JS, or don't reliably wait through a client-side redirect chain.
They see no business content, no terms link, no pricing — and decline review.
## Goal
Make `/` serve the public landing page directly so the apex URL renders
marketing content immediately. Move the authenticated dashboard index
(currently `QuickStartPage` at `/`) to `/home`. All other authenticated
child routes (`/trees`, `/pilot`, `/admin/*`, `/account/*`, etc.) stay
where they are — only the index page and the route grouping change.
This is the architectural fix. If Stripe's reviewer still cannot see the
site after this lands (i.e. their crawler executes zero JS), the documented
next escalation is server-side prerendering of public routes via
`vite-plugin-ssg` — captured below under Follow-ups.
## Approach
### Router restructure ([`frontend/src/router.tsx`](../../frontend/src/router.tsx))
Use a react-router *layout route* (no `path`, just an `element`) for the
authenticated tree so children carry absolute paths and don't all need
renaming:
```tsx
// Public
{ path: '/', element: page(PublicLanding), errorElement: <RouteError /> },
// Stale-bookmark redirect — keep for one release, delete in a follow-up
{ path: '/landing', element: <Navigate to="/" replace /> },
// Authenticated app — layout route
{
element: <ProtectedRoute><AppLayout /></ProtectedRoute>,
errorElement: <RouteError />,
children: [
{ path: '/home', element: page(QuickStartPage) },
{ path: '/trees', element: page(TreeLibraryPage) },
{ path: '/my-trees', element: page(MyTreesPage) },
// …all other existing children, unchanged (admin/*, account/*, pilot/*, …)
],
},
```
### `PublicLanding` wrapper (no-flicker authed redirect)
Authenticated users hitting `/` should not see marketing. Use a thin
router-level wrapper so `LandingPage` stays a pure marketing component
and there's no frame-flash before redirect:
```tsx
function PublicLanding() {
const isAuthed = useAuthStore(s => s.isAuthenticated);
return isAuthed ? <Navigate to="/home" replace /> : <LandingPage />;
}
```
### Auth gate ([`frontend/src/components/layout/ProtectedRoute.tsx:25`](../../frontend/src/components/layout/ProtectedRoute.tsx#L25))
`<Navigate to="/landing" state={{ from: location }} replace />`
`<Navigate to="/" state={{ from: location }} replace />`.
The `state.from` preservation stays.
### Reference updates (21 sites)
**Post-login / post-onboarding destinations**
| File | Line | Change |
|---|---|---|
| [`frontend/src/pages/OAuthCallbackPage.tsx`](../../frontend/src/pages/OAuthCallbackPage.tsx#L114) | 114 | `let dest = '/'``'/home'`; `'/?welcome=teammate'``'/home?welcome=teammate'` |
| [`frontend/src/pages/welcome/WelcomeStep1.tsx`](../../frontend/src/pages/welcome/WelcomeStep1.tsx#L88) | 88 | `navigate('/')``navigate('/home')` |
| [`frontend/src/pages/welcome/WelcomeStep2.tsx`](../../frontend/src/pages/welcome/WelcomeStep2.tsx#L72) | 72 | same |
| [`frontend/src/pages/welcome/WelcomeStep3.tsx`](../../frontend/src/pages/welcome/WelcomeStep3.tsx#L194) | 194 | same |
| [`frontend/src/pages/AssistantChatPage.tsx`](../../frontend/src/pages/AssistantChatPage.tsx#L2419) | 2419 | same |
**Authenticated chrome (logo, mobile nav)**
| File | Line | Change |
|---|---|---|
| [`frontend/src/components/layout/TopBar.tsx`](../../frontend/src/components/layout/TopBar.tsx#L66) | 66 | logo `to="/"``to="/home"` |
| [`frontend/src/components/layout/AppLayout.tsx`](../../frontend/src/components/layout/AppLayout.tsx#L60) | 60 | mobile nav `path: '/'``'/home'` |
| [`frontend/src/components/layout/AppLayout.tsx`](../../frontend/src/components/layout/AppLayout.tsx#L107) | 107 | logo `to="/"``to="/home"` |
**Dashboard onboarding (has in-progress edits — layer carefully)**
| File | Line | Change |
|---|---|---|
| [`frontend/src/components/dashboard/SetupChecklist.tsx`](../../frontend/src/components/dashboard/SetupChecklist.tsx#L54) | 54 | `path: '/'``'/home'` |
| [`frontend/src/components/dashboard/NextStepCard.tsx`](../../frontend/src/components/dashboard/NextStepCard.tsx#L82) | 82 | `ctaPath: '/'``'/home'` |
These two files already have uncommitted edits for the "Start a session"
pulse/scroll onboarding fix from earlier this session. Layer onto whatever's
there — don't overwrite.
**Public page back-links**
| File | Line | Change |
|---|---|---|
| [`frontend/src/pages/TermsPage.tsx`](../../frontend/src/pages/TermsPage.tsx#L10) | 10 | `to="/landing"``to="/"` |
| [`frontend/src/pages/PoliciesPage.tsx`](../../frontend/src/pages/PoliciesPage.tsx#L10) | 10 | same |
| [`frontend/src/pages/PrivacyPage.tsx`](../../frontend/src/pages/PrivacyPage.tsx#L10) | 10 | same |
| [`frontend/src/pages/ContactPage.tsx`](../../frontend/src/pages/ContactPage.tsx#L10) | 10 | same |
| [`frontend/src/pages/PromotionsPage.tsx`](../../frontend/src/pages/PromotionsPage.tsx#L10) | 10 | same |
| [`frontend/src/pages/PublicTemplatesPage.tsx`](../../frontend/src/pages/PublicTemplatesPage.tsx#L171) | 171, 409 | same |
### robots.txt + sitemap.xml ([`frontend/public/`](../../frontend/public/))
Neither file exists today. Create both.
**`frontend/public/robots.txt`**
```
User-agent: *
Allow: /
Allow: /terms
Allow: /policies
Allow: /privacy
Allow: /contact
Allow: /contact-sales
Allow: /pricing
Allow: /promotions
Allow: /templates
Disallow: /home
Disallow: /trees/
Disallow: /my-trees
Disallow: /pilot/
Disallow: /admin/
Disallow: /account/
Disallow: /script-builder
Disallow: /scripts
Disallow: /sessions
Disallow: /analytics
Disallow: /escalations
Disallow: /queue
Disallow: /review-queue
Disallow: /network-diagrams
Disallow: /kb-accelerator
Disallow: /step-library
Disallow: /tickets
Disallow: /shares
Disallow: /feedback
Disallow: /welcome
Disallow: /flow-assist
Disallow: /dev/
Disallow: /flows/
Disallow: /guides
Sitemap: https://resolutionflow.com/sitemap.xml
```
**`frontend/public/sitemap.xml`** — entries for `/`, `/pricing`,
`/contact-sales`, `/contact`, `/templates`, `/terms`, `/privacy`,
`/policies`, `/promotions`. Standard `<urlset>` schema with `<loc>` and
`<lastmod>` of `2026-05-13`.
### Open Graph + Twitter cards
[`frontend/src/components/common/PageMeta.tsx`](../../frontend/src/components/common/PageMeta.tsx)
already emits `og:title/description/type/site_name` and
`twitter:card=summary/title/description`. Gaps:
1. **No `og:url`** in `PageMeta` — add a `url` prop that defaults to
`window.location.href` (or take a canonical override).
2. **`twitter:card` is always `summary`** — when an `ogImage` is passed,
emit `summary_large_image` instead.
3. **`LandingPage` doesn't pass `ogImage`** — currently no social preview
image at all. Need a 1200×630 asset. Acceptable to ship a placeholder
(logo on existing landing gradient) and flag for design polish.
### Tests
**Update**
| File | Change |
|---|---|
| [`frontend/src/components/layout/__tests__/AppLayout.test.tsx`](../../frontend/src/components/layout/__tests__/AppLayout.test.tsx) | `initialEntries={['/']}``['/home']` |
| [`frontend/src/pages/__tests__/LandingPage.test.tsx`](../../frontend/src/pages/__tests__/LandingPage.test.tsx) | Keep `['/']` — now correct |
**Add**
`frontend/src/components/layout/__tests__/ProtectedRoute.test.tsx` (or
extend existing) — unauthenticated visit to `/home` should:
- Redirect to `/` (not `/landing`).
- Preserve original location in `state.from` so post-login flow can return
the user to their intended destination.
## Out of scope / non-issues verified
- **Service worker / PWA cache invalidation.** `vite.config.ts` has no
`vite-plugin-pwa`, no `injectManifest`, no SW registration anywhere in
`frontend/src`. The "PWA Icons" comment in `index.html` is iOS
apple-touch-icon only. Vite's content-hashed bundles + browser HTTP cache
handle invalidation. Flagged during review; no action needed.
- **Backend redirects / CORS / OAuth.** Grep of `backend/` shows no
hard-coded `/landing` or root-path redirects. OAuth callbacks render
client-side via [`OAuthCallbackPage.tsx`](../../frontend/src/pages/OAuthCallbackPage.tsx).
No backend changes required.
## Manual follow-ups (not code changes)
- **PostHog dashboard audit.** [`frontend/src/main.tsx:20`](../../frontend/src/main.tsx#L20)
sets `capture_pageview: true`, so `$pageview` auto-fires for every URL.
After this ships, `$current_url ends with /` shifts meaning from
"authenticated dashboard visit" to "anonymous marketing visit." Any
saved PostHog insight or funnel keyed on `/` will silently mis-interpret.
No in-code filters on `'/'` exist (grepped `lib/analytics.ts` and the
wider tree). Sweep PostHog dashboards in the PostHog UI before merging
this PR and update filters as needed.
- **OG image asset.** Placeholder is acceptable to unblock Stripe; design
polish can follow.
## Follow-ups (deferred — future PRs)
- **Stripe SSR escalation.** If Stripe's reviewer still cannot see the
site after this lands (i.e. their crawler executes zero JavaScript), the
next step is server-side prerendering of public routes. Cheapest path:
`vite-plugin-ssg` for static HTML output of `/`, `/pricing`, `/terms`,
`/privacy`, `/policies`, `/contact`, `/contact-sales`, `/promotions`,
`/templates`. Keeps the SPA architecture for the authed app. Larger
move (only if needed): split marketing to a separate Astro/Next-static
project at the apex and move the SPA to `app.resolutionflow.com`.
Do not pre-optimize for this. Capture as a decision in
[`.ai/DECISIONS.md`](../../.ai/DECISIONS.md) when this PR lands.
- **Delete `/landing` redirect alias** after one release cycle.
## Rollout / sequencing
1. Router restructure + `PublicLanding` wrapper.
2. 21 reference updates (post-login, chrome, dashboard onboarding, public
page back-links).
3. `ProtectedRoute` redirect target flip.
4. `robots.txt`, `sitemap.xml`.
5. `PageMeta` enhancements (`og:url`, `summary_large_image` toggle).
6. OG image asset, wired into `LandingPage`.
7. Test updates + new `ProtectedRoute` test.
8. Manual: PostHog dashboard sweep.
9. `.ai/DECISIONS.md` entry noting SSR-prerender as next-escalation path.
10. Single PR, single deploy.
## Risk
Necessary but not necessarily sufficient for Stripe's crawler. If their
bot executes zero JS, even a `/`-routed `LandingPage.tsx` is invisible —
Vite still client-renders. The Follow-ups section above captures the
escalation path.

View File

@@ -0,0 +1,493 @@
# Session Expiration Policy — Design & Implementation Plan
**Date:** 2026-05-13
**Owner:** Michael Chihlas
**Status:** Draft — pending review
**Related issue:** none yet (file after plan approval)
---
## 1. Problem
Today, once a user logs in to ResolutionFlow, they effectively stay logged in forever:
- Access token: 5 minutes — fine.
- Refresh token: 7 days, with JTI rotation. Every `/auth/refresh` mints a fresh 7-day window and revokes the old JTI.
- Frontend stores both in `localStorage`; Axios interceptor silently refreshes on every 401.
Net effect: a **sliding 7-day session with no absolute cap**. As long as a user opens the app at least once a week, the refresh token rolls forward indefinitely. There is no enforced re-authentication, no idle-timeout cap, no maximum session lifetime — and no per-account control for MSP owners whose customers may demand stricter security.
This was acceptable for pilot but is **not acceptable for self-serve launch**:
- MSP buyers' SOC2 / cyber-insurance auditors routinely require enforced session timeouts.
- A stolen device with an unlocked browser hands an attacker indefinite access.
- Owners of paying accounts expect to be able to set policy for their members.
## 2. Goals
1. **System-level absolute cap** — no session can exceed N days regardless of activity.
2. **Idle cap** — sessions inactive for N days must require re-login.
3. **Per-account owner override** — account owners can tighten or (within sysadmin-imposed ceilings) loosen the policy for their account.
4. **Graceful UX** — users get warned before forced re-login; rotation continues to be silent within the active window.
5. **Backward-compatible rollout** — existing refresh tokens are grandfathered for one rotation, not invalidated at deploy.
## 3. Non-goals
- Multi-device session management (revoke individual devices). Tracked separately; out of scope here.
- "Remember this device" / trusted device list. Out of scope.
- Per-user (vs per-account) overrides. Out of scope.
- Re-auth on sensitive action (step-up auth). Out of scope.
- Annual review of session policy (analytics dashboards). Out of scope.
## 4. Design
### 4.1 Two windows, both enforced
| Window | Default | Meaning |
|---|---|---|
| **Idle** | 3 days | Maximum time between `/auth/refresh` calls. Rotation extends this window. |
| **Absolute** | 14 days | Hard cap from original login (`auth_time`). Rotation does **not** extend this. |
The shorter of the two governs: a token is valid only if `now < min(idle_exp, auth_time + absolute_max)`.
### 4.2 JWT payload changes
Refresh-token JWT today (`backend/app/core/security.py:36`):
```json
{ "sub": "<user_id>", "type": "refresh", "jti": "<uuid>", "exp": <idle_exp> }
```
New refresh-token JWT:
```json
{
"sub": "<user_id>",
"type": "refresh",
"jti": "<uuid>",
"exp": <idle_exp>, // unchanged semantics, now = idle window
"auth_time": <login_unix_ts>, // original login (Unix seconds); NOT reset on rotation
"idle_max": <idle_seconds>, // captured at login (account policy snapshot, seconds)
"abs_max": <abs_seconds> // captured at login (account policy snapshot, seconds)
}
```
**Unit convention (single source of truth):**
| Surface | Unit | Why |
|---|---|---|
| `Settings.SESSION_*_MINUTES`, `accounts.session_*_minutes`, PATCH `/accounts/me/security` request/response, frontend form inputs | **minutes** | Human-readable, matches the column names, what owners actually edit |
| `idle_max`, `abs_max` inside the refresh JWT, `auth_time` | **seconds (Unix)** | Lets `auth_time + abs_max` be direct Unix math against `int(time.time())` with no conversion at check time |
| `idle_expires_at`, `absolute_expires_at` on API responses, `useAuthSessionExpiry` hook | **ISO 8601 UTC strings** | Matches the rest of the API surface (`DateTime(timezone=True)` everywhere) |
`resolve_session_policy(account)` (see §4.4) returns minutes; the `_mint_session_tokens` helper multiplies by 60 once when stamping the JWT. That's the only place the conversion happens.
Why snapshot `idle_max`/`abs_max` into the JWT instead of looking up the account policy on every refresh? Two reasons:
- Refresh path stays DB-cheap (one query, not two).
- If an owner tightens the policy after a user has logged in, the user's existing session continues under the policy in effect at login — fairer UX, matches what Okta and Microsoft do. New logins pick up the tightened policy.
Counter-consideration: if an owner *loosens* policy, existing sessions stay tight until next login. Acceptable; users won't notice. The owner-tightens case (security event) is the one that matters, and a kill-all-sessions admin button covers that scenario (out of scope here — log an issue).
### 4.3 Per-account policy storage
New columns on `accounts`:
| Column | Type | Nullable | Meaning |
|---|---|---|---|
| `session_idle_minutes` | `Integer` | yes | NULL = use system default |
| `session_absolute_minutes` | `Integer` | yes | NULL = use system default |
Minutes (not days) so admins can configure shorter windows for high-security tenants if needed. Stored as Integer to match existing pattern; conversion to `timedelta` happens at use site.
System-imposed bounds (in `Settings`, environment-overridable):
| Setting | Default | Floor | Ceiling |
|---|---|---|---|
| `SESSION_IDLE_MINUTES_DEFAULT` | 4320 (3d) | n/a | n/a |
| `SESSION_ABSOLUTE_MINUTES_DEFAULT` | 20160 (14d) | n/a | n/a |
| `SESSION_IDLE_MINUTES_MIN` | 15 | hard floor | account override cannot go below |
| `SESSION_IDLE_MINUTES_MAX` | 43200 (30d) | account override cannot go above | |
| `SESSION_ABSOLUTE_MINUTES_MIN` | 60 (1h) | hard floor | |
| `SESSION_ABSOLUTE_MINUTES_MAX` | 129600 (90d) | account override cannot go above | |
Plus invariant: an account's *effective* idle window must not exceed its *effective* absolute window. Enforcement is layered:
- **App-level (PATCH endpoint, authoritative):** before writing the row, resolve both effective values (`override ?? system_default`) and reject when effective idle > effective absolute. This is the only place that knows the current system defaults, so it's the only place that can catch a partial-override hole like `session_idle_minutes=43200, session_absolute_minutes=NULL` when the system absolute default is 20160.
- **DB CHECK constraint (defense in depth, narrower):** `session_idle_minutes IS NULL OR session_absolute_minutes IS NULL OR session_idle_minutes <= session_absolute_minutes`. This only catches the both-set case; the partial-override case is intentionally outside the DB's reach because the DB can't see `Settings`. Document this in a comment on the constraint.
Alternative considered: require both columns to be NULL or both set (XOR-with-NULL). Rejected because it forces an owner who only wants to override idle to also re-declare the absolute window, which leaks the system default into account data and makes the system default harder to evolve later.
### 4.4 Resolution function
```python
# backend/app/core/security.py
def resolve_session_policy(account: Account) -> tuple[int, int]:
"""Return (idle_minutes, absolute_minutes) for an account, applying defaults."""
idle = account.session_idle_minutes or settings.SESSION_IDLE_MINUTES_DEFAULT
abs_ = account.session_absolute_minutes or settings.SESSION_ABSOLUTE_MINUTES_DEFAULT
return idle, abs_
```
Called once at each of the four token-issuing entry points listed in §4.6 (`/auth/login`, `/auth/login/json`, `/auth/google/callback`, `/auth/microsoft/callback`) and snapshotted into the JWT via `_mint_session_tokens`. Not called on `/auth/refresh` — that path carries forward the existing snapshot.
### 4.5 Refresh endpoint changes
`POST /auth/refresh` (`backend/app/api/endpoints/auth.py:377`) currently:
1. Decodes refresh JWT (via `get_refresh_token_payload` dep).
2. Atomically revokes old JTI (`UPDATE … SET revoked_at=now() WHERE token_hash=? AND revoked_at IS NULL RETURNING …`).
3. Mints new refresh + access tokens with same `sub`.
New algorithm (precise):
1. Decode refresh JWT (idle expiry already surfaced as `session_expired_idle` by `decode_refresh_token_strict`; see §4.10).
2. **NEW:** load `user` and `user.account` by `sub` from the decoded payload. Needed before any legacy-token handling because the grandfather path needs to read the account's current policy. If the user is missing or inactive, return 401 with `detail="invalid_refresh_token"` (existing behavior, unchanged).
3. **NEW (grandfather path):** if `auth_time` is missing from the payload (legacy token issued before this PR), treat it as `now()` and snapshot the loaded account's current policy via `resolve_session_policy(account)` into `idle_max`/`abs_max`. One free rotation under the new policy.
4. **NEW:** compute `absolute_deadline = auth_time + abs_max` (both in Unix seconds). Compare with `now >= absolute_deadline`, not `>` — a token whose deadline equals `now()` is expired, not valid.
5. **Atomically revoke the JTI regardless of outcome** (single UPDATE, same statement as today). This consumes the token whether or not the absolute check passes — so an absolute-expired token cannot be replayed forever; a second attempt finds the row already `revoked_at IS NOT NULL` and falls through to the existing "invalid or revoked refresh token" 401.
6. If the atomic UPDATE matched zero rows (already revoked): 401 with `detail="invalid_refresh_token"`.
7. If `now >= absolute_deadline`: 401 with `detail="session_expired_absolute"`. (The row is already revoked from step 5.)
8. Otherwise mint new tokens, **carrying forward `auth_time`, `idle_max`, `abs_max` unchanged** from the old token (or freshly snapshotted if grandfathered in step 3).
Helper contract: `_refresh_session_tokens(payload, user, account, db) -> Token`. Takes the validated decoded payload plus the already-loaded user/account so it doesn't re-query. Returns the same `Token` shape as `_mint_session_tokens` (with the two new ISO expiry fields). Distinct from `_mint_session_tokens` because the refresh path carries claims forward instead of resolving policy.
Idle expiry is handled earlier in the chain: `get_refresh_token_payload` calls `decode_token`, which returns `None` for any JWT past `exp` — that's the existing 401 path. See §4.10 for distinguishing idle expiry from generic invalid-token errors in the response.
### 4.6 Login endpoints
Token-issuing endpoints that need the snapshot logic (verified against the codebase):
| Endpoint | File:line | Response model |
|---|---|---|
| `POST /auth/login` (form-encoded, OAuth2PasswordRequestForm) | `backend/app/api/endpoints/auth.py:303` | `Token` |
| `POST /auth/login/json` (JSON body — what the frontend actually calls) | `backend/app/api/endpoints/auth.py:342` | `Token` |
| `POST /auth/google/callback` | `backend/app/api/endpoints/oauth.py:174` | `OAuthCallbackResponse` |
| `POST /auth/microsoft/callback` | `backend/app/api/endpoints/oauth.py:204` | `OAuthCallbackResponse` |
| `POST /auth/refresh` | `backend/app/api/endpoints/auth.py:377` | `Token` |
`POST /auth/register` (`auth.py:92`) returns `UserResponse` and **does not auto-login** — the frontend follows up with a separate call to `/auth/login/json`. No token-minting changes needed in `/register` itself; the subsequent `/login/json` call will pick up the new claims naturally.
Each of the four token-issuing endpoints (login, login/json, both OAuth callbacks) calls `create_refresh_token` with the extra claims. Wrap in a helper `_mint_session_tokens(user, account, db) -> Token` (or `OAuthCallbackResponse` — see §4.10 on shared response fields) to avoid drift across four sites. `/auth/refresh` uses a variant that carries forward existing claims instead of re-snapshotting policy.
### 4.7 Account security endpoint
New endpoint module: `backend/app/api/endpoints/account_security.py`
```
GET /accounts/me/security → returns {
idle_minutes, absolute_minutes,
effective_idle_minutes, effective_absolute_minutes,
system_min/max bounds,
active_users: [{user_id, name, email, last_login_at}, ...]
}
PATCH /accounts/me/security → owner only; validates bounds + invariant; writes account row
```
`require_account_owner` from `app/api/deps.py:189` enforces ownership. Returns the *effective* values (after defaults applied) so the frontend doesn't have to know about NULL semantics.
**`active_users` field** (added during plan-design-review pass on 2026-05-13): the GET response includes a list of users with at least one un-revoked refresh token in this account. Query: `SELECT DISTINCT u.id, u.email, u.name, u.last_login FROM users u JOIN refresh_tokens rt ON rt.user_id = u.id WHERE u.account_id = :acct AND rt.revoked_at IS NULL`. The frontend uses this to render the "Active sessions" section with names + relative last-login timestamps (see §4.8) rather than a faceless count. Caveat: `last_login` updates only at login, not on refresh — so the relative timestamp is honest about "when they signed in," not "last touched the app." Per-refresh activity needs the deferred `refresh_tokens.last_used_at` follow-up (§9).
### 4.8 Frontend changes
**Response-field naming (single scheme, used everywhere):**
Both `Token` (`/auth/login`, `/auth/login/json`, `/auth/refresh`) and `OAuthCallbackResponse` (`/auth/google/callback`, `/auth/microsoft/callback`) gain two new fields:
| Field | Type | Source |
|---|---|---|
| `idle_expires_at` | ISO 8601 UTC string | derived from refresh JWT `exp` |
| `absolute_expires_at` | ISO 8601 UTC string | derived from refresh JWT `auth_time + abs_max` |
ISO strings (not Unix ints) for consistency with the rest of the API surface, which uses `DateTime(timezone=True)` everywhere. Frontend parses with `new Date(...)`.
**New hook:** `frontend/src/hooks/useAuthSessionExpiry.ts`
- Reads `idleExpiresAt` and `absoluteExpiresAt` from `authStore`.
- Returns `{ idleExpiresAt, absoluteExpiresAt, warning, reason }` where `warning ∈ {"none", "soon", "now"}` and `reason ∈ {"idle", "absolute"}` indicating which window is closer.
- "soon" fires at T-5min on whichever window comes first.
- Pairs with a top-of-app `<SessionExpiryToast />` mounted in `AppLayout.tsx`.
**SessionExpiryToast — differentiated by `reason`** (locked during plan-design-review):
- **`reason === "idle"`** (idle window is closer): warning-amber tone. Copy: *"Your session times out in 5 minutes."* Action button: `[Stay signed in]` → triggers a manual `/auth/refresh` call (resets the idle window). On success, toast dismisses + the store updates `idleExpiresAt`. On failure (e.g. absolute cap is also nearby and the refresh hits `session_expired_absolute`), fall through to the standard 401-handling redirect.
- **`reason === "absolute"`** (absolute window is closer): info-cyan tone (matching the `?reason=session_expired` banner). Copy: *"Your session ends at HH:MM for security. You'll need to sign in again."* No action button — nothing the user can do extends an absolute cap. Optional secondary action: `[Sign in now]` link to `/login` for users who want to re-auth proactively.
- Toast does not auto-dismiss (persists until acted on or window expires).
- Re-fires only after a successful `/auth/refresh` extends the idle window past T-5min and we cross back into "soon" later. Does not nag.
**Modified:** `frontend/src/api/client.ts` interceptor
- On 401 with `detail="session_expired_absolute"` **or** `detail="session_expired_idle"`: **skip the refresh attempt**, flush tokens, redirect to `/login?reason=session_expired`. (Both surfaces go through the same banner — users don't need to distinguish the two.)
- On 401 with `detail="invalid_refresh_token"` or any other detail: current behavior (drop to `/login` without the reason banner).
- Existing access-token-expired flow (transparent `/auth/refresh`) unchanged.
**Modified:** `frontend/src/store/authStore.ts`
- `setTokens(token: Token)` (`authStore.ts:140`) is the single token-persistence path used by both `login()` and the OAuth flow. Extend the `Token` type with `idle_expires_at` + `absolute_expires_at`; `setTokens` writes them to store + localStorage alongside the access/refresh tokens. No new action.
- The Axios refresh interceptor (`api/client.ts:139`) destructures `access_token, refresh_token` today — extend to read the two new fields and call `setTokens` so refreshed sessions update their expiry metadata.
- **Legacy-state migration:** on store rehydrate, if tokens exist but `idle_expires_at` / `absolute_expires_at` are missing from localStorage, leave them `null` and let the next `/auth/refresh` populate them via response fields. The hook treats `null` as "unknown — don't warn yet." No forced logout for pre-deploy localStorage.
**Modified:** `frontend/src/pages/OAuthCallbackPage.tsx`
- The `setTokens({...})` call at `OAuthCallbackPage.tsx:102` currently passes `{access_token, refresh_token, token_type}` from the `OAuthCallbackResponse`. Add `idle_expires_at` and `absolute_expires_at` to the spread so OAuth-issued sessions get the same expiry metadata as password logins.
**New page:** `frontend/src/pages/account/AccountSecuritySettingsPage.tsx`
- Lives under existing `/account` routing with `requireRoleOwner` style guard. Card lives in `AccountSettingsPage.tsx` grid alongside Branding / Chat Retention; **hidden entirely for non-owners** (matches existing role-conditional rendering at `AccountSettingsPage.tsx:597-651`).
- Page shell matches `ChatRetentionSettingsPage.tsx`: `max-w-2xl mx-auto py-8 px-6`, header row with Lucide icon + Bricolage 22px page title, `card-flat rounded-2xl p-6 space-y-6` body.
- **Vertical order (top → bottom):**
1. Page header (Lucide `Shield` icon + "Session Security")
2. One-line intro paragraph (`text-muted-foreground`): *"Control how long sessions can last before users must sign in again."*
3. **Session policy** card: three radios (Strict / Standard / Custom) with effective minute values visible per option ("Strict — 3d idle, 14d absolute"), then two numeric inputs (Idle minutes, Absolute minutes). **Inputs are always visible; disabled when a preset is selected.** Below inputs: hint text showing the system min/max from the GET response. Save button (primary) + inline `text-emerald-400 "Settings saved"` success ping for 3s after save (matching `ChatRetentionSettingsPage.tsx:112-114`).
4. Info line directly below Save: *"New policy applies the next time each person signs in. Use **Active sessions** below to force it immediately."* (`text-muted-foreground`, bold on "Active sessions" — anchor link or just visual emphasis).
5. Visual divider (1px `border-default`).
6. **Active sessions** section (see below for details).
- **Initial GET loading state:** centered `Loader2 animate-spin` page-body, matching `ChatRetentionSettingsPage.tsx:46-51`.
- **Inline validation** on Custom inputs: debounced 300ms; red border (`border-danger`) + small error text below field; Save button disabled when any field is invalid. Server-side 422 from PATCH surfaces via the existing axios interceptor toast.
**Active sessions section (within the same page):**
- GET response includes `active_users: [{user_id, name, email, last_login_at}, ...]` — backend addition; see §4.7.
- Section header: "Active sessions"
- Subhead: "N people are signed in to this account." (singular: "Only you are signed in.")
- Active-users list: one row per active user — `name (email) · logged in 2d ago` (relative time from `last_login_at`). Caller's own row marked with a small "(you)" tag.
- Buttons below the list — count-aware:
- **count > 1:** Two ghost buttons side-by-side — `[Sign out everyone except me]` and `[Sign me out and everyone else]` (the latter uses `text-danger` color to telegraph the self-impact).
- **count = 1 (solo owner):** Hide the "except me" button (it would revoke 0 — confusing). Show only `[Sign me out everywhere]` (still useful — signs the owner out from their other devices).
**Bulk-revoke confirmation modal** (via `components/common/Modal.tsx`):
- **scope=others:** title *"Sign out other users?"* · body *"This signs out the N other active users in your account. They'll need to sign in again. You stay signed in."* · buttons `[Cancel]` (ghost) + `[Sign out N users]` (`text-danger`).
- **scope=all:** title *"Sign out everyone?"* · body *"This signs out all N active users including yourself. Everyone will need to sign in again."* · buttons `[Cancel]` (ghost) + `[Sign out everyone]` (`text-danger`).
- After success: modal closes, `toast.success("Signed out N sessions")`. For scope=all: 1.5s delay → `useAuthStore.getState().logout()` + `window.location = '/login'` (no banner — they just did this, they know why they're here).
**Modified:** `AccountSettingsPage.tsx`
- Add a "Session Security" link card to the existing grid (owner-only visibility, alongside Branding / Chat Retention). Lucide `Shield` icon.
**New login page banner:** when `?reason=session_expired` is present, show a small info-tone banner **above the email/password form**:
- Background: `info-dim` (cyan-dim, `rgba(103,232,249,0.10)` dark / `rgba(8,145,178,0.07)` light per DESIGN-SYSTEM.md)
- Text color: `info` text token
- Border: `1px solid info-dim`
- Padding: 12px 16px, `radius-sm` (5px)
- Icon: Lucide `Info` (16px, info color, left-aligned)
- Copy: *"You were signed out for security. Sign back in to continue."*
- Not dismissable — disappears naturally when the user submits the form (the query string clears on navigate).
- Note: this is the first cyan info-tone banner in the app; sets the precedent we'll reuse for future neutral system messages.
**Modified:** `AccountSettingsPage.tsx`
- Add a "Session Security" link card to the existing grid (owner-only visibility).
**New login page banner:** when `?reason=session_expired` is present, show a calm info banner: "Your session ended for security. Please sign in again." (No alarm UI, just clarity. Same banner for both idle and absolute expiry; the user doesn't need to learn the distinction.)
### 4.9 Migration
`alembic revision -m "add session policy columns to accounts"` (manual, per Lesson 77).
```sql
ALTER TABLE accounts
ADD COLUMN session_idle_minutes INTEGER,
ADD COLUMN session_absolute_minutes INTEGER,
ADD CONSTRAINT session_idle_le_absolute_when_both_set
CHECK (session_idle_minutes IS NULL
OR session_absolute_minutes IS NULL
OR session_idle_minutes <= session_absolute_minutes);
COMMENT ON CONSTRAINT session_idle_le_absolute_when_both_set ON accounts IS
'Defense in depth: catches idle > absolute when both are overridden. '
'The partial-override case (one NULL, one set) is validated at the app layer '
'against current system defaults, since the DB cannot see Settings.';
```
No backfill: NULL is the intended state for "use system default."
Confirm: `accounts` is in the global-tables list per PROJECT_CONTEXT.md, so the migration does **not** add RLS predicates. Verified — `accounts` is explicitly named there.
### 4.10 Error-detail taxonomy
`/auth/refresh` returns 401 with one of these `detail` values, so the frontend can distinguish UX paths:
| `detail` | When | Frontend action |
|---|---|---|
| `session_expired_idle` | refresh JWT past `exp` (idle window elapsed) | flush tokens, redirect `/login?reason=session_expired` |
| `session_expired_absolute` | refresh JWT alive, but `now >= auth_time + abs_max` | flush tokens, redirect `/login?reason=session_expired` |
| `invalid_refresh_token` | JTI not in DB, already revoked, signature bad, type mismatch | flush tokens, redirect `/login` (no banner) |
Implementation note: `decode_token` currently swallows `JWTError` and returns `None`, so idle expiry is indistinguishable from a signature failure at the dep level. Fix by switching `get_refresh_token_payload` (or adding a sibling) to call `jwt.decode` directly and catch `ExpiredSignatureError` separately from generic `JWTError`. Idle-expired tokens raise the former; map that to `session_expired_idle`. All other JWT errors map to `invalid_refresh_token`.
### 4.11 Bulk session revocation (kill-all-sessions)
**Endpoint:** `POST /accounts/me/security/revoke-sessions`, owner-only via `require_account_owner`.
**Request body:**
```json
{ "scope": "all" | "others" }
```
Default `"all"` if body omitted. `"others"` excludes the calling user's own refresh tokens (so the owner stays signed in); `"all"` includes them.
**Response:**
```json
{ "revoked_count": <int> }
```
**Behavior:**
- Single SQL UPDATE: `refresh_tokens.revoked_at = now()` for rows where `user_id IN (SELECT id FROM users WHERE account_id = :caller_account_id)` AND `revoked_at IS NULL`. If `scope="others"`, also AND `user_id != caller.id`.
- All affected users' next `/auth/refresh` matches zero rows in the atomic revoke (§4.5 step 5) → 401 `invalid_refresh_token` → redirect to `/login` (no banner — the user was signed out by an admin, not by expiry; the plain `/login` redirect is honest UX).
- Caller's access token is not revoked (we don't track access JTIs by design); it dies naturally on its 5-minute timer. For `scope="all"`, the frontend handles UX by clearing localStorage and redirecting to `/login` after the response — so the stale access token simply isn't used. Accept the 5-minute window where the caller's access token could in theory still hit endpoints; this matches the existing logout flow and is consistent with the threat model (the action is "kick everyone out," not "instantly invalidate every credential").
**Audit:** writes one `account.sessions_revoked_bulk` event with `{actor_user_id, account_id, scope, revoked_count}`.
**Out of scope:** distinguishing `session_revoked_by_admin` from `invalid_refresh_token` on the wire for affected users. Doing so requires tracking the revocation reason per `refresh_tokens` row (new column). Not worth the complexity right now — the affected user just sees they're logged out, same as if they'd been logged out for any other reason. Revisit if pilots ask for it.
**Why not also per-user-device revoke?** Refresh tokens today don't carry device/user-agent metadata; the unit of granularity is "all of user X's active sessions" (which is most of what people want anyway — e.g., I lost my laptop). The endpoint is account-scoped because that's the owner-control story we're shipping. Per-user device list is a follow-up if/when needed (§9).
## 5. Backward compatibility
### 5.1 Existing refresh tokens (no `auth_time` claim)
On first `/auth/refresh` after deploy:
- Backend detects missing `auth_time`, treats current time as `auth_time`, snapshots current account policy.
- User effectively gets one free 14-day absolute window starting at first post-deploy refresh.
Trade-off vs forcing universal re-login on deploy:
- ✅ Zero deploy-day support burden (no pilots flood Slack with "I got logged out").
- ❌ Users with active sessions see no enforcement for up to 14 days.
Given the user base is small (pilot phase) and the bigger goal is *new* signups have a secure default, the friendly path wins.
### 5.2 If we ever need to invalidate everyone
`SECRET_KEY` rotation kills all existing tokens. Documented in `DEV-ENV.md` but not part of this PR.
## 6. Test plan
Backend (`backend/tests/test_session_policy.py` — new file, unless noted):
1. **Default policy applied** — login without account override → JWT has `idle_max=259200`, `abs_max=1209600` (seconds; 3d/14d). Account/settings columns are minutes (4320/20160); the helper multiplies by 60 when stamping.
2. **Account override honored** — owner PATCHes `session_idle_minutes=60`, `session_absolute_minutes=240` → next login JWT has `idle_max=3600`, `abs_max=14400` (seconds).
3. **Override bounds enforced** — PATCH idle below `SESSION_IDLE_MINUTES_MIN` → 422; PATCH absolute above `SESSION_ABSOLUTE_MINUTES_MAX` → 422.
4. **Invariant enforced (both-set)** — PATCH idle=300, absolute=120 → 422.
5. **Invariant enforced (partial override)** — system default absolute=20160; PATCH idle=43200 with absolute=NULL → 422 (effective idle > effective absolute, app-layer check).
6. **DB constraint catches both-set inversion** — direct SQL `UPDATE accounts SET session_idle_minutes=300, session_absolute_minutes=120` rolls back with `CheckViolation`.
7. **Non-owner cannot PATCH** — engineer/viewer get 403.
8. **Refresh respects absolute cap (boundary)** — set `auth_time = now - abs_max` exactly → refresh 401 with `session_expired_absolute` (deadline check is `>=`, not `>`).
9. **Absolute-expired token is consumed** — attempt #1 returns `session_expired_absolute`; attempt #2 with the same token returns `invalid_refresh_token` (row was revoked atomically in #1, cannot be replayed).
10. **Refresh extends idle but not absolute** — rotate twice within `abs_max`; both succeed; `auth_time` unchanged across rotations.
11. **Idle expiry (boundary)** — set refresh `exp = now` → 401 with `session_expired_idle` (not generic `invalid_refresh_token`).
12. **Grandfather path** — legacy refresh token without `auth_time`/`idle_max`/`abs_max` → one successful rotation; new JWT has all three claims, `auth_time≈now()`.
13. **Tightening after login doesn't affect existing sessions** — login under policy A, owner tightens to policy B, refresh succeeds under A's snapshot.
14. **`/auth/login/json` carries new claims and response fields** — JWT decode shows `auth_time`/`idle_max`/`abs_max`; response body has `idle_expires_at` + `absolute_expires_at` as ISO strings.
15. **OAuth callback responses include expiry fields**`/auth/google/callback` and `/auth/microsoft/callback` `OAuthCallbackResponse` bodies have both `idle_expires_at` and `absolute_expires_at`. Mock the Google/Microsoft token-exchange step; assert on the final response shape.
16. **Policy update writes audit row** — PATCH `/accounts/me/security` emits one `account.session_policy_update` audit event with `actor_user_id`, `account_id`, and a payload of `{old: {...}, new: {...}, effective_old: {...}, effective_new: {...}}`. Verify via the existing audit-log query in `core/audit.py`.
17. **Bulk revoke scope=all** — seed three active refresh tokens for two users in the account (caller + one other). POST `/accounts/me/security/revoke-sessions` with `{"scope": "all"}``revoked_count=3`; caller's own refresh token is now revoked too. Their next `/auth/refresh` → 401 `invalid_refresh_token`.
18. **Bulk revoke scope=others** — same seed. POST with `{"scope": "others"}``revoked_count=2` (caller's token survives). Caller's `/auth/refresh` still succeeds; the other user's `/auth/refresh` → 401 `invalid_refresh_token`.
19. **Bulk revoke is account-scoped** — seed tokens for users in account A and account B. Owner of A POSTs revoke → `revoked_count` reflects only A's tokens; B's tokens remain active.
20. **Bulk revoke is owner-only** — engineer/viewer POST → 403; super_admin POST against `/me` works only if they own an account (the endpoint is `/me`, not `/{account_id}`).
21. **Bulk revoke writes audit row**`account.sessions_revoked_bulk` with `{actor_user_id, account_id, scope, revoked_count}`.
22. **Bulk revoke is idempotent** — second immediate POST returns `revoked_count=0` (no already-revoked rows are double-stamped).
Frontend (`frontend/src/__tests__/` or colocated `*.test.tsx`):
- `useAuthSessionExpiry` returns `"soon"` within 5min of whichever of `idleExpiresAt`/`absoluteExpiresAt` comes first; `reason` field indicates which.
- Axios interceptor on 401 with `session_expired_absolute` redirects to `/login?reason=session_expired` instead of attempting refresh.
- Axios interceptor on 401 with `session_expired_idle` does the same.
- Axios interceptor on 401 with `invalid_refresh_token` redirects to `/login` *without* the reason banner.
- `authStore` rehydrate handles legacy localStorage shape (no `idleExpiresAt`/`absoluteExpiresAt`) without throwing or forced logout; hook treats `null` as "no warning."
Manual:
- Log in as `owner@`, set **Custom (idle=60 min, absolute=240 min)** under Account → Session Security, log out, log in as `engineer@` (same account), decode the refresh JWT in localStorage, confirm `idle_max=3600` and `abs_max=14400` (seconds — the configured minutes × 60).
- Confirm the existing `useSessionTimer` (troubleshooting-flow timer) is unaffected by the new hook.
- Pre-deploy localStorage path: install build, log in to capture token, deploy session-policy build, refresh page — confirm no forced logout and that the next `/auth/refresh` populates the new fields.
## 7. Rollout
1. Land migration + backend changes behind no flag (the absolute cap is the whole point — flagging it defeats the purpose).
2. Default policy is Strict (3d/14d) for new accounts. Existing pilot accounts get NULL → defaults; user can manually loosen any pilot account via the new endpoint or direct SQL if friction emerges.
3. After deploy, watch Sentry for spikes in `session_expired_absolute` 401s (expected: tiny — only legacy tokens approaching 14-day mark hit this) and unexpected refresh failures.
4. Announce in pilot Slack: "We added session expiration. You'll be asked to log in again every 2 weeks max. Account owners can adjust under Account → Session Security."
## 8. Files touched
### Backend
- `backend/app/core/config.py` — new `SESSION_*` settings (defaults + min/max bounds).
- `backend/app/core/security.py``create_refresh_token` signature change (accepts `auth_time`/`idle_max`/`abs_max`), `resolve_session_policy(account)` helper, `decode_refresh_token_strict()` that distinguishes `ExpiredSignatureError` from generic `JWTError`.
- `backend/app/api/deps.py` — update `get_refresh_token_payload` to surface idle-expiry as `session_expired_idle` instead of collapsing into a generic 401.
- `backend/app/api/endpoints/auth.py` — refresh-endpoint logic (atomic-revoke-then-check-absolute), `_mint_session_tokens(user, account, db) -> Token` helper, login + login/json call sites.
- `backend/app/api/endpoints/oauth.py` — both callbacks call `_mint_session_tokens`; `OAuthCallbackResponse` gains the two new fields.
- `backend/app/schemas/token.py``Token` (`token.py:5`) adds `idle_expires_at` + `absolute_expires_at` (ISO strings).
- `backend/app/schemas/oauth.py``OAuthCallbackResponse` adds the same two fields.
- `backend/app/api/endpoints/account_security.py` — NEW (~130 lines: GET/PATCH for policy + POST `/revoke-sessions`, audit logging for both mutations).
- `backend/app/api/router.py` — register new router.
- `backend/app/models/account.py` — two new columns + DB CHECK constraint.
- `backend/app/schemas/account_security.py` — NEW (request/response: policy GET/PATCH with effective + bounds; `RevokeSessionsRequest` + `RevokeSessionsResponse`).
- `backend/app/core/audit.py` — add `account.session_policy_update` event type (or use the existing generic emitter if it accepts free-form types — verify during impl).
- `backend/alembic/versions/<hash>_session_policy_columns.py` — NEW (manual; per Lesson 77, never `--rev-id`).
- `backend/tests/test_session_policy.py` — NEW.
### Frontend
- `frontend/src/api/client.ts` — interceptor branches on both `session_expired_idle` and `session_expired_absolute` (same redirect target `/login?reason=session_expired`); also propagates new expiry fields from successful `/auth/refresh` responses into `setTokens`.
- `frontend/src/api/auth.ts``Token` type adds the two new ISO fields.
- `frontend/src/store/authStore.ts``setTokens` persists the new expiry fields (no new action).
- `frontend/src/pages/OAuthCallbackPage.tsx` — pass `idle_expires_at` + `absolute_expires_at` through `setTokens({...})` at line 102.
- `frontend/src/hooks/useAuthSessionExpiry.ts` — NEW.
- `frontend/src/components/common/SessionExpiryToast.tsx` — NEW.
- `frontend/src/components/layout/AppLayout.tsx` — mount toast.
- `frontend/src/pages/account/AccountSecuritySettingsPage.tsx` — NEW (policy form + Active Sessions section with two revoke buttons + confirmation modal).
- `frontend/src/pages/AccountSettingsPage.tsx` — add link card.
- `frontend/src/router.tsx` — register route.
- `frontend/src/pages/LoginPage.tsx``?reason=session_expired` banner.
### Docs
- `.ai/DECISIONS.md` — entry for the 3d/14d default + per-account-override architecture.
- `CURRENT-STATE.md` — add session policy to "auth surface" summary.
Approx ~600 LoC across backend + frontend, plus tests.
## 9. Resolved decisions & follow-ups
Decisions baked into this plan (not open questions):
- **Audit logging is required.** PATCH `/accounts/me/security` writes one `account.session_policy_update` audit event; POST `/revoke-sessions` writes `account.sessions_revoked_bulk`. Security-relevant by definition. Covered in §6 tests #16 and #21 and §8 backend file list.
- **Presets are Strict and Standard only**, plus Custom. No "Loose" preset; owners who want a loose policy can use Custom and own the choice explicitly.
- **Tightening policy mid-session does NOT force-logout existing sessions** — but owners *can* force it via the bulk-revoke endpoint in §4.11. Existing sessions continue under the policy snapshot they were issued under unless explicitly revoked. The Account Security page surfaces this in copy (§4.8).
- **Bulk revoke is account-scoped, two-mode (`all` / `others`).** Per-user device lists are out of scope (§4.11).
Follow-up issues to file after this plan is approved (not blocking this PR):
1. **Super-admin global lock with UI** — today, env-var ceilings cover this. File an issue to expose `SESSION_*_MAX` as a sysadmin-editable setting if/when a customer asks.
2. **Per-user device list + per-device revoke** — refresh tokens would gain `user_agent` + `ip` + `last_used_at` columns; a new "Active devices" page would let users self-revoke individual sessions. File only if a real ask arrives. The account-wide bulk revoke covers the breach-response use case in the meantime.
3. **Per-user (not per-account) policy** — out of scope. File only if a real ask arrives.
## 10. Sequence of commits
1. `feat(auth): add session policy settings + account columns + migration` (settings + model + migration + DB CHECK; no behavior change yet).
2. `feat(auth): distinguish idle expiry from invalid refresh tokens` (`decode_refresh_token_strict`, `session_expired_idle` detail, test #11). Lands the error-detail taxonomy from §4.10 before anything depends on it.
3. `feat(auth): embed auth_time/idle_max/abs_max in refresh tokens` (`security.py` + `_mint_session_tokens` helper called from `/auth/login`, `/auth/login/json`, both OAuth callbacks; `Token` and `OAuthCallbackResponse` gain `idle_expires_at` + `absolute_expires_at`). Refresh still doesn't enforce absolute cap yet.
4. `feat(auth): enforce absolute session cap in /auth/refresh` (atomic-revoke-then-check, `session_expired_absolute` detail, grandfather logic, tests #8#13).
5. `feat(api): add GET/PATCH /accounts/me/security endpoint` (router, schemas, owner gate, bounds + partial-override invariant validation, audit logging on PATCH).
6. `feat(api): add POST /accounts/me/security/revoke-sessions` (bulk-revoke endpoint with `scope=all|others`, single-UPDATE implementation, audit logging, tests #17#22).
7. `feat(ui): handle session_expired_{idle,absolute} in axios interceptor + authStore` (new fields persisted, legacy-state migration, redirect to `/login?reason=session_expired`).
8. `feat: AccountSecuritySettingsPage + active-users list + toasts + login banner` (Strict/Standard/Custom presets with always-visible-disabled Custom inputs, count-aware Active Sessions section with name/email/last-login rows, differentiated SessionExpiryToast for idle-vs-absolute, cyan info-tone login banner, scope=all auto-redirect-after-toast UX. Includes a small backend addition: `active_users` field on `GET /accounts/me/security` — see §4.7).
9. `docs: add decision entry + update CURRENT-STATE auth surface` (`.ai/DECISIONS.md`, `CURRENT-STATE.md`).
Each commit independently passes `pytest --override-ini="addopts="` and `npm run build`. The two backend behavior gates (#2 and #4) ship behind no flag — they're the point of the work — but they're sequenced so any rollback is a single commit.
---
**Review checklist before implementation:**
- [x] Defaults confirmed: 3d idle / 14d absolute.
- [x] Per-account override approved.
- [x] Grandfather strategy (one free rotation) approved vs hard cutover.
- [x] Error-detail taxonomy approved (idle vs absolute distinct on the wire; same UX in the frontend).
- [x] Audit logging is a requirement, not optional.
- [x] Loose preset dropped; Strict / Standard / Custom only.
- [x] ISO timestamps (not Unix ints) for `idle_expires_at` / `absolute_expires_at` everywhere.
- [x] DB CHECK constraint scope documented; partial-override case validated app-side.
- [ ] System bounds in §4.3 acceptable as specified (15min floor, 30d idle ceiling, 90d absolute ceiling).
- [ ] Final approval on commit sequence in §10.
- [ ] No conflict with Phase O cutover sequencing (this can ship before OR after EIN/Stripe lands; independent path).
- [ ] File the kill-all-sessions follow-up issue per §9 before implementation begins, so the Account Security page can link to it (or leave the support-contact copy in place).
---
## GSTACK REVIEW REPORT
| Review | Trigger | Why | Runs | Status | Findings |
|--------|---------|-----|------|--------|----------|
| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | — | not run |
| Codex Review | `/codex review` | Independent 2nd opinion | 0 | — | not run |
| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 0 | — | not run (the plan itself was eng-reviewed inline across 7 commits — backend complete & green) |
| Design Review | `/plan-design-review` | UI/UX gaps | 1 | CLEAR (PLAN) | score: 4/10 → 9/10, 7 decisions added |
| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | not run |
**UNRESOLVED:** 0 design decisions; 3 plan-level checklist items remain (system bounds, commit sequence, Phase O sequencing — none block design).
**VERDICT:** DESIGN CLEARED — page layout, state coverage, post-revoke flow, toast logic, login banner tone, and form copy all locked. Commit 8 has a complete spec.

View File

@@ -0,0 +1,477 @@
# Tutorial: Build a Contact page
By the end of this tutorial, ResolutionFlow will have a working `/contact` page. A visitor can land on it, fill out a form, and see a thank-you state when it submits. You'll touch the router, build a page component, style it with the design system, manage form state, and link to it from the landing footer.
This is a **tutorial**, not a reference. It's one concrete path that's known to work. The point is to learn how the pieces fit together by actually building something. Don't substitute steps. After you finish, you'll be ready to read the code with confidence.
**Estimated time:** 3045 minutes.
---
## What you'll know by the end
- Where new pages live in the codebase
- How the router lazy-loads page components
- How public pages differ from in-app pages
- How to apply the design system without inventing chrome
- How to wire form state, validation, and submit
- How to verify your work and ship a clean commit
---
## Before you start
You need:
- The frontend container running (`docker ps` should show `resolutionflow_frontend` listening on 5173). Vite hot-module-reload is what makes this tutorial pleasant. Every file save shows up in the browser within a second.
- An editor open at the repo root.
- A logged-out browser tab pointed at the dev server. The contact page is public, so you don't need an account to visit it. (If you've been logged in, open a private window or sign out.)
> Quick sanity check: navigate to `/landing` in the browser. If you see the marketing page, you're set up correctly. If you see anything else, fix that first.
---
## Step 1: Decide where the page lives
Two parts of the app could host a "contact" page: the **public marketing layer** (`/landing`, `/privacy`, `/terms`) or the **in-app shell** (`/account`, `/sessions`, etc.). The right answer depends on the audience.
A contact page is for visitors who *aren't* logged in: prospects, leads, support requests from people without accounts. So it belongs at the public layer, parallel to `/privacy` and `/terms`. No app shell, no sidebar, just a simple centered page.
**Decision:** route it at `/contact`, no auth required, model it after `frontend/src/pages/PrivacyPage.tsx` for layout.
---
## Step 2: Create the page component
Create a new file at `frontend/src/pages/ContactPage.tsx`. Start with the smallest possible skeleton so we can confirm the route works before adding form complexity.
```tsx
import { Link } from 'react-router-dom'
import { PageMeta } from '@/components/common/PageMeta'
export default function ContactPage() {
return (
<>
<PageMeta title="Contact" description="Get in touch with ResolutionFlow" />
<div className="min-h-screen bg-background text-foreground">
<div className="mx-auto max-w-xl px-6 py-16">
<Link
to="/landing"
className="mb-8 inline-block text-sm text-muted-foreground hover:text-foreground"
>
&larr; Back to home
</Link>
<h1 className="text-3xl font-bold font-heading mb-3">Contact</h1>
<p className="text-muted-foreground">
Send us a note and we'll get back to you within one business day.
</p>
</div>
</div>
</>
)
}
```
A few things worth pointing out:
- **`PageMeta`** sets the document title and description. Every page should have one. It's how you keep tab titles informative without scattering `<Helmet>` calls everywhere.
- **`min-h-screen bg-background`** ensures the page fills the viewport with the brand background color. Critical for public pages that don't sit inside an app layout.
- **`mx-auto max-w-xl`** caps line length around 6575 characters of body text, per the shared design laws. `max-w-xl` is ~36rem; for the form we'll keep at this width.
- **`font-heading`** maps to the heading font defined in `frontend/src/index.css`. Use it on H1s, not body text.
Save the file. Nothing visible happens yet: we haven't told the router that `/contact` exists.
---
## Step 3: Wire up the route
Open `frontend/src/router.tsx`. Near the top of the file, you'll see a list of `lazyWithRetry` imports for every page. Add yours, alphabetized in the public-page group:
```tsx
const ContactPage = lazyWithRetry(() => import('@/pages/ContactPage'))
```
`lazyWithRetry` is a thin wrapper around React's `lazy()` that retries once if the chunk fails to load (which can happen during a deploy). Use it for everything; never plain `lazy()`.
Now scroll down to the `sentryCreateBrowserRouter` array and add a route entry next to the other public ones (`/landing`, `/privacy`, `/terms`):
```tsx
{
path: '/contact',
element: page(ContactPage),
errorElement: <RouteError />,
},
```
The `page()` helper wraps the component in `<ErrorBoundary>` and `<Suspense fallback={<PageLoader />}>`. That gives you a graceful loader while the chunk loads and an error boundary if something throws. The `errorElement: <RouteError />` handles router-level errors (e.g., a 404 thrown deeper in the tree).
Save. Vite reloads. Navigate to `http://your-dev-host:5173/contact` (or whatever URL serves the dev frontend). You should see the heading, the description, and the back-to-home link.
> If you see a blank page or an error, check the browser console first. The two common mistakes here are: (1) wrong import path, (2) forgetting `export default`. Fix and re-save.
---
## Step 4: Add the form
Now we add the actual contact form. Replace the body of the page (everything inside `max-w-xl`) with the form scaffolding. Keep imports for now; we'll add more in the next step.
```tsx
<div className="mx-auto max-w-xl px-6 py-16">
<Link
to="/landing"
className="mb-8 inline-block text-sm text-muted-foreground hover:text-foreground"
>
&larr; Back to home
</Link>
<h1 className="text-3xl font-bold font-heading mb-3">Contact</h1>
<p className="text-muted-foreground mb-10">
Send us a note and we'll get back to you within one business day.
</p>
<form className="space-y-5">
<div>
<label htmlFor="contact-name" className="block text-sm font-medium text-foreground">
Name
</label>
<input
id="contact-name"
type="text"
required
className="mt-1 block w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary/30 focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/>
</div>
<div>
<label htmlFor="contact-email" className="block text-sm font-medium text-foreground">
Email
</label>
<input
id="contact-email"
type="email"
required
className="mt-1 block w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary/30 focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/>
</div>
<div>
<label htmlFor="contact-message" className="block text-sm font-medium text-foreground">
Message
</label>
<textarea
id="contact-message"
rows={6}
required
className="mt-1 block w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary/30 focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/>
</div>
<button
type="submit"
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-white hover:brightness-110 active:scale-[0.98]"
>
Send message
</button>
</form>
</div>
```
Notice what we **did not** do:
- No outer card wrapper (`rounded-2xl border bg-card p-6`). The page background and the centered `max-w-xl` container are enough structure. Wrapping a single form in a card adds chrome that says nothing. Per `PRODUCT.md`: *"Cards are the lazy answer."*
- No icons next to labels. The labels carry the meaning; icons would be decoration.
- No fancy gradient on the submit button. The accent color is reserved for ≤5% of the UI; one solid button is the pattern.
- No nested borders or shadows.
Save. The form renders. The fields are real HTML inputs: they accept focus, browser autofill works, validation messages appear if you submit empty.
> If your form fields look unstyled, check that the `className` strings copied without line breaks. Tailwind compiles class strings literally; a stray newline inside the quotes breaks every utility on that line.
The `inputClass` you see here is duplicated three times. That's intentional for the tutorial; repetition makes it easy to read. In real code you'd extract a constant once you have three matching calls. Look at `frontend/src/pages/account/ProfileSettingsPage.tsx` for the project's existing convention.
---
## Step 5: Manage form state
Right now the inputs are uncontrolled (the browser owns their values) and submitting reloads the page. We need React state so we can read the values, validate them, and prevent the default submit.
At the top of the file, add `useState`:
```tsx
import { useState } from 'react'
```
Inside the component, above the `return`, add three pieces of state and a submit handler:
```tsx
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [message, setMessage] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim() || !email.trim() || !message.trim()) return
setIsSubmitting(true)
try {
// Replaced with a real API call in Step 7.
await new Promise((resolve) => setTimeout(resolve, 600))
// Success handling lands in Step 6.
} finally {
setIsSubmitting(false)
}
}
```
Then wire the inputs and the form:
```tsx
<form className="space-y-5" onSubmit={handleSubmit}>
<div>
<label htmlFor="contact-name" /* ... */>Name</label>
<input
id="contact-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="..."
/>
</div>
{/* ... same pattern for email and message ... */}
<button
type="submit"
disabled={isSubmitting}
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-white hover:brightness-110 active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Sending…' : 'Send message'}
</button>
</form>
```
What changed:
- **`value` + `onChange`** makes each input a controlled component. React owns the truth; the input mirrors it.
- **`e.preventDefault()`** stops the browser's default form submit (which would do a full page reload).
- **`isSubmitting`** disables the button during the in-flight request and swaps the label. Users get immediate feedback that something happened.
- **The trim() guards** catch empty submissions even when the browser's `required` attribute is bypassed (e.g., autofill anomalies).
Save. Try typing in the fields. Click Send message. The button briefly says "Sending…" then re-enables. Nothing user-visible happens after that yet. That's the next step.
---
## Step 6: Show a success state
When the submit succeeds, the form should disappear and a confirmation should take its place. That's both a clearer signal and a stronger feeling than a toast that vanishes after three seconds.
Add one more piece of state:
```tsx
const [submitted, setSubmitted] = useState(false)
```
Update the submit handler so it flips `submitted` on success:
```tsx
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim() || !email.trim() || !message.trim()) return
setIsSubmitting(true)
try {
await new Promise((resolve) => setTimeout(resolve, 600))
setSubmitted(true)
} finally {
setIsSubmitting(false)
}
}
```
Now branch the JSX so the form renders only when `!submitted`:
```tsx
{submitted ? (
<div className="rounded-lg border border-border bg-card/50 p-6">
<h2 className="text-lg font-semibold text-foreground">Message sent</h2>
<p className="mt-2 text-sm text-muted-foreground">
Thanks, {name.trim()}. We'll reply at{' '}
<span className="text-foreground">{email.trim()}</span> within one business day.
</p>
<button
type="button"
onClick={() => {
setName('')
setEmail('')
setMessage('')
setSubmitted(false)
}}
className="mt-4 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
Send another message
</button>
</div>
) : (
<form className="space-y-5" onSubmit={handleSubmit}>
{/* ...form contents... */}
</form>
)}
```
A few teaching moments here:
- **The success state is a single bordered region**, not a confetti card with a check icon. PRODUCT.md's tone is "competent, no fluff."
- **It echoes the user's name and email back** so they know the right address received their message. This is a small touch that builds trust.
- **There's a "Send another message" affordance** that resets the form. Don't trap users in success. Give them a way back.
Save. Submit the form. The fields disappear and the confirmation appears. Click "Send another message" and you're back to the empty form.
---
## Step 7: Wire it to a real API endpoint
So far the submit is a mock 600ms delay. To make it real, we need three things: an API endpoint, a frontend client function, and updated error handling.
The backend endpoint setup is its own tutorial; for now we'll add the frontend client and call a not-yet-existing path, so the call fails gracefully with a toast. When the backend lands, you change one line of your client and you're done.
Create `frontend/src/api/contact.ts`:
```ts
import { apiClient } from './client'
export const contactApi = {
submit: (data: { name: string; email: string; message: string }) =>
apiClient.post('/contact', data).then((r) => r.data),
}
```
That's the whole pattern. `apiClient` is a pre-configured Axios instance from `frontend/src/api/client.ts` with the base URL, auth, and error interceptors already wired. Every API module in `frontend/src/api/` follows this same shape. Read `frontend/src/api/betaFeedback.ts` to see another minimal example.
Now in `ContactPage.tsx`, swap the mock for a real call. Add to imports:
```tsx
import { contactApi } from '@/api/contact'
import { toast } from '@/lib/toast'
```
Update the submit handler:
```tsx
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim() || !email.trim() || !message.trim()) return
setIsSubmitting(true)
try {
await contactApi.submit({
name: name.trim(),
email: email.trim(),
message: message.trim(),
})
setSubmitted(true)
} catch (err) {
console.error('Failed to send contact message:', err)
toast.error("We couldn't send your message. Please try again.")
} finally {
setIsSubmitting(false)
}
}
```
What this gets you:
- Backend errors (500, network failure, etc.) show a toast and keep the form filled. The user can retry without retyping.
- The success path only fires if the API call succeeds, with no false positives.
- `toast` comes from `@/lib/toast`, the project's wrapper around Sonner. It's themed and consistent with every other toast in the app.
Save. Submit the form. Because there's no `/contact` backend endpoint yet, the call will fail and you'll see an error toast. That's correct behavior. The frontend is doing exactly what it should. When someone implements the backend, no frontend change is required.
---
## Step 8: Link from the landing page
A page that nobody can reach isn't a page. Open `frontend/src/pages/LandingPage.tsx` and find the `<footer>` section near the bottom (search for `landing-footer`). Add a `Contact` link next to the existing Privacy and Terms links. The exact markup depends on the surrounding code, but the pattern looks like:
```tsx
<Link to="/contact" className="...">
Contact
</Link>
```
Match the styling of the adjacent links. Don't invent a new visual treatment. Consistency is what makes a footer feel like a footer.
Save. Reload `/landing`. The Contact link appears in the footer. Click it. The contact page loads.
---
## Step 9: Verify your work
You're not done until the toolchain agrees with you. Run all three from the repo root:
```bash
docker exec resolutionflow_frontend npx tsc --noEmit
docker exec resolutionflow_frontend npx eslint src/pages/ContactPage.tsx src/api/contact.ts
docker exec resolutionflow_frontend npx vite build
```
All three should pass with no errors. (Vite may print pre-existing chunk-size warnings; those are unrelated to your change.)
Then go through the page in the browser one more time:
- [ ] Empty submit attempts are blocked by the browser (`required` attribute) and by your `trim()` guard
- [ ] Filling all three fields and submitting shows "Sending…" briefly, then either a success state or an error toast (depending on whether the backend exists)
- [ ] The "Send another message" button on the success state clears the form and brings the inputs back
- [ ] The back-to-home link returns you to `/landing`
- [ ] The footer link from `/landing` brings you to `/contact`
If any of those don't work, fix them before continuing. Don't ship a tutorial-shaped bug.
---
## Step 10: Commit
The project's commit conventions live in `CLAUDE.md`. Follow them:
```bash
git add frontend/src/pages/ContactPage.tsx \
frontend/src/api/contact.ts \
frontend/src/router.tsx \
frontend/src/pages/LandingPage.tsx
git commit -m "feat(contact): add public Contact page with submit form
Add /contact at the public marketing layer (parallel to /privacy,
/terms). Single-column form with controlled inputs, success state
that echoes the submitter's name and email, error toast on submit
failure. Backend endpoint POST /contact is referenced but not yet
implemented; submits will toast-error until it lands.
Linked from the landing page footer.
"
```
If the project requires a co-author trailer (check `CLAUDE.md`), add it. Don't push directly to `main` if it's a protected branch; branch first, push, open a PR.
---
## What you learned
You touched every layer of a public-facing page:
- **Routing** (`router.tsx` + `lazyWithRetry` + `page()`)
- **Page composition** (`PageMeta` + layout primitives)
- **Design system tokens** (`bg-background`, `text-foreground`, `border-border`, `bg-primary`)
- **Form state** (controlled inputs, submit guards, in-flight feedback)
- **API clients** (`frontend/src/api/`, `apiClient`)
- **Error UX** (toast on failure, success state on… success)
- **Verification** (tsc, eslint, build, manual browser pass)
The pattern transfers. An in-app page (under `/account`, `/sessions`, etc.) is the same set of moves with one difference: it sits inside the app shell instead of standing alone, so the route is nested and you skip the `min-h-screen bg-background` outer wrapper.
---
## Where to go next
- **Read** `frontend/src/pages/account/ProfileSettingsPage.tsx` for the in-app form convention with shared `inputClass` and a save-changes pattern.
- **Read** `PRODUCT.md` and `DESIGN-SYSTEM.md` end-to-end. They're short and they're the source of truth for "is this design right?"
- **Try** building a second page on your own. Pick a simple one like a `/changelog` route that just lists releases. Apply what you learned without rereading this tutorial.
- **Skim** `frontend/src/pages/account/IntegrationsPage.tsx` once you're comfortable with the basics; it's a real working complex page that exercises forms, API state, optimistic updates, and modals together.

View File

@@ -0,0 +1,49 @@
import apiClient from './client'
export interface ActiveUser {
user_id: string
name: string
email: string
last_login_at: string | null
}
export interface SessionPolicyResponse {
idle_minutes: number | null
absolute_minutes: number | null
effective_idle_minutes: number
effective_absolute_minutes: number
idle_minutes_min: number
idle_minutes_max: number
absolute_minutes_min: number
absolute_minutes_max: number
active_users: ActiveUser[]
}
export interface SessionPolicyUpdateRequest {
idle_minutes: number | null
absolute_minutes: number | null
}
export interface RevokeSessionsResponse {
revoked_count: number
}
export const accountSecurityApi = {
async get(): Promise<SessionPolicyResponse> {
const response = await apiClient.get<SessionPolicyResponse>('/accounts/me/security')
return response.data
},
async update(body: SessionPolicyUpdateRequest): Promise<SessionPolicyResponse> {
const response = await apiClient.patch<SessionPolicyResponse>('/accounts/me/security', body)
return response.data
},
async revokeSessions(scope: 'all' | 'others'): Promise<RevokeSessionsResponse> {
const response = await apiClient.post<RevokeSessionsResponse>(
'/accounts/me/security/revoke-sessions',
{ scope },
)
return response.data
},
}

View File

@@ -6,6 +6,8 @@ export interface OAuthCallbackResponse {
refresh_token: string
token_type: string
is_new_user: boolean
idle_expires_at?: string | null
absolute_expires_at?: string | null
}
export const authApi = {

View File

@@ -136,15 +136,18 @@ apiClient.interceptors.response.use(
},
})
const { access_token, refresh_token } = response.data
const { access_token, refresh_token, idle_expires_at, absolute_expires_at } = response.data
localStorage.setItem('access_token', access_token)
localStorage.setItem('refresh_token', refresh_token)
// Sync Zustand auth store
// Sync Zustand auth store — include the new expiry fields so
// useAuthSessionExpiry stays accurate after each refresh.
useAuthStore.getState().setTokens({
access_token,
refresh_token,
token_type: 'bearer',
idle_expires_at,
absolute_expires_at,
})
isRefreshing = false
@@ -159,11 +162,28 @@ apiClient.interceptors.response.use(
isRefreshing = false
onRefreshFailed(refreshError)
// Refresh failed — clear tokens and redirect to login
// Refresh failed — clear tokens and redirect to login. The redirect
// target depends on WHY the refresh failed (plan §4.10):
// - session_expired_idle / session_expired_absolute: the user hit a
// policy boundary. Show the calm "session ended for security"
// banner via ?reason=session_expired.
// - invalid_refresh_token (or anything else): plain logout, no
// banner — the user wasn't kicked by policy, the token just
// wasn't recognized.
const refreshAxiosErr = refreshError as AxiosError
const refreshDetail = (refreshAxiosErr.response?.data as { detail?: string })?.detail
const isPolicyExpiry =
refreshDetail === 'session_expired_idle' ||
refreshDetail === 'session_expired_absolute'
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
useAuthStore.getState().logout()
window.location.href = '/login'
if (!window.location.pathname.startsWith('/login')) {
window.location.href = isPolicyExpiry
? '/login?reason=session_expired'
: '/login'
}
return Promise.reject(refreshError)
}
}

View File

@@ -0,0 +1,89 @@
import { useState } from 'react'
import { Modal } from '@/components/common/Modal'
import { cn } from '@/lib/utils'
interface RevokeSessionsModalProps {
isOpen: boolean
onClose: () => void
onConfirm: () => Promise<void>
scope: 'all' | 'others'
activeUserCount: number
}
/**
* Confirmation modal for bulk session revocation. Two scopes:
*
* - "others" — revokes other users' sessions, caller stays signed in.
* - "all" — revokes everyone including the caller; the parent handles
* the post-revoke auto-redirect to /login (see plan §4.8 D4).
*/
export function RevokeSessionsModal({
isOpen,
onClose,
onConfirm,
scope,
activeUserCount,
}: RevokeSessionsModalProps) {
const [busy, setBusy] = useState(false)
const isAll = scope === 'all'
const otherCount = isAll ? activeUserCount : Math.max(activeUserCount - 1, 0)
const title = isAll ? 'Sign out everyone?' : 'Sign out other users?'
const body = isAll
? `This signs out all ${activeUserCount} active users including yourself. Everyone will need to sign in again.`
: `This signs out the ${otherCount} other active users in your account. They'll need to sign in again. You stay signed in.`
const confirmLabel = isAll
? 'Sign out everyone'
: otherCount === 1
? 'Sign out 1 user'
: `Sign out ${otherCount} users`
const handleConfirm = async () => {
setBusy(true)
try {
await onConfirm()
} finally {
setBusy(false)
}
}
return (
<Modal
isOpen={isOpen}
onClose={busy ? () => undefined : onClose}
title={title}
size="sm"
footer={
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onClose}
disabled={busy}
className={cn(
'rounded-md border px-4 py-2 text-sm font-medium',
'border-border text-foreground hover:bg-card-hover',
'disabled:opacity-50',
)}
>
Cancel
</button>
<button
type="button"
onClick={handleConfirm}
disabled={busy}
className={cn(
'rounded-md border px-4 py-2 text-sm font-medium',
'border-danger/40 bg-danger/10 text-danger hover:bg-danger/15',
'disabled:opacity-50',
)}
>
{busy ? 'Working…' : confirmLabel}
</button>
</div>
}
>
<p className="text-sm text-foreground">{body}</p>
</Modal>
)
}

View File

@@ -0,0 +1,35 @@
import { Link } from 'react-router-dom'
// Styles live in src/styles/landing.css under `.landing-footer*`. The component
// must be rendered inside a `.landing-page` wrapper so the `--lp-*` CSS
// variables resolve. All current marketing surfaces (LandingPage,
// PricingPage, ContactSalesPage) already provide that wrapper.
export function MarketingFooter() {
return (
<footer className="landing-footer">
<div className="landing-footer-inner">
<div className="landing-footer-left">
<div className="landing-nav-logo-icon" style={{ width: 24, height: 24, borderRadius: 6 }}>
<svg viewBox="0 0 24 24" fill="none" stroke="#000" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ width: 14, height: 14 }}>
<circle cx="12" cy="5" r="2" />
<line x1="12" y1="7" x2="12" y2="11" />
<circle cx="6" cy="15" r="2" />
<circle cx="18" cy="15" r="2" />
<line x1="12" y1="11" x2="6" y2="13" />
<line x1="12" y1="11" x2="18" y2="13" />
</svg>
</div>
<span className="landing-footer-copy">&copy; 2026 ResolutionFlow</span>
</div>
<ul className="landing-footer-links">
<li><Link to="/privacy">Privacy</Link></li>
<li><Link to="/terms">Terms</Link></li>
<li><Link to="/policies">Policies</Link></li>
<li><Link to="/contact">Contact</Link></li>
</ul>
</div>
</footer>
)
}
export default MarketingFooter

View File

@@ -8,6 +8,7 @@ interface PageMetaProps {
}
const SITE_NAME = 'ResolutionFlow'
const DEFAULT_TAGLINE = 'AI-Powered Troubleshooting for MSPs'
const DEFAULT_DESCRIPTION = 'Transform troubleshooting into guided workflows with automatic documentation'
/**
@@ -20,7 +21,7 @@ export function PageMeta({
ogImage,
ogType = 'website',
}: PageMetaProps) {
const fullTitle = title ? `${title} | ${SITE_NAME}` : `${SITE_NAME} - Decision Tree Platform`
const fullTitle = title ? `${title} | ${SITE_NAME}` : `${SITE_NAME} ${DEFAULT_TAGLINE}`
return (
<Helmet>

View File

@@ -0,0 +1,125 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { AlertCircle, Info, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAuthSessionExpiry } from '@/hooks/useAuthSessionExpiry'
import { authApi } from '@/api/auth'
import { useAuthStore } from '@/store/authStore'
/**
* Top-of-app notice that fires when the session is within 5 minutes of
* idle OR absolute expiry. Behavior differs by which window is closer
* (per docs/plans/2026-05-13-session-expiration-policy.md §4.8):
*
* - Idle: warning-amber tone, "Stay signed in" button hits /auth/refresh.
* - Absolute: info-cyan tone, no action — re-auth is required.
*
* Persists until the user dismisses, refreshes, or the window expires.
*/
export function SessionExpiryToast() {
const { warning, reason, idleExpiresAt, absoluteExpiresAt } = useAuthSessionExpiry()
const setTokens = useAuthStore((s) => s.setTokens)
const navigate = useNavigate()
const [busy, setBusy] = useState(false)
const [dismissed, setDismissed] = useState(false)
if (warning !== 'soon' || dismissed) return null
const handleStay = async () => {
setBusy(true)
try {
const refreshed = await authApi.refresh()
localStorage.setItem('access_token', refreshed.access_token)
localStorage.setItem('refresh_token', refreshed.refresh_token)
setTokens(refreshed)
setDismissed(true)
} catch {
// The axios interceptor handles the redirect on session_expired_*;
// if we land here, something else went wrong — just close the toast.
setDismissed(true)
} finally {
setBusy(false)
}
}
const handleSignInNow = () => navigate('/login')
// ── Format the deadline for the absolute case ──
const deadline = reason === 'idle' ? idleExpiresAt : absoluteExpiresAt
const deadlineLabel = deadline
? deadline.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
: ''
const isIdle = reason === 'idle'
const Icon = isIdle ? AlertCircle : Info
return (
<div
role="status"
aria-live="polite"
className={cn(
'fixed top-4 right-4 z-50 max-w-md rounded-lg border p-4 shadow-lg',
'flex items-start gap-3',
isIdle
? 'bg-warning-dim border-warning/30 text-warning'
: 'bg-info-dim border-info/30 text-info',
)}
>
<Icon size={18} className="mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">
{isIdle
? 'Your session times out in 5 minutes.'
: `Your session ends at ${deadlineLabel} for security.`}
</p>
<p className="mt-0.5 text-xs opacity-90">
{isIdle
? 'Click to stay signed in.'
: "You'll need to sign in again."}
</p>
<div className="mt-2 flex items-center gap-2">
{isIdle ? (
<button
type="button"
onClick={handleStay}
disabled={busy}
className={cn(
'rounded-md px-3 py-1.5 text-xs font-medium',
'bg-warning/20 text-warning border border-warning/40 hover:bg-warning/30',
'disabled:opacity-50',
)}
>
{busy ? 'Refreshing…' : 'Stay signed in'}
</button>
) : (
<button
type="button"
onClick={handleSignInNow}
className={cn(
'rounded-md px-3 py-1.5 text-xs font-medium',
'bg-info/20 text-info border border-info/40 hover:bg-info/30',
)}
>
Sign in now
</button>
)}
<button
type="button"
onClick={() => setDismissed(true)}
className="text-xs opacity-70 hover:opacity-100"
>
Dismiss
</button>
</div>
</div>
<button
type="button"
onClick={() => setDismissed(true)}
aria-label="Dismiss"
className="shrink-0 opacity-70 hover:opacity-100"
>
<X size={14} />
</button>
</div>
)
}

View File

@@ -6,6 +6,7 @@ import type { OnboardingStatus } from '@/api/onboarding'
import { useTrialBanner } from '@/hooks/useTrialBanner'
import type { TrialBannerStage } from '@/hooks/useTrialBanner'
import { useOnboardingStatus } from '@/hooks/useOnboardingStatus'
import { FOCUS_START_SESSION_EVENT } from '@/components/dashboard/StartSessionInput'
/**
* Next-step card — surfaces the single highest-priority incomplete onboarding
@@ -114,9 +115,10 @@ export function pickNextStep(
export function NextStepCard() {
const status = useOnboardingStatus()
const [locallyDismissed, setLocallyDismissed] = useState(false)
const [locallyHidden, setLocallyHidden] = useState(false)
const { stage } = useTrialBanner()
if (!status || status.dismissed || locallyDismissed) return null
if (!status || status.dismissed || locallyDismissed || locallyHidden) return null
const next = pickNextStep(status, stage)
if (!next) return null
@@ -154,14 +156,29 @@ export function NextStepCard() {
</button>
</div>
<div className="mt-3">
<Link
to={next.ctaPath}
data-testid="next-step-cta"
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{next.ctaLabel}
<ArrowRight size={14} />
</Link>
{next.key === 'ran_session' ? (
<button
type="button"
onClick={() => {
window.dispatchEvent(new Event(FOCUS_START_SESSION_EVENT))
setLocallyHidden(true)
}}
data-testid="next-step-cta"
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{next.ctaLabel}
<ArrowRight size={14} />
</button>
) : (
<Link
to={next.ctaPath}
data-testid="next-step-cta"
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{next.ctaLabel}
<ArrowRight size={14} />
</Link>
)}
</div>
</div>
)

View File

@@ -5,6 +5,7 @@ import type { OnboardingStatus } from '@/api/onboarding'
import { useTrialBanner } from '@/hooks/useTrialBanner'
import type { TrialBannerStage } from '@/hooks/useTrialBanner'
import { useOnboardingStatus } from '@/hooks/useOnboardingStatus'
import { FOCUS_START_SESSION_EVENT } from '@/components/dashboard/StartSessionInput'
/**
* Unified setup checklist — single list (no SOLO/TEAM bifurcation).
@@ -112,6 +113,21 @@ export function SetupChecklist() {
{item.label}
</span>
</div>
) : item.key === 'ran_session' ? (
<button
type="button"
onClick={() => window.dispatchEvent(new Event(FOCUS_START_SESSION_EVENT))}
className={cn(
'w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors text-left',
'hover:bg-[rgba(255,255,255,0.04)]',
)}
data-testid={`checklist-item-${item.key}`}
data-done="false"
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-border" />
<span className="flex-1 text-foreground">{item.label}</span>
<ChevronRight size={14} className="text-muted-foreground shrink-0" />
</button>
) : (
<Link
to={item.path}

View File

@@ -18,19 +18,38 @@ const SUGGESTIONS: { icon: LucideIcon; label: string }[] = [
const ACCEPTED_FILE_TYPES = 'image/png,image/jpeg,image/gif,image/webp,.txt,.log,.csv,.pdf,.docx'
export const FOCUS_START_SESSION_EVENT = 'rf:focus-start-session'
export function StartSessionInput() {
const [value, setValue] = useState('')
const [showLogs, setShowLogs] = useState(false)
const [logContent, setLogContent] = useState('')
const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([])
const [isDragOver, setIsDragOver] = useState(false)
const [nudge, setNudge] = useState(false)
const navigate = useNavigate()
const wrapperRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const dragCounterRef = useRef(0)
useEffect(() => { textareaRef.current?.focus() }, [])
// External "focus me" trigger (e.g. NextStepCard "Start a session" CTA on
// the same page). Scrolls into view, focuses the textarea, and pulses a
// ring so the click feels intentional even when the input was already
// partially visible.
useEffect(() => {
const handler = () => {
wrapperRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
textareaRef.current?.focus({ preventScroll: true })
setNudge(true)
window.setTimeout(() => setNudge(false), 900)
}
window.addEventListener(FOCUS_START_SESSION_EVENT, handler)
return () => window.removeEventListener(FOCUS_START_SESSION_EVENT, handler)
}, [])
// Auto-grow textarea
useEffect(() => {
const el = textareaRef.current
@@ -190,7 +209,8 @@ export function StartSessionInput() {
return (
<div
className="w-full"
ref={wrapperRef}
className="w-full scroll-mt-6"
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
@@ -198,9 +218,11 @@ export function StartSessionInput() {
>
{/* Main input area */}
<div className={cn(
'relative rounded-2xl border bg-card transition-all',
'relative rounded-2xl border bg-card transition-all duration-300',
isDragOver ? 'border-primary/50 bg-primary/5' : 'border-border',
'focus-within:border-[rgba(96,165,250,0.25)] focus-within:ring-1 focus-within:ring-[rgba(96,165,250,0.1)]'
nudge
? 'border-[rgba(96,165,250,0.6)] ring-2 ring-[rgba(96,165,250,0.35)] shadow-[0_0_0_6px_rgba(96,165,250,0.12)]'
: 'focus-within:border-[rgba(96,165,250,0.25)] focus-within:ring-1 focus-within:ring-[rgba(96,165,250,0.1)]'
)}>
{/* Drag overlay */}
{isDragOver && (

View File

@@ -12,6 +12,7 @@ import { EmailVerificationBanner } from './EmailVerificationBanner'
import { EmailVerificationGate } from '@/components/common/EmailVerificationGate'
import { ViewTransitionOutlet } from './ViewTransitionOutlet'
import { FeedbackWidget } from '@/components/common/FeedbackWidget'
import { SessionExpiryToast } from '@/components/common/SessionExpiryToast'
import { cn } from '@/lib/utils'
export function AppLayout() {
@@ -69,6 +70,7 @@ export function AppLayout() {
return (
<>
<SessionExpiryToast />
<div
className={cn('app-shell relative z-1', sidebarPinned && 'app-shell--pinned')}
data-testid="app-shell"

View File

@@ -1,12 +1,12 @@
import { cn } from '@/lib/utils'
interface CheckoutButtonProps {
plan: 'pro' | 'team'
plan: 'starter' | 'pro' | 'enterprise'
className?: string
}
export function CheckoutButton({ plan, className }: CheckoutButtonProps) {
const planLabels = { pro: 'Pro', team: 'Team' }
const planLabels = { starter: 'Starter', pro: 'Pro', enterprise: 'Enterprise' }
return (
<button

View File

@@ -0,0 +1,69 @@
import { useEffect, useReducer } from 'react'
import { useAuthStore } from '@/store/authStore'
const SOON_MS = 5 * 60 * 1000 // 5 minutes
export type ExpiryWarning = 'none' | 'soon' | 'now'
export type ExpiryReason = 'idle' | 'absolute'
interface ExpiryState {
idleExpiresAt: Date | null
absoluteExpiresAt: Date | null
warning: ExpiryWarning
/**
* Which window is the closer (and therefore the active) deadline. Used by
* SessionExpiryToast to pick the right copy + action button: idle gets
* "Stay signed in" (calls /auth/refresh); absolute is informational only.
*/
reason: ExpiryReason | null
}
function computeState(token: ReturnType<typeof useAuthStore.getState>['token']): ExpiryState {
const idleStr = token?.idle_expires_at
const absStr = token?.absolute_expires_at
if (!idleStr || !absStr) {
return { idleExpiresAt: null, absoluteExpiresAt: null, warning: 'none', reason: null }
}
const idle = new Date(idleStr)
const abs = new Date(absStr)
const now = Date.now()
const idleMs = idle.getTime() - now
const absMs = abs.getTime() - now
// Closer window wins.
const reason: ExpiryReason = idleMs <= absMs ? 'idle' : 'absolute'
const closestMs = Math.min(idleMs, absMs)
let warning: ExpiryWarning = 'none'
if (closestMs <= 0) warning = 'now'
else if (closestMs <= SOON_MS) warning = 'soon'
return { idleExpiresAt: idle, absoluteExpiresAt: abs, warning, reason }
}
/**
* Track how close the active session is to its idle/absolute deadline.
*
* Returns `warning: "soon"` within 5 min of whichever window comes first,
* and `reason: "idle" | "absolute"` so callers can choose the right UX
* (idle is recoverable via /auth/refresh; absolute is not). Re-evaluates
* every 30 seconds while authenticated; cheap (single Date subtraction).
*
* See docs/plans/2026-05-13-session-expiration-policy.md §4.8.
*/
export function useAuthSessionExpiry(): ExpiryState {
const token = useAuthStore((s) => s.token)
// Derived state — computed during render, not synced via setState in an
// effect. The reducer here is only a tick counter that forces re-render on
// the 30s cadence so the relative timestamps stay current. See React 19
// guidance: https://react.dev/learn/you-might-not-need-an-effect
const [, tick] = useReducer((n: number) => n + 1, 0)
useEffect(() => {
if (!token?.idle_expires_at || !token?.absolute_expires_at) return
const interval = window.setInterval(tick, 30_000)
return () => window.clearInterval(interval)
}, [token])
return computeState(token)
}

View File

@@ -8,7 +8,7 @@ export function useSubscription() {
const usage = subscription?.usage ?? null
const isActive = subscription?.subscription.status === 'active' || subscription?.subscription.status === 'trialing'
const isPaidPlan = plan === 'pro' || plan === 'team'
const isPaidPlan = plan === 'pro' || plan === 'starter' || plan === 'enterprise'
const canUseFeature = (feature: 'custom_branding' | 'priority_support'): boolean => {
if (!limits) return false

View File

@@ -17,6 +17,7 @@ import {
Plug,
RefreshCw,
Server,
Shield,
UserCog,
X,
} from 'lucide-react'
@@ -418,10 +419,10 @@ export function AccountSettingsPage() {
<p className="text-sm text-muted-foreground">Plan limits unavailable.</p>
)}
{plan !== 'team' && (
{plan !== 'enterprise' && (
<div className="flex flex-wrap justify-end gap-2 pt-2">
{plan === 'free' && <CheckoutButton plan="pro" />}
<CheckoutButton plan="team" />
<CheckoutButton plan="enterprise" />
</div>
)}
</section>
@@ -632,6 +633,12 @@ export function AccountSettingsPage() {
title="Chat retention"
description="Conversation retention and assistant data lifecycle"
/>
<SettingsRow
to="/account/security"
icon={<Shield className="h-4 w-4" />}
title="Session security"
description="Session-expiration policy and active sessions"
/>
<SettingsRow
to="/account/categories"
icon={<FolderTree className="h-4 w-4" />}

View File

@@ -0,0 +1,88 @@
import { Link } from 'react-router-dom'
import { PageMeta } from '@/components/common/PageMeta'
export default function ContactPage() {
return (
<>
<PageMeta title="Contact" description="Contact ResolutionFlow customer service, sales, billing, or security." />
<div className="min-h-screen bg-background text-foreground">
<div className="mx-auto max-w-3xl px-6 py-16">
<Link to="/landing" className="text-sm text-muted-foreground hover:text-foreground mb-8 inline-block">&larr; Back to home</Link>
<h1 className="text-3xl font-bold font-heading mb-4">Contact ResolutionFlow</h1>
<p className="text-muted-foreground mb-10">
We respond to customer inquiries Monday through Friday during U.S. business hours, excluding federal holidays. Email is the fastest path to a response.
</p>
<div className="space-y-8 text-muted-foreground leading-relaxed">
<section>
<h2 className="text-xl font-semibold text-foreground mb-3">Phone</h2>
<p>
<a href="tel:+14709494131" className="text-primary hover:underline">(470) 949-4131</a>
</p>
<p className="text-sm mt-1">Monday&ndash;Friday, 9:00 AM&ndash;5:00 PM ET, excluding U.S. federal holidays.</p>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground mb-3">Email</h2>
<ul className="space-y-2">
<li>
<strong className="text-foreground">General support:</strong>{' '}
<a href="mailto:support@resolutionflow.com" className="text-primary hover:underline">support@resolutionflow.com</a>
</li>
<li>
<strong className="text-foreground">Sales and Enterprise:</strong>{' '}
<a href="mailto:sales@resolutionflow.com" className="text-primary hover:underline">sales@resolutionflow.com</a>
</li>
<li>
<strong className="text-foreground">Billing and account:</strong>{' '}
<a href="mailto:billing@resolutionflow.com" className="text-primary hover:underline">billing@resolutionflow.com</a>
</li>
<li>
<strong className="text-foreground">Security and privacy:</strong>{' '}
<a href="mailto:security@resolutionflow.com" className="text-primary hover:underline">security@resolutionflow.com</a>
</li>
</ul>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground mb-3">Response times</h2>
<ul className="list-disc list-inside space-y-1">
<li>General support: within one (1) business day</li>
<li>Billing or account access: within one (1) business day</li>
<li>Security disclosures: within twenty-four (24) hours, including weekends</li>
</ul>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground mb-3">Mailing address</h2>
{/* TODO: replace with full mailing address once P.O. Box is set up. */}
<p>
Available on request. Email{' '}
<a href="mailto:support@resolutionflow.com" className="text-primary hover:underline">support@resolutionflow.com</a>{' '}
and we will provide our current mailing address.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground mb-3">Sales and demos</h2>
<p>
Interested in a guided demo or Enterprise pricing? Use our{' '}
<Link to="/contact-sales" className="text-primary hover:underline">sales contact form</Link>{' '}
to book a time directly.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground mb-3">Related</h2>
<ul className="list-disc list-inside space-y-1">
<li><Link to="/policies" className="text-primary hover:underline">Customer Policies</Link> &mdash; billing, refunds, cancellation, and promotions</li>
<li><Link to="/terms" className="text-primary hover:underline">Terms of Service</Link></li>
<li><Link to="/privacy" className="text-primary hover:underline">Privacy Policy</Link></li>
</ul>
</section>
</div>
</div>
</div>
</>
)
}

View File

@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
import { salesApi, type SalesLeadSource } from '@/api/sales'
import { PageMeta } from '@/components/common/PageMeta'
import { MarketingFooter } from '@/components/common/MarketingFooter'
import { useAppConfig } from '@/hooks/useAppConfig'
import '@/styles/landing.css'
@@ -342,6 +343,8 @@ export function ContactSalesPage() {
</form>
)}
</section>
<MarketingFooter />
</main>
</div>
)

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import { PageMeta } from '@/components/common/PageMeta'
import { useAppConfig } from '@/hooks/useAppConfig'
import { MarketingFooter } from '@/components/common/MarketingFooter'
import '@/styles/landing.css'
const FAQ_ITEMS = [
@@ -15,7 +16,7 @@ const FAQ_ITEMS = [
},
{
q: 'What PSA tools do you integrate with?',
a: 'Launching with ConnectWise PSA \u2014 session documentation exports directly as internal ticket notes. Atera and Syncro integrations are next. During beta, you can copy formatted notes into any PSA.',
a: 'Launching with ConnectWise PSA session documentation exports directly as internal ticket notes. Atera and Syncro integrations are next. During beta, you can copy formatted notes into any PSA.',
},
{
q: 'What counts as a \u201csession\u201d?',
@@ -23,7 +24,7 @@ const FAQ_ITEMS = [
},
{
q: 'What if FlowPilot gets it wrong?',
a: 'FlowPilot is a copilot, not autopilot. Every suggestion is a recommendation \u2014 you decide what to act on. And because every step is documented, you always have a full audit trail of what was tried and why.',
a: 'FlowPilot is a copilot, not autopilot. Every suggestion is a recommendation you decide what to act on. And because every step is documented, you always have a full audit trail of what was tried and why.',
},
]
@@ -75,8 +76,8 @@ export default function LandingPage() {
return (
<>
<PageMeta
title="ResolutionFlow \u2014 From Issue to Resolution, Documented"
description="Your AI troubleshooting copilot. Describe the issue, get help fixing it, and get clean ticket notes \u2014 automatically."
title="ResolutionFlow From Issue to Resolution, Documented"
description="Your AI troubleshooting copilot. Describe the issue, get help fixing it, and get clean ticket notes automatically."
/>
<div className="landing-page">
@@ -410,29 +411,7 @@ export default function LandingPage() {
</div>
</section>
{/* Footer */}
<footer className="landing-footer">
<div className="landing-footer-inner">
<div className="landing-footer-left">
<div className="landing-nav-logo-icon" style={{ width: 24, height: 24, borderRadius: 6 }}>
<svg viewBox="0 0 24 24" fill="none" stroke="#000" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ width: 14, height: 14 }}>
<circle cx="12" cy="5" r="2" />
<line x1="12" y1="7" x2="12" y2="11" />
<circle cx="6" cy="15" r="2" />
<circle cx="18" cy="15" r="2" />
<line x1="12" y1="11" x2="6" y2="13" />
<line x1="12" y1="11" x2="18" y2="13" />
</svg>
</div>
<span className="landing-footer-copy">&copy; 2026 ResolutionFlow</span>
</div>
<ul className="landing-footer-links">
<li><Link to="/privacy">Privacy</Link></li>
<li><Link to="/terms">Terms</Link></li>
<li><a href="mailto:hello@resolutionflow.com">Contact</a></li>
</ul>
</div>
</footer>
<MarketingFooter />
</main>
</div>
</>

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import { Info } from 'lucide-react'
import { useAuthStore } from '@/store/authStore'
import { BrandLogo } from '@/components/common/BrandLogo'
import { PasswordInput } from '@/components/common/PasswordInput'
@@ -17,6 +18,11 @@ export function LoginPage() {
const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/'
// When the user lands here after the session-policy axios interceptor
// forcibly logged them out, show a calm info-tone banner above the form.
// See docs/plans/2026-05-13-session-expiration-policy.md §4.8.
const showSessionExpiredBanner = new URLSearchParams(location.search).get('reason') === 'session_expired'
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLocalError('')
@@ -60,6 +66,15 @@ export function LoginPage() {
</p>
</div>
{showSessionExpiredBanner && (
<div className="rounded-md border border-info/30 bg-info-dim px-4 py-3 flex items-start gap-2">
<Info size={16} className="text-info mt-0.5 shrink-0" />
<p className="text-sm text-info">
You were signed out for security. Sign back in to continue.
</p>
</div>
)}
<form onSubmit={handleSubmit} className="mt-8 space-y-6" data-testid="login-form">
<div className="card-flat p-6 space-y-4">
{(error || localError) && (

Some files were not shown because too many files have changed in this diff Show More