Generated by the resolutionflow-legal skill from a code scan of the FastAPI
backend + React frontend on commit 0564646. Each document is a starting
point for attorney review, not legal advice.
Includes:
- privacy-policy.md, terms-of-service.md, cookie-policy.md (public-facing)
- dpa.md (contractual; signed with MSP customers)
- subprocessor-list.md (Railway, Anthropic, Voyage, Stripe, Resend, Sentry,
PostHog, Google Fonts — confirmed live as of scan)
- data-inventory.md + classification.md (Phase 1/2 working files)
- attorney-review-checklist.md (consolidated [LEGAL REVIEW] punch list)
- implementation-verification.md (claim-by-claim audit vs. actual code)
Three blocking issues filed before public publication:
- #175 deletion-on-offboarding (or rewrite retention claims)
- #176 narrow Sentry send_default_pii + Session Replay config
- #177 EU/UK consent for PostHog + Google Fonts
Public-facing documents intentionally route physical-mail requests through
support@ rather than publishing the LLC's registered address.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
The kb_setup fixture asserts free-plan quota numbers (lifetime_conversions_limit=3),
but Phase 1 conftest seeds test_user on Pro. Downgrade explicitly inside kb_setup
to preserve the original test intent without affecting other suites.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mounts on Pro routers (trees, sessions, scripts, FlowPilot, etc.) and
returns 402 with structured detail when an account's subscription is
missing or locked. Allowlist bypasses billing/account/auth flows so
users can recover from a lapsed subscription.
Conftest now seeds a default Pro/active Subscription on test_user and
test_admin (delete-then-insert because the register endpoint already
creates a free/active sub by default). Two existing tests adapted to
the new seeded plan; tenant-isolation tests seed Subscription rows for
the accounts they create directly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>