Skip to content

feat: enterprise-grade auth -- HttpOnly cookie sessions, CSRF, lockout, session limits#1102

Merged
Aureliolo merged 14 commits intomainfrom
feat/enterprise-auth
Apr 7, 2026
Merged

feat: enterprise-grade auth -- HttpOnly cookie sessions, CSRF, lockout, session limits#1102
Aureliolo merged 14 commits intomainfrom
feat/enterprise-auth

Conversation

@Aureliolo
Copy link
Copy Markdown
Owner

Summary

Enterprise-grade auth hardening for multi-user deployments. Replaces sessionStorage JWT tokens with HttpOnly cookie sessions, adds CSRF protection, concurrent session limits, account lockout, and refresh token infrastructure.

Backend

  • HttpOnly cookie sessions: Login/setup set Set-Cookie: session=<JWT>; HttpOnly; Secure; SameSite=Strict instead of returning JWT in response body. CookieSessionResponse replaces TokenResponse
  • CSRF middleware: Custom ASGI double-submit cookie pattern -- validates X-CSRF-Token header against csrf_token cookie on mutating requests. Skips API key requests (no cookie = no CSRF risk). Exempt paths for login/setup
  • Cookie-first auth middleware: Reads JWT from session cookie first, falls back to Authorization header for API keys and system user CLI JWTs
  • Concurrent session limits: max_concurrent_sessions config (default 5), enforce_session_limit() revokes oldest sessions on exceed
  • Account lockout: Hybrid in-memory + SQLite LockoutStore -- tracks failed login attempts per username, enforces temp lockout after threshold (default 10 in 15 min), timing-safe dummy hash on locked accounts, load_locked() restores state after restart
  • Refresh token store: SQLite-backed RefreshStore with HMAC-SHA256 hashed tokens, single-use consume via UPDATE...RETURNING (no TOCTOU), replay detection, session/user revocation cascade
  • Password change rotates cookie: New JWT + session cookies issued on password change
  • Logout: Clears all cookies + Clear-Site-Data: "cookies" header
  • AuthConfig: 15 new fields (cookie, CSRF, session limits, lockout, refresh) with NotBlankStr validation, cross-field validators

Frontend

  • Remove all sessionStorage token management: No more auth_token in sessionStorage, no expiry timer, no Authorization header injection
  • Tri-state auth: authStatus: 'unknown' | 'authenticated' | 'unauthenticated' replaces !!token check. checkSession() calls /auth/me on page load
  • CSRF interceptor: Reads non-HttpOnly csrf_token cookie, attaches X-CSRF-Token header on POST/PUT/PATCH/DELETE
  • withCredentials: true on Axios client + credentials: 'include' on SSE fetch
  • Route guards: Tri-state loading/redirect/render in AuthGuard/GuestGuard
  • Session management API: listSessions(scope), revokeSession(id), async logout()

Test coverage

  • 205 backend auth tests (60+ new), 2418 frontend tests pass
  • New test files: test_cookies.py, test_csrf.py, test_lockout_store.py, test_refresh_store.py
  • Updated: test_middleware.py, test_controller.py, test_config.py, test_session_store.py, test_app.py, test_errors.py, test_migrations.py, test_ws.py + 8 frontend test files

Review coverage

Pre-reviewed by 11 agents, 28 findings addressed:

  • Security reviewer, code reviewer, issue resolution verifier, silent failure hunter, type design analyzer, comment analyzer, test analyzer, async concurrency reviewer, API contract drift, frontend reviewer, docs consistency, persistence reviewer

Documentation

  • docs/security.md updated with new threat model (HttpOnly cookies, CSRF, lockout, refresh tokens, concurrent sessions)
  • CLAUDE.md package structure updated with new auth modules
  • docs/design/operations.md auth endpoint description updated

Test plan

uv run python -m pytest tests/unit/api/auth/ -n 8     # 205 auth tests
uv run python -m pytest tests/ -n 8                    # full suite
npm --prefix web run test                              # 2418 frontend tests
npm --prefix web run lint && npm --prefix web run type-check

Closes #1068

Copilot AI review requested due to automatic review settings April 6, 2026 23:25
@Aureliolo Aureliolo temporarily deployed to cloudflare-preview April 6, 2026 23:26 — with GitHub Actions Inactive
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 6, 2026

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Snapshot Warnings

⚠️: No snapshots were found for the head SHA d2bf47a.
Ensure that dependencies are being submitted on PR branches. Re-running this action after a short time may resolve the issue. See the documentation for more information and troubleshooting advice.

Scanned Files

None

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request migrates the authentication system to a secure cookie-based architecture, implementing HttpOnly session cookies, CSRF protection, account lockout mechanisms, and session limit enforcement. It includes new backend stores for lockouts and refresh tokens, along with corresponding frontend updates to handle cookie-based sessions. Review feedback correctly identified several syntax errors in exception handling blocks where deprecated Python 2 syntax was used instead of tuples.

try:
if app_state.has_lockout_store:
await app_state.lockout_store.cleanup_expired()
except MemoryError, RecursionError:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using a comma in an except clause is deprecated in Python 3 and will raise a SyntaxError. Use a tuple instead: except (MemoryError, RecursionError):.

except (MemoryError, RecursionError):

"""
try:
return app_state.config.api.auth
except AttributeError, TypeError:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using a comma in an except clause is deprecated in Python 3 and will raise a SyntaxError. Use a tuple instead: except (AttributeError, TypeError):.

except (AttributeError, TypeError):

return token
try:
return app_state.config.api.auth.cookie_name
except AttributeError, TypeError:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using a comma in an except clause is deprecated in Python 3 and will raise a SyntaxError. Use a tuple instead: except (AttributeError, TypeError):.

except (AttributeError, TypeError):

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Hardens authentication for enterprise/multi-user deployments by migrating from JS-managed JWTs to HttpOnly cookie sessions, adding CSRF protection, account lockout, concurrent session limits, and refresh-token infrastructure, with corresponding frontend and test updates.

Changes:

  • Frontend: remove sessionStorage JWT handling, introduce tri-state auth (unknown/authenticated/unauthenticated), add CSRF header attachment + withCredentials / credentials: 'include'.
  • Backend: add cookie-first auth + CSRF middleware, session concurrency enforcement, lockout + refresh-token stores, and cookie helpers/configuration.
  • Tests & persistence: extend unit tests and update SQLite schema/migrations to cover new auth components.

Reviewed changes

Copilot reviewed 46 out of 46 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
web/src/utils/csrf.ts Adds helper to read CSRF token from cookie.
web/src/stores/auth.ts Reworks auth store for cookie sessions + tri-state auth flow.
web/src/router/guards.tsx Updates route guards to validate session via checkSession().
web/src/mocks/handlers/auth.ts Updates MSW auth mocks to new auth response shape.
web/src/hooks/useWebSocket.ts Enables WS connection based on auth status (not token).
web/src/components/layout/Sidebar.stories.tsx Storybook auth state setup updated for tri-state auth.
web/src/api/types.ts Adds AuthResponse + SessionInfo, deprecates TokenResponse.
web/src/api/endpoints/providers.ts Migrates provider pull to cookie auth + CSRF header.
web/src/api/endpoints/auth.ts Updates auth endpoints, adds logout + session management calls.
web/src/api/client.ts Switches Axios to cookie auth (withCredentials) + CSRF interceptor.
web/src/tests/stores/auth.test.ts Updates auth store tests for cookie-based flow.
web/src/tests/router/guards.test.tsx Updates guard tests for tri-state session validation.
web/src/tests/hooks/useWebSocket.test.ts Updates WS hook tests for authStatus gating.
web/src/tests/hooks/useAuth.test.ts Updates useAuth tests to authStatus semantics.
web/src/tests/components/layout/Sidebar.test.tsx Updates sidebar tests for new auth state shape.
web/src/tests/App.test.tsx Updates app shell tests to authStatus-based auth.
web/src/tests/api/client.test.ts Updates Axios client tests for CSRF + credentials.
web/src/tests/api/client-bypass.test.ts Updates bypass-mode interceptor test expectations.
tests/unit/persistence/sqlite/test_migrations.py Extends expected tables/indexes for new auth tables.
tests/unit/api/test_errors.py Adds new auth/security error codes to expectations.
tests/unit/api/test_app.py Updates middleware stack length expectation.
tests/unit/api/controllers/test_ws.py Updates auth middleware index due to CSRF middleware insertion.
tests/unit/api/auth/test_session_store.py Adds tests for concurrent session limit enforcement.
tests/unit/api/auth/test_refresh_store.py Adds unit tests for refresh token store behavior.
tests/unit/api/auth/test_middleware.py Adds tests for cookie-first auth behavior and precedence.
tests/unit/api/auth/test_lockout_store.py Adds unit tests for lockout tracking + expiry behavior.
tests/unit/api/auth/test_csrf.py Adds CSRF middleware unit tests (double-submit pattern).
tests/unit/api/auth/test_cookies.py Adds cookie helper tests (session/csrf/refresh + clearing).
tests/unit/api/auth/test_controller.py Updates controller tests for cookie-based auth responses.
tests/unit/api/auth/test_config.py Adds tests for new AuthConfig cookie/CSRF/lockout/refresh fields.
src/synthorg/persistence/sqlite/schema.sql Adds login_attempts + refresh_tokens tables/indexes.
src/synthorg/persistence/sqlite/migrations.py Allows new auth tables in schema validation.
src/synthorg/observability/events/api.py Adds new observability event constants for new auth flows.
src/synthorg/api/state.py Adds lockout store to application state container.
src/synthorg/api/lifecycle.py Initializes lockout store during startup with shared DB.
src/synthorg/api/errors.py Adds new API error codes + lockout/CSRF error types.
src/synthorg/api/config.py Enables CORS credentials and allows CSRF header by default.
src/synthorg/api/auth/session_store.py Adds server-side enforcement of max concurrent sessions.
src/synthorg/api/auth/refresh_store.py Implements SQLite refresh token store + consume/revoke/cleanup.
src/synthorg/api/auth/middleware.py Updates auth middleware to prefer session cookie over header.
src/synthorg/api/auth/lockout_store.py Implements hybrid in-memory + SQLite lockout tracking.
src/synthorg/api/auth/csrf.py Adds ASGI CSRF middleware using double-submit cookie validation.
src/synthorg/api/auth/cookies.py Adds helpers for issuing/clearing auth-related cookies.
src/synthorg/api/auth/controller.py Switches login/setup to Set-Cookie sessions; adds lockout + cookie clearing on logout.
src/synthorg/api/auth/config.py Adds AuthConfig fields for cookies, CSRF, lockout, refresh, session limits.
src/synthorg/api/app.py Inserts CSRF middleware into stack and adds periodic lockout cleanup.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

return token
try:
return app_state.config.api.auth.cookie_name
except AttributeError, TypeError:
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

except AttributeError, TypeError: is invalid Python syntax (multiple exception types must be parenthesized) and will raise a SyntaxError at import time. Use except (AttributeError, TypeError): instead.

Suggested change
except AttributeError, TypeError:
except (AttributeError, TypeError):

Copilot uses AI. Check for mistakes.
"""
try:
return app_state.config.api.auth
except AttributeError, TypeError:
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

except AttributeError, TypeError: is invalid Python syntax (multiple exception types must be parenthesized) and will raise a SyntaxError at import time. Use except (AttributeError, TypeError): instead.

Suggested change
except AttributeError, TypeError:
except (AttributeError, TypeError):

Copilot uses AI. Check for mistakes.
Comment on lines +338 to +344
# Lockout cleanup.
try:
if app_state.has_lockout_store:
await app_state.lockout_store.cleanup_expired()
except MemoryError, RecursionError:
raise
except Exception:
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

except MemoryError, RecursionError: is invalid Python syntax (multiple exception types must be parenthesized) and will raise a SyntaxError at import time. Use except (MemoryError, RecursionError): instead.

Copilot uses AI. Check for mistakes.
retry_after: int = 0,
) -> None:
super().__init__(message, status_code=429)
self.retry_after = retry_after
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AccountLockedError stores retry_after on the exception instance, but the ApiError exception handler path doesn't propagate it into the response body or a Retry-After header. Either add a standard headers/retry_after mechanism to ApiError handling (and use it here), or set the Retry-After header directly when raising this error so clients can back off correctly.

Suggested change
self.retry_after = retry_after
normalized_retry_after = max(0, retry_after)
self.retry_after = normalized_retry_after
if normalized_retry_after > 0:
self.headers = {"Retry-After": str(normalized_retry_after)}

Copilot uses AI. Check for mistakes.
Comment on lines +103 to +140
now = datetime.now(UTC).isoformat()
cursor = await self._db.execute(
"UPDATE refresh_tokens "
"SET used = 1 "
"WHERE token_hash = ? AND used = 0 AND expires_at > ?",
(token_hash, now),
)
await self._db.commit()

if cursor.rowcount == 0:
# Check if it was a replay (token exists but already used)
check = await self._db.execute(
"SELECT used FROM refresh_tokens WHERE token_hash = ?",
(token_hash,),
)
row = await check.fetchone()
if row is not None and row["used"]:
logger.warning(
API_AUTH_REFRESH_REJECTED,
reason="replay_detected",
token_hash=token_hash[:8],
)
else:
logger.warning(
API_AUTH_REFRESH_REJECTED,
reason="not_found_or_expired",
token_hash=token_hash[:8],
)
return None

cursor = await self._db.execute(
"SELECT * FROM refresh_tokens WHERE token_hash = ?",
(token_hash,),
)
row = await cursor.fetchone()
if row is None:
return None

Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consume() commits the UPDATE and then does a separate SELECT to fetch the record. Because there’s an await between these statements, another coroutine using the same DB connection could interleave and delete the just-consumed row (e.g., via cleanup), causing consume() to return None even though the token was successfully consumed. Consider using UPDATE ... RETURNING (SQLite supports this) or wrapping the update+select in a single transaction without committing until the row is read.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to 14
import { apiClient, unwrap, unwrapVoid } from '../client'
import type {
ApiResponse,
AuthResponse,
ChangePasswordRequest,
LoginRequest,
PaginatedResponse,
SessionInfo,
SetupRequest,
TokenResponse,
UserInfoResponse,
WsTicketResponse,
} from '../types'
import { unwrapPaginated, type PaginatedResult } from '../client'

Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file imports from ../client twice, which typically violates lint rules like no-duplicate-imports and makes the imports harder to maintain. Combine these into a single import statement from ../client.

Copilot uses AI. Check for mistakes.
Comment on lines 162 to 168
useAuthStore.setState({
...useAuthStore.getState(),
token: 'test-token',
authStatus: 'authenticated',
user: { id: '1', username: 'admin', role: 'ceo', must_change_password: false, org_roles: [], scoped_departments: [] },
loading: false,
_mustChangePasswordFallback: false,
logout: logoutSpy,
logout: logoutSpy,
})
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useAuthStore.setState object literal has inconsistent indentation (extra leading spaces before logout:), which can trip formatting/lint checks and reduces readability. Please align this property with the others.

Copilot uses AI. Check for mistakes.
Comment on lines 261 to +265
useAuthStore.setState({
token: 'test-token',
authStatus: 'authenticated',
user: { id: '1', username: 'admin', role: 'ceo', must_change_password: false, org_roles: [], scoped_departments: [] },
loading: false,
_mustChangePasswordFallback: false,
})
})
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useAuthStore.setState call closes with a mis-indented }) (loading: false, then an over-indented })), which appears to be a formatting mistake and may fail lint/format checks. Reformat this block so the closing braces align correctly.

Copilot uses AI. Check for mistakes.
@codecov
Copy link
Copy Markdown

codecov bot commented Apr 6, 2026

Codecov Report

❌ Patch coverage is 72.44444% with 124 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.18%. Comparing base (aac2029) to head (d2bf47a).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/synthorg/api/auth/controller.py 25.35% 45 Missing and 8 partials ⚠️
src/synthorg/api/auth/lockout_store.py 73.33% 19 Missing and 1 partial ⚠️
src/synthorg/api/lifecycle.py 0.00% 12 Missing ⚠️
src/synthorg/api/auth/config.py 75.67% 6 Missing and 3 partials ⚠️
src/synthorg/api/app.py 30.00% 7 Missing ⚠️
src/synthorg/api/state.py 53.33% 7 Missing ⚠️
src/synthorg/api/auth/refresh_store.py 90.90% 2 Missing and 4 partials ⚠️
src/synthorg/api/exception_handlers.py 42.85% 2 Missing and 2 partials ⚠️
src/synthorg/api/auth/csrf.py 97.33% 2 Missing ⚠️
src/synthorg/api/auth/session_store.py 86.66% 0 Missing and 2 partials ⚠️
... and 1 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1102      +/-   ##
==========================================
- Coverage   89.31%   89.18%   -0.14%     
==========================================
  Files         787      791       +4     
  Lines       46091    46515     +424     
  Branches     4624     4680      +56     
==========================================
+ Hits        41167    41484     +317     
- Misses       4070     4158      +88     
- Partials      854      873      +19     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 7, 2026

Walkthrough

Replaces JWT-in-response and Authorization-header flows with HttpOnly cookie sessions and CSRF double-submit protection. Adds CSRF ASGI middleware, cookie helpers, LockoutStore and RefreshStore (SQLite-backed), refresh-token rotation, and SessionStore concurrent-session enforcement. Auth controllers now set/clear/rotate session, CSRF, and optional refresh cookies; middleware prefers cookie-based JWTs and falls back to headers. Frontend switches to cookie-based auth with Axios withCredentials and a CSRF header interceptor. Config, DB schema, observability events, docs, and tests updated accordingly.

Suggested labels

autorelease: tagged

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title directly summarizes the main changes: enterprise-grade auth with HttpOnly cookie sessions, CSRF protection, lockout, and session limits.
Description check ✅ Passed The PR description is comprehensive and clearly related to the changeset, detailing backend and frontend auth hardening, test coverage, and documentation updates.
Linked Issues check ✅ Passed The PR successfully implements all major objectives from #1068: HttpOnly cookie sessions, CSRF middleware, concurrent session limits, account lockout, refresh tokens, frontend migration, and documentation updates.
Out of Scope Changes check ✅ Passed All code changes are directly aligned with the #1068 objectives. No unrelated modifications detected; changes focus on auth hardening across backend, frontend, tests, and docs.
Docstring Coverage ✅ Passed Docstring coverage is 52.54% which is sufficient. The required threshold is 40.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Warning

Review ran into problems

🔥 Problems

Timed out fetching pipeline failures after 30000ms


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
web/src/__tests__/api/client-bypass.test.ts (1)

11-21: ⚠️ Potential issue | 🟠 Major

The test does not actually verify “no auth cleanup.”

Right now it only verifies rejection. It can still pass if handleUnauthorized() is called. Add an explicit assertion that cleanup was not invoked.

✅ Suggested test hardening
+import { useAuthStore } from '@/stores/auth'
 import { apiClient } from '@/api/client'
@@
   it('does NOT trigger auth cleanup on 401 when bypass is active', async () => {
+    const handleUnauthorizedSpy = vi.spyOn(
+      useAuthStore.getState(),
+      'handleUnauthorized',
+    )
+
     const error = new (await import('axios')).AxiosError(
@@
-    await expect(apiClient.interceptors.response.handlers?.[0]?.rejected?.(error)).rejects.toBeDefined()
+    await expect(
+      apiClient.interceptors.response.handlers?.[0]?.rejected?.(error),
+    ).rejects.toBe(error)
+    expect(handleUnauthorizedSpy).not.toHaveBeenCalled()
   })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/__tests__/api/client-bypass.test.ts` around lines 11 - 21, The test
currently only asserts the interceptor rejection but not that auth cleanup
wasn't invoked; add a spy/mock on the handleUnauthorized function (or the
module/export that performs cleanup) before calling
apiClient.interceptors.response.handlers?.[0]?.rejected and then assert that
handleUnauthorized was not called (e.g.,
expect(handleUnauthorizedSpy).not.toHaveBeenCalled()) after awaiting the
rejected promise; keep the existing AxiosError creation and rejection assertion
but add this negative assertion referencing handleUnauthorized and the
interceptor rejection handler to ensure no cleanup ran.
src/synthorg/api/auth/controller.py (1)

702-737: ⚠️ Potential issue | 🟠 Major

Old session not revoked on password change.

After password change, a new session is created but the old session (from the current request's JWT) remains valid until expiry. This undermines the security benefit of session rotation on password change — if a session was compromised, the attacker's old session continues to work.

Consider revoking the old session before creating the new one:

🔒 Proposed fix to revoke old session
+        # Revoke the old session before issuing a new one
+        old_jti = _extract_jti(request)
+        if old_jti and app_state.has_session_store:
+            await app_state.session_store.revoke(old_jti)
+
         # Rotate session cookie so the new pwd_sig is embedded.
         token, expires_in, session_id = auth_service.create_token(
             updated_user,
         )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/auth/controller.py` around lines 702 - 737, The current flow
creates a new token with auth_service.create_token(updated_user) and calls
_create_session without revoking the old session derived from the incoming
request, leaving the previous JWT valid; before calling create_token and
_create_session, extract the current session identifier from the request (e.g.,
request.state.session_id or decode the incoming JWT) and call the
session-revocation method on the same service used elsewhere (e.g.,
auth_service.revoke_session or auth_service.delete_session) to invalidate the
old session, then proceed to create the new token and session and return cookies
via _make_session_cookies.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/synthorg/api/app.py`:
- Around line 1281-1294: Remove the refresh endpoint from the CSRF exemption set
so `/auth/refresh` is subject to double-submit CSRF validation: edit the
csrf_exempt frozenset (used when calling create_csrf_middleware_class(auth,
exempt_paths=csrf_exempt)) to delete the f"{prefix}/auth/refresh" entry,
ensuring the refresh POST remains protected by the same X-CSRF-Token requirement
as other cookie-authenticated endpoints.

In `@src/synthorg/api/auth/controller.py`:
- Around line 512-534: Remove the redundant call to _get_auth_config by reusing
the auth_config already retrieved when app_state.has_session_store is true;
ensure auth_config (from the first _get_auth_config(app_state) used for
app_state.session_store.enforce_session_limit(user.id,
auth_config.max_concurrent_sessions)) is in scope and passed to
_make_session_cookies(token, expires_in, auth_config) instead of calling
_get_auth_config a second time.
- Around line 612-633: Redundant call to _get_auth_config: remove the second
invocation and reuse the auth_config obtained before calling
app_state.session_store.enforce_session_limit; specifically, keep auth_config =
_get_auth_config(app_state) before enforce_session_limit and pass that same
auth_config into _make_session_cookies and Response creation instead of calling
_get_auth_config(app_state) again, ensuring app_state, _get_auth_config,
session_store.enforce_session_limit, Response, and _make_session_cookies are
referenced accordingly.

In `@src/synthorg/api/auth/session_store.py`:
- Around line 241-247: The code doesn't guard against negative max_sessions
which makes excess positive and can revoke sessions; change the early check to
treat negatives as zero—replace the current "if max_sessions == 0: return 0"
with a defensive check like "if max_sessions <= 0: return 0" (or clamp
max_sessions via max_sessions = max(0, max_sessions)) so that the later
computation using self.list_by_user(user_id) and excess = len(active) -
max_sessions cannot mistakenly revoke sessions when max_sessions is negative.

In `@src/synthorg/api/errors.py`:
- Around line 271-279: The constructor (__init__) currently accepts negative
retry_after values which can produce invalid Retry-After headers; update the
__init__ for this 429 error class to validate the retry_after parameter by
coercing it to an int and clamping it to a minimum of 0 (e.g., self.retry_after
= max(0, int(retry_after))) so negative inputs become 0 and non-int values are
safely converted; preserve the existing super().__init__(message,
status_code=429) behavior and ensure the attribute name retry_after is set after
validation.

In `@src/synthorg/persistence/sqlite/schema.sql`:
- Around line 509-510: Add a separate single-column index on
login_attempts.attempted_at to support TTL cleanup queries; specifically create
an index (e.g., idx_la_attempted_at) on the attempted_at column so cleanup
queries that filter by attempted_at can use the index instead of full-scanning
the login_attempts table (this complements the existing
idx_la_username_attempted index).
- Around line 513-526: Refresh token consumption currently ignores session
revocation: update RefreshStore.consume() (or the refresh endpoint flow) to
reject tokens whose session has been revoked by either (a) modifying the SQL in
RefreshStore.consume() to join refresh_tokens with sessions and enforce
sessions.revoked = 0 when selecting the token, or (b) after successfully
consuming the token call SessionStore.is_revoked(session_id) and abort issuance
if it returns true; reference the refresh_tokens table and the
SessionStore.is_revoked(session_id) method when implementing the check so
revoked sessions cannot mint new credentials.

In `@tests/unit/api/auth/test_config.py`:
- Around line 66-69: Replace the for-loop in test_cookie_samesite_accepts_valid
with pytest parameterization so each samesite value is a separate test case;
specifically convert test_cookie_samesite_accepts_valid to use
`@pytest.mark.parametrize` on values ("strict","lax","none") and assert that
AuthConfig(cookie_samesite=value).cookie_samesite == value, keeping the test
name or renaming if desired for clarity.

In `@tests/unit/api/auth/test_lockout_store.py`:
- Around line 126-131: The test uses LockoutStore.__new__(LockoutStore) which
bypasses __init__ and yields a partially initialized object; change the test in
TestLockoutDurationSeconds to construct a real LockoutStore via its normal
constructor (instead of __new__) and supply/mocking any required DB or
dependencies (or use dependency injection/mocker fixture) so you can set
duration via _make_config(duration_minutes=15) and then assert
store.lockout_duration_seconds == 900; reference the LockoutStore class, its
lockout_duration_seconds property, and the _make_config helper when replacing
the __new__ pattern with a proper instantiation and mocked connection.

In `@tests/unit/api/auth/test_middleware.py`:
- Around line 804-816: Extract a reusable fake state factory or base class to
remove duplication between _RevokedState and _CustomState: create a factory
function (e.g., make_fake_state) or a base class (e.g., FakeState) that accepts
optional overrides for attributes like auth_service, persistence,
has_session_store, session_store, and config, then replace both _RevokedState
and _CustomState instantiations with calls to the factory or subclasses that
only supply differing values; update usages around app.state["app_state"]
assignments in the tests to call the new factory to ensure consistent state
shape across tests.

In `@tests/unit/api/controllers/test_ws.py`:
- Around line 278-279: The test currently hard-codes auth middleware as
middleware[2]; instead, locate the auth middleware by type/shape to avoid
brittle ordering. Replace uses of middleware[2] (variable auth_cls) with a
search like next(m for m in middleware if getattr(m, "__name__", "") ==
"AuthMiddleware" or hasattr(m, "authenticate") or isinstance(m,
AuthMiddlewareClass)) to find the auth middleware by its class name or
distinguishing attribute, and apply the same change to the other occurrence that
mirrors lines 398-399 so both tests stop relying on positional indexing.

In `@web/src/hooks/useWebSocket.ts`:
- Around line 49-55: When authStatus transitions to 'unauthenticated' the
WebSocket transport is left running; update auth.ts so handleUnauthorized() (and
logout() if present) calls useWebSocketStore.getState().disconnect() to
explicitly tear down the socket and stop reconnection attempts. Locate
handleUnauthorized and logout in auth.ts and invoke
useWebSocketStore.getState().disconnect() right after you set authStatus/unset
tokens so the store's shouldBeConnected is cleared and the transport is fully
closed.

In `@web/src/mocks/handlers/auth.ts`:
- Around line 10-20: authLoginSuccess and authSetupSuccess currently return only
a JSON body (via HttpResponse.json(apiSuccess(mockAuthResponse))) so the CSRF
interceptor never sees a csrf_token cookie; update these handlers to also model
the Set-Cookie for csrf_token (e.g., return a response with a Set-Cookie:
csrf_token=... header) or explicitly set document.cookie with the csrf_token
inside the handler before returning the response so that getCsrfToken()/the CSRF
interceptor will attach X-CSRF-Token on subsequent POST/PUT/PATCH/DELETE
requests.

In `@web/src/router/guards.tsx`:
- Around line 52-59: SetupCompleteGuard still uses useIsAuthenticated() and
doesn't call checkSession, so replace its reliance on useIsAuthenticated with
the tri-state pattern: import and use useAuthStatus() and the checkSession
selector from useAuthStore inside SetupCompleteGuard, call checkSession() inside
a useEffect when authStatus === 'unknown', and treat the 'unknown' state as "not
resolved" (i.e., prevent rendering or redirect until authStatus is
'authenticated' or 'unauthenticated') so the route fails closed on hard
refreshes with a session cookie.

In `@web/src/stores/auth.ts`:
- Around line 148-164: In checkSession (the async method using authApi.getMe and
isAxiosError), avoid forcing authStatus to 'unauthenticated' on
transient/non-401 errors; instead log the error with getErrorMessage(err) and
preserve the current session state by leaving authStatus as 'unknown' (or
explicitly set it to 'unknown') so we don't drop valid sessions on network/5xx
failures; only set authStatus to 'unauthenticated' when err.response?.status ===
401. Optionally add a short retry/backoff around authApi.getMe if you want
automatic recovery.

In `@web/src/utils/csrf.ts`:
- Around line 16-19: The cookie parsing truncates CSRF tokens that contain '='
because it uses match.split('=')[1]; change the extraction to take everything
after the first '=' (e.g., use match.indexOf('=') + 1 and substring/slice) so
the full value is preserved, then pass that full substring into
decodeURIComponent; update the logic around the match variable (the same place
where match.split('=')[1] is used) to avoid splitting on all '=' characters.

---

Outside diff comments:
In `@src/synthorg/api/auth/controller.py`:
- Around line 702-737: The current flow creates a new token with
auth_service.create_token(updated_user) and calls _create_session without
revoking the old session derived from the incoming request, leaving the previous
JWT valid; before calling create_token and _create_session, extract the current
session identifier from the request (e.g., request.state.session_id or decode
the incoming JWT) and call the session-revocation method on the same service
used elsewhere (e.g., auth_service.revoke_session or
auth_service.delete_session) to invalidate the old session, then proceed to
create the new token and session and return cookies via _make_session_cookies.

In `@web/src/__tests__/api/client-bypass.test.ts`:
- Around line 11-21: The test currently only asserts the interceptor rejection
but not that auth cleanup wasn't invoked; add a spy/mock on the
handleUnauthorized function (or the module/export that performs cleanup) before
calling apiClient.interceptors.response.handlers?.[0]?.rejected and then assert
that handleUnauthorized was not called (e.g.,
expect(handleUnauthorizedSpy).not.toHaveBeenCalled()) after awaiting the
rejected promise; keep the existing AxiosError creation and rejection assertion
but add this negative assertion referencing handleUnauthorized and the
interceptor rejection handler to ensure no cleanup ran.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ca2672bf-1495-468e-b895-abcdd434337f

📥 Commits

Reviewing files that changed from the base of the PR and between c845d22 and 0e36300.

📒 Files selected for processing (51)
  • CLAUDE.md
  • docs/design/operations.md
  • docs/security.md
  • src/synthorg/api/app.py
  • src/synthorg/api/auth/config.py
  • src/synthorg/api/auth/controller.py
  • src/synthorg/api/auth/cookies.py
  • src/synthorg/api/auth/csrf.py
  • src/synthorg/api/auth/lockout_store.py
  • src/synthorg/api/auth/middleware.py
  • src/synthorg/api/auth/refresh_store.py
  • src/synthorg/api/auth/session_store.py
  • src/synthorg/api/config.py
  • src/synthorg/api/errors.py
  • src/synthorg/api/exception_handlers.py
  • src/synthorg/api/lifecycle.py
  • src/synthorg/api/state.py
  • src/synthorg/observability/events/api.py
  • src/synthorg/persistence/sqlite/migrations.py
  • src/synthorg/persistence/sqlite/schema.sql
  • tests/integration/api/test_first_run_flow.py
  • tests/unit/api/auth/test_config.py
  • tests/unit/api/auth/test_controller.py
  • tests/unit/api/auth/test_cookies.py
  • tests/unit/api/auth/test_csrf.py
  • tests/unit/api/auth/test_lockout_store.py
  • tests/unit/api/auth/test_middleware.py
  • tests/unit/api/auth/test_refresh_store.py
  • tests/unit/api/auth/test_session_store.py
  • tests/unit/api/controllers/test_ws.py
  • tests/unit/api/test_app.py
  • tests/unit/api/test_errors.py
  • tests/unit/persistence/sqlite/test_migrations.py
  • web/src/__tests__/App.test.tsx
  • web/src/__tests__/api/client-bypass.test.ts
  • web/src/__tests__/api/client.test.ts
  • web/src/__tests__/components/layout/Sidebar.test.tsx
  • web/src/__tests__/hooks/useAuth.test.ts
  • web/src/__tests__/hooks/useWebSocket.test.ts
  • web/src/__tests__/router/guards.test.tsx
  • web/src/__tests__/stores/auth.test.ts
  • web/src/api/client.ts
  • web/src/api/endpoints/auth.ts
  • web/src/api/endpoints/providers.ts
  • web/src/api/types.ts
  • web/src/components/layout/Sidebar.stories.tsx
  • web/src/hooks/useWebSocket.ts
  • web/src/mocks/handlers/auth.ts
  • web/src/router/guards.tsx
  • web/src/stores/auth.ts
  • web/src/utils/csrf.ts

Comment on lines +49 to +55
const authStatus = useAuthStore((s) => s.authStatus)
const connected = useWebSocketStore((s) => s.connected)
const reconnectExhausted = useWebSocketStore((s) => s.reconnectExhausted)
const [setupError, setSetupError] = useState<string | null>(null)
const disposedRef = useRef(false)

const isEnabled = enabled !== undefined ? enabled : !!token
const isEnabled = enabled !== undefined ? enabled : authStatus === 'authenticated'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- auth store ---'
fd '^auth\.ts$' web/src/stores -x sed -n '1,260p' {}

echo '--- websocket store ---'
fd '^websocket\.ts$' web/src/stores -x sed -n '1,260p' {}

echo '--- auth/logout and websocket disconnect call sites ---'
rg -n -C3 --type=ts --type=tsx '\b(handleUnauthorized|logout)\b|useWebSocketStore\.getState\(\)\.disconnect|disconnect\s*[:(]' web/src

Repository: Aureliolo/synthorg

Length of output: 15362


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- All disconnect() calls in codebase ---'
rg 'disconnect\(' web/src --type ts -A2 -B2

echo '--- useWebSocketStore full disconnect implementation ---'
fd '^websocket\.ts$' web/src/stores -x cat {}

echo '--- useWebSocket.ts full implementation ---'
fd '^useWebSocket\.ts$' web/src/hooks -x cat {}

Repository: Aureliolo/synthorg

Length of output: 19180


Add explicit websocket disconnect on auth loss.

When authStatus becomes 'unauthenticated' (via logout() or handleUnauthorized()), the websocket socket persists in the background. Although useWebSocket's effect cleanup removes handlers when isEnabled flips to false, the websocket store itself is never told to disconnect—shouldBeConnected remains true and reconnection will retry on any network hiccup. Call useWebSocketStore.getState().disconnect() in auth.ts's handleUnauthorized() to properly tear down the transport on auth loss.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/hooks/useWebSocket.ts` around lines 49 - 55, When authStatus
transitions to 'unauthenticated' the WebSocket transport is left running; update
auth.ts so handleUnauthorized() (and logout() if present) calls
useWebSocketStore.getState().disconnect() to explicitly tear down the socket and
stop reconnection attempts. Locate handleUnauthorized and logout in auth.ts and
invoke useWebSocketStore.getState().disconnect() right after you set
authStatus/unset tokens so the store's shouldBeConnected is cleared and the
transport is fully closed.

Comment on lines +52 to +59
const authStatus = useAuthStatus()
const checkSession = useAuthStore((s) => s.checkSession)

useEffect(() => {
if (!isAuthenticated || user || validatedRef.current) return
validatedRef.current = true

// Validate token by fetching user profile. On 401,
// fetchUser calls clearAuth() -> isAuthenticated=false ->
// redirect to /login. Non-401 errors are tolerated.
fetchUser()
.catch(() => {})
.finally(() => setValidating(false))
}, [isAuthenticated, user, fetchUser])
if (authStatus === 'unknown') {
checkSession()
}
}, [authStatus, checkSession])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Finish the tri-state auth migration in SetupCompleteGuard.

This file now treats 'unknown' as a first-class auth state in AuthGuard and GuestGuard, but SetupCompleteGuard still relies on useIsAuthenticated() and never calls checkSession(). After a hard refresh with a valid session cookie, /setup can render before auth resolves instead of fail-closing back to the dashboard.

Also applies to: 110-117

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/router/guards.tsx` around lines 52 - 59, SetupCompleteGuard still
uses useIsAuthenticated() and doesn't call checkSession, so replace its reliance
on useIsAuthenticated with the tri-state pattern: import and use useAuthStatus()
and the checkSession selector from useAuthStore inside SetupCompleteGuard, call
checkSession() inside a useEffect when authStatus === 'unknown', and treat the
'unknown' state as "not resolved" (i.e., prevent rendering or redirect until
authStatus is 'authenticated' or 'unauthenticated') so the route fails closed on
hard refreshes with a session cookie.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/synthorg/api/auth/controller.py (1)

360-391: ⚠️ Potential issue | 🟠 Major

Make _extract_jti() mirror middleware auth resolution.

The middleware falls back to Authorization when a session cookie is present but invalid, but this helper only consults the header when the cookie is missing. A stale cookie plus valid header therefore authenticates the request yet returns None here, so logout/change-password will skip revocation and list_sessions() cannot mark the current session correctly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/auth/controller.py` around lines 360 - 391, _extract_jti
currently returns None if a session cookie exists but is invalid because it
doesn't fall back to the Authorization header; change it to mirror the
middleware by attempting to decode the cookie token first (using
app_state.auth_service.decode_token) and if decoding raises
jwt.InvalidTokenError or other decode failure, then attempt to read and decode
the Bearer token from the Authorization header (auth_config.cookie_name,
request.cookies, request.headers["authorization"]). Ensure both decode attempts
log similarly (API_AUTH_FAILED with distinct reasons) and that the function
returns the jti from whichever token successfully decodes or None only if both
attempts fail.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/synthorg/api/auth/controller.py`:
- Around line 229-249: _make_session_cookies currently only builds the session
and CSRF cookies so when refresh tokens are enabled the initial refresh cookie
is never sent; update _make_session_cookies(token, expires_in, config) to also
create and include the refresh cookie (using make_refresh_cookie(...) with the
proper refresh token lifetime/contents) when the AuthConfig indicates refresh
tokens are enabled, and ensure callers (setup, login, change_password) that
reuse _make_session_cookies receive that extra cookie automatically; reference
make_session_cookie, make_csrf_cookie, make_refresh_cookie, generate_csrf_token,
and the setup/login/change_password usages to locate where to add the
conditional append.
- Around line 968-972: The logout path currently dereferences
app_state.session_store whenever _extract_jti(request) returns a jti, causing a
500 if no session store is configured; guard that logic by checking the existing
has_session_store predicate (or a similar boolean on app_state) before accessing
app_state.session_store and calling store.revoke(jti). Concretely, after jti =
_extract_jti(request) fetch app_state as before, then only retrieve store =
app_state.session_store and await store.revoke(jti) if
app_state.has_session_store() (or getattr/app_state attribute check) so
deployments without a session store simply skip revoke and continue clearing
cookies.
- Around line 582-599: The branch that handles failed passwords calls
app_state.lockout_store.record_failure but ignores its boolean return; change it
to capture the result (e.g. locked = await
app_state.lockout_store.record_failure(data.username, ip_address=ip)) and if
locked is True raise the lockout response (AccountLockedError or the appropriate
lockout error/response) instead of UnauthorizedError, otherwise continue to log
and raise UnauthorizedError; update the code paths around record_failure,
UnauthorizedError, and AccountLockedError to ensure the request that crosses the
threshold immediately returns the lockout response.

In `@src/synthorg/api/auth/refresh_store.py`:
- Around line 69-98: The create(), revoke_by_session(), revoke_by_user(), and
cleanup_expired() methods are missing INFO-level audit logs for state-changing
refresh-token operations; add an INFO log in each (e.g., immediately after the
DB commit) that records the action ("refresh_token.created",
"refresh_token.revoked_by_session", "refresh_token.revoked_by_user",
"refresh_token.cleaned_expired"), includes non-secret identifying fields
(token_hash or session_id or user_id or number of rows affected), and uses the
same logger used elsewhere in this module (referencing the create,
revoke_by_session, revoke_by_user, and cleanup_expired symbols) so issuance and
bulk revocations are auditable without exposing secret token material.

In `@web/src/stores/auth.ts`:
- Around line 93-101: The logout flow currently calls get().handleUnauthorized()
even when authApi.logout() throws, causing a local-only logout when the server
still has a valid HttpOnly cookie; modify the logout method so
handleUnauthorized() is only invoked when authApi.logout() completes
successfully (or when the API response explicitly confirms session cleared),
i.e., move the call into the try block after await authApi.logout() (or check a
success flag/response) and do NOT call handleUnauthorized() inside the catch so
the client doesn't mark the user logged out when the server didn't clear the
cookie; reference the logout method, authApi.logout(), handleUnauthorized(), and
checkSession() when making this change.

In `@web/src/utils/csrf.ts`:
- Around line 16-22: The CSRF cookie parsing can throw when decodeURIComponent
encounters malformed encoding; update the function that reads document.cookie
(the block using match, eqIdx and decodeURIComponent) to wrap the
decodeURIComponent call in a try/catch and return null on any exception so
malformed cookie values don't throw and break request interception; preserve the
existing behavior of returning null for missing or malformed values.

---

Outside diff comments:
In `@src/synthorg/api/auth/controller.py`:
- Around line 360-391: _extract_jti currently returns None if a session cookie
exists but is invalid because it doesn't fall back to the Authorization header;
change it to mirror the middleware by attempting to decode the cookie token
first (using app_state.auth_service.decode_token) and if decoding raises
jwt.InvalidTokenError or other decode failure, then attempt to read and decode
the Bearer token from the Authorization header (auth_config.cookie_name,
request.cookies, request.headers["authorization"]). Ensure both decode attempts
log similarly (API_AUTH_FAILED with distinct reasons) and that the function
returns the jti from whichever token successfully decodes or None only if both
attempts fail.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 756769fd-5de2-4352-bae4-f55d759bae79

📥 Commits

Reviewing files that changed from the base of the PR and between 0e36300 and d53c80f.

📒 Files selected for processing (16)
  • src/synthorg/api/app.py
  • src/synthorg/api/auth/controller.py
  • src/synthorg/api/auth/refresh_store.py
  • src/synthorg/api/auth/session_store.py
  • src/synthorg/api/errors.py
  • src/synthorg/persistence/sqlite/schema.sql
  • tests/unit/api/auth/test_config.py
  • tests/unit/api/auth/test_lockout_store.py
  • tests/unit/api/auth/test_middleware.py
  • tests/unit/api/controllers/test_ws.py
  • tests/unit/persistence/sqlite/test_migrations.py
  • web/src/__tests__/api/client-bypass.test.ts
  • web/src/__tests__/stores/auth.test.ts
  • web/src/mocks/handlers/auth.ts
  • web/src/stores/auth.ts
  • web/src/utils/csrf.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
  • GitHub Check: Build Backend
  • GitHub Check: Build Web
  • GitHub Check: Test (Python 3.14)
  • GitHub Check: Dashboard Test
  • GitHub Check: Dependency Review
  • GitHub Check: Analyze (go)
  • GitHub Check: Analyze (python)
🧰 Additional context used
📓 Path-based instructions (7)
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: No from __future__ import annotations—Python 3.14 has PEP 649 native lazy annotations.
Use PEP 758 except syntax: except A, B: (no parentheses)—ruff enforces this on Python 3.14.
Type hints: all public functions required, mypy strict mode enforced.
Docstrings: Google style, required on public classes/functions (enforced by ruff D rules).
Create new objects, never mutate existing ones. For non-Pydantic internal collections (registries, BaseTool), use copy.deepcopy() at construction + MappingProxyType wrapping for read-only enforcement.
For dict/list fields in frozen Pydantic models, rely on frozen=True for field reassignment prevention and copy.deepcopy() at system boundaries (tool execution, LLM provider serialization, inter-agent delegation, serializing for persistence).
Use Pydantic v2 (BaseModel, model_validator, computed_field, ConfigDict). Use allow_inf_nan=False in all ConfigDict declarations to reject NaN/Inf in numeric fields at validation time.
Use @computed_field for derived values instead of storing + validating redundant fields (e.g. TokenUsage.total_tokens).
Use NotBlankStr (from core.types) for all identifier/name fields—including optional (NotBlankStr | None) and tuple (tuple[NotBlankStr, ...]) variants—instead of manual whitespace validators.
Prefer asyncio.TaskGroup for fan-out/fan-in parallel operations in new code (e.g. multiple tool invocations, parallel agent calls). Prefer structured concurrency over bare create_task.
Line length: 88 characters (ruff enforced).
Functions: < 50 lines, files < 800 lines.
Handle errors explicitly, never silently swallow.
Validate at system boundaries (user input, external APIs, config files).
Variable name: always logger (not _logger, not log).
All error paths must log at WARNING or ERROR with context before raising.
All state transitions must log at INFO.
DEBUG for object creation, internal flow, entry/exit of key functions.
Pure data models, enums, and re-exports do NOT need logging.

Files:

  • tests/unit/api/controllers/test_ws.py
  • tests/unit/api/auth/test_config.py
  • src/synthorg/api/auth/session_store.py
  • src/synthorg/api/app.py
  • tests/unit/api/auth/test_lockout_store.py
  • src/synthorg/api/errors.py
  • src/synthorg/api/auth/controller.py
  • src/synthorg/api/auth/refresh_store.py
  • tests/unit/persistence/sqlite/test_migrations.py
  • tests/unit/api/auth/test_middleware.py
tests/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

tests/**/*.py: Markers: @pytest.mark.unit, @pytest.mark.integration, @pytest.mark.e2e, @pytest.mark.slow.
Async: asyncio_mode = "auto"—no manual @pytest.mark.asyncio needed.
Timeout: 30 seconds per test (global in pyproject.toml—do not add per-file pytest.mark.timeout(30) markers; non-default overrides like timeout(60) are allowed).
Parametrize: Prefer @pytest.mark.parametrize for testing similar cases.
Tests must use test-provider, test-small-001, etc. (not real vendor names).
Property-based testing: Python uses Hypothesis (@given + @settings).
Hypothesis profiles configured in tests/conftest.py: ci (deterministic, max_examples=10 + derandomize=True), dev (1000 examples), fuzz (10,000 examples, no deadline), extreme (500,000 examples, no deadline). Controlled via HYPOTHESIS_PROFILE env var.
Never skip, dismiss, or ignore flaky tests—always fix them fully and fundamentally. For timing-sensitive tests, mock time.monotonic() and asyncio.sleep() to make them deterministic. For tasks that must block indefinitely until cancelled, use asyncio.Event().wait() instead of asyncio.sleep(large_number).

Files:

  • tests/unit/api/controllers/test_ws.py
  • tests/unit/api/auth/test_config.py
  • tests/unit/api/auth/test_lockout_store.py
  • tests/unit/persistence/sqlite/test_migrations.py
  • tests/unit/api/auth/test_middleware.py

⚙️ CodeRabbit configuration file

Test files do not require Google-style docstrings on classes or functions -- ruff D rules are only enforced on src/. A bare @settings() decorator with no arguments on Hypothesis property tests is a no-op and should not be suggested -- the HYPOTHESIS_PROFILE env var controls example counts via registered profiles, which @given() honors automatically.

Files:

  • tests/unit/api/controllers/test_ws.py
  • tests/unit/api/auth/test_config.py
  • tests/unit/api/auth/test_lockout_store.py
  • tests/unit/persistence/sqlite/test_migrations.py
  • tests/unit/api/auth/test_middleware.py
web/src/**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (web/CLAUDE.md)

web/src/**/*.{ts,tsx,js,jsx}: Always use createLogger from @/lib/logger -- never bare console.warn/console.error/console.debug in application code
Logger variable name must always be const log (e.g. const log = createLogger('module-name'))
Pass dynamic/untrusted values as separate arguments to logger methods (not interpolated into the message string) so they go through sanitizeArg
Attacker-controlled fields inside structured objects must be wrapped in sanitizeForLog() before embedding in log calls

Files:

  • web/src/utils/csrf.ts
  • web/src/__tests__/api/client-bypass.test.ts
  • web/src/mocks/handlers/auth.ts
  • web/src/stores/auth.ts
  • web/src/__tests__/stores/auth.test.ts
web/src/**/*.{ts,tsx}

📄 CodeRabbit inference engine (web/CLAUDE.md)

web/src/**/*.{ts,tsx}: Use Tailwind semantic classes (text-foreground, bg-card, text-accent, text-success, bg-danger, etc.) or CSS variables (var(--so-*)) for colors; NEVER hardcode hex values in .tsx/.ts files
Use font-sans or font-mono (Geist tokens) for typography; NEVER set fontFamily directly in .tsx/.ts files
Use density-aware tokens (p-card, gap-section-gap, gap-grid-gap) or standard Tailwind spacing; NEVER hardcode pixel values for layout spacing in components
Use token variables (var(--so-shadow-card-hover), border-border, border-bright) for shadows and borders; NEVER hardcode values in .tsx/.ts files
Use @/lib/motion presets for Framer Motion transition durations; NEVER hardcode transition durations
CSS side-effect imports in TypeScript 6 require type declarations -- add /// <reference types="vite/client" /> at the top of files with CSS imports

ALWAYS reuse existing components from web/src/components/ui/ before creating new ones.

Files:

  • web/src/utils/csrf.ts
  • web/src/__tests__/api/client-bypass.test.ts
  • web/src/mocks/handlers/auth.ts
  • web/src/stores/auth.ts
  • web/src/__tests__/stores/auth.test.ts
web/src/**/*.{ts,tsx,css}

📄 CodeRabbit inference engine (CLAUDE.md)

NEVER hardcode hex colors, font-family, pixel spacing, or Framer Motion transitions—use design tokens and @/lib/motion presets.

Files:

  • web/src/utils/csrf.ts
  • web/src/__tests__/api/client-bypass.test.ts
  • web/src/mocks/handlers/auth.ts
  • web/src/stores/auth.ts
  • web/src/__tests__/stores/auth.test.ts
src/synthorg/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

src/synthorg/**/*.py: Every module with business logic MUST have: from synthorg.observability import get_logger then logger = get_logger(name)
Never use import logging / logging.getLogger() / print() in application code. Exception: observability/setup.py, observability/sinks.py, observability/syslog_handler.py, and observability/http_handler.py may use stdlib logging and print(..., file=sys.stderr).
Event names: always use constants from domain-specific modules under synthorg.observability.events (e.g., API_REQUEST_STARTED from events.api). Import directly: from synthorg.observability.events. import EVENT_CONSTANT
Structured kwargs: always logger.info(EVENT, key=value)—never logger.info("msg %s", val)
All provider calls go through BaseCompletionProvider which applies retry + rate limiting automatically. Never implement retry logic in driver subclasses or calling code.
RetryConfig and RateLimiterConfig are set per-provider in ProviderConfig.
Retryable errors (is_retryable=True): RateLimitError, ProviderTimeoutError, ProviderConnectionError, ProviderInternalError.
Non-retryable errors raise immediately without retry.
RetryExhaustedError signals that all retries failed—the engine layer catches this to trigger fallback chains.
Rate limiter respects RateLimitError.retry_after from providers—automatically pauses future requests.

Files:

  • src/synthorg/api/auth/session_store.py
  • src/synthorg/api/app.py
  • src/synthorg/api/errors.py
  • src/synthorg/api/auth/controller.py
  • src/synthorg/api/auth/refresh_store.py
src/**/*.py

⚙️ CodeRabbit configuration file

This project uses Python 3.14+ with PEP 758 except syntax: "except A, B:" (comma-separated, no parentheses) is correct and mandatory -- do NOT flag it as a typo or suggest parenthesized form. The "except builtins.MemoryError, RecursionError: raise" pattern is intentional project convention for system-error propagation. When evaluating the 50-line function limit, count only the function body excluding the signature lines, decorators, and docstring. Functions 1-5 lines over due to docstrings or multi-line signatures should not be flagged. Do not suggest extracting single-use helper functions called exactly once -- this reduces readability without improving maintainability.

Files:

  • src/synthorg/api/auth/session_store.py
  • src/synthorg/api/app.py
  • src/synthorg/api/errors.py
  • src/synthorg/api/auth/controller.py
  • src/synthorg/api/auth/refresh_store.py
🧠 Learnings (46)
📚 Learning: 2026-03-19T07:12:14.508Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-19T07:12:14.508Z
Learning: Applies to src/synthorg/api/**/*.py : API package (api/): Litestar REST + WebSocket with controllers, guards, channels, JWT + API key + WS ticket auth, approval gate integration, coordination endpoint, collaboration endpoint, settings endpoint, provider management endpoint (CRUD + test + presets), backup endpoint, RFC 9457 structured errors, AppState hot-reload slots, service auto-wiring (Phase 1 at construction, Phase 2 on startup), lifecycle helpers

Applied to files:

  • tests/unit/api/controllers/test_ws.py
  • src/synthorg/api/auth/session_store.py
  • src/synthorg/api/app.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-20T21:44:04.528Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T21:44:04.528Z
Learning: Applies to tests/**/*.py : Fix flaky tests completely and fundamentally; for timing-sensitive tests, mock `time.monotonic()` and `asyncio.sleep()` to make them deterministic instead of widening timing margins

Applied to files:

  • tests/unit/api/controllers/test_ws.py
  • tests/unit/api/auth/test_lockout_store.py
📚 Learning: 2026-03-17T06:30:14.180Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T06:30:14.180Z
Learning: Applies to src/synthorg/api/**/*.py : Use Litestar for REST + WebSocket API. Controllers, guards, channels, JWT + API key + WS ticket auth, RFC 9457 structured errors.

Applied to files:

  • tests/unit/api/controllers/test_ws.py
  • src/synthorg/api/app.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-20T11:18:48.128Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T11:18:48.128Z
Learning: Applies to src/synthorg/api/**/*.py : Use Litestar for REST API and WebSocket API with JWT + API key + WS ticket authentication, RFC 9457 structured errors, and content negotiation.

Applied to files:

  • tests/unit/api/controllers/test_ws.py
  • src/synthorg/api/app.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-26T15:18:16.848Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-26T15:18:16.848Z
Learning: Applies to src/synthorg/api/**/*.py : Litestar API must include setup wizard, auth/, auto-wiring, and lifecycle management

Applied to files:

  • tests/unit/api/controllers/test_ws.py
  • src/synthorg/api/app.py
📚 Learning: 2026-04-02T12:21:16.739Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: web/CLAUDE.md:0-0
Timestamp: 2026-04-02T12:21:16.739Z
Learning: Applies to web/src/__tests__/**/*.{test,spec}.{ts,tsx} : Vitest unit tests must use coverage scoped to files changed vs origin/main branch

Applied to files:

  • web/src/__tests__/api/client-bypass.test.ts
📚 Learning: 2026-03-17T22:08:13.456Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T22:08:13.456Z
Learning: Applies to tests/**/*.py : Test markers: `pytest.mark.unit`, `pytest.mark.integration`, `pytest.mark.e2e`, `pytest.mark.slow`. Coverage: 80% minimum. Async: `asyncio_mode = 'auto'` — no manual `pytest.mark.asyncio` needed. Timeout: 30 seconds per test. Parallelism: `pytest-xdist` via `-n auto` — ALWAYS include `-n auto` when running pytest, never run tests sequentially.

Applied to files:

  • tests/unit/api/auth/test_config.py
📚 Learning: 2026-03-17T22:08:13.456Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T22:08:13.456Z
Learning: Applies to tests/**/*.py : Prefer `pytest.mark.parametrize` for testing similar cases.

Applied to files:

  • tests/unit/api/auth/test_config.py
📚 Learning: 2026-04-06T20:42:41.219Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-06T20:42:41.219Z
Learning: Applies to tests/**/*.py : Parametrize: Prefer pytest.mark.parametrize for testing similar cases.

Applied to files:

  • tests/unit/api/auth/test_config.py
📚 Learning: 2026-03-31T20:29:10.177Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-31T20:29:10.177Z
Learning: Applies to tests/**/*.py : Prefer `pytest.mark.parametrize` for testing similar cases

Applied to files:

  • tests/unit/api/auth/test_config.py
📚 Learning: 2026-03-18T21:23:23.586Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-18T21:23:23.586Z
Learning: Applies to src/synthorg/**/*.py : Event names: always use constants from the domain-specific module under synthorg.observability.events (e.g., API_REQUEST_STARTED from events.api, TOOL_INVOKE_START from events.tool). Import directly from synthorg.observability.events.<domain>.

Applied to files:

  • src/synthorg/api/auth/session_store.py
📚 Learning: 2026-04-02T07:18:02.381Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-02T07:18:02.381Z
Learning: Applies to src/synthorg/**/*.py : Use event name constants from domain-specific modules under `synthorg.observability.events` (e.g., `API_REQUEST_STARTED` from `events.api`, `TOOL_INVOKE_START` from `events.tool`); import directly from the domain module

Applied to files:

  • src/synthorg/api/auth/session_store.py
📚 Learning: 2026-03-19T11:33:01.580Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-19T11:33:01.580Z
Learning: Applies to src/synthorg/**/*.py : Use event constants from `synthorg.observability.events.<domain>` (e.g., `API_REQUEST_STARTED` from `events.api`); import directly and log with structured kwargs: `logger.info(EVENT, key=value)`, never interpolated strings

Applied to files:

  • src/synthorg/api/auth/session_store.py
📚 Learning: 2026-04-06T20:42:41.219Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-06T20:42:41.219Z
Learning: Applies to src/synthorg/**/*.py : Event names: always use constants from domain-specific modules under synthorg.observability.events (e.g., API_REQUEST_STARTED from events.api). Import directly: from synthorg.observability.events.<domain> import EVENT_CONSTANT

Applied to files:

  • src/synthorg/api/auth/session_store.py
📚 Learning: 2026-03-20T11:18:48.128Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T11:18:48.128Z
Learning: Applies to src/synthorg/**/*.py : Use event name constants from domain-specific modules under `synthorg.observability.events` (e.g., `API_REQUEST_STARTED` from `events.api`, `TOOL_INVOKE_START` from `events.tool`). Import directly: `from synthorg.observability.events.<domain> import EVENT_CONSTANT`.

Applied to files:

  • src/synthorg/api/auth/session_store.py
📚 Learning: 2026-03-19T07:12:14.508Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-19T07:12:14.508Z
Learning: Applies to src/synthorg/observability/**/*.py : Observability package (observability/): structured logging, correlation tracking, log sinks; event constants organized by domain under observability/events/ (e.g., events.api, events.tool, events.git, events.context_budget, events.backup)

Applied to files:

  • src/synthorg/api/auth/session_store.py
📚 Learning: 2026-03-31T16:09:24.320Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-31T16:09:24.320Z
Learning: Applies to src/synthorg/**/*.py : Use event name constants from `synthorg.observability.events.<domain>` modules (e.g., `API_REQUEST_STARTED` from `events.api`, `TOOL_INVOKE_START` from `events.tool`); import directly and use in structured logging

Applied to files:

  • src/synthorg/api/auth/session_store.py
📚 Learning: 2026-03-15T19:14:27.144Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T19:14:27.144Z
Learning: Applies to src/synthorg/**/*.py : Use event name constants from synthorg.observability.events domain-specific modules (e.g., PROVIDER_CALL_START from events.provider). Import directly: from synthorg.observability.events.<domain> import EVENT_CONSTANT.

Applied to files:

  • src/synthorg/api/auth/session_store.py
📚 Learning: 2026-03-20T21:44:04.528Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T21:44:04.528Z
Learning: Applies to src/synthorg/**/*.py : Always use event name constants from domain-specific modules under `synthorg.observability.events` (e.g., `API_REQUEST_STARTED` from `events.api`, `TOOL_INVOKE_START` from `events.tool`); import directly rather than using string literals

Applied to files:

  • src/synthorg/api/auth/session_store.py
📚 Learning: 2026-03-17T22:08:13.456Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T22:08:13.456Z
Learning: Applies to src/synthorg/api/**/*.py : REST API: Litestar framework, controllers with guards, channels for WebSocket, JWT + API key + WS ticket auth, approval gate integration, coordination endpoint, collaboration endpoint, settings endpoint. RFC 9457 structured errors (ErrorCategory, ErrorCode, ErrorDetail, ProblemDetail, CATEGORY_TITLES, category_title, category_type_uri, content negotiation).

Applied to files:

  • src/synthorg/api/app.py
  • src/synthorg/api/errors.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-19T07:12:14.508Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-19T07:12:14.508Z
Learning: Applies to src/synthorg/**/*.py : Package structure: src/synthorg/ organized as: api/ (REST+WebSocket, Litestar), auth/ (auth subpackage), backup/ (scheduled/manual backups), budget/ (cost tracking, CFO), cli/ (superseded by Go CLI), communication/ (message bus, meetings), config/ (YAML loading), core/ (domain models, resilience config), engine/ (orchestration, task state, coordination, approval gates, stagnation detection, context budget, compaction), hr/ (hiring, performance, promotion), memory/ (pluggable backend, Mem0, retrieval, consolidation), persistence/ (operational data, SQLite, settings), observability/ (logging, correlation, sinks), providers/ (LLM abstraction, LiteLLM, auth types, presets, runtime CRUD), settings/ (runtime-editable, typed definitions, encryption, config bridge), security/ (SecOps, rule engine, output scanning, progressive trust, autonomy levels), templates/ (company templates, personalities), tools/ (registry, built-in tools, git, sandbox, code_runner, MCP...

Applied to files:

  • src/synthorg/api/app.py
📚 Learning: 2026-03-15T19:14:27.144Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T19:14:27.144Z
Learning: Applies to src/synthorg/api/**/*.py : Authentication uses JWT + API key. Approval gate integration for high-risk operations.

Applied to files:

  • src/synthorg/api/app.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-20T08:28:32.845Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T08:28:32.845Z
Learning: Applies to src/synthorg/providers/**/*.py : Providers: LLM provider abstraction (LiteLLM adapter), auth types (api_key/oauth/custom_header/none), presets (PROVIDER_PRESETS), runtime CRUD (ProviderManagementService with asyncio.Lock serialization), hot-reload via AppState swap.

Applied to files:

  • src/synthorg/api/app.py
📚 Learning: 2026-03-31T21:07:37.470Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-31T21:07:37.470Z
Learning: Applies to **/*.py : Use `except A, B:` (no parentheses) per PEP 758 exception syntax on Python 3.14

Applied to files:

  • src/synthorg/api/app.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-20T21:44:04.528Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T21:44:04.528Z
Learning: Applies to **/*.py : Use `except A, B:` syntax (without parentheses) per PEP 758 for exception handling in Python 3.14

Applied to files:

  • src/synthorg/api/app.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-16T07:22:28.134Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-16T07:22:28.134Z
Learning: Applies to **/*.py : Use `except A, B:` syntax (no parentheses) for exception handling — PEP 758 exception syntax enforced by ruff on Python 3.14

Applied to files:

  • src/synthorg/api/app.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-14T15:43:05.601Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-14T15:43:05.601Z
Learning: Applies to **/*.py : Use PEP 758 except syntax: `except A, B:` (no parentheses) — enforced by ruff on Python 3.14

Applied to files:

  • src/synthorg/api/app.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-14T16:18:57.267Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-14T16:18:57.267Z
Learning: Applies to **/*.py : Use PEP 758 except syntax with `except A, B:` (no parentheses) for multiple exceptions—ruff enforces this on Python 3.14.

Applied to files:

  • src/synthorg/api/app.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-04-06T20:42:41.219Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-06T20:42:41.219Z
Learning: Applies to **/*.py : Use PEP 758 except syntax: `except A, B:` (no parentheses)—ruff enforces this on Python 3.14.

Applied to files:

  • src/synthorg/api/app.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-15T16:55:07.730Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T16:55:07.730Z
Learning: Applies to **/*.py : Use PEP 758 except syntax: use `except A, B:` (no parentheses) — ruff enforces this on Python 3.14.

Applied to files:

  • src/synthorg/api/app.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-16T07:22:28.134Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-16T07:22:28.134Z
Learning: Applies to **/*.py : Handle errors explicitly; never silently swallow exceptions

Applied to files:

  • src/synthorg/api/app.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-14T15:43:05.601Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-14T15:43:05.601Z
Learning: Applies to **/*.py : Handle errors explicitly, never silently swallow exceptions

Applied to files:

  • src/synthorg/api/app.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-14T16:18:57.267Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-14T16:18:57.267Z
Learning: Applies to **/*.py : Handle errors explicitly—never silently swallow exceptions.

Applied to files:

  • src/synthorg/api/app.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-04-06T20:42:41.219Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-06T20:42:41.219Z
Learning: Version locations: pyproject.toml ([tool.commitizen].version), src/synthorg/__init__.py (__version__).

Applied to files:

  • src/synthorg/api/app.py
📚 Learning: 2026-03-15T19:14:27.144Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T19:14:27.144Z
Learning: Applies to src/synthorg/**/*.py : Use Pydantic v2 BaseModel, model_validator, computed_field, ConfigDict.

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-15T18:42:17.990Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T18:42:17.990Z
Learning: Applies to src/synthorg/**/*.py : Use Pydantic v2 conventions: `BaseModel`, `model_validator`, `computed_field`, `ConfigDict`

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-17T22:08:13.456Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T22:08:13.456Z
Learning: Applies to src/synthorg/**/*.py : Use Pydantic v2 conventions: `BaseModel`, `model_validator`, `computed_field`, `ConfigDict`. For derived values use `computed_field` instead of storing + validating redundant fields. Use `NotBlankStr` (from `core.types`) for all identifier/name fields — including optional (`NotBlankStr | None`) and tuple (`tuple[NotBlankStr, ...]`) variants — instead of manual whitespace validators.

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-15T18:38:44.202Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T18:38:44.202Z
Learning: Applies to src/synthorg/**/*.py : Use frozen Pydantic models for config/identity; separate mutable-via-copy models (using `model_copy(update=...)`) for runtime state

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-20T21:44:04.528Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T21:44:04.528Z
Learning: Applies to src/synthorg/**/*.py : Use frozen Pydantic models for config/identity; use separate mutable-via-copy models (via `model_copy(update=...)`) for runtime state that evolves

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-27T12:44:29.466Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-27T12:44:29.466Z
Learning: Applies to web/src/stores/**/*.{ts,tsx} : Use Zustand stores in web dashboard for state management (auth, WebSocket, toast, analytics, domain shells)

Applied to files:

  • web/src/stores/auth.ts
  • web/src/__tests__/stores/auth.test.ts
📚 Learning: 2026-03-30T10:41:40.176Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-30T10:41:40.176Z
Learning: Applies to web/src/**/*.{ts,tsx} : Use Zustand stores for state management in the web dashboard; each domain has its own store module (auth, WebSocket, toast, analytics, setup, company, agents, budget, tasks, settings, providers, theme, per-domain stores)

Applied to files:

  • web/src/stores/auth.ts
  • web/src/__tests__/stores/auth.test.ts
📚 Learning: 2026-03-30T10:41:40.176Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-30T10:41:40.176Z
Learning: Applies to web/src/**/*.stories.tsx : Storybook 10: import from `storybook/test` (not `storybook/test`), `storybook/actions` (not `storybook/addon-actions`)

Applied to files:

  • web/src/__tests__/stores/auth.test.ts
📚 Learning: 2026-04-02T12:21:16.739Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: web/CLAUDE.md:0-0
Timestamp: 2026-04-02T12:21:16.739Z
Learning: Applies to web/src/**/*.stories.tsx : Storybook 10: Import from `storybook/test` instead of `storybook/test`

Applied to files:

  • web/src/__tests__/stores/auth.test.ts
📚 Learning: 2026-03-30T10:20:08.544Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-30T10:20:08.544Z
Learning: Applies to web/**/*.stories.{ts,tsx} : Storybook 10: Use storybook/test (not storybook/test) and storybook/actions (not storybook/addon-actions) import paths

Applied to files:

  • web/src/__tests__/stores/auth.test.ts
📚 Learning: 2026-03-17T06:30:14.180Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T06:30:14.180Z
Learning: Applies to src/synthorg/persistence/**/*.py : Persistence uses pluggable PersistenceBackend protocol. SQLite is the initial backend. Settings use SettingsRepository (namespaced settings CRUD).

Applied to files:

  • src/synthorg/api/auth/refresh_store.py
📚 Learning: 2026-03-17T22:08:13.456Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T22:08:13.456Z
Learning: Persistence backend: pluggable PersistenceBackend protocol in `src/synthorg/persistence/`, SQLite initial, SettingsRepository (namespaced settings CRUD).

Applied to files:

  • src/synthorg/api/auth/refresh_store.py
🔇 Additional comments (14)
web/src/__tests__/api/client-bypass.test.ts (3)

4-11: Bypass-path mocking is correctly scoped and explicit.
The file-scoped mocks for IS_DEV_AUTH_BYPASS and useAuthStore.getState().handleUnauthorized are clean and match the interceptor behavior under test.


16-17: Spy reset is correct for test isolation.
handleUnauthorizedSpy.mockClear() in beforeEach prevents state bleed between cases.


20-31: 401 bypass expectation is validated correctly.
The test now asserts the key contract: interceptor rejects the 401 error but does not trigger auth cleanup when dev bypass is active.

tests/unit/api/auth/test_config.py (4)

56-73: LGTM - Cookie configuration tests are well-structured.

The cookie settings tests properly validate defaults and samesite value constraints. The parametrization of valid samesite values (line 66) is the recommended approach per project guidelines and provides better test isolation.

Note: The parametrization suggestion from the past review comment has already been implemented.


75-94: LGTM - CSRF and session limit tests correctly validate configuration bounds.

The tests properly verify:

  • CSRF defaults for cookie and header names
  • Session limit default (5), unlimited mode (0), and upper bound enforcement (rejects 101)

All assertions align with the field constraints defined in AuthConfig (ge=0, le=100 for max_concurrent_sessions per context snippet).


96-103: LGTM - Refresh token defaults test is comprehensive.

The test verifies all four refresh token configuration defaults (enabled flag, expiry, cookie name, and path), aligning with the new refresh token feature introduced in this PR.


105-119: LGTM - Account lockout tests correctly validate configuration constraints.

The tests properly verify:

  • Lockout defaults (threshold=10, window=15min, duration=15min)
  • Min bound enforcement (rejects 0, requires ge=1)
  • Max bound enforcement (rejects 101, requires le=100)

All assertions align with the field constraints defined in AuthConfig (ge=1, le=100 for lockout_threshold per context snippet).

web/src/mocks/handlers/auth.ts (1)

5-23: Good alignment with cookie-based auth mocks.

Using AuthResponse plus seeding csrf_token in both login/setup handlers keeps MSW behavior aligned with the new CSRF interceptor flow.

src/synthorg/api/auth/session_store.py (1)

223-264: Session-limit enforcement implementation looks solid.

Oldest-session eviction, defensive max_sessions <= 0 handling, and structured enforcement logging are correctly wired.

src/synthorg/persistence/sqlite/schema.sql (1)

501-528: Schema additions are well-structured for lockout and refresh flows.

Tables, FK relationships, and indexes match expected access/cleanup patterns.

tests/unit/persistence/sqlite/test_migrations.py (1)

40-41: Migration expectations are correctly updated.

New tables and indexes are reflected in both expected sets, keeping schema assertions in sync.

Also applies to: 103-107

tests/unit/api/controllers/test_ws.py (1)

278-283: Nice improvement: middleware lookup is now order-agnostic.

Switching away from positional indexing makes these tests less brittle.

Also applies to: 402-407

src/synthorg/api/app.py (1)

35-35: CSRF and lockout-cleanup wiring is well integrated.

Middleware placement and periodic lockout cleanup logging are consistent with the new auth hardening flow.

Also applies to: 96-96, 339-350, 1281-1297

src/synthorg/api/errors.py (1)

44-47: AccountLockedError is implemented cleanly.

Non-negative retry_after normalization and new auth error codes are consistent with the error model.

Also applies to: 259-279

Comment on lines +69 to +98
async def create(
self,
token_hash: str,
session_id: str,
user_id: str,
expires_at: datetime,
) -> None:
"""Store a new refresh token.

Args:
token_hash: HMAC-SHA256 hash of the opaque token.
session_id: Associated JWT session ID.
user_id: Token owner's user ID.
expires_at: Expiry timestamp.
"""
now = datetime.now(UTC)
await self._db.execute(
"INSERT INTO refresh_tokens "
"(token_hash, session_id, user_id, expires_at, "
"used, created_at) "
"VALUES (?, ?, ?, ?, 0, ?)",
(
token_hash,
session_id,
user_id,
expires_at.isoformat(),
now.isoformat(),
),
)
await self._db.commit()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Log refresh-token mutations at INFO.

consume() already emits auth events, but create(), revoke_by_session(), revoke_by_user(), and cleanup_expired() all change persisted refresh-token state without a matching INFO-level audit event. That leaves issuance and bulk revocation hard to trace during incident response.

As per coding guidelines, "All state transitions must log at INFO."

Also applies to: 182-229

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/auth/refresh_store.py` around lines 69 - 98, The create(),
revoke_by_session(), revoke_by_user(), and cleanup_expired() methods are missing
INFO-level audit logs for state-changing refresh-token operations; add an INFO
log in each (e.g., immediately after the DB commit) that records the action
("refresh_token.created", "refresh_token.revoked_by_session",
"refresh_token.revoked_by_user", "refresh_token.cleaned_expired"), includes
non-secret identifying fields (token_hash or session_id or user_id or number of
rows affected), and uses the same logger used elsewhere in this module
(referencing the create, revoke_by_session, revoke_by_user, and cleanup_expired
symbols) so issuance and bulk revocations are auditable without exposing secret
token material.

Comment on lines +93 to +101
async logout() {
try {
await authApi.logout()
} catch (err) {
// Log but don't block -- server may have already cleared the cookie
log.warn('Logout API call failed:', getErrorMessage(err))
}
get().handleUnauthorized()
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't mark the user logged out when the server never cleared the cookie.

With HttpOnly cookies, the client cannot complete logout on its own. If authApi.logout() fails before the backend responds, calling handleUnauthorized() here creates a local-only logout and the next checkSession() will resurrect the still-valid session.

♻️ Proposed fix
     async logout() {
       try {
         await authApi.logout()
+        get().handleUnauthorized()
       } catch (err) {
-        // Log but don't block -- server may have already cleared the cookie
         log.warn('Logout API call failed:', getErrorMessage(err))
+        throw new Error('Logout failed. Please try again.', { cause: err })
       }
-      get().handleUnauthorized()
     },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async logout() {
try {
await authApi.logout()
} catch (err) {
// Log but don't block -- server may have already cleared the cookie
log.warn('Logout API call failed:', getErrorMessage(err))
}
get().handleUnauthorized()
},
async logout() {
try {
await authApi.logout()
get().handleUnauthorized()
} catch (err) {
log.warn('Logout API call failed:', getErrorMessage(err))
throw new Error('Logout failed. Please try again.', { cause: err })
}
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/stores/auth.ts` around lines 93 - 101, The logout flow currently
calls get().handleUnauthorized() even when authApi.logout() throws, causing a
local-only logout when the server still has a valid HttpOnly cookie; modify the
logout method so handleUnauthorized() is only invoked when authApi.logout()
completes successfully (or when the API response explicitly confirms session
cleared), i.e., move the call into the try block after await authApi.logout()
(or check a success flag/response) and do NOT call handleUnauthorized() inside
the catch so the client doesn't mark the user logged out when the server didn't
clear the cookie; reference the logout method, authApi.logout(),
handleUnauthorized(), and checkSession() when making this change.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/synthorg/api/auth/controller.py`:
- Around line 17-24: The import usage is inconsistent: move make_refresh_cookie
to the top import list alongside make_clear_refresh_cookie so both
refresh-cookie helpers are imported together; update the top import block that
currently lists generate_csrf_token, make_clear_csrf_cookie,
make_clear_refresh_cookie, make_clear_session_cookie, make_csrf_cookie,
make_session_cookie to also include make_refresh_cookie, and remove the late
import of make_refresh_cookie inside _make_session_cookies so that
_make_session_cookies uses the top-level import instead.
- Around line 993-1004: The code redundantly reads
request.app.state["app_state"] when app_state was already retrieved earlier;
update the call to _get_auth_config to pass the existing app_state variable
(instead of request.app.state["app_state"]) so the Response creation (using
make_clear_session_cookie, make_clear_csrf_cookie, make_clear_refresh_cookie and
the headers) reuses the previously fetched app_state and avoids duplicate
access.
- Around line 254-260: The refresh token is generated and added to cookies via
make_refresh_cookie but never persisted to RefreshStore, so make the enclosing
function async and after generating refresh_token/hash it should call the
RefreshStore API (e.g., refresh_store.create or refresh_store.save) to persist a
hashed token record tied to user_id and expiry (refresh_max_age) to support
single-use invalidation; ensure you hash the plain refresh_token before storing,
handle any errors, and update all call sites to pass in refresh_store and
user_id when config.jwt_refresh_enabled so the server can validate /auth/refresh
requests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 76b84ec6-2fdd-40b3-b853-b2c298616aa1

📥 Commits

Reviewing files that changed from the base of the PR and between d53c80f and 82481aa.

📒 Files selected for processing (1)
  • src/synthorg/api/auth/controller.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
  • GitHub Check: Deploy Preview
  • GitHub Check: Build Web
  • GitHub Check: Build Backend
  • GitHub Check: Dashboard Test
  • GitHub Check: Test (Python 3.14)
  • GitHub Check: Dependency Review
  • GitHub Check: Analyze (python)
🧰 Additional context used
📓 Path-based instructions (3)
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: No from __future__ import annotations—Python 3.14 has PEP 649 native lazy annotations.
Use PEP 758 except syntax: except A, B: (no parentheses)—ruff enforces this on Python 3.14.
Type hints: all public functions required, mypy strict mode enforced.
Docstrings: Google style, required on public classes/functions (enforced by ruff D rules).
Create new objects, never mutate existing ones. For non-Pydantic internal collections (registries, BaseTool), use copy.deepcopy() at construction + MappingProxyType wrapping for read-only enforcement.
For dict/list fields in frozen Pydantic models, rely on frozen=True for field reassignment prevention and copy.deepcopy() at system boundaries (tool execution, LLM provider serialization, inter-agent delegation, serializing for persistence).
Use Pydantic v2 (BaseModel, model_validator, computed_field, ConfigDict). Use allow_inf_nan=False in all ConfigDict declarations to reject NaN/Inf in numeric fields at validation time.
Use @computed_field for derived values instead of storing + validating redundant fields (e.g. TokenUsage.total_tokens).
Use NotBlankStr (from core.types) for all identifier/name fields—including optional (NotBlankStr | None) and tuple (tuple[NotBlankStr, ...]) variants—instead of manual whitespace validators.
Prefer asyncio.TaskGroup for fan-out/fan-in parallel operations in new code (e.g. multiple tool invocations, parallel agent calls). Prefer structured concurrency over bare create_task.
Line length: 88 characters (ruff enforced).
Functions: < 50 lines, files < 800 lines.
Handle errors explicitly, never silently swallow.
Validate at system boundaries (user input, external APIs, config files).
Variable name: always logger (not _logger, not log).
All error paths must log at WARNING or ERROR with context before raising.
All state transitions must log at INFO.
DEBUG for object creation, internal flow, entry/exit of key functions.
Pure data models, enums, and re-exports do NOT need logging.

Files:

  • src/synthorg/api/auth/controller.py
src/synthorg/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

src/synthorg/**/*.py: Every module with business logic MUST have: from synthorg.observability import get_logger then logger = get_logger(name)
Never use import logging / logging.getLogger() / print() in application code. Exception: observability/setup.py, observability/sinks.py, observability/syslog_handler.py, and observability/http_handler.py may use stdlib logging and print(..., file=sys.stderr).
Event names: always use constants from domain-specific modules under synthorg.observability.events (e.g., API_REQUEST_STARTED from events.api). Import directly: from synthorg.observability.events. import EVENT_CONSTANT
Structured kwargs: always logger.info(EVENT, key=value)—never logger.info("msg %s", val)
All provider calls go through BaseCompletionProvider which applies retry + rate limiting automatically. Never implement retry logic in driver subclasses or calling code.
RetryConfig and RateLimiterConfig are set per-provider in ProviderConfig.
Retryable errors (is_retryable=True): RateLimitError, ProviderTimeoutError, ProviderConnectionError, ProviderInternalError.
Non-retryable errors raise immediately without retry.
RetryExhaustedError signals that all retries failed—the engine layer catches this to trigger fallback chains.
Rate limiter respects RateLimitError.retry_after from providers—automatically pauses future requests.

Files:

  • src/synthorg/api/auth/controller.py
src/**/*.py

⚙️ CodeRabbit configuration file

This project uses Python 3.14+ with PEP 758 except syntax: "except A, B:" (comma-separated, no parentheses) is correct and mandatory -- do NOT flag it as a typo or suggest parenthesized form. The "except builtins.MemoryError, RecursionError: raise" pattern is intentional project convention for system-error propagation. When evaluating the 50-line function limit, count only the function body excluding the signature lines, decorators, and docstring. Functions 1-5 lines over due to docstrings or multi-line signatures should not be flagged. Do not suggest extracting single-use helper functions called exactly once -- this reduces readability without improving maintainability.

Files:

  • src/synthorg/api/auth/controller.py
🧠 Learnings (19)
📚 Learning: 2026-03-19T07:12:14.508Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-19T07:12:14.508Z
Learning: Applies to src/synthorg/api/**/*.py : API package (api/): Litestar REST + WebSocket with controllers, guards, channels, JWT + API key + WS ticket auth, approval gate integration, coordination endpoint, collaboration endpoint, settings endpoint, provider management endpoint (CRUD + test + presets), backup endpoint, RFC 9457 structured errors, AppState hot-reload slots, service auto-wiring (Phase 1 at construction, Phase 2 on startup), lifecycle helpers

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-15T19:14:27.144Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T19:14:27.144Z
Learning: Applies to src/synthorg/api/**/*.py : Authentication uses JWT + API key. Approval gate integration for high-risk operations.

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-31T21:07:37.470Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-31T21:07:37.470Z
Learning: Applies to **/*.py : Use `except A, B:` (no parentheses) per PEP 758 exception syntax on Python 3.14

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-20T21:44:04.528Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T21:44:04.528Z
Learning: Applies to **/*.py : Use `except A, B:` syntax (without parentheses) per PEP 758 for exception handling in Python 3.14

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-14T15:43:05.601Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-14T15:43:05.601Z
Learning: Applies to **/*.py : Use PEP 758 except syntax: `except A, B:` (no parentheses) — enforced by ruff on Python 3.14

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-16T07:22:28.134Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-16T07:22:28.134Z
Learning: Applies to **/*.py : Use `except A, B:` syntax (no parentheses) for exception handling — PEP 758 exception syntax enforced by ruff on Python 3.14

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-04-06T20:42:41.219Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-06T20:42:41.219Z
Learning: Applies to **/*.py : Use PEP 758 except syntax: `except A, B:` (no parentheses)—ruff enforces this on Python 3.14.

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-14T16:18:57.267Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-14T16:18:57.267Z
Learning: Applies to **/*.py : Use PEP 758 except syntax with `except A, B:` (no parentheses) for multiple exceptions—ruff enforces this on Python 3.14.

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-15T16:55:07.730Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T16:55:07.730Z
Learning: Applies to **/*.py : Use PEP 758 except syntax: use `except A, B:` (no parentheses) — ruff enforces this on Python 3.14.

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-16T07:22:28.134Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-16T07:22:28.134Z
Learning: Applies to **/*.py : Handle errors explicitly; never silently swallow exceptions

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-14T15:43:05.601Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-14T15:43:05.601Z
Learning: Applies to **/*.py : Handle errors explicitly, never silently swallow exceptions

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-14T16:18:57.267Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-14T16:18:57.267Z
Learning: Applies to **/*.py : Handle errors explicitly—never silently swallow exceptions.

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-17T06:30:14.180Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T06:30:14.180Z
Learning: Applies to src/synthorg/api/**/*.py : Use Litestar for REST + WebSocket API. Controllers, guards, channels, JWT + API key + WS ticket auth, RFC 9457 structured errors.

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-15T19:14:27.144Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T19:14:27.144Z
Learning: Applies to src/synthorg/**/*.py : Use Pydantic v2 BaseModel, model_validator, computed_field, ConfigDict.

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-15T18:42:17.990Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T18:42:17.990Z
Learning: Applies to src/synthorg/**/*.py : Use Pydantic v2 conventions: `BaseModel`, `model_validator`, `computed_field`, `ConfigDict`

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-17T22:08:13.456Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T22:08:13.456Z
Learning: Applies to src/synthorg/**/*.py : Use Pydantic v2 conventions: `BaseModel`, `model_validator`, `computed_field`, `ConfigDict`. For derived values use `computed_field` instead of storing + validating redundant fields. Use `NotBlankStr` (from `core.types`) for all identifier/name fields — including optional (`NotBlankStr | None`) and tuple (`tuple[NotBlankStr, ...]`) variants — instead of manual whitespace validators.

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-15T18:38:44.202Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T18:38:44.202Z
Learning: Applies to src/synthorg/**/*.py : Use frozen Pydantic models for config/identity; separate mutable-via-copy models (using `model_copy(update=...)`) for runtime state

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-20T21:44:04.528Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T21:44:04.528Z
Learning: Applies to src/synthorg/**/*.py : Use frozen Pydantic models for config/identity; use separate mutable-via-copy models (via `model_copy(update=...)`) for runtime state that evolves

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-17T22:08:13.456Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T22:08:13.456Z
Learning: Applies to src/synthorg/api/**/*.py : REST API: Litestar framework, controllers with guards, channels for WebSocket, JWT + API key + WS ticket auth, approval gate integration, coordination endpoint, collaboration endpoint, settings endpoint. RFC 9457 structured errors (ErrorCategory, ErrorCode, ErrorDetail, ProblemDetail, CATEGORY_TITLES, category_title, category_type_uri, content negotiation).

Applied to files:

  • src/synthorg/api/auth/controller.py
🔇 Additional comments (9)
src/synthorg/api/auth/controller.py (9)

371-402: LGTM!

Cookie-first authentication extraction with proper fallback to Authorization header. Exception handling appropriately logs at debug level for expected JWT errors and warning level for unexpected failures.


405-420: LGTM!

Defensive config accessor with proper fallback. PEP 758 exception syntax correctly used. As per coding guidelines: except A, B: (no parentheses) per PEP 758 on Python 3.14.


523-544: LGTM!

Session limit enforcement and cookie generation are well-structured. The auth_config is fetched once and reused for both enforce_session_limit and _make_session_cookies, addressing the previous review feedback.


563-614: LGTM!

Account lockout implementation is timing-safe with dummy hash verification. The threshold-crossing detection correctly raises AccountLockedError immediately when record_failure returns True, addressing the previous review feedback.


626-646: LGTM!

Session limit enforcement follows the same clean pattern as setup(), with auth_config fetched once and reused. Previous redundant call issue has been addressed.


715-754: LGTM!

Session rotation on password change is properly implemented: old session is revoked, new token issued, and _make_session_cookies handles all cookie generation including refresh tokens when enabled.


148-163: LGTM!

Clean DTO design - the JWT is correctly excluded from the response body and delivered via HttpOnly cookie. Pydantic v2 conventions followed with ConfigDict(frozen=True, allow_inf_nan=False).


359-368: LGTM!

Exception handling correctly re-raises system errors (MemoryError, RecursionError) while gracefully handling other failures as non-fatal with proper logging. The PEP 758 exception syntax is correct. As per coding guidelines: Use except A, B: (no parentheses) per PEP 758 on Python 3.14.


999-1003: No action required. The make_clear_refresh_cookie function correctly uses config.refresh_cookie_path (line 162 in src/synthorg/api/auth/cookies.py), which matches the path used in make_refresh_cookie. The browser will correctly recognize and delete the refresh cookie.

Aureliolo added 10 commits April 7, 2026 09:30
- Extend AuthConfig with cookie, CSRF, session limit, lockout, and
  refresh token fields (15 new fields, all with safe defaults)
- Update CorsConfig: allow_credentials=True, add X-CSRF-Token header
- Add cookies.py: 7 pure helpers for session/CSRF/refresh cookie
  creation and deletion (HttpOnly, Secure, SameSite=Strict)
- Add csrf.py: custom ASGI CSRF middleware using double-submit cookie
  pattern -- only validates when session cookie is present, skips
  API key requests (raw ASGI 403 response, not Litestar exception)
- Modify auth middleware: try session cookie first, fall back to
  Authorization header for API keys and system user CLI JWTs
- Modify controller: login/setup set HttpOnly cookies instead of
  returning JWT in response body (CookieSessionResponse replaces
  TokenResponse), logout clears cookies with Clear-Site-Data header
- Add SessionStore.enforce_session_limit: revokes oldest sessions
  when user exceeds max_concurrent_sessions (default 5)
- Add error codes: ACCOUNT_LOCKED, CSRF_REJECTED,
  REFRESH_TOKEN_INVALID, SESSION_LIMIT_EXCEEDED
- Add AccountLockedError (429) and CsrfRejectedError (403)
- Add 13 observability event constants
- Wire CSRF middleware into app.py middleware stack
- 186 auth tests pass (50+ new tests)
Backend:
- Add LockoutStore: hybrid in-memory + SQLite, tracks failed login
  attempts per username, enforces temp lockout after threshold (default
  10 in 15 min), sync O(1) is_locked check, auto-unlock
- Integrate lockout into login flow: check before password verify
  (timing-safe), record failure/success, raise AccountLockedError (429)
- Add RefreshStore: SQLite-backed, HMAC-SHA256 hashed tokens,
  single-use consume with replay detection, revoke by session/user
- Add login_attempts + refresh_tokens tables to schema.sql
- Update migrations allowlist for new tables
- Wire LockoutStore into AppState, lifecycle, cleanup loop

Frontend (complete cookie migration):
- Create csrf.ts: reads non-HttpOnly csrf_token cookie
- Rewrite auth store: tri-state authStatus (unknown/authenticated/
  unauthenticated), remove token/sessionStorage/expiry timer, add
  checkSession/handleUnauthorized/async logout
- Update Axios client: withCredentials=true, CSRF interceptor replaces
  JWT header interceptor, simplified 401 handler
- Update auth API: AuthResponse replaces TokenResponse, add logout/
  listSessions/revokeSession endpoints
- Update providers SSE: credentials=include + CSRF header
- Update route guards: tri-state loading/redirect/render
- Update useWebSocket: authStatus check replaces token check
- Update all auth-related tests (8 test files)
- 205 backend auth tests + 2418 frontend tests pass
…dleware

- Fix CsrfMiddleware: remove MiddlewareProtocol inheritance, use Any
  for ASGI send message types, fix scope type comparison
- Remove stale type: ignore comments in middleware and controller
- Update _EXPECTED_TABLES/INDEXES for login_attempts + refresh_tokens
- Update _EXPECTED_CODES for 4 new error codes
- Update middleware stack count test: 4 entries to 5 (added CSRF)
- Update WS middleware index: auth moved from [1] to [2] (CSRF at [1])
Critical fixes:
- Wire enforce_session_limit() into login and setup flows
- Forward AccountLockedError.retry_after to HTTP response + header
- Rotate session cookie on password change (new JWT + cookies)
- Fix listSessions: use unwrap (not unwrapPaginated), accept scope param
- Update docs/security.md with new cookie/CSRF/lockout threat model
- Update CLAUDE.md package structure with new auth modules

High fixes:
- CSRF: normalize path (strip trailing slash) before exempt check
- Lockout: normalize username to lowercase for consistent counting
- Lockout: add load_locked() to restore state after restart
- Fix lockout cleanup event constant (was API_SESSION_CLEANUP)
- Config: use NotBlankStr for cookie/CSRF identifier fields
- Middleware: log warning when cookie JWT fails before header fallback
- Schema: add idx_rt_session_id index, FK constraints on refresh_tokens
- Providers SSE: conditionally spread CSRF header (not empty string)
- checkSession: distinguish 401 from network errors

Medium fixes:
- RefreshStore.consume: use RETURNING to eliminate TOCTOU race
- Config: cross-field validator for refresh expiry vs access expiry
- RefreshRecord: add created_at <= expires_at temporal validator
- Fix docstrings (AuthConfig, cookies, lockout store)
- Update operations.md auth endpoint description

Minor fixes:
- Remove unused CsrfRejectedError, event constants
- Add error_code/error_category to CSRF 403 response

Pre-reviewed by 11 agents, 28 findings addressed
test_first_run_flow.py still expected token in response body and
used Authorization header. Extract cookie parsing helper, use
session + CSRF cookies for subsequent requests.
- Remove /auth/refresh from CSRF exempt paths (must require CSRF)
- Deduplicate _get_auth_config calls in setup/login (reuse auth_config)
- Guard enforce_session_limit against negative max_sessions
- Clamp AccountLockedError.retry_after to non-negative
- Add idx_la_attempted_at index for lockout cleanup queries
- Add session revocation check in RefreshStore.consume
- Revoke old session before issuing new one on password change
- Parametrize cookie samesite test
- Fix lockout duration test to use real constructor (not __new__)
- Extract _FakeState factory in middleware tests
- Find auth middleware by class name in WS tests (not index)
- Disconnect WebSocket on handleUnauthorized
- Preserve 'unknown' authStatus on non-401 errors in checkSession
- Fix CSRF token parser to handle '=' in cookie values
- Set csrf_token cookie in MSW mock handlers
- Assert handleUnauthorized not called in dev bypass test
- Return AccountLockedError (429) on the threshold-crossing failure
  instead of generic UnauthorizedError (401) -- the request that trips
  the lock now gets the correct status code and Retry-After header
- Guard logout session revocation with has_session_store check to
  prevent AttributeError in deployments without SQLite persistence
- Issue refresh cookie in _make_session_cookies when jwt_refresh_enabled
  is True -- browser receives the initial refresh token on login/setup
… persist refresh token

- Move make_refresh_cookie to top-level import alongside other cookie
  helpers (remove late import inside _make_session_cookies)
- Fix logout: reuse existing app_state variable instead of redundantly
  reading request.app.state['app_state']
- Persist refresh token: _make_session_cookies now async, accepts
  app_state/session_id/user_id, hashes and stores refresh token via
  RefreshStore.create when jwt_refresh_enabled is True
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
web/src/api/endpoints/providers.ts (1)

186-197: ⚠️ Potential issue | 🟡 Minor

Keep the SSE 401 path aligned with the dev-bypass rules.

This custom unauthorized branch calls handleUnauthorized() unconditionally. In dev-auth-bypass mode that diverges from the shared Axios client, so a 401 from the pull stream can still clear auth state / redirect even though normal requests intentionally skip cleanup. Gate this with the same bypass check or move both paths onto a shared helper.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/api/endpoints/providers.ts` around lines 186 - 197, The SSE 401
branch currently calls useAuthStore.getState().handleUnauthorized() (and
redirect) unconditionally which bypasses the dev-auth-bypass rules; update this
to use the same bypass gate as the shared Axios client (e.g., check the
dev-auth-bypass flag/function used elsewhere) so that handleUnauthorized() and
the window.location.href redirect only run when dev-auth-bypass is disabled, or
refactor both paths to call a shared helper (e.g., handleUnauthorizedResponse)
that encapsulates the bypass check and cleanup+redirect logic.
♻️ Duplicate comments (5)
web/src/utils/csrf.ts (1)

16-21: ⚠️ Potential issue | 🟠 Major

Guard decodeURIComponent to avoid interceptor-breaking throws.

Line 21 can throw on malformed cookie encoding, which can break all mutating-request interception instead of failing closed to null.

🔧 Proposed hardening
 export function getCsrfToken(): string | null {
   const match = document.cookie
-    .split('; ')
+    .split(';')
+    .map((row) => row.trim())
     .find((row) => row.startsWith('csrf_token='))
   if (!match) return null
   const eqIdx = match.indexOf('=')
-  return eqIdx === -1 ? null : decodeURIComponent(match.slice(eqIdx + 1))
+  if (eqIdx === -1) return null
+  try {
+    return decodeURIComponent(match.slice(eqIdx + 1))
+  } catch {
+    return null
+  }
 }
#!/bin/bash
set -euo pipefail

echo "Show CSRF token parser implementation:"
sed -n '1,40p' web/src/utils/csrf.ts

echo
echo "Locate raw decodeURIComponent usage:"
rg -n "decodeURIComponent\\(" web/src/utils/csrf.ts -n -C2
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/utils/csrf.ts` around lines 16 - 21, The current CSRF cookie parsing
uses decodeURIComponent directly and can throw on malformed encodings; wrap the
decodeURIComponent call in a try/catch (inside the function that contains this
snippet — e.g., the cookie lookup that defines match and eqIdx) and return null
on any URIError or generic exception to avoid breaking request interception;
specifically, keep the match and eqIdx logic, but replace the direct return of
decodeURIComponent(match.slice(eqIdx + 1)) with a guarded block that catches
errors and returns null.
web/src/hooks/useWebSocket.ts (1)

49-56: ⚠️ Potential issue | 🟠 Major

Auth loss may disable bindings without tearing down the WebSocket transport.

When isEnabled flips false, this hook stops setup and cleans handlers, but it does not ensure a store-level disconnect(). That can leave reconnection state alive unless auth flows explicitly disconnect.

#!/bin/bash
set -euo pipefail

echo "=== auth store ==="
fd '^auth\.ts$' web/src/stores -x sed -n '1,260p' {}

echo "=== websocket store ==="
fd '^websocket\.ts$' web/src/stores -x sed -n '1,260p' {}

echo "=== logout/unauthorized + disconnect call sites ==="
rg -n -C3 --type=ts --type=tsx \
  'handleUnauthorized|logout|authStatus|useWebSocketStore\.getState\(\)\.disconnect\(|\bdisconnect\s*[:(]' \
  web/src

Expected verification result: auth loss paths (handleUnauthorized/logout) explicitly call useWebSocketStore.getState().disconnect() so shouldBeConnected is cleared.

Also applies to: 60-61

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/hooks/useWebSocket.ts` around lines 49 - 56, When isEnabled becomes
false (auth loss), the hook currently cleans handlers but doesn't clear the
store connection state; call the websocket store's disconnect to fully tear down
reconnection state. In the useWebSocket hook add logic in the effect that
watches isEnabled (and authStatus) to invoke
useWebSocketStore.getState().disconnect() (or the store's disconnect action) and
set disposedRef appropriately when isEnabled flips false so
shouldBeConnected/reconnectExhausted are cleared; update teardown paths in the
same effect where setup/cleanup handlers are removed to consistently call
disconnect().
web/src/router/guards.tsx (1)

137-152: ⚠️ Potential issue | 🟠 Major

Finish the tri-state auth migration in SetupCompleteGuard.

This guard still keys off useIsAuthenticated() and never calls checkSession(). After a hard refresh with a valid session cookie, /setup can render while auth is still unresolved instead of failing closed back to the dashboard. Mirror the authStatus === 'unknown' handling used in the other guards here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/router/guards.tsx` around lines 137 - 152, Replace the current
useIsAuthenticated() flow in SetupCompleteGuard with the tri-state auth
handling: read authStatus and checkSession() (instead of relying solely on
useIsAuthenticated()), update the useEffect so fetchSetupStatus runs only when
authStatus === 'authenticated' (and setupComplete === null and !loading &&
!error), and add an authStatus === 'unknown' branch that calls checkSession()
and returns null (or a loader) while resolving; keep the existing "allow
through" behavior for unauthenticated users. Ensure you add authStatus and
checkSession to the effect/conditional dependencies and reference the existing
symbols (useIsAuthenticated(), fetchSetupStatus, setupComplete, loading, error,
checkSession, authStatus, children) when making the changes.
web/src/stores/auth.ts (1)

93-100: ⚠️ Potential issue | 🟠 Major

Only clear client auth after logout actually succeeds.

With HttpOnly cookies, the server response is still authoritative. If authApi.logout() fails before the cookie is cleared, this local handleUnauthorized() creates a fake logout and the next checkSession() will resurrect the session.

💡 Proposed fix
     async logout() {
       try {
         await authApi.logout()
+        get().handleUnauthorized()
       } catch (err) {
-        // Log but don't block -- server may have already cleared the cookie
         log.warn('Logout API call failed:', getErrorMessage(err))
+        throw new Error('Logout failed. Please try again.', { cause: err })
       }
-      get().handleUnauthorized()
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/stores/auth.ts` around lines 93 - 100, The logout flow calls
get().handleUnauthorized() even when authApi.logout() fails, which can create a
false local logout; change logout() so it only invokes
get().handleUnauthorized() after authApi.logout() successfully resolves (i.e.,
move the handleUnauthorized() call inside the try block after await
authApi.logout()), and keep the catch to log the failure (using
getErrorMessage(err)) but do not clear client auth state or call
handleUnauthorized() on error; reference: logout(), authApi.logout(),
handleUnauthorized(), and checkSession().
src/synthorg/api/auth/refresh_store.py (1)

69-99: ⚠️ Potential issue | 🟠 Major

Log refresh-token mutations at INFO.

consume() is auditable, but create(), revoke_by_session(), revoke_by_user(), and cleanup_expired() still mutate persisted auth state without an INFO-level event. cleanup_expired() currently logs only at DEBUG, so issuance and bulk revocation remain hard to reconstruct.

Based on learnings, "All state transitions must log at INFO level."

Also applies to: 182-229

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/auth/refresh_store.py` around lines 69 - 99, The create(),
revoke_by_session(), revoke_by_user(), and cleanup_expired() methods must emit
INFO-level audit logs like consume() does: add an INFO log in create() including
token_hash, session_id, user_id and expires_at (use ISO timestamps), add an INFO
log in revoke_by_session() including session_id and affected user_id(s) and
reason, add an INFO log in revoke_by_user() including user_id and (optionally)
scope/initiator, and change cleanup_expired() to log at INFO the expiry cutoff
timestamp and the number of rows removed; use the same logger instance and
message style/fields as consume() to keep logs consistent and auditable.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/security.md`:
- Line 73: Update the "Refresh token rotation" bullet to state that replay of a
consumed refresh token not only logs a warning but also triggers cascade
revocation of the session/related tokens; replace the current line "- **Refresh
token rotation** -- optional single-use refresh tokens with replay detection
(reuse of a consumed token triggers a warning)." with wording that explicitly
mentions "single-use refresh tokens with replay detection; on replay of a
consumed refresh token the system logs the event and performs cascade revocation
of the affected session and related tokens, causing sign-out," so operators
understand the side effect when diagnosing unexpected sign-outs.

In `@src/synthorg/api/auth/config.py`:
- Around line 175-187: Extend the model-level validation (e.g., in
_validate_refresh_expiry or a new `@model_validator`(mode="after") in the same
config model) to enforce cookie cross-field rules: if cookie_samesite == "none"
then require cookie_secure == True (raise ValueError with clear message
referencing cookie_samesite/cookie_secure), and check that session_cookie_name,
csrf_cookie_name, and refresh_cookie_name are all unique (raise ValueError
listing the colliding names). Use the existing attribute names
jwt_refresh_enabled, jwt_refresh_expiry_minutes, jwt_expiry_minutes,
cookie_samesite, cookie_secure, session_cookie_name, csrf_cookie_name,
refresh_cookie_name to locate and implement the checks and return self on
success.

In `@src/synthorg/api/auth/controller.py`:
- Around line 263-296: The refresh cookie is appended before the refresh token
is persisted, allowing issuance when persistence fails; change the flow so
persistence happens first: generate refresh_token and token_hash as you already
do, then if app_state and session_id and user_id are present, import
RefreshStore, get store via getattr(app_state, "_refresh_store", None) and call
await store.create(...); only after store.create completes successfully append
make_refresh_cookie(refresh_token, ...) to cookies; if _refresh_store is missing
or store.create raises, do NOT append the cookie and instead log the failure and
surface an explicit error (or return an appropriate failure response) rather
than swallowing the exception—keep references to make_refresh_cookie, app_state,
_refresh_store, store.create, refresh_token, and token_hash to locate the code
to change.
- Around line 258-261: The CSRF cookie is being issued with the access token TTL
(cookies list using make_csrf_cookie(generate_csrf_token(), expires_in, config))
which will expire before the refresh token; update the implementation so CSRF
validation won't block refresh calls by either extending the CSRF cookie
lifetime to match the refresh TTL (use the refresh expiry value instead of
expires_in when calling make_csrf_cookie in the cookies list) or add the refresh
route to the CSRF exemption set (add '/api/v1/auth/refresh' to the csrf_exempt
set referenced in src/synthorg/api/app.py); adjust whichever approach you take
to keep symbol names intact (make_csrf_cookie, generate_csrf_token, expires_in,
csrf_exempt, and the '/api/v1/auth/refresh' route).

In `@src/synthorg/api/auth/csrf.py`:
- Around line 55-58: The CSRF skip logic currently only checks for the session
cookie (self._session_cookie_name) and thus bypasses CSRF for refresh-token-only
requests; update the middleware in the CSRF check to not treat a request
carrying only the refresh cookie or hitting POST /auth/refresh as safe.
Concretely, modify the branch that uses self._session_cookie_name,
self._csrf_cookie_name and self._csrf_header_name and self._exempt_paths so that
CSRF is skipped only when the session cookie is present or the request path is
explicitly in self._exempt_paths; do not skip when only a refresh cookie is
present (or explicitly add POST /auth/refresh to require CSRF), and ensure the
CSRF header/cookie validation still runs for refresh requests.
- Around line 175-179: The _parse_cookies function currently calls
SimpleCookie(value.decode("latin-1")) directly which can raise CookieError on
malformed Cookie headers and cause a 500; wrap the SimpleCookie(...) call in a
try/except that catches http.cookies.CookieError (or CookieError) and on
exception either continue without parsing that header or return/leave result
empty so no cookies are added (ensuring the middleware proceeds and triggers the
intended 403 for missing session cookies); update the loop that populates result
(in _parse_cookies) to skip parsing when an exception is caught.

In `@src/synthorg/api/auth/lockout_store.py`:
- Around line 87-111: load_locked() currently grants a fresh full lock duration
by setting self._locked[uname] = time.monotonic() + self._duration_seconds after
finding users with >= threshold attempts; instead persist or reconstruct the
original expiry so restart only restores remaining TTL: update the SELECT to
retrieve the timestamp of the threshold-crossing attempt (e.g.,
MIN(attempted_at) or the Nth attempt time) for each username, compute
remaining_seconds = original_locked_until - now (or compute locked_until =
attempted_at_of_threshold + self._duration_seconds), and set self._locked[uname]
= time.monotonic() + max(0, remaining_seconds) only when remaining_seconds > 0;
also add unit tests for load_locked() to cover (1) restarting mid-lockout
(remaining TTL restored) and (2) restarting after lock expired but within
failure window (no lock restored).

In `@src/synthorg/api/auth/middleware.py`:
- Around line 123-132: The missing-auth branch currently raises
NotAuthorizedException without emitting the API_AUTH_FAILED event like other
auth failures; update the branch where auth_header is falsy (and session_cookie
falsy) to first log a WARNING using the
synthorg.observability.events.API_AUTH_FAILED event constant with relevant
context (e.g., connection info and session_cookie state) and then raise
NotAuthorizedException(detail="Missing authentication"); do the same for the
"Cookie was present but invalid" path if it doesn't already log, ensuring you
use the same event constant and context for consistency with other auth failure
branches.

In `@src/synthorg/api/config.py`:
- Around line 45-52: The allow_headers tuple currently hardcodes "X-CSRF-Token"
which can diverge from the configurable CSRF header in AuthConfig
(csrf_header_name); update ApiConfig to derive the CORS header entry from
AuthConfig or add a Pydantic root/cross-field validator on ApiConfig that reads
AuthConfig.csrf_header_name and ensures allow_headers includes that value (or
appends it if missing), referencing the allow_headers field, ApiConfig (or its
validator), and AuthConfig.csrf_header_name so CORS and CSRF remain in sync
automatically.

In `@src/synthorg/api/exception_handlers.py`:
- Around line 351-355: The current guard uses truthiness and will drop valid
retry_after=0; change the logic around retry_after_val (from getattr(exc,
"retry_after", None)) to check "is not None" and then validate/normalize its
numeric shape before setting headers: accept ints >= 0 (or numeric strings that
can be safely int()-parsed) and only then set headers = {"Retry-After":
str(retry_after_val)}; apply the same is-not-None + numeric validation pattern
where retry_after is handled (including the similar block around _build_response
at the later occurrence).

In `@src/synthorg/api/lifecycle.py`:
- Around line 233-248: Wrap the LockoutStore initialization and load sequence in
a try/except: catch any Exception raised by LockoutStore(db, auth_cfg) or await
lockout_store.load_locked(), log the failure with the existing API_APP_STARTUP
context (e.g., logger.error(API_APP_STARTUP, note="Lockout store initialization
failed", error=err) or similar) and then re-raise the exception so startup still
aborts; keep using the same symbols (LockoutStore, load_locked,
app_state.set_lockout_store, API_APP_STARTUP) to locate and instrument the
block.

In `@src/synthorg/persistence/sqlite/schema.sql`:
- Around line 515-524: The refresh_tokens.used column should be constrained to
boolean values; update the CREATE TABLE for refresh_tokens to add a CHECK
constraint on the used column (e.g., CHECK(used IN (0,1)) or a named constraint
like CONSTRAINT chk_refresh_tokens_used CHECK(used IN (0,1))) so that used
remains 0 or 1 and still keeps NOT NULL DEFAULT 0; ensure the constraint is
added in the CREATE TABLE statement for refresh_tokens to enforce the domain at
the DB level.

In `@tests/integration/api/test_first_run_flow.py`:
- Around line 155-157: The test is only asserting the session cookie but not the
CSRF cookie after auth; update the assertions in test_first_run_flow to require
both cookies by asserting that csrf_token (returned by
_extract_auth_cookies(resp)) is truthy wherever you already assert session_token
(e.g., after the /auth/setup and /auth/login responses), so call
_extract_auth_cookies(resp) and assert both session_token and csrf_token are
present immediately after those calls to validate the cookie-issuance contract.

In `@tests/unit/api/auth/test_controller.py`:
- Around line 19-33: The helper _extract_auth_cookies currently returns an empty
dict when cookies are missing which leads to KeyError later; update
_extract_auth_cookies to check for and assert the presence of both cookies
before returning (or raise an AssertionError with a clear message), e.g., after
extracting lookups from response.headers.multi_items() assert "session" in
result and "csrf_token" in result and include descriptive text like "missing
session cookie" / "missing csrf_token cookie" so tests fail with clear
diagnostics instead of a cryptic KeyError.

In `@tests/unit/api/auth/test_refresh_store.py`:
- Around line 19-32: The current db/store fixtures use an in-memory aiosqlite
connection which prevents testing atomic concurrent consumption across
independent connections; update or add a test that creates a file-backed SQLite
DB (use tmp_path to create a refresh.sqlite3), open two separate
aiosqlite.connect() connections (set row_factory and call apply_schema on each),
instantiate two RefreshStore objects from those connections, use
RefreshStore.create(...) to insert a token, then concurrently call
RefreshStore.consume(token_hash) from both stores (e.g., asyncio.TaskGroup) and
assert that exactly one call returns a non-None result, referencing the db
fixture replace or new test and the RefreshStore.create and RefreshStore.consume
methods.

In `@web/src/stores/auth.ts`:
- Around line 103-105: fetchUser() currently returns early when get().authStatus
=== 'unauthenticated', which prevents getMe() from running after the backend
sets the cookie during performAuthFlow(); update fetchUser() so it does not
short-circuit on 'unauthenticated' — only skip when already 'authenticated' (or
add an explicit force/fresh param) and always call getMe() otherwise so
performAuthFlow() can detect the new session and avoid falling into
handleUnauthorized(); adjust any callers or tests that relied on the old
short-circuit (references: fetchUser(), get().authStatus, getMe(),
performAuthFlow(), handleUnauthorized(), IS_DEV_AUTH_BYPASS).

---

Outside diff comments:
In `@web/src/api/endpoints/providers.ts`:
- Around line 186-197: The SSE 401 branch currently calls
useAuthStore.getState().handleUnauthorized() (and redirect) unconditionally
which bypasses the dev-auth-bypass rules; update this to use the same bypass
gate as the shared Axios client (e.g., check the dev-auth-bypass flag/function
used elsewhere) so that handleUnauthorized() and the window.location.href
redirect only run when dev-auth-bypass is disabled, or refactor both paths to
call a shared helper (e.g., handleUnauthorizedResponse) that encapsulates the
bypass check and cleanup+redirect logic.

---

Duplicate comments:
In `@src/synthorg/api/auth/refresh_store.py`:
- Around line 69-99: The create(), revoke_by_session(), revoke_by_user(), and
cleanup_expired() methods must emit INFO-level audit logs like consume() does:
add an INFO log in create() including token_hash, session_id, user_id and
expires_at (use ISO timestamps), add an INFO log in revoke_by_session()
including session_id and affected user_id(s) and reason, add an INFO log in
revoke_by_user() including user_id and (optionally) scope/initiator, and change
cleanup_expired() to log at INFO the expiry cutoff timestamp and the number of
rows removed; use the same logger instance and message style/fields as consume()
to keep logs consistent and auditable.

In `@web/src/hooks/useWebSocket.ts`:
- Around line 49-56: When isEnabled becomes false (auth loss), the hook
currently cleans handlers but doesn't clear the store connection state; call the
websocket store's disconnect to fully tear down reconnection state. In the
useWebSocket hook add logic in the effect that watches isEnabled (and
authStatus) to invoke useWebSocketStore.getState().disconnect() (or the store's
disconnect action) and set disposedRef appropriately when isEnabled flips false
so shouldBeConnected/reconnectExhausted are cleared; update teardown paths in
the same effect where setup/cleanup handlers are removed to consistently call
disconnect().

In `@web/src/router/guards.tsx`:
- Around line 137-152: Replace the current useIsAuthenticated() flow in
SetupCompleteGuard with the tri-state auth handling: read authStatus and
checkSession() (instead of relying solely on useIsAuthenticated()), update the
useEffect so fetchSetupStatus runs only when authStatus === 'authenticated' (and
setupComplete === null and !loading && !error), and add an authStatus ===
'unknown' branch that calls checkSession() and returns null (or a loader) while
resolving; keep the existing "allow through" behavior for unauthenticated users.
Ensure you add authStatus and checkSession to the effect/conditional
dependencies and reference the existing symbols (useIsAuthenticated(),
fetchSetupStatus, setupComplete, loading, error, checkSession, authStatus,
children) when making the changes.

In `@web/src/stores/auth.ts`:
- Around line 93-100: The logout flow calls get().handleUnauthorized() even when
authApi.logout() fails, which can create a false local logout; change logout()
so it only invokes get().handleUnauthorized() after authApi.logout()
successfully resolves (i.e., move the handleUnauthorized() call inside the try
block after await authApi.logout()), and keep the catch to log the failure
(using getErrorMessage(err)) but do not clear client auth state or call
handleUnauthorized() on error; reference: logout(), authApi.logout(),
handleUnauthorized(), and checkSession().

In `@web/src/utils/csrf.ts`:
- Around line 16-21: The current CSRF cookie parsing uses decodeURIComponent
directly and can throw on malformed encodings; wrap the decodeURIComponent call
in a try/catch (inside the function that contains this snippet — e.g., the
cookie lookup that defines match and eqIdx) and return null on any URIError or
generic exception to avoid breaking request interception; specifically, keep the
match and eqIdx logic, but replace the direct return of
decodeURIComponent(match.slice(eqIdx + 1)) with a guarded block that catches
errors and returns null.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: c639c6e5-6df9-4c53-afd6-c53d0f9fe6fe

📥 Commits

Reviewing files that changed from the base of the PR and between 82481aa and d7e23db.

📒 Files selected for processing (51)
  • CLAUDE.md
  • docs/design/operations.md
  • docs/security.md
  • src/synthorg/api/app.py
  • src/synthorg/api/auth/config.py
  • src/synthorg/api/auth/controller.py
  • src/synthorg/api/auth/cookies.py
  • src/synthorg/api/auth/csrf.py
  • src/synthorg/api/auth/lockout_store.py
  • src/synthorg/api/auth/middleware.py
  • src/synthorg/api/auth/refresh_store.py
  • src/synthorg/api/auth/session_store.py
  • src/synthorg/api/config.py
  • src/synthorg/api/errors.py
  • src/synthorg/api/exception_handlers.py
  • src/synthorg/api/lifecycle.py
  • src/synthorg/api/state.py
  • src/synthorg/observability/events/api.py
  • src/synthorg/persistence/sqlite/migrations.py
  • src/synthorg/persistence/sqlite/schema.sql
  • tests/integration/api/test_first_run_flow.py
  • tests/unit/api/auth/test_config.py
  • tests/unit/api/auth/test_controller.py
  • tests/unit/api/auth/test_cookies.py
  • tests/unit/api/auth/test_csrf.py
  • tests/unit/api/auth/test_lockout_store.py
  • tests/unit/api/auth/test_middleware.py
  • tests/unit/api/auth/test_refresh_store.py
  • tests/unit/api/auth/test_session_store.py
  • tests/unit/api/controllers/test_ws.py
  • tests/unit/api/test_app.py
  • tests/unit/api/test_errors.py
  • tests/unit/persistence/sqlite/test_migrations.py
  • web/src/__tests__/App.test.tsx
  • web/src/__tests__/api/client-bypass.test.ts
  • web/src/__tests__/api/client.test.ts
  • web/src/__tests__/components/layout/Sidebar.test.tsx
  • web/src/__tests__/hooks/useAuth.test.ts
  • web/src/__tests__/hooks/useWebSocket.test.ts
  • web/src/__tests__/router/guards.test.tsx
  • web/src/__tests__/stores/auth.test.ts
  • web/src/api/client.ts
  • web/src/api/endpoints/auth.ts
  • web/src/api/endpoints/providers.ts
  • web/src/api/types.ts
  • web/src/components/layout/Sidebar.stories.tsx
  • web/src/hooks/useWebSocket.ts
  • web/src/mocks/handlers/auth.ts
  • web/src/router/guards.tsx
  • web/src/stores/auth.ts
  • web/src/utils/csrf.ts

Comment on lines +258 to +261
cookies: list[Any] = [
make_session_cookie(token, expires_in, config),
make_csrf_cookie(generate_csrf_token(), expires_in, config),
]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== CSRF middleware exemptions ==="
fd -i 'csrf.py' src | while read -r file; do
  echo "--- $file ---"
  sed -n '1,260p' "$file"
done

echo
echo "=== Refresh route and CSRF cookie lifetime ==="
rg -n -C3 --type=py \
  '/auth/refresh|make_csrf_cookie|csrf_cookie_name|csrf_header_name|jwt_refresh_expiry_minutes' \
  src tests

Repository: Aureliolo/synthorg

Length of output: 17540


🏁 Script executed:

rg -n 'create_csrf_middleware_class|CsrfMiddleware|exempt_paths' src --type=py -B2 -A2

Repository: Aureliolo/synthorg

Length of output: 4025


🏁 Script executed:

fd -name '*.py' src -path '*app*' -o -name 'main.py' -o -name 'server.py' -o -name 'asgi.py' | head -20

Repository: Aureliolo/synthorg

Length of output: 292


🏁 Script executed:

rg -n 'def.*refresh|@router.*refresh' src --type=py -A1 | head -40

Repository: Aureliolo/synthorg

Length of output: 566


🏁 Script executed:

rg -n 'csrf_exempt' src/synthorg/api/app.py -B10 -A1

Repository: Aureliolo/synthorg

Length of output: 759


🏁 Script executed:

# Also look for the refresh endpoint definition
rg -n 'auth.*refresh' src/synthorg/api/auth/controller.py -A10 | head -50

Repository: Aureliolo/synthorg

Length of output: 897


🏁 Script executed:

rg -n 'jwt_expiry_minutes|JWT_EXPIRY' src/synthorg/api/auth/config.py -B1 -A1

Repository: Aureliolo/synthorg

Length of output: 438


🏁 Script executed:

# Look for where refresh endpoint is defined
rg -n 'def.*refresh|@.*refresh' src/synthorg/api/auth/controller.py -A15 | head -80

Repository: Aureliolo/synthorg

Length of output: 44


🏁 Script executed:

rg -n '/auth/refresh' src/synthorg/api/auth/controller.py -B5 -A15

Repository: Aureliolo/synthorg

Length of output: 44


🏁 Script executed:

# Search for POST/PUT methods with refresh in the name
rg -n 'refresh.*POST|POST.*refresh|@post.*refresh' src --type=py -i

Repository: Aureliolo/synthorg

Length of output: 163


🏁 Script executed:

# Look at the entire controller.py file to find refresh endpoint
cat src/synthorg/api/auth/controller.py | grep -n 'refresh' -i

Repository: Aureliolo/synthorg

Length of output: 1536


🏁 Script executed:

# Check what routes are in the auth controller
rg -n '@(get|post|put|patch|delete)' src/synthorg/api/auth/controller.py -A2 | head -100

Repository: Aureliolo/synthorg

Length of output: 679


🏁 Script executed:

# Search for refresh endpoint in all auth-related files
find src/synthorg/api/auth -name '*.py' -exec rg -l 'refresh' {} \;

Repository: Aureliolo/synthorg

Length of output: 201


🏁 Script executed:

# Look for any router definitions with refresh
rg -n 'refresh.*@|@.*post.*refresh' src --type=py -i -A5 -B5

Repository: Aureliolo/synthorg

Length of output: 44


🏁 Script executed:

# Check if there's a separate refresh router or if it's in a different file
find src -name '*.py' -exec grep -l 'auth/refresh' {} \;

Repository: Aureliolo/synthorg

Length of output: 93


🏁 Script executed:

# Check the config.py file for refresh path definition
cat -n src/synthorg/api/auth/config.py | grep -i refresh -A3 -B3

Repository: Aureliolo/synthorg

Length of output: 1920


🏁 Script executed:

# Look at make_refresh_cookie to understand the path
cat -n src/synthorg/api/auth/cookies.py | sed -n '72,90p'

Repository: Aureliolo/synthorg

Length of output: 691


🏁 Script executed:

# Look for any endpoint that handles refresh - check the full controller file
grep -n 'def ' src/synthorg/api/auth/controller.py | grep -i refresh

Repository: Aureliolo/synthorg

Length of output: 44


🏁 Script executed:

# Check if there's a refresh endpoint elsewhere
find src -name '*.py' -exec grep -l '@post.*refresh\|@get.*refresh' {} \;

Repository: Aureliolo/synthorg

Length of output: 44


🏁 Script executed:

# Look at the entire router setup in app.py to see all routes
rg -n 'router.*=|include_router' src/synthorg/api/app.py | head -20

Repository: Aureliolo/synthorg

Length of output: 91


🏁 Script executed:

# Check if refresh endpoint exists anywhere in src
rg -n '@(post|get|put|patch|delete)' src/synthorg/api/auth/controller.py | grep -A2 -B2 'logout\|sessions'

Repository: Aureliolo/synthorg

Length of output: 44


🏁 Script executed:

# Let's look at the full router definition to understand the structure
sed -n '1016,1100p' src/synthorg/api/app.py

Repository: Aureliolo/synthorg

Length of output: 3532


🏁 Script executed:

# Search for any endpoint pattern that might handle refresh
rg -n 'refresh' src/synthorg/api/auth/controller.py | grep '@\|def '

Repository: Aureliolo/synthorg

Length of output: 44


🏁 Script executed:

# Look at the entire controller.py to see all endpoints
wc -l src/synthorg/api/auth/controller.py

Repository: Aureliolo/synthorg

Length of output: 102


🏁 Script executed:

# Get a list of all methods in the controller
grep -n 'def ' src/synthorg/api/auth/controller.py

Repository: Aureliolo/synthorg

Length of output: 699


🏁 Script executed:

# Check if there's a separate refresh_store module that might have an endpoint
cat -n src/synthorg/api/auth/refresh_store.py | head -50

Repository: Aureliolo/synthorg

Length of output: 1906


🏁 Script executed:

# Search for refresh endpoint in the entire codebase
rg -n 'path.*refresh|refresh.*path' src --type=py

Repository: Aureliolo/synthorg

Length of output: 395


🏁 Script executed:

# Let's check what ALL_CONTROLLERS contains
grep -n 'ALL_CONTROLLERS' src/synthorg/api/app.py -B5 -A5

Repository: Aureliolo/synthorg

Length of output: 909


🏁 Script executed:

# Find where ALL_CONTROLLERS is defined
find src -name '*.py' -exec grep -l 'ALL_CONTROLLERS.*=' {} \;

Repository: Aureliolo/synthorg

Length of output: 102


🏁 Script executed:

# Check the controllers module
cat -n src/synthorg/api/controllers/__init__.py

Repository: Aureliolo/synthorg

Length of output: 5132


🏁 Script executed:

# Get all `@post/`@get/@put/@patch/@delete decorators in AuthController
sed -n '400,1059p' src/synthorg/api/auth/controller.py | grep -n '@'

Repository: Aureliolo/synthorg

Length of output: 180


🏁 Script executed:

# Let's check if refresh is handled elsewhere - search for any POST handler on the refresh path
rg -n 'refresh' src/synthorg/api/controllers --type=py

Repository: Aureliolo/synthorg

Length of output: 44


🏁 Script executed:

# Check if there's a nested route under auth
find src/synthorg/api -name '*.py' -path '*auth*' | xargs grep -l 'post\|get' 2>/dev/null

Repository: Aureliolo/synthorg

Length of output: 454


🏁 Script executed:

# Get the actual endpoint paths for the `@post` decorators
sed -n '493,510p' src/synthorg/api/auth/controller.py

Repository: Aureliolo/synthorg

Length of output: 653


🏁 Script executed:

sed -n '591,610p' src/synthorg/api/auth/controller.py

Repository: Aureliolo/synthorg

Length of output: 782


🏁 Script executed:

sed -n '700,720p' src/synthorg/api/auth/controller.py

Repository: Aureliolo/synthorg

Length of output: 822


🏁 Script executed:

# Most importantly, check if there's a refresh endpoint at all
grep -n '@post' src/synthorg/api/auth/controller.py | tail -5

Repository: Aureliolo/synthorg

Length of output: 137


🏁 Script executed:

# Check the endpoints at lines 843 and 1018
sed -n '843,855p' src/synthorg/api/auth/controller.py

Repository: Aureliolo/synthorg

Length of output: 540


🏁 Script executed:

sed -n '1018,1030p' src/synthorg/api/auth/controller.py

Repository: Aureliolo/synthorg

Length of output: 457


🏁 Script executed:

# Search for any mention of a refresh endpoint being planned or missing
rg -n 'TODO.*refresh|FIXME.*refresh|refresh.*endpoint|implement.*refresh' src --type=py

Repository: Aureliolo/synthorg

Length of output: 249


🏁 Script executed:

# Double-check: look for any routes that might be nested or added elsewhere
rg -n '/auth/refresh|refresh.*route|route.*refresh' src --type=py

Repository: Aureliolo/synthorg

Length of output: 149


🏁 Script executed:

# Check the test files for any refresh endpoint tests
find tests -name '*.py' -exec grep -l 'refresh' {} \;

Repository: Aureliolo/synthorg

Length of output: 487


Extend CSRF token lifetime or exempt the refresh endpoint from CSRF protection.

Line 260 sets the CSRF cookie lifetime to the access token lifetime (expires_in = 1440 minutes default), while the refresh token lifetime is 7 days (10080 minutes). The /auth/refresh endpoint path is not currently exempted from CSRF validation (line 1292 in src/synthorg/api/app.py exempts only login, setup, and health). When the refresh endpoint is implemented, clients will encounter CSRF validation failures once the access token expires but before the refresh token expires.

Either add /api/v1/auth/refresh to the csrf_exempt set, or reissue the CSRF token with a longer lifetime matching the refresh token expiry.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/auth/controller.py` around lines 258 - 261, The CSRF cookie
is being issued with the access token TTL (cookies list using
make_csrf_cookie(generate_csrf_token(), expires_in, config)) which will expire
before the refresh token; update the implementation so CSRF validation won't
block refresh calls by either extending the CSRF cookie lifetime to match the
refresh TTL (use the refresh expiry value instead of expires_in when calling
make_csrf_cookie in the cookies list) or add the refresh route to the CSRF
exemption set (add '/api/v1/auth/refresh' to the csrf_exempt set referenced in
src/synthorg/api/app.py); adjust whichever approach you take to keep symbol
names intact (make_csrf_cookie, generate_csrf_token, expires_in, csrf_exempt,
and the '/api/v1/auth/refresh' route).

Comment on lines +55 to +58
self._session_cookie_name = config.cookie_name
self._csrf_cookie_name = config.csrf_cookie_name
self._csrf_header_name = config.csrf_header_name
self._exempt_paths = exempt_paths
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't skip CSRF on refresh-token-only requests.

The skip path only looks for the session cookie. Once the short-lived access session expires, POST /auth/refresh will typically carry only the refresh cookie, so this middleware bypasses CSRF on a cookie-authenticated mutating endpoint. That leaves the refresh flow as a gap in the hardening.

💡 Proposed fix
         self._session_cookie_name = config.cookie_name
+        self._refresh_cookie_name = config.refresh_cookie_name
         self._csrf_cookie_name = config.csrf_cookie_name
         self._csrf_header_name = config.csrf_header_name
         self._exempt_paths = exempt_paths
@@
-        session_cookie = cookies.get(self._session_cookie_name)
-        if not session_cookie:
+        session_cookie = cookies.get(self._session_cookie_name)
+        refresh_cookie = cookies.get(self._refresh_cookie_name)
+        if not session_cookie and not refresh_cookie:
             # No session cookie -> not cookie-authenticated -> skip
             logger.debug(
                 API_CSRF_SKIPPED,
-                reason="no_session_cookie",
+                reason="no_auth_cookie",
                 path=path,
             )
             await self.app(scope, receive, send)
             return

Also applies to: 90-99

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/auth/csrf.py` around lines 55 - 58, The CSRF skip logic
currently only checks for the session cookie (self._session_cookie_name) and
thus bypasses CSRF for refresh-token-only requests; update the middleware in the
CSRF check to not treat a request carrying only the refresh cookie or hitting
POST /auth/refresh as safe. Concretely, modify the branch that uses
self._session_cookie_name, self._csrf_cookie_name and self._csrf_header_name and
self._exempt_paths so that CSRF is skipped only when the session cookie is
present or the request path is explicitly in self._exempt_paths; do not skip
when only a refresh cookie is present (or explicitly add POST /auth/refresh to
require CSRF), and ensure the CSRF header/cookie validation still runs for
refresh requests.

Comment on lines +233 to +248
# Lockout store shares the same DB connection.
from synthorg.api.auth.lockout_store import ( # noqa: PLC0415
LockoutStore,
)

auth_cfg = (
app_state.config.api.auth if app_state.config is not None else None
)
if auth_cfg is not None:
lockout_store = LockoutStore(db, auth_cfg)
await lockout_store.load_locked()
app_state.set_lockout_store(lockout_store)
logger.info(
API_APP_STARTUP,
note="Lockout store initialized",
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Log lockout-store startup failures explicitly.

If LockoutStore(...) or load_locked() fails here, startup aborts without the step-specific error log that the surrounding startup phases already emit. Wrap this block in a try/except that logs API_APP_STARTUP context before re-raising. As per coding guidelines "All error paths must log at WARNING or ERROR with context before raising exceptions."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/lifecycle.py` around lines 233 - 248, Wrap the LockoutStore
initialization and load sequence in a try/except: catch any Exception raised by
LockoutStore(db, auth_cfg) or await lockout_store.load_locked(), log the failure
with the existing API_APP_STARTUP context (e.g., logger.error(API_APP_STARTUP,
note="Lockout store initialization failed", error=err) or similar) and then
re-raise the exception so startup still aborts; keep using the same symbols
(LockoutStore, load_locked, app_state.set_lockout_store, API_APP_STARTUP) to
locate and instrument the block.

…lockout TTL, audit logs

Config validators:
- Reject SameSite=None without Secure (browser requirement)
- Reject colliding cookie names (session/csrf/refresh must be distinct)

Controller:
- Persist refresh token before appending cookie (skip cookie on failure)
- Fix CSRF cookie lifetime note in docs/security.md (cascade revocation)

Security hardening:
- Wrap SimpleCookie parsing in try/except (malformed cookies = absent)
- Lockout load_locked() restores remaining TTL, not fresh full duration
- Log warning on missing authentication before raising 401
- Fix retry_after=0 truthiness (use 'is not None' check)
- Wrap lockout store init in try/except (non-fatal on failure)
- CHECK(used IN (0,1)) constraint on refresh_tokens
- Gate SSE 401 handler with dev bypass check

Audit logging:
- Add INFO logs to RefreshStore create/revoke_by_session/revoke_by_user
- New event constants: API_AUTH_REFRESH_CREATED, API_AUTH_REFRESH_REVOKED

Tests:
- Assert both session + CSRF cookies in integration test
- Validate _extract_auth_cookies returns both cookies
- Fix fetchUser test for new short-circuit logic
- Wrap getCsrfToken decodeURIComponent in try/catch
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

♻️ Duplicate comments (7)
tests/integration/api/test_first_run_flow.py (1)

173-178: ⚠️ Potential issue | 🟡 Minor

Assert the login cookies before reusing them for the rest of the flow.

_extract_auth_cookies(resp) can return empty strings here, and the failure will only surface later as a 401/403 on /setup/*. Assert both cookies right after /auth/login so this test keeps verifying the login cookie-issuance contract directly.

✅ Suggested assertion
         assert resp.status_code == 200
         assert resp.json()["data"]["expires_in"] > 0
         jwt_token, csrf_token = _extract_auth_cookies(resp)
+        assert jwt_token, "missing session cookie after login"
+        assert csrf_token, "missing CSRF cookie after login"
 
         # Use cookie + CSRF for all subsequent requests.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/api/test_first_run_flow.py` around lines 173 - 178, After
calling _extract_auth_cookies(resp) in the login flow, assert that the returned
jwt_token and csrf_token are non-empty before mutating client.headers;
specifically verify jwt_token != "" and csrf_token != "" (or assert truthiness)
immediately after _extract_auth_cookies(resp) so the test fails fast if login
did not issue cookies, then proceed to set client.headers["Cookie"] and
client.headers["X-CSRF-Token"] as before.
src/synthorg/api/exception_handlers.py (1)

351-357: ⚠️ Potential issue | 🟡 Minor

Validate retry_after before copying it into the response.

Any non-None value is forwarded into both ErrorDetail.retry_after and Retry-After. A stray bool, negative number, or string can emit an invalid header or trip response-model validation and collapse the original error into the generic 500 fallback.

🛠️ Proposed hardening
-    retry_after_raw = getattr(exc, "retry_after", None)
-    retry_after_val: int | None = (
-        retry_after_raw if retry_after_raw is not None else None
-    )
+    retry_after_raw = getattr(exc, "retry_after", None)
+    retry_after_val: int | None = None
+    if isinstance(retry_after_raw, int) and not isinstance(retry_after_raw, bool):
+        if retry_after_raw >= 0:
+            retry_after_val = retry_after_raw
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/exception_handlers.py` around lines 351 - 357, The code
copies retry_after_raw into retry_after_val and headers without validation;
update the logic around retry_after_raw / retry_after_val / headers to accept
only a non-boolean integer >= 0 (or a numeric string you explicitly parse to
int) before assigning it to ErrorDetail.retry_after or adding the "Retry-After"
header, and otherwise treat it as None; ensure you explicitly filter out
booleans (isinstance(..., bool)), negative numbers, and non-numeric strings so
invalid values are not forwarded into the response model or headers.
web/src/utils/csrf.ts (1)

16-18: ⚠️ Potential issue | 🟠 Major

Don't assume document.cookie inserts a space after ;.

This misses csrf_token when the cookie string is a=b;csrf_token=... (no space), so mutating requests go out without the CSRF header. Split on ';' and trim each segment before matching.

🔧 Proposed parsing fix
   const match = document.cookie
-    .split('; ')
-    .find((row) => row.startsWith('csrf_token='))
+    .split(';')
+    .map((row) => row.trim())
+    .find((row) => row.startsWith('csrf_token='))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/utils/csrf.ts` around lines 16 - 18, The current cookie parsing
assumes a space after semicolons which fails when cookies are like
"a=b;csrf_token=..."; update the logic that computes the match variable (where
document.cookie is split and .find is used) to split on ';' instead of '; ' and
trim each segment before checking startsWith('csrf_token=') so csrf_token is
reliably found regardless of spacing; adjust the code in web/src/utils/csrf.ts
where match is defined and ensure the subsequent token extraction uses the same
variable.
src/synthorg/api/lifecycle.py (1)

242-257: ⚠️ Potential issue | 🟠 Major

Fail closed if lockout store restoration breaks.

This except Exception: only logs and continues. If LockoutStore(...) or load_locked() fails, /auth/login still comes up but account lockout is silently disabled. Re-raise after logging so startup fails closed instead of dropping the hardening feature.

🛡️ Proposed fix
                     except MemoryError, RecursionError:
                         raise
                     except Exception:
                         logger.error(
                             API_APP_STARTUP,
                             note="Lockout store initialization failed",
                             exc_info=True,
                         )
+                        raise

As per coding guidelines "All error paths must log at WARNING or ERROR with context before raising exceptions."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/lifecycle.py` around lines 242 - 257, The current try/except
around LockoutStore(db, auth_cfg) and await lockout_store.load_locked() only
logs errors and continues, leaving lockout disabled; change the Exception
handler to log the failure (using logger.error with API_APP_STARTUP and
exc_info=True) and then re-raise the exception so startup fails closed; locate
the block around LockoutStore, load_locked, app_state.set_lockout_store and
update the generic except Exception: to re-raise after logging (preserving the
existing MemoryError/RecursionError passthrough behavior).
web/src/stores/auth.ts (1)

93-101: ⚠️ Potential issue | 🟠 Major

Don't clear local auth when server logout is unconfirmed.

With HttpOnly cookies the client cannot delete the session itself. If authApi.logout() fails on network or 5xx, this still marks the user logged out locally, and the next checkSession() can resurrect the still-valid server session.

♻️ Suggested fix
     async logout() {
       try {
         await authApi.logout()
+        get().handleUnauthorized()
       } catch (err) {
-        // Log but don't block -- server may have already cleared the cookie
         log.warn('Logout API call failed:', getErrorMessage(err))
+        if (isAxiosError(err) && err.response?.status === 401) {
+          get().handleUnauthorized()
+          return
+        }
+        throw new Error('Logout failed. Please try again.', { cause: err })
       }
-      get().handleUnauthorized()
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/stores/auth.ts` around lines 93 - 101, The current logout() clears
local auth unconditionally even if authApi.logout() fails; change logout() so
get().handleUnauthorized() is only invoked when authApi.logout() completes
successfully (or when the response explicitly indicates the session is gone,
e.g., 204/401/404), and do not clear local state on network/5xx errors so a
still-valid HttpOnly cookie session can be detected by checkSession(); update
the logic around authApi.logout() and get().handleUnauthorized() accordingly.
src/synthorg/api/auth/csrf.py (1)

90-99: ⚠️ Potential issue | 🟠 Major

Don't skip CSRF on refresh-token-only requests.

This branch still treats “no session cookie” as “not cookie-authenticated”. Once the access cookie expires, /auth/refresh will typically arrive with only refresh_token, so the middleware bypasses CSRF on a mutating cookie flow.

🔐 Suggested fix
     def __init__(
         self,
         app: ASGIApp,
         *,
         config: AuthConfig,
         exempt_paths: frozenset[str] = frozenset(),
     ) -> None:
         self.app = app
         self._session_cookie_name = config.cookie_name
+        self._refresh_cookie_name = config.refresh_cookie_name
         self._csrf_cookie_name = config.csrf_cookie_name
         self._csrf_header_name = config.csrf_header_name
         self._exempt_paths = exempt_paths
@@
-        session_cookie = cookies.get(self._session_cookie_name)
-        if not session_cookie:
-            # No session cookie -> not cookie-authenticated -> skip
+        session_cookie = cookies.get(self._session_cookie_name)
+        refresh_cookie = cookies.get(self._refresh_cookie_name)
+        if not session_cookie and not refresh_cookie:
+            # No auth cookie -> not cookie-authenticated -> skip
             logger.debug(
                 API_CSRF_SKIPPED,
-                reason="no_session_cookie",
+                reason="no_auth_cookie",
                 path=path,
             )
             await self.app(scope, receive, send)
             return
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/auth/csrf.py` around lines 90 - 99, The current branch
returns early when session_cookie is missing (session_cookie /
self._session_cookie_name / API_CSRF_SKIPPED / path), which incorrectly skips
CSRF for refresh-token-only requests; change the condition so you only skip CSRF
when there is no session cookie AND either the request is not the refresh
endpoint or there is no refresh-token cookie (check the refresh cookie name such
as self._refresh_cookie_name and the refresh path like "/auth/refresh" or your
configured refresh path), i.e. for path == refresh path and a present refresh
cookie do not early-return—let the normal CSRF checks run instead of calling
await self.app(...).
src/synthorg/api/auth/refresh_store.py (1)

246-249: ⚠️ Potential issue | 🟠 Major

Promote cleanup audit logging to INFO.

This method deletes persisted auth state, so keeping the event at DEBUG makes the cleanup invisible in normal audit trails.

As per coding guidelines, "All state transitions must log at INFO level."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/auth/refresh_store.py` around lines 246 - 249, The cleanup
log is at DEBUG so removal of persisted auth state is not auditable; change the
logger call in the commit block (after await self._db.commit() where count =
cursor.rowcount and API_AUTH_REFRESH_CLEANUP is used) from logger.debug(...) to
logger.info(...), preserving the structured parameter removed=count so the event
is recorded at INFO level per the state-transition logging guideline.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/synthorg/api/auth/controller.py`:
- Around line 262-314: Add a POST /api/v1/auth/refresh route that validates and
consumes the persisted refresh token created by _make_session_cookies: read the
refresh cookie (created by make_refresh_cookie), verify its value by hashing
with AuthService.hash_api_key, look up and delete the matching record via the
RefreshStore (app_state._refresh_store) create/read/delete methods to ensure
single-use, check expiry (expires_at) and session_id/user_id association, and on
success issue new access JWT (and optionally a new refresh token/cookie) while
returning appropriate error responses for missing/invalid/expired tokens; ensure
the handler uses the same AuthService and RefreshStore types and logs failures
similarly to existing logger usage.

In `@src/synthorg/api/auth/lockout_store.py`:
- Around line 72-75: Replace the constant lockout_duration_seconds property with
a username-aware helper that returns the remaining TTL for that user's lock; add
a method remaining_lockout_seconds(username: str) -> int (or similarly named) on
the same class (where lockout_duration_seconds currently exists) that checks the
store for the user's lock key TTL and returns the remaining seconds (as an int,
clamped to >=0) and falls back to the configured _duration_seconds if the key is
missing; update callers that used lockout_duration_seconds to call this new
method with the username (ensure the method uses the same internal key
construction used by set/has/remove lock methods).
- Around line 87-119: load_locked currently only restores users with >=
threshold failures inside self._window, which can drop active locks; change the
DB query logic in load_locked (and the similar block at lines ~200-212) to
restore based on the most recent failure time plus the lock duration: compute
cutoff = (now - self._duration).isoformat() and select usernames whose
MAX(attempted_at) >= cutoff AND COUNT(*) >= self._threshold (or add HAVING
MAX(attempted_at) >= ? AND COUNT(*) >= ?), then keep the existing logic that
computes locked_until = max_at + self._duration and sets self._locked[uname] =
mono_now + remaining; also ensure cleanup_expired preserves rows until
locked_until (apply the same cutoff logic there).

In `@src/synthorg/api/auth/refresh_store.py`:
- Around line 107-124: The consume method currently takes an optional
is_session_revoked callback which can be omitted by callers; change the API so
session-revocation checking is enforced: make is_session_revoked a required
parameter on RefreshStore.consume (remove the | None option) or, better, add an
is_session_revoked callable as a non-optional dependency on the RefreshStore
class (inject via the RefreshStore constructor and store it as
self._is_session_revoked) and update consume to call
self._is_session_revoked(token_record.session_id) unconditionally; update any
call sites to supply the checker and remove the optional branch handling inside
consume (also apply the same change to the overload/variant referenced at lines
140-144).
- Around line 169-179: The replay-detection currently removes used refresh_token
rows during cleanup which lets replays later appear as not_found_or_expired;
update the cleanup logic (the routine that deletes from the refresh_tokens
table) so it only deletes rows whose expiry timestamp has passed (or implement a
separate tombstone-retention window) and retain rows with used=1 until their
expiry; locate the code paths that read used from refresh_tokens (the check
using token_hash and replay_row["used"]) and the cleanup/deletion SQL (mentioned
around lines 242-244) and change the DELETE WHERE clause to include expiry <
now() (or add a retention condition) so used tombstones persist until expiry.
- Around line 71-98: The stored expires_at must be normalized to UTC and naive
datetimes rejected: in the create method (and any other code paths that
INSERT/UPDATE or compare the expires_at column) validate that expires_at.tzinfo
is not None and raise an explicit error for naive datetimes, then convert using
expires_at.astimezone(UTC) and store the resulting UTC ISO string (consistent
format) instead of the caller's original offset; also ensure any comparisons
elsewhere that build UTC ISO strings (the code around the functions that
query/compare the expires_at column) use the same UTC-normalized ISO format so
lexical comparisons remain correct.

---

Duplicate comments:
In `@src/synthorg/api/auth/csrf.py`:
- Around line 90-99: The current branch returns early when session_cookie is
missing (session_cookie / self._session_cookie_name / API_CSRF_SKIPPED / path),
which incorrectly skips CSRF for refresh-token-only requests; change the
condition so you only skip CSRF when there is no session cookie AND either the
request is not the refresh endpoint or there is no refresh-token cookie (check
the refresh cookie name such as self._refresh_cookie_name and the refresh path
like "/auth/refresh" or your configured refresh path), i.e. for path == refresh
path and a present refresh cookie do not early-return—let the normal CSRF checks
run instead of calling await self.app(...).

In `@src/synthorg/api/auth/refresh_store.py`:
- Around line 246-249: The cleanup log is at DEBUG so removal of persisted auth
state is not auditable; change the logger call in the commit block (after await
self._db.commit() where count = cursor.rowcount and API_AUTH_REFRESH_CLEANUP is
used) from logger.debug(...) to logger.info(...), preserving the structured
parameter removed=count so the event is recorded at INFO level per the
state-transition logging guideline.

In `@src/synthorg/api/exception_handlers.py`:
- Around line 351-357: The code copies retry_after_raw into retry_after_val and
headers without validation; update the logic around retry_after_raw /
retry_after_val / headers to accept only a non-boolean integer >= 0 (or a
numeric string you explicitly parse to int) before assigning it to
ErrorDetail.retry_after or adding the "Retry-After" header, and otherwise treat
it as None; ensure you explicitly filter out booleans (isinstance(..., bool)),
negative numbers, and non-numeric strings so invalid values are not forwarded
into the response model or headers.

In `@src/synthorg/api/lifecycle.py`:
- Around line 242-257: The current try/except around LockoutStore(db, auth_cfg)
and await lockout_store.load_locked() only logs errors and continues, leaving
lockout disabled; change the Exception handler to log the failure (using
logger.error with API_APP_STARTUP and exc_info=True) and then re-raise the
exception so startup fails closed; locate the block around LockoutStore,
load_locked, app_state.set_lockout_store and update the generic except
Exception: to re-raise after logging (preserving the existing
MemoryError/RecursionError passthrough behavior).

In `@tests/integration/api/test_first_run_flow.py`:
- Around line 173-178: After calling _extract_auth_cookies(resp) in the login
flow, assert that the returned jwt_token and csrf_token are non-empty before
mutating client.headers; specifically verify jwt_token != "" and csrf_token !=
"" (or assert truthiness) immediately after _extract_auth_cookies(resp) so the
test fails fast if login did not issue cookies, then proceed to set
client.headers["Cookie"] and client.headers["X-CSRF-Token"] as before.

In `@web/src/stores/auth.ts`:
- Around line 93-101: The current logout() clears local auth unconditionally
even if authApi.logout() fails; change logout() so get().handleUnauthorized() is
only invoked when authApi.logout() completes successfully (or when the response
explicitly indicates the session is gone, e.g., 204/401/404), and do not clear
local state on network/5xx errors so a still-valid HttpOnly cookie session can
be detected by checkSession(); update the logic around authApi.logout() and
get().handleUnauthorized() accordingly.

In `@web/src/utils/csrf.ts`:
- Around line 16-18: The current cookie parsing assumes a space after semicolons
which fails when cookies are like "a=b;csrf_token=..."; update the logic that
computes the match variable (where document.cookie is split and .find is used)
to split on ';' instead of '; ' and trim each segment before checking
startsWith('csrf_token=') so csrf_token is reliably found regardless of spacing;
adjust the code in web/src/utils/csrf.ts where match is defined and ensure the
subsequent token extraction uses the same variable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 53b1a243-30b8-41a3-bd0d-4b7000f22f45

📥 Commits

Reviewing files that changed from the base of the PR and between d7e23db and 63f0a43.

📒 Files selected for processing (17)
  • docs/security.md
  • src/synthorg/api/auth/config.py
  • src/synthorg/api/auth/controller.py
  • src/synthorg/api/auth/csrf.py
  • src/synthorg/api/auth/lockout_store.py
  • src/synthorg/api/auth/middleware.py
  • src/synthorg/api/auth/refresh_store.py
  • src/synthorg/api/exception_handlers.py
  • src/synthorg/api/lifecycle.py
  • src/synthorg/observability/events/api.py
  • src/synthorg/persistence/sqlite/schema.sql
  • tests/integration/api/test_first_run_flow.py
  • tests/unit/api/auth/test_controller.py
  • web/src/__tests__/stores/auth.test.ts
  • web/src/api/endpoints/providers.ts
  • web/src/stores/auth.ts
  • web/src/utils/csrf.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (8)
  • GitHub Check: Deploy Preview
  • GitHub Check: Test (Python 3.14)
  • GitHub Check: Dashboard Test
  • GitHub Check: Build Sandbox
  • GitHub Check: Build Web
  • GitHub Check: Build Backend
  • GitHub Check: Dependency Review
  • GitHub Check: Analyze (python)
🧰 Additional context used
📓 Path-based instructions (5)
web/src/**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (web/CLAUDE.md)

web/src/**/*.{ts,tsx,js,jsx}: Always use createLogger from @/lib/logger -- never bare console.warn/console.error/console.debug in application code
Logger variable name must always be const log (e.g. const log = createLogger('module-name'))
Pass dynamic/untrusted values as separate arguments to logger methods (not interpolated into the message string) so they go through sanitizeArg
Attacker-controlled fields inside structured objects must be wrapped in sanitizeForLog() before embedding in log calls

Files:

  • web/src/utils/csrf.ts
  • web/src/api/endpoints/providers.ts
  • web/src/stores/auth.ts
  • web/src/__tests__/stores/auth.test.ts
web/src/**/*.{ts,tsx}

📄 CodeRabbit inference engine (web/CLAUDE.md)

web/src/**/*.{ts,tsx}: Use Tailwind semantic classes (text-foreground, bg-card, text-accent, text-success, bg-danger, etc.) or CSS variables (var(--so-*)) for colors; NEVER hardcode hex values in .tsx/.ts files
Use font-sans or font-mono (Geist tokens) for typography; NEVER set fontFamily directly in .tsx/.ts files
Use density-aware tokens (p-card, gap-section-gap, gap-grid-gap) or standard Tailwind spacing; NEVER hardcode pixel values for layout spacing in components
Use token variables (var(--so-shadow-card-hover), border-border, border-bright) for shadows and borders; NEVER hardcode values in .tsx/.ts files
Use @/lib/motion presets for Framer Motion transition durations; NEVER hardcode transition durations
CSS side-effect imports in TypeScript 6 require type declarations -- add /// <reference types="vite/client" /> at the top of files with CSS imports

web/src/**/*.{ts,tsx}: Always reuse existing components from web/src/components/ui/ before creating new ones in React dashboard code
Never hardcode hex colors, font-family, pixel spacing, or Framer Motion transitions in web dashboard code - use design tokens and @/lib/motion presets
Web dashboard: Node.js 22+, TypeScript 6.0+, use React 19, react-router, shadcn/ui, Base UI, Tailwind CSS 4, Zustand, @tanstack/react-query, Framer Motion, Storybook 10
TypeScript strict mode for web dashboard with zero ESLint warnings

Files:

  • web/src/utils/csrf.ts
  • web/src/api/endpoints/providers.ts
  • web/src/stores/auth.ts
  • web/src/__tests__/stores/auth.test.ts
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: No from __future__ import annotations - Python 3.14 has PEP 649 native lazy annotations
Use except A, B: syntax (no parentheses) for exception handling in Python 3.14 per PEP 758 - ruff enforces this
Use 88-character line length (enforced by ruff) in Python code

Files:

  • tests/unit/api/auth/test_controller.py
  • src/synthorg/api/exception_handlers.py
  • tests/integration/api/test_first_run_flow.py
  • src/synthorg/api/lifecycle.py
  • src/synthorg/api/auth/middleware.py
  • src/synthorg/api/auth/csrf.py
  • src/synthorg/api/auth/lockout_store.py
  • src/synthorg/api/auth/config.py
  • src/synthorg/observability/events/api.py
  • src/synthorg/api/auth/refresh_store.py
  • src/synthorg/api/auth/controller.py
tests/**/*.py

⚙️ CodeRabbit configuration file

Test files do not require Google-style docstrings on classes or functions -- ruff D rules are only enforced on src/. A bare @settings() decorator with no arguments on Hypothesis property tests is a no-op and should not be suggested -- the HYPOTHESIS_PROFILE env var controls example counts via registered profiles, which @given() honors automatically.

Files:

  • tests/unit/api/auth/test_controller.py
  • tests/integration/api/test_first_run_flow.py
src/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

src/**/*.py: Type hints required on all public functions; use mypy strict mode
Docstrings required on all public classes and functions using Google style
Create new objects, never mutate existing ones; for non-Pydantic internal collections, use copy.deepcopy() at construction and MappingProxyType wrapping for read-only enforcement
For dict/list fields in frozen Pydantic models, use copy.deepcopy() at system boundaries (tool execution, LLM provider serialization, inter-agent delegation, persistence serialization)
Use frozen Pydantic models for config/identity; use separate mutable-via-copy models for runtime state that evolves; never mix static config fields with mutable runtime fields in one model
Use Pydantic v2 with BaseModel, model_validator, computed_field, and ConfigDict
Use allow_inf_nan=False in all ConfigDict declarations to reject NaN/Inf in numeric fields at validation time
Use @computed_field for derived values instead of storing and validating redundant fields (e.g., TokenUsage.total_tokens)
Use NotBlankStr (from core.types) for all identifier/name fields - including optional and tuple variants - instead of manual whitespace validators
Prefer asyncio.TaskGroup for fan-out/fan-in parallel operations in new code (e.g., multiple tool invocations, parallel agent calls); prefer structured concurrency over bare create_task
Functions should be less than 50 lines; files should be less than 800 lines
Handle errors explicitly; never silently swallow exceptions
Validate at system boundaries: user input, external APIs, and config files
Every module with business logic MUST import from synthorg.observability import get_logger and use logger = get_logger(__name__)
Never use import logging / logging.getLogger() / print() in application code; exceptions allowed only in observability/setup.py, observability/sinks.py, observability/syslog_handler.py, and observability/http_handler.py
Use variable name logger (not...

Files:

  • src/synthorg/api/exception_handlers.py
  • src/synthorg/api/lifecycle.py
  • src/synthorg/api/auth/middleware.py
  • src/synthorg/api/auth/csrf.py
  • src/synthorg/api/auth/lockout_store.py
  • src/synthorg/api/auth/config.py
  • src/synthorg/observability/events/api.py
  • src/synthorg/api/auth/refresh_store.py
  • src/synthorg/api/auth/controller.py

⚙️ CodeRabbit configuration file

This project uses Python 3.14+ with PEP 758 except syntax: "except A, B:" (comma-separated, no parentheses) is correct and mandatory -- do NOT flag it as a typo or suggest parenthesized form. The "except builtins.MemoryError, RecursionError: raise" pattern is intentional project convention for system-error propagation. When evaluating the 50-line function limit, count only the function body excluding the signature lines, decorators, and docstring. Functions 1-5 lines over due to docstrings or multi-line signatures should not be flagged. Do not suggest extracting single-use helper functions called exactly once -- this reduces readability without improving maintainability.

Files:

  • src/synthorg/api/exception_handlers.py
  • src/synthorg/api/lifecycle.py
  • src/synthorg/api/auth/middleware.py
  • src/synthorg/api/auth/csrf.py
  • src/synthorg/api/auth/lockout_store.py
  • src/synthorg/api/auth/config.py
  • src/synthorg/observability/events/api.py
  • src/synthorg/api/auth/refresh_store.py
  • src/synthorg/api/auth/controller.py
🧠 Learnings (73)
📚 Learning: 2026-04-06T06:45:22.965Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: web/CLAUDE.md:0-0
Timestamp: 2026-04-06T06:45:22.965Z
Learning: Applies to web/src/**/*.{ts,tsx,js,jsx} : Always use `createLogger` from `@/lib/logger` -- never bare `console.warn`/`console.error`/`console.debug` in application code

Applied to files:

  • web/src/api/endpoints/providers.ts
📚 Learning: 2026-04-01T17:49:14.133Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-01T17:49:14.133Z
Learning: Applies to src/synthorg/{providers,engine}/**/*.py : Retryable errors are `RateLimitError`, `ProviderTimeoutError`, `ProviderConnectionError`, `ProviderInternalError`; non-retryable errors raise immediately; `RetryExhaustedError` signals all retries failed

Applied to files:

  • src/synthorg/api/exception_handlers.py
📚 Learning: 2026-03-20T11:18:48.128Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T11:18:48.128Z
Learning: Applies to src/synthorg/**/*.py : Retryable errors (`is_retryable=True`): `RateLimitError`, `ProviderTimeoutError`, `ProviderConnectionError`, `ProviderInternalError`. Non-retryable errors raise immediately without retry.

Applied to files:

  • src/synthorg/api/exception_handlers.py
📚 Learning: 2026-03-16T19:13:34.746Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-16T19:13:34.746Z
Learning: Applies to src/synthorg/providers/**/*.py : Retryable errors (is_retryable=True): RateLimitError, ProviderTimeoutError, ProviderConnectionError, ProviderInternalError. Non-retryable errors raise immediately without retry. RetryExhaustedError signals that all retries failed — the engine layer catches this to trigger fallback chains.

Applied to files:

  • src/synthorg/api/exception_handlers.py
📚 Learning: 2026-04-07T07:27:16.474Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-07T07:27:16.474Z
Learning: Applies to src/synthorg/providers/**/*.py : Retryable errors (`is_retryable=True`): `RateLimitError`, `ProviderTimeoutError`, `ProviderConnectionError`, `ProviderInternalError`; non-retryable errors raise immediately without retry

Applied to files:

  • src/synthorg/api/exception_handlers.py
📚 Learning: 2026-04-07T07:27:16.474Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-07T07:27:16.474Z
Learning: Applies to src/synthorg/providers/**/*.py : Rate limiter respects `RateLimitError.retry_after` from providers - automatically pauses future requests

Applied to files:

  • src/synthorg/api/exception_handlers.py
📚 Learning: 2026-04-07T07:27:16.474Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-07T07:27:16.474Z
Learning: Applies to src/synthorg/engine/**/*.py : Handle `RetryExhaustedError` at the engine layer to trigger fallback chains

Applied to files:

  • src/synthorg/api/exception_handlers.py
📚 Learning: 2026-03-31T14:17:24.182Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-31T14:17:24.182Z
Learning: Applies to src/synthorg/providers/**/*.py : Rate limiter respects `RateLimitError.retry_after` from providers — automatically pauses future requests

Applied to files:

  • src/synthorg/api/exception_handlers.py
📚 Learning: 2026-03-17T22:08:13.456Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T22:08:13.456Z
Learning: Applies to src/synthorg/**/*.py : `RetryConfig` and `RateLimiterConfig` are set per-provider in `ProviderConfig`. Retryable errors (`is_retryable=True`): `RateLimitError`, `ProviderTimeoutError`, `ProviderConnectionError`, `ProviderInternalError`. Non-retryable errors raise immediately without retry. `RetryExhaustedError` signals that all retries failed — the engine layer catches this to trigger fallback chains. Rate limiter respects `RateLimitError.retry_after` from providers — automatically pauses future requests.

Applied to files:

  • src/synthorg/api/exception_handlers.py
📚 Learning: 2026-03-15T18:28:13.207Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T18:28:13.207Z
Learning: Applies to src/synthorg/providers/**/*.py : Rate limiter respects RateLimitError.retry_after from providers — automatically pauses future requests.

Applied to files:

  • src/synthorg/api/exception_handlers.py
📚 Learning: 2026-03-20T08:28:32.845Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T08:28:32.845Z
Learning: Applies to src/synthorg/**/*.py : `RetryConfig` and `RateLimiterConfig` are set per-provider in `ProviderConfig`. Retryable errors: `RateLimitError`, `ProviderTimeoutError`, `ProviderConnectionError`, `ProviderInternalError`. Non-retryable errors raise immediately.

Applied to files:

  • src/synthorg/api/exception_handlers.py
📚 Learning: 2026-03-26T15:18:16.848Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-26T15:18:16.848Z
Learning: Applies to src/synthorg/api/**/*.py : Litestar API must include setup wizard, auth/, auto-wiring, and lifecycle management

Applied to files:

  • src/synthorg/api/lifecycle.py
📚 Learning: 2026-03-19T07:12:14.508Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-19T07:12:14.508Z
Learning: Applies to src/synthorg/api/**/*.py : API package (api/): Litestar REST + WebSocket with controllers, guards, channels, JWT + API key + WS ticket auth, approval gate integration, coordination endpoint, collaboration endpoint, settings endpoint, provider management endpoint (CRUD + test + presets), backup endpoint, RFC 9457 structured errors, AppState hot-reload slots, service auto-wiring (Phase 1 at construction, Phase 2 on startup), lifecycle helpers

Applied to files:

  • src/synthorg/api/lifecycle.py
  • docs/security.md
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-16T07:22:28.134Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-16T07:22:28.134Z
Learning: Applies to src/synthorg/**/*.py : All error paths must log at WARNING or ERROR with context before raising. All state transitions must log at INFO. DEBUG for object creation, internal flow, and key function entry/exit

Applied to files:

  • src/synthorg/api/lifecycle.py
📚 Learning: 2026-03-17T22:08:13.456Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T22:08:13.456Z
Learning: Applies to src/synthorg/**/*.py : All error paths must log at WARNING or ERROR with context before raising. All state transitions must log at INFO. DEBUG for object creation, internal flow, entry/exit of key functions.

Applied to files:

  • src/synthorg/api/lifecycle.py
  • src/synthorg/api/auth/refresh_store.py
📚 Learning: 2026-03-17T06:43:14.114Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T06:43:14.114Z
Learning: Applies to src/synthorg/**/*.py : All error paths must log at WARNING or ERROR with context before raising. All state transitions must log at INFO. DEBUG for object creation, internal flow, entry/exit of key functions. Pure data models, enums, and re-exports do NOT need logging.

Applied to files:

  • src/synthorg/api/lifecycle.py
  • src/synthorg/api/auth/refresh_store.py
📚 Learning: 2026-03-14T16:18:57.267Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-14T16:18:57.267Z
Learning: Applies to src/ai_company/!(observability)/**/*.py : All error paths must log at WARNING or ERROR with context before raising.

Applied to files:

  • src/synthorg/api/lifecycle.py
📚 Learning: 2026-03-15T16:55:07.730Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T16:55:07.730Z
Learning: Applies to src/synthorg/**/*.py : All error paths must log at WARNING or ERROR with context before raising.

Applied to files:

  • src/synthorg/api/lifecycle.py
📚 Learning: 2026-03-31T20:07:03.035Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-31T20:07:03.035Z
Learning: Applies to src/synthorg/**/*.py : All error paths must log at WARNING or ERROR with context before raising; all state transitions must log at INFO; DEBUG for object creation, internal flow, entry/exit of key functions

Applied to files:

  • src/synthorg/api/lifecycle.py
📚 Learning: 2026-03-15T18:38:44.202Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T18:38:44.202Z
Learning: Applies to src/synthorg/**/*.py : All error paths must log at WARNING or ERROR with context before raising

Applied to files:

  • src/synthorg/api/lifecycle.py
📚 Learning: 2026-03-31T21:07:37.470Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-31T21:07:37.470Z
Learning: Applies to **/*.py : Use `except A, B:` (no parentheses) per PEP 758 exception syntax on Python 3.14

Applied to files:

  • src/synthorg/api/auth/middleware.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-20T21:44:04.528Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T21:44:04.528Z
Learning: Applies to **/*.py : Use `except A, B:` syntax (without parentheses) per PEP 758 for exception handling in Python 3.14

Applied to files:

  • src/synthorg/api/auth/middleware.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-14T15:43:05.601Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-14T15:43:05.601Z
Learning: Applies to **/*.py : Use PEP 758 except syntax: `except A, B:` (no parentheses) — enforced by ruff on Python 3.14

Applied to files:

  • src/synthorg/api/auth/middleware.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-16T07:22:28.134Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-16T07:22:28.134Z
Learning: Applies to **/*.py : Use `except A, B:` syntax (no parentheses) for exception handling — PEP 758 exception syntax enforced by ruff on Python 3.14

Applied to files:

  • src/synthorg/api/auth/middleware.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-04-07T07:27:16.474Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-07T07:27:16.474Z
Learning: Applies to **/*.py : Use `except A, B:` syntax (no parentheses) for exception handling in Python 3.14 per PEP 758 - ruff enforces this

Applied to files:

  • src/synthorg/api/auth/middleware.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-14T16:18:57.267Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-14T16:18:57.267Z
Learning: Applies to **/*.py : Use PEP 758 except syntax with `except A, B:` (no parentheses) for multiple exceptions—ruff enforces this on Python 3.14.

Applied to files:

  • src/synthorg/api/auth/middleware.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-15T16:55:07.730Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T16:55:07.730Z
Learning: Applies to **/*.py : Use PEP 758 except syntax: use `except A, B:` (no parentheses) — ruff enforces this on Python 3.14.

Applied to files:

  • src/synthorg/api/auth/middleware.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-16T07:22:28.134Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-16T07:22:28.134Z
Learning: Applies to **/*.py : Handle errors explicitly; never silently swallow exceptions

Applied to files:

  • src/synthorg/api/auth/middleware.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-14T15:43:05.601Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-14T15:43:05.601Z
Learning: Applies to **/*.py : Handle errors explicitly, never silently swallow exceptions

Applied to files:

  • src/synthorg/api/auth/middleware.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-14T16:18:57.267Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-14T16:18:57.267Z
Learning: Applies to **/*.py : Handle errors explicitly—never silently swallow exceptions.

Applied to files:

  • src/synthorg/api/auth/middleware.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-19T11:33:01.580Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-19T11:33:01.580Z
Learning: Applies to src/synthorg/**/*.py : Use event constants from `synthorg.observability.events.<domain>` (e.g., `API_REQUEST_STARTED` from `events.api`); import directly and log with structured kwargs: `logger.info(EVENT, key=value)`, never interpolated strings

Applied to files:

  • src/synthorg/api/auth/middleware.py
  • src/synthorg/observability/events/api.py
📚 Learning: 2026-03-31T16:09:24.320Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-31T16:09:24.320Z
Learning: Applies to src/synthorg/**/*.py : Use event name constants from `synthorg.observability.events.<domain>` modules (e.g., `API_REQUEST_STARTED` from `events.api`, `TOOL_INVOKE_START` from `events.tool`); import directly and use in structured logging

Applied to files:

  • src/synthorg/api/auth/middleware.py
  • src/synthorg/observability/events/api.py
📚 Learning: 2026-03-15T19:14:27.144Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T19:14:27.144Z
Learning: Applies to src/synthorg/api/**/*.py : Authentication uses JWT + API key. Approval gate integration for high-risk operations.

Applied to files:

  • src/synthorg/api/auth/middleware.py
  • docs/security.md
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-20T11:18:48.128Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T11:18:48.128Z
Learning: Applies to src/synthorg/api/**/*.py : Use Litestar for REST API and WebSocket API with JWT + API key + WS ticket authentication, RFC 9457 structured errors, and content negotiation.

Applied to files:

  • docs/security.md
📚 Learning: 2026-03-17T06:30:14.180Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T06:30:14.180Z
Learning: Applies to src/synthorg/api/**/*.py : Use Litestar for REST + WebSocket API. Controllers, guards, channels, JWT + API key + WS ticket auth, RFC 9457 structured errors.

Applied to files:

  • docs/security.md
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-19T07:12:14.508Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-19T07:12:14.508Z
Learning: Applies to src/synthorg/security/**/*.py : Security package (security/): SecOps agent, rule engine (soft-allow/hard-deny, fail-closed), audit log, output scanner, output scan response policies (redact/withhold/log-only/autonomy-tiered), risk classifier, risk tier classifier, action type registry, ToolInvoker security integration, progressive trust (4 strategies), autonomy levels (presets, resolver, change strategy), timeout policies (park/resume)

Applied to files:

  • docs/security.md
📚 Learning: 2026-04-06T06:45:22.965Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: web/CLAUDE.md:0-0
Timestamp: 2026-04-06T06:45:22.965Z
Learning: Applies to web/src/App.tsx : Wrap `App.tsx` with `<CSPProvider nonce={getCspNonce()}>` + `<MotionConfig nonce>` so every inline `<style>` tag injected by Base UI and Framer Motion carries the per-request CSP nonce

Applied to files:

  • docs/security.md
📚 Learning: 2026-04-01T20:43:51.878Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-01T20:43:51.878Z
Learning: Applies to web/src/**/*.{ts,tsx} : Always reuse existing components from `web/src/components/ui/` before creating new ones. Never hardcode hex colors, font-family, pixel spacing, or Framer Motion transitions -- use design tokens and `@/lib/motion` presets.

Applied to files:

  • docs/security.md
📚 Learning: 2026-04-06T06:45:22.965Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: web/CLAUDE.md:0-0
Timestamp: 2026-04-06T06:45:22.965Z
Learning: Applies to web/src/**/*.{ts,tsx,js,jsx} : Attacker-controlled fields inside structured objects must be wrapped in `sanitizeForLog()` before embedding in log calls

Applied to files:

  • docs/security.md
📚 Learning: 2026-03-31T14:17:24.182Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-31T14:17:24.182Z
Learning: Applies to .github/zap-rules.tsv : ZAP DAST security scanning rules in `.github/zap-rules.tsv`

Applied to files:

  • docs/security.md
📚 Learning: 2026-03-17T22:08:13.456Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T22:08:13.456Z
Learning: Security: SecOps agent, rule engine (soft-allow/hard-deny, fail-closed), audit log, output scanner, output scan response policies (redact/withhold/log-only/autonomy-tiered), risk classifier, risk tier classifier, action type registry, ToolInvoker security integration, progressive trust (4 strategies: disabled/weighted/per-category/milestone), autonomy levels (presets, resolver, change strategy), timeout policies (park/resume).

Applied to files:

  • docs/security.md
📚 Learning: 2026-03-27T12:44:29.466Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-27T12:44:29.466Z
Learning: Applies to web/src/stores/**/*.{ts,tsx} : Use Zustand stores in web dashboard for state management (auth, WebSocket, toast, analytics, domain shells)

Applied to files:

  • web/src/stores/auth.ts
  • web/src/__tests__/stores/auth.test.ts
📚 Learning: 2026-03-30T10:41:40.176Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-30T10:41:40.176Z
Learning: Applies to web/src/**/*.{ts,tsx} : Use Zustand stores for state management in the web dashboard; each domain has its own store module (auth, WebSocket, toast, analytics, setup, company, agents, budget, tasks, settings, providers, theme, per-domain stores)

Applied to files:

  • web/src/stores/auth.ts
  • web/src/__tests__/stores/auth.test.ts
📚 Learning: 2026-03-20T21:44:04.528Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T21:44:04.528Z
Learning: Applies to src/synthorg/**/*.py : Use frozen Pydantic models for config/identity; use separate mutable-via-copy models (via `model_copy(update=...)`) for runtime state that evolves

Applied to files:

  • src/synthorg/api/auth/config.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-15T18:38:44.202Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T18:38:44.202Z
Learning: Applies to src/synthorg/**/*.py : Use frozen Pydantic models for config/identity; separate mutable-via-copy models (using `model_copy(update=...)`) for runtime state

Applied to files:

  • src/synthorg/api/auth/config.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-15T19:14:27.144Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T19:14:27.144Z
Learning: Applies to src/synthorg/**/*.py : Use frozen Pydantic models for config/identity; use separate mutable-via-copy models (using model_copy(update=...)) for runtime state that evolves. Never mix static config fields with mutable runtime fields in one model.

Applied to files:

  • src/synthorg/api/auth/config.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-17T22:08:13.456Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T22:08:13.456Z
Learning: Applies to src/synthorg/**/*.py : Use Pydantic v2 conventions: `BaseModel`, `model_validator`, `computed_field`, `ConfigDict`. For derived values use `computed_field` instead of storing + validating redundant fields. Use `NotBlankStr` (from `core.types`) for all identifier/name fields — including optional (`NotBlankStr | None`) and tuple (`tuple[NotBlankStr, ...]`) variants — instead of manual whitespace validators.

Applied to files:

  • src/synthorg/api/auth/config.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-15T19:14:27.144Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T19:14:27.144Z
Learning: Applies to src/synthorg/**/*.py : Use Pydantic v2 BaseModel, model_validator, computed_field, ConfigDict.

Applied to files:

  • src/synthorg/api/auth/config.py
  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-16T20:14:00.937Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-16T20:14:00.937Z
Learning: Applies to **/*.py : Validate: at system boundaries (user input, external APIs, config files).

Applied to files:

  • src/synthorg/api/auth/config.py
📚 Learning: 2026-04-01T14:22:06.315Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-01T14:22:06.315Z
Learning: Applies to {**/*.py,web/src/**/*.{ts,tsx}} : Validate at system boundaries (user input, external APIs, config files)

Applied to files:

  • src/synthorg/api/auth/config.py
📚 Learning: 2026-03-19T07:13:44.964Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-19T07:13:44.964Z
Learning: Applies to **/*.py : Validate at system boundaries (user input, external APIs, config files)

Applied to files:

  • src/synthorg/api/auth/config.py
📚 Learning: 2026-03-15T19:14:27.144Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T19:14:27.144Z
Learning: Applies to **/*.py : Models: Pydantic v2 (BaseModel, model_validator, computed_field, ConfigDict). Use computed_field for derived values instead of storing + validating redundant fields. Use NotBlankStr (from core.types) for all identifier/name fields — including optional (NotBlankStr | None) and tuple (tuple[NotBlankStr, ...]) variants — instead of manual whitespace validators.

Applied to files:

  • src/synthorg/api/auth/config.py
📚 Learning: 2026-03-17T11:41:02.964Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T11:41:02.964Z
Learning: Applies to src/**/*.py : Models: Pydantic v2 (`BaseModel`, `model_validator`, `computed_field`, `ConfigDict`). Use `computed_field` for derived values instead of storing + validating redundant fields. Use `NotBlankStr` for all identifier/name fields — including optional (`NotBlankStr | None`) and tuple (`tuple[NotBlankStr, ...]`) variants — instead of manual whitespace validators.

Applied to files:

  • src/synthorg/api/auth/config.py
📚 Learning: 2026-04-02T11:49:21.202Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-02T11:49:21.202Z
Learning: Applies to **/*.py : Use Pydantic v2 (`BaseModel`, `model_validator`, `computed_field`, `ConfigDict`) with `allow_inf_nan=False` in all `ConfigDict` declarations

Applied to files:

  • src/synthorg/api/auth/config.py
📚 Learning: 2026-04-02T12:21:16.739Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: web/CLAUDE.md:0-0
Timestamp: 2026-04-02T12:21:16.739Z
Learning: Applies to web/src/**/*.stories.tsx : Storybook 10: Import from `storybook/test` instead of `storybook/test`

Applied to files:

  • web/src/__tests__/stores/auth.test.ts
📚 Learning: 2026-03-30T10:41:40.176Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-30T10:41:40.176Z
Learning: Applies to web/src/**/*.stories.tsx : Storybook 10: import from `storybook/test` (not `storybook/test`), `storybook/actions` (not `storybook/addon-actions`)

Applied to files:

  • web/src/__tests__/stores/auth.test.ts
📚 Learning: 2026-03-30T10:20:08.544Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-30T10:20:08.544Z
Learning: Applies to web/**/*.stories.{ts,tsx} : Storybook 10: Use storybook/test (not storybook/test) and storybook/actions (not storybook/addon-actions) import paths

Applied to files:

  • web/src/__tests__/stores/auth.test.ts
📚 Learning: 2026-03-20T11:18:48.128Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T11:18:48.128Z
Learning: Applies to src/synthorg/**/*.py : Use event name constants from domain-specific modules under `synthorg.observability.events` (e.g., `API_REQUEST_STARTED` from `events.api`, `TOOL_INVOKE_START` from `events.tool`). Import directly: `from synthorg.observability.events.<domain> import EVENT_CONSTANT`.

Applied to files:

  • src/synthorg/observability/events/api.py
📚 Learning: 2026-04-02T07:18:02.381Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-02T07:18:02.381Z
Learning: Applies to src/synthorg/**/*.py : Use event name constants from domain-specific modules under `synthorg.observability.events` (e.g., `API_REQUEST_STARTED` from `events.api`, `TOOL_INVOKE_START` from `events.tool`); import directly from the domain module

Applied to files:

  • src/synthorg/observability/events/api.py
📚 Learning: 2026-03-19T07:12:14.508Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-19T07:12:14.508Z
Learning: Applies to src/synthorg/observability/**/*.py : Observability package (observability/): structured logging, correlation tracking, log sinks; event constants organized by domain under observability/events/ (e.g., events.api, events.tool, events.git, events.context_budget, events.backup)

Applied to files:

  • src/synthorg/observability/events/api.py
📚 Learning: 2026-03-18T21:23:23.586Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-18T21:23:23.586Z
Learning: Applies to src/synthorg/**/*.py : Event names: always use constants from the domain-specific module under synthorg.observability.events (e.g., API_REQUEST_STARTED from events.api, TOOL_INVOKE_START from events.tool). Import directly from synthorg.observability.events.<domain>.

Applied to files:

  • src/synthorg/observability/events/api.py
📚 Learning: 2026-03-20T21:44:04.528Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T21:44:04.528Z
Learning: Applies to src/synthorg/**/*.py : Always use event name constants from domain-specific modules under `synthorg.observability.events` (e.g., `API_REQUEST_STARTED` from `events.api`, `TOOL_INVOKE_START` from `events.tool`); import directly rather than using string literals

Applied to files:

  • src/synthorg/observability/events/api.py
📚 Learning: 2026-03-15T19:14:27.144Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T19:14:27.144Z
Learning: Applies to src/synthorg/**/*.py : Use event name constants from synthorg.observability.events domain-specific modules (e.g., PROVIDER_CALL_START from events.provider). Import directly: from synthorg.observability.events.<domain> import EVENT_CONSTANT.

Applied to files:

  • src/synthorg/observability/events/api.py
📚 Learning: 2026-03-15T18:28:13.207Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T18:28:13.207Z
Learning: Applies to src/synthorg/**/*.py : Event names: always use constants from domain-specific modules under synthorg.observability.events (e.g., PROVIDER_CALL_START from events.provider, BUDGET_RECORD_ADDED from events.budget, etc.). Import directly: `from synthorg.observability.events.<domain> import EVENT_CONSTANT`.

Applied to files:

  • src/synthorg/observability/events/api.py
📚 Learning: 2026-03-14T16:18:57.267Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-14T16:18:57.267Z
Learning: Applies to src/ai_company/!(observability)/**/*.py : Use event name constants from domain-specific modules under `ai_company.observability.events` (e.g., `PROVIDER_CALL_START` from `events.provider`). Import directly: `from ai_company.observability.events.<domain> import EVENT_CONSTANT`.

Applied to files:

  • src/synthorg/observability/events/api.py
📚 Learning: 2026-03-17T06:30:14.180Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T06:30:14.180Z
Learning: Applies to src/synthorg/persistence/**/*.py : Persistence uses pluggable PersistenceBackend protocol. SQLite is the initial backend. Settings use SettingsRepository (namespaced settings CRUD).

Applied to files:

  • src/synthorg/api/auth/refresh_store.py
📚 Learning: 2026-03-15T18:28:13.207Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T18:28:13.207Z
Learning: Applies to src/synthorg/**/*.py : All state transitions must log at INFO.

Applied to files:

  • src/synthorg/api/auth/refresh_store.py
📚 Learning: 2026-04-07T07:27:16.474Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-07T07:27:16.474Z
Learning: Applies to src/**/*.py : All state transitions must log at INFO level

Applied to files:

  • src/synthorg/api/auth/refresh_store.py
📚 Learning: 2026-03-19T07:12:14.508Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-19T07:12:14.508Z
Learning: Applies to src/synthorg/**/*.py : All state transitions must log at INFO level

Applied to files:

  • src/synthorg/api/auth/refresh_store.py
📚 Learning: 2026-03-14T16:18:57.267Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-14T16:18:57.267Z
Learning: Applies to src/ai_company/!(observability)/**/*.py : Use DEBUG logging for object creation, internal flow, and entry/exit of key functions.

Applied to files:

  • src/synthorg/api/auth/refresh_store.py
📚 Learning: 2026-03-15T18:42:17.990Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T18:42:17.990Z
Learning: Applies to src/synthorg/**/*.py : Use Pydantic v2 conventions: `BaseModel`, `model_validator`, `computed_field`, `ConfigDict`

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-04-07T07:27:16.474Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-07T07:27:16.474Z
Learning: Applies to src/**/*.py : Use Pydantic v2 with `BaseModel`, `model_validator`, `computed_field`, and `ConfigDict`

Applied to files:

  • src/synthorg/api/auth/controller.py
📚 Learning: 2026-03-17T22:08:13.456Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T22:08:13.456Z
Learning: Applies to src/synthorg/api/**/*.py : REST API: Litestar framework, controllers with guards, channels for WebSocket, JWT + API key + WS ticket auth, approval gate integration, coordination endpoint, collaboration endpoint, settings endpoint. RFC 9457 structured errors (ErrorCategory, ErrorCode, ErrorDetail, ProblemDetail, CATEGORY_TITLES, category_title, category_type_uri, content negotiation).

Applied to files:

  • src/synthorg/api/auth/controller.py

Comment on lines +262 to +314
if config.jwt_refresh_enabled:
refresh_token = secrets.token_urlsafe(32)
refresh_max_age = config.jwt_refresh_expiry_minutes * 60
# Persist hashed refresh token BEFORE setting the cookie.
# Only append the cookie if persistence succeeds.
refresh_persisted = False
if app_state is not None and session_id and user_id:
try:
from synthorg.api.auth.refresh_store import ( # noqa: PLC0415, TC001
RefreshStore,
)

auth_service: AuthService = app_state.auth_service
token_hash = auth_service.hash_api_key(refresh_token)
refresh_expiry = datetime.now(UTC) + timedelta(
seconds=refresh_max_age,
)
# Access refresh store from app_state if available.
store: RefreshStore | None = getattr(app_state, "_refresh_store", None)
if store is not None:
await store.create(
token_hash=token_hash,
session_id=session_id,
user_id=user_id,
expires_at=refresh_expiry,
)
refresh_persisted = True
else:
logger.warning(
API_AUTH_FAILED,
reason="refresh_store_not_available",
)
except MemoryError, RecursionError:
raise
except Exception:
logger.warning(
API_AUTH_FAILED,
reason="refresh_token_persist_failed",
exc_info=True,
)
else:
logger.warning(
API_AUTH_FAILED,
reason="refresh_token_persist_skipped",
has_app_state=app_state is not None,
has_session_id=bool(session_id),
has_user_id=bool(user_id),
)
if refresh_persisted:
cookies.append(
make_refresh_cookie(refresh_token, refresh_max_age, config),
)
return cookies
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Refresh route definitions ==="
rg -n --type=py '/auth/refresh|@post\(\s*"/refresh"|@get\(\s*"/refresh"|@put\(\s*"/refresh"|@patch\(\s*"/refresh"|@delete\(\s*"/refresh"' src/synthorg/api

echo
echo "=== Refresh cookie issuance sites ==="
rg -n --type=py 'make_refresh_cookie|jwt_refresh_enabled|refresh_cookie_path' src/synthorg/api/auth

Repository: Aureliolo/synthorg

Length of output: 1023


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== All Python files in auth module ==="
find src/synthorg/api/auth -name "*.py" -type f | sort

echo
echo "=== All route decorators in auth module ==="
rg -n --type=py '@(app|router|api)\.(get|post|put|patch|delete|options)' src/synthorg/api/auth

echo
echo "=== Search for 'refresh' in any route context ==="
rg -n -B2 -A2 --type=py 'refresh' src/synthorg/api/auth/*.py | head -100

Repository: Aureliolo/synthorg

Length of output: 703


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== All function definitions in controller.py ==="
rg -n --type=py '^(async )?def ' src/synthorg/api/auth/controller.py

echo
echo "=== Full controller.py file (first 100 lines) ==="
head -100 src/synthorg/api/auth/controller.py

echo
echo "=== Lines around route/handler setup in controller.py ==="
head -50 src/synthorg/api/auth/controller.py | cat -n

Repository: Aureliolo/synthorg

Length of output: 5867


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Controller class definition and methods ==="
rg -n --type=py '^class Controller|^    @(get|post|put|patch|delete)\(|^    async def |^    def ' src/synthorg/api/auth/controller.py | head -80

Repository: Aureliolo/synthorg

Length of output: 566


🏁 Script executed:

#!/bin/bash
set -euo pipefixeuo pipefail

echo "=== Search for refresh endpoint handler anywhere in API ==="
rg -n --type=py 'refresh' src/synthorg/api --glob='*.py' -A2 -B2 | grep -E '(`@post`|@get|@put|async def|def )' | head -50

echo
echo "=== Check if there are router mounts or includes in main app setup ==="
find src/synthorg/api -name "*.py" -type f -exec grep -l 'include_router\|mount_controller\|Router\|Controller' {} \; | head -20

Repository: Aureliolo/synthorg

Length of output: 117


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Search for 'refresh' endpoint handlers in entire API ==="
rg -n --type=py 'def.*refresh|refresh.*endpoint|@.*refresh' src/synthorg/api

echo
echo "=== Check app initialization and router setup ==="
find src/synthorg/api -name "*.py" -exec grep -l 'include_router\|mount.*controller\|create_app' {} \;

Repository: Aureliolo/synthorg

Length of output: 726


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Check app.py for controller registration ==="
cat src/synthorg/api/app.py | head -150

echo
echo "=== Check if AuthController is mentioned elsewhere ==="
rg -n --type=py 'AuthController\|from.*controller import' src/synthorg/api

Repository: Aureliolo/synthorg

Length of output: 5878


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Check controllers/__init__.py for ALL_CONTROLLERS ==="
cat src/synthorg/api/controllers/__init__.py

echo
echo "=== List all files in controllers directory ==="
ls -la src/synthorg/api/controllers/

Repository: Aureliolo/synthorg

Length of output: 6981


Implement POST /api/v1/auth/refresh endpoint to consume refresh tokens.

The code issues and persists refresh tokens in _make_session_cookies (lines 262–314), but there is no route handler at /api/v1/auth/refresh to validate and consume them. Without this endpoint, refresh tokens are dead state and sessions cannot be extended beyond the access JWT expiry.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/auth/controller.py` around lines 262 - 314, Add a POST
/api/v1/auth/refresh route that validates and consumes the persisted refresh
token created by _make_session_cookies: read the refresh cookie (created by
make_refresh_cookie), verify its value by hashing with AuthService.hash_api_key,
look up and delete the matching record via the RefreshStore
(app_state._refresh_store) create/read/delete methods to ensure single-use,
check expiry (expires_at) and session_id/user_id association, and on success
issue new access JWT (and optionally a new refresh token/cookie) while returning
appropriate error responses for missing/invalid/expired tokens; ensure the
handler uses the same AuthService and RefreshStore types and logs failures
similarly to existing logger usage.

Comment on lines +72 to +75
@property
def lockout_duration_seconds(self) -> int:
"""Return the lockout duration in seconds for Retry-After."""
return self._duration_seconds
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Retry-After should use the remaining lock TTL.

The controller uses this property for every AccountLockedError, so a user who retries halfway through an existing lock still receives the full configured duration. Expose a username aware remaining seconds helper instead of a constant property.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/auth/lockout_store.py` around lines 72 - 75, Replace the
constant lockout_duration_seconds property with a username-aware helper that
returns the remaining TTL for that user's lock; add a method
remaining_lockout_seconds(username: str) -> int (or similarly named) on the same
class (where lockout_duration_seconds currently exists) that checks the store
for the user's lock key TTL and returns the remaining seconds (as an int,
clamped to >=0) and falls back to the configured _duration_seconds if the key is
missing; update callers that used lockout_duration_seconds to call this new
method with the username (ensure the method uses the same internal key
construction used by set/has/remove lock methods).

Comment on lines +87 to +119
now = datetime.now(UTC)
window_start = (now - self._window).isoformat()
cursor = await self._db.execute(
"SELECT username, COUNT(*) AS cnt, "
"MAX(attempted_at) AS max_attempted_at "
"FROM login_attempts "
"WHERE attempted_at >= ? "
"GROUP BY username "
"HAVING cnt >= ?",
(window_start, self._threshold),
)
rows = await cursor.fetchall()
mono_now = time.monotonic()
restored = 0
for row in rows:
uname = row["username"]
uname = uname.lower()
if uname not in self._locked:
max_at = datetime.fromisoformat(
row["max_attempted_at"],
)
locked_until = max_at + self._duration
remaining = (locked_until - now).total_seconds()
if remaining > 0:
self._locked[uname] = mono_now + remaining
restored += 1
if restored:
logger.info(
API_AUTH_ACCOUNT_LOCKED,
note="Restored lockout state from database",
restored=restored,
)
return restored
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

load_locked() can drop active locks after a restart.

This restores only usernames that still have >= threshold failures inside self._window, but an account stays locked until locked_until, not until the current sliding window still contains all of those failures. After enough early failures age out, a restart clears an otherwise active lock, and cleanup_expired() later deletes the only rows that could restore it.

Also applies to: 200-212

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/auth/lockout_store.py` around lines 87 - 119, load_locked
currently only restores users with >= threshold failures inside self._window,
which can drop active locks; change the DB query logic in load_locked (and the
similar block at lines ~200-212) to restore based on the most recent failure
time plus the lock duration: compute cutoff = (now - self._duration).isoformat()
and select usernames whose MAX(attempted_at) >= cutoff AND COUNT(*) >=
self._threshold (or add HAVING MAX(attempted_at) >= ? AND COUNT(*) >= ?), then
keep the existing logic that computes locked_until = max_at + self._duration and
sets self._locked[uname] = mono_now + remaining; also ensure cleanup_expired
preserves rows until locked_until (apply the same cutoff logic there).

Comment on lines +71 to +98
async def create(
self,
token_hash: str,
session_id: str,
user_id: str,
expires_at: datetime,
) -> None:
"""Store a new refresh token.

Args:
token_hash: HMAC-SHA256 hash of the opaque token.
session_id: Associated JWT session ID.
user_id: Token owner's user ID.
expires_at: Expiry timestamp.
"""
now = datetime.now(UTC)
await self._db.execute(
"INSERT INTO refresh_tokens "
"(token_hash, session_id, user_id, expires_at, "
"used, created_at) "
"VALUES (?, ?, ?, ?, 0, ?)",
(
token_hash,
session_id,
user_id,
expires_at.isoformat(),
now.isoformat(),
),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Normalize expires_at before any SQL compares it.

Line 96 persists whatever offset the caller passes, but Lines 131-133 and 243-244 later compare that column lexically against a UTC ISO string. That ordering is only correct if every value is normalized to the same timezone, so a non-UTC expiry can make a refresh token expire early or stay valid too long. Store UTC (or an epoch) here and reject naive datetimes at the boundary.

Also applies to: 131-133, 243-244

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/auth/refresh_store.py` around lines 71 - 98, The stored
expires_at must be normalized to UTC and naive datetimes rejected: in the create
method (and any other code paths that INSERT/UPDATE or compare the expires_at
column) validate that expires_at.tzinfo is not None and raise an explicit error
for naive datetimes, then convert using expires_at.astimezone(UTC) and store the
resulting UTC ISO string (consistent format) instead of the caller's original
offset; also ensure any comparisons elsewhere that build UTC ISO strings (the
code around the functions that query/compare the expires_at column) use the same
UTC-normalized ISO format so lexical comparisons remain correct.

Comment on lines +107 to +124
async def consume(
self,
token_hash: str,
*,
is_session_revoked: Callable[[str], bool] | None = None,
) -> RefreshRecord | None:
"""Atomically consume a refresh token.

Marks the token as used and returns its record.
Returns ``None`` if the token does not exist, is
expired, was already consumed (replay detection), or
belongs to a revoked session.

Args:
token_hash: HMAC-SHA256 hash of the presented token.
is_session_revoked: Optional sync callback (e.g.
``SessionStore.is_revoked``) to reject tokens
whose session has been revoked.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't make revoked-session checks optional.

With is_session_revoked optional here, a caller that forgets to pass it can still rotate a refresh token for a revoked session. That makes cascade revocation a call-site convention instead of an auth invariant. Make the checker mandatory on consume() or inject it into RefreshStore.

Also applies to: 140-144

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/auth/refresh_store.py` around lines 107 - 124, The consume
method currently takes an optional is_session_revoked callback which can be
omitted by callers; change the API so session-revocation checking is enforced:
make is_session_revoked a required parameter on RefreshStore.consume (remove the
| None option) or, better, add an is_session_revoked callable as a non-optional
dependency on the RefreshStore class (inject via the RefreshStore constructor
and store it as self._is_session_revoked) and update consume to call
self._is_session_revoked(token_record.session_id) unconditionally; update any
call sites to supply the checker and remove the optional branch handling inside
consume (also apply the same change to the overload/variant referenced at lines
140-144).

…after validation

- csrf.ts: split on ';' and trim (handles 'a=b;csrf_token=...' without space)
- refresh_store cleanup_expired: only delete expired rows, retain used
  tombstones until expiry for replay detection
- cleanup_expired log level: DEBUG to INFO (state-transition guideline)
- exception_handlers: reject booleans/negatives in retry_after validation
- integration test: assert login cookies are non-empty before use
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/synthorg/api/auth/refresh_store.py`:
- Around line 156-167: The datetime.fromisoformat calls used to build the
RefreshRecord (for expires_at and created_at) can raise ValueError on malformed
DB strings; wrap each conversion in a try/except ValueError, use the module
logger to emit a warning that includes the field name and the raw row value
(e.g., row["expires_at"] / row["created_at"]), and on exception set the
corresponding attribute to None (or a safe default) before returning the
RefreshRecord; keep the rest of the fields (token_hash, session_id, user_id,
used) unchanged.

In `@tests/integration/api/test_first_run_flow.py`:
- Around line 114-125: The test currently hardcodes cookie names in
_extract_auth_cookies which will break when cookie names are overridden; change
the function to parse all Set-Cookie headers into a key->value map (split each
header on ';' then on first '='), then try to obtain the session and CSRF values
by first checking for configured names (e.g. SESSION_COOKIE_NAME /
CSRF_COOKIE_NAME from the app/config if accessible) and if those aren’t
available fall back to a case-insensitive key match (key contains "session" for
the session cookie and "csrf" or "csrf_token" for the CSRF cookie); update
_extract_auth_cookies to return the values found from that lookup instead of
using startswith("session=")/startswith("csrf_token=").

In `@tests/unit/api/auth/test_refresh_store.py`:
- Around line 51-88: Add a new async test method in TestRefreshConsume that
verifies consume() respects the is_session_revoked callback: create a record via
RefreshStore.create with token_hash "hash-revoked-sess" and session_id
"revoked-sess" and expires_at _FUTURE, then call
store.consume("hash-revoked-sess", is_session_revoked=lambda sid: sid ==
"revoked-sess") and assert the result is None; name the test e.g.
test_consume_rejects_revoked_session so it clearly covers the is_session_revoked
path.

In `@web/src/utils/csrf.ts`:
- Around line 15-28: getCsrfToken is implemented correctly (preserves '=' in
token and safely wraps decodeURIComponent), so no code changes are required; if
you want optional observability, add a non-failing log inside the catch of
getCsrfToken (log the malformed cookie context via your app logger or
console.warn) to help detect backend cookie issues without changing return
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f9c5c0cc-c493-48ec-88a4-b144f5a2b474

📥 Commits

Reviewing files that changed from the base of the PR and between 63f0a43 and adfdafa.

📒 Files selected for processing (5)
  • src/synthorg/api/auth/refresh_store.py
  • src/synthorg/api/exception_handlers.py
  • tests/integration/api/test_first_run_flow.py
  • tests/unit/api/auth/test_refresh_store.py
  • web/src/utils/csrf.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
  • GitHub Check: Dashboard Test
  • GitHub Check: Test (Python 3.14)
  • GitHub Check: Build Web
  • GitHub Check: Build Sandbox
  • GitHub Check: Build Backend
  • GitHub Check: Dependency Review
  • GitHub Check: Analyze (python)
🧰 Additional context used
📓 Path-based instructions (5)
web/src/**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (web/CLAUDE.md)

web/src/**/*.{ts,tsx,js,jsx}: Always use createLogger from @/lib/logger -- never bare console.warn/console.error/console.debug in application code
Logger variable name must always be const log (e.g. const log = createLogger('module-name'))
Pass dynamic/untrusted values as separate arguments to logger methods (not interpolated into the message string) so they go through sanitizeArg
Attacker-controlled fields inside structured objects must be wrapped in sanitizeForLog() before embedding in log calls

Files:

  • web/src/utils/csrf.ts
web/src/**/*.{ts,tsx}

📄 CodeRabbit inference engine (web/CLAUDE.md)

web/src/**/*.{ts,tsx}: Use Tailwind semantic classes (text-foreground, bg-card, text-accent, text-success, bg-danger, etc.) or CSS variables (var(--so-*)) for colors; NEVER hardcode hex values in .tsx/.ts files
Use font-sans or font-mono (Geist tokens) for typography; NEVER set fontFamily directly in .tsx/.ts files
Use density-aware tokens (p-card, gap-section-gap, gap-grid-gap) or standard Tailwind spacing; NEVER hardcode pixel values for layout spacing in components
Use token variables (var(--so-shadow-card-hover), border-border, border-bright) for shadows and borders; NEVER hardcode values in .tsx/.ts files
Use @/lib/motion presets for Framer Motion transition durations; NEVER hardcode transition durations
CSS side-effect imports in TypeScript 6 require type declarations -- add /// <reference types="vite/client" /> at the top of files with CSS imports

web/src/**/*.{ts,tsx}: Always reuse existing components from web/src/components/ui/ before creating new ones in React dashboard code
Never hardcode hex colors, font-family, pixel spacing, or Framer Motion transitions in web dashboard code - use design tokens and @/lib/motion presets
Web dashboard: Node.js 22+, TypeScript 6.0+, use React 19, react-router, shadcn/ui, Base UI, Tailwind CSS 4, Zustand, @tanstack/react-query, Framer Motion, Storybook 10
TypeScript strict mode for web dashboard with zero ESLint warnings

Files:

  • web/src/utils/csrf.ts
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: No from __future__ import annotations - Python 3.14 has PEP 649 native lazy annotations
Use except A, B: syntax (no parentheses) for exception handling in Python 3.14 per PEP 758 - ruff enforces this
Use 88-character line length (enforced by ruff) in Python code

Files:

  • src/synthorg/api/exception_handlers.py
  • tests/integration/api/test_first_run_flow.py
  • src/synthorg/api/auth/refresh_store.py
  • tests/unit/api/auth/test_refresh_store.py
src/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

src/**/*.py: Type hints required on all public functions; use mypy strict mode
Docstrings required on all public classes and functions using Google style
Create new objects, never mutate existing ones; for non-Pydantic internal collections, use copy.deepcopy() at construction and MappingProxyType wrapping for read-only enforcement
For dict/list fields in frozen Pydantic models, use copy.deepcopy() at system boundaries (tool execution, LLM provider serialization, inter-agent delegation, persistence serialization)
Use frozen Pydantic models for config/identity; use separate mutable-via-copy models for runtime state that evolves; never mix static config fields with mutable runtime fields in one model
Use Pydantic v2 with BaseModel, model_validator, computed_field, and ConfigDict
Use allow_inf_nan=False in all ConfigDict declarations to reject NaN/Inf in numeric fields at validation time
Use @computed_field for derived values instead of storing and validating redundant fields (e.g., TokenUsage.total_tokens)
Use NotBlankStr (from core.types) for all identifier/name fields - including optional and tuple variants - instead of manual whitespace validators
Prefer asyncio.TaskGroup for fan-out/fan-in parallel operations in new code (e.g., multiple tool invocations, parallel agent calls); prefer structured concurrency over bare create_task
Functions should be less than 50 lines; files should be less than 800 lines
Handle errors explicitly; never silently swallow exceptions
Validate at system boundaries: user input, external APIs, and config files
Every module with business logic MUST import from synthorg.observability import get_logger and use logger = get_logger(__name__)
Never use import logging / logging.getLogger() / print() in application code; exceptions allowed only in observability/setup.py, observability/sinks.py, observability/syslog_handler.py, and observability/http_handler.py
Use variable name logger (not...

Files:

  • src/synthorg/api/exception_handlers.py
  • src/synthorg/api/auth/refresh_store.py

⚙️ CodeRabbit configuration file

This project uses Python 3.14+ with PEP 758 except syntax: "except A, B:" (comma-separated, no parentheses) is correct and mandatory -- do NOT flag it as a typo or suggest parenthesized form. The "except builtins.MemoryError, RecursionError: raise" pattern is intentional project convention for system-error propagation. When evaluating the 50-line function limit, count only the function body excluding the signature lines, decorators, and docstring. Functions 1-5 lines over due to docstrings or multi-line signatures should not be flagged. Do not suggest extracting single-use helper functions called exactly once -- this reduces readability without improving maintainability.

Files:

  • src/synthorg/api/exception_handlers.py
  • src/synthorg/api/auth/refresh_store.py
tests/**/*.py

⚙️ CodeRabbit configuration file

Test files do not require Google-style docstrings on classes or functions -- ruff D rules are only enforced on src/. A bare @settings() decorator with no arguments on Hypothesis property tests is a no-op and should not be suggested -- the HYPOTHESIS_PROFILE env var controls example counts via registered profiles, which @given() honors automatically.

Files:

  • tests/integration/api/test_first_run_flow.py
  • tests/unit/api/auth/test_refresh_store.py
🧠 Learnings (18)
📚 Learning: 2026-04-01T17:49:14.133Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-01T17:49:14.133Z
Learning: Applies to src/synthorg/{providers,engine}/**/*.py : Retryable errors are `RateLimitError`, `ProviderTimeoutError`, `ProviderConnectionError`, `ProviderInternalError`; non-retryable errors raise immediately; `RetryExhaustedError` signals all retries failed

Applied to files:

  • src/synthorg/api/exception_handlers.py
📚 Learning: 2026-03-20T11:18:48.128Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T11:18:48.128Z
Learning: Applies to src/synthorg/**/*.py : Retryable errors (`is_retryable=True`): `RateLimitError`, `ProviderTimeoutError`, `ProviderConnectionError`, `ProviderInternalError`. Non-retryable errors raise immediately without retry.

Applied to files:

  • src/synthorg/api/exception_handlers.py
📚 Learning: 2026-04-07T07:27:16.474Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-07T07:27:16.474Z
Learning: Applies to src/synthorg/providers/**/*.py : Rate limiter respects `RateLimitError.retry_after` from providers - automatically pauses future requests

Applied to files:

  • src/synthorg/api/exception_handlers.py
📚 Learning: 2026-03-31T14:17:24.182Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-31T14:17:24.182Z
Learning: Applies to src/synthorg/providers/**/*.py : Rate limiter respects `RateLimitError.retry_after` from providers — automatically pauses future requests

Applied to files:

  • src/synthorg/api/exception_handlers.py
📚 Learning: 2026-04-07T07:27:16.474Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-07T07:27:16.474Z
Learning: Applies to src/synthorg/providers/**/*.py : Retryable errors (`is_retryable=True`): `RateLimitError`, `ProviderTimeoutError`, `ProviderConnectionError`, `ProviderInternalError`; non-retryable errors raise immediately without retry

Applied to files:

  • src/synthorg/api/exception_handlers.py
📚 Learning: 2026-03-16T19:13:34.746Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-16T19:13:34.746Z
Learning: Applies to src/synthorg/providers/**/*.py : Retryable errors (is_retryable=True): RateLimitError, ProviderTimeoutError, ProviderConnectionError, ProviderInternalError. Non-retryable errors raise immediately without retry. RetryExhaustedError signals that all retries failed — the engine layer catches this to trigger fallback chains.

Applied to files:

  • src/synthorg/api/exception_handlers.py
📚 Learning: 2026-04-07T07:27:16.474Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-07T07:27:16.474Z
Learning: Applies to src/synthorg/engine/**/*.py : Handle `RetryExhaustedError` at the engine layer to trigger fallback chains

Applied to files:

  • src/synthorg/api/exception_handlers.py
📚 Learning: 2026-03-15T18:28:13.207Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T18:28:13.207Z
Learning: Applies to src/synthorg/providers/**/*.py : Rate limiter respects RateLimitError.retry_after from providers — automatically pauses future requests.

Applied to files:

  • src/synthorg/api/exception_handlers.py
📚 Learning: 2026-03-17T22:08:13.456Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T22:08:13.456Z
Learning: Applies to src/synthorg/**/*.py : `RetryConfig` and `RateLimiterConfig` are set per-provider in `ProviderConfig`. Retryable errors (`is_retryable=True`): `RateLimitError`, `ProviderTimeoutError`, `ProviderConnectionError`, `ProviderInternalError`. Non-retryable errors raise immediately without retry. `RetryExhaustedError` signals that all retries failed — the engine layer catches this to trigger fallback chains. Rate limiter respects `RateLimitError.retry_after` from providers — automatically pauses future requests.

Applied to files:

  • src/synthorg/api/exception_handlers.py
📚 Learning: 2026-03-20T08:28:32.845Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T08:28:32.845Z
Learning: Applies to src/synthorg/**/*.py : `RetryConfig` and `RateLimiterConfig` are set per-provider in `ProviderConfig`. Retryable errors: `RateLimitError`, `ProviderTimeoutError`, `ProviderConnectionError`, `ProviderInternalError`. Non-retryable errors raise immediately.

Applied to files:

  • src/synthorg/api/exception_handlers.py
📚 Learning: 2026-03-17T06:30:14.180Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T06:30:14.180Z
Learning: Applies to src/synthorg/persistence/**/*.py : Persistence uses pluggable PersistenceBackend protocol. SQLite is the initial backend. Settings use SettingsRepository (namespaced settings CRUD).

Applied to files:

  • src/synthorg/api/auth/refresh_store.py
📚 Learning: 2026-03-17T22:08:13.456Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T22:08:13.456Z
Learning: Persistence backend: pluggable PersistenceBackend protocol in `src/synthorg/persistence/`, SQLite initial, SettingsRepository (namespaced settings CRUD).

Applied to files:

  • src/synthorg/api/auth/refresh_store.py
📚 Learning: 2026-03-15T18:28:13.207Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T18:28:13.207Z
Learning: Applies to src/synthorg/**/*.py : All state transitions must log at INFO.

Applied to files:

  • src/synthorg/api/auth/refresh_store.py
📚 Learning: 2026-04-07T07:27:16.474Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-07T07:27:16.474Z
Learning: Applies to src/**/*.py : All state transitions must log at INFO level

Applied to files:

  • src/synthorg/api/auth/refresh_store.py
📚 Learning: 2026-03-19T07:12:14.508Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-19T07:12:14.508Z
Learning: Applies to src/synthorg/**/*.py : All state transitions must log at INFO level

Applied to files:

  • src/synthorg/api/auth/refresh_store.py
📚 Learning: 2026-03-17T06:43:14.114Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T06:43:14.114Z
Learning: Applies to src/synthorg/**/*.py : All error paths must log at WARNING or ERROR with context before raising. All state transitions must log at INFO. DEBUG for object creation, internal flow, entry/exit of key functions. Pure data models, enums, and re-exports do NOT need logging.

Applied to files:

  • src/synthorg/api/auth/refresh_store.py
📚 Learning: 2026-03-17T22:08:13.456Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T22:08:13.456Z
Learning: Applies to src/synthorg/**/*.py : All error paths must log at WARNING or ERROR with context before raising. All state transitions must log at INFO. DEBUG for object creation, internal flow, entry/exit of key functions.

Applied to files:

  • src/synthorg/api/auth/refresh_store.py
📚 Learning: 2026-03-14T16:18:57.267Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-14T16:18:57.267Z
Learning: Applies to src/ai_company/!(observability)/**/*.py : Use DEBUG logging for object creation, internal flow, and entry/exit of key functions.

Applied to files:

  • src/synthorg/api/auth/refresh_store.py
🔇 Additional comments (11)
src/synthorg/api/exception_handlers.py (1)

351-372: Well-implemented retry_after validation and propagation.

The validation correctly handles Python's bool subclassing int by checking not isinstance(retry_after_raw, bool) first. The is not None check on line 361 preserves valid retry_after=0 values (e.g., lockout just expired). This properly addresses the previous review feedback and aligns with AccountLockedError's contract.

src/synthorg/api/auth/refresh_store.py (5)

71-105: expires_at should be normalized to UTC before storage.

The create() method stores expires_at.isoformat() directly without ensuring it's in UTC. Since consume() and cleanup_expired() compare against UTC ISO strings (Lines 129, 244), a caller passing an offset-aware datetime (e.g., +05:30) will produce lexically incorrect comparisons, causing tokens to expire early or remain valid too long.

Suggested fix
     async def create(
         self,
         token_hash: str,
         session_id: str,
         user_id: str,
         expires_at: datetime,
     ) -> None:
+        if expires_at.tzinfo is None:
+            msg = "expires_at must be timezone-aware"
+            raise ValueError(msg)
+        expires_at_utc = expires_at.astimezone(UTC)
         now = datetime.now(UTC)
         await self._db.execute(
             "INSERT INTO refresh_tokens "
             "(token_hash, session_id, user_id, expires_at, "
             "used, created_at) "
             "VALUES (?, ?, ?, ?, 0, ?)",
             (
                 token_hash,
                 session_id,
                 user_id,
-                expires_at.isoformat(),
+                expires_at_utc.isoformat(),
                 now.isoformat(),
             ),
         )

107-150: Session revocation check should be mandatory.

Making is_session_revoked optional (Line 111) means callers can omit it and successfully rotate a refresh token for a revoked session. This undermines the cascade revocation guarantee. Inject the checker at construction time or make it a required parameter.

Suggested approach
 class RefreshStore:
-    def __init__(self, db: aiosqlite.Connection) -> None:
+    def __init__(
+        self,
+        db: aiosqlite.Connection,
+        is_session_revoked: Callable[[str], bool],
+    ) -> None:
         self._db = db
+        self._is_session_revoked = is_session_revoked

     async def consume(
         self,
         token_hash: str,
-        *,
-        is_session_revoked: Callable[[str], bool] | None = None,
     ) -> RefreshRecord | None:
         # ...
         if row is not None:
-            if is_session_revoked and is_session_revoked(row["session_id"]):
+            if self._is_session_revoked(row["session_id"]):
                 # reject

29-59: Well-structured frozen Pydantic model with proper validation.

The RefreshRecord model correctly uses frozen=True, allow_inf_nan=False, AwareDatetime for timezone-aware timestamps, and NotBlankStr for identifiers. The temporal order validator prevents logical inconsistencies.


189-233: Revocation methods correctly log at INFO when rows are affected.

Both revoke_by_session() and revoke_by_user() emit API_AUTH_REFRESH_REVOKED with context when count > 0. This satisfies the audit trail requirement.


235-253: Cleanup correctly retains tombstones for replay detection.

The DELETE now only removes rows where expires_at <= ?, preserving used tokens until expiry. This addresses the prior concern about replay detection being undermined by premature tombstone deletion.

tests/unit/api/auth/test_refresh_store.py (3)

19-32: In-memory fixture is appropriate for unit tests but cannot verify cross-connection atomicity.

The :memory: database is sufficient for testing business logic in isolation. However, concurrent consume() calls from separate connections cannot be tested this way since each :memory: connection creates a separate database. A file-backed integration test would be needed to verify the UPDATE...RETURNING atomicity guarantee.


14-16: Timestamp constants are well-designed for deterministic tests.

Using _NOW, _PAST, and _FUTURE based on a single captured datetime.now(UTC) ensures consistent test behavior regardless of execution time.


141-175: Cleanup test correctly validates tombstone retention for replay detection.

The test verifies that cleanup_expired() removes only expired tokens while retaining used-but-not-expired tokens (Lines 154-161, 172-173). This confirms replay detection continues to work after cleanup runs.

tests/integration/api/test_first_run_flow.py (1)

155-158: Good contract coverage for cookie-based auth issuance.

Asserting non-empty session and csrf_token immediately after both setup and login is a solid improvement and localizes regressions well.

Also applies to: 173-176

web/src/utils/csrf.ts (1)

1-14: LGTM – Clear documentation of CSRF token flow.

The file header and JSDoc accurately describe the double-submit CSRF pattern: backend sets a non-HttpOnly csrf_token cookie, frontend reads it and attaches as X-CSRF-Token header on mutating requests. The documentation correctly notes that null is returned before login or after expiry.

Comment on lines +156 to +167
return RefreshRecord(
token_hash=row["token_hash"],
session_id=row["session_id"],
user_id=row["user_id"],
expires_at=datetime.fromisoformat(
row["expires_at"],
),
used=bool(row["used"]),
created_at=datetime.fromisoformat(
row["created_at"],
),
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Parsing expires_at may fail on malformed DB values.

datetime.fromisoformat() will raise ValueError if the stored string is malformed. While unlikely given controlled inserts, adding a try/except with WARNING log would improve resilience and debuggability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/auth/refresh_store.py` around lines 156 - 167, The
datetime.fromisoformat calls used to build the RefreshRecord (for expires_at and
created_at) can raise ValueError on malformed DB strings; wrap each conversion
in a try/except ValueError, use the module logger to emit a warning that
includes the field name and the raw row value (e.g., row["expires_at"] /
row["created_at"]), and on exception set the corresponding attribute to None (or
a safe default) before returning the RefreshRecord; keep the rest of the fields
(token_hash, session_id, user_id, used) unchanged.

Comment on lines +114 to +125
def _extract_auth_cookies(resp: Any) -> tuple[str, str]:
"""Extract session token and CSRF token from Set-Cookie headers."""
session = ""
csrf = ""
for k, v in resp.headers.multi_items():
if k != "set-cookie":
continue
if v.startswith("session="):
session = v.split("session=")[1].split(";")[0]
elif v.startswith("csrf_token="):
csrf = v.split("csrf_token=")[1].split(";")[0]
return session, csrf
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Avoid hardcoded cookie keys in _extract_auth_cookies.

Line 121 and Line 123 hardcode "session" / "csrf_token". If auth cookie names are overridden, this test will false-fail while the app still behaves correctly.

Proposed patch
 from synthorg.api.auth.service import AuthService
+from synthorg.api.auth.config import (
+    DEFAULT_COOKIE_NAME,
+    DEFAULT_CSRF_COOKIE_NAME,
+)
@@
 def _extract_auth_cookies(resp: Any) -> tuple[str, str]:
@@
-        if v.startswith("session="):
-            session = v.split("session=")[1].split(";")[0]
-        elif v.startswith("csrf_token="):
-            csrf = v.split("csrf_token=")[1].split(";")[0]
+        if v.startswith(f"{DEFAULT_COOKIE_NAME}="):
+            session = v.split(f"{DEFAULT_COOKIE_NAME}=", 1)[1].split(";", 1)[0]
+        elif v.startswith(f"{DEFAULT_CSRF_COOKIE_NAME}="):
+            csrf = v.split(f"{DEFAULT_CSRF_COOKIE_NAME}=", 1)[1].split(";", 1)[0]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/api/test_first_run_flow.py` around lines 114 - 125, The
test currently hardcodes cookie names in _extract_auth_cookies which will break
when cookie names are overridden; change the function to parse all Set-Cookie
headers into a key->value map (split each header on ';' then on first '='), then
try to obtain the session and CSRF values by first checking for configured names
(e.g. SESSION_COOKIE_NAME / CSRF_COOKIE_NAME from the app/config if accessible)
and if those aren’t available fall back to a case-insensitive key match (key
contains "session" for the session cookie and "csrf" or "csrf_token" for the
CSRF cookie); update _extract_auth_cookies to return the values found from that
lookup instead of using startswith("session=")/startswith("csrf_token=").

- Add test_consume_rejects_revoked_session and
  test_consume_allows_non_revoked_session to cover the
  is_session_revoked callback path in RefreshStore.consume
- Add log.warn in getCsrfToken catch for malformed cookie diagnosis
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/unit/api/auth/test_refresh_store.py`:
- Around line 19-32: Add a new test that verifies atomic consumption of a
single-use refresh token using two independent DB connections instead of the
in-memory fixture: create a temporary file-backed SQLite DB (e.g., via tmp_path
or tempfile), open two separate aiosqlite.connect(...) connections, apply_schema
on one connection, instantiate two RefreshStore instances (one per connection),
insert a single-use refresh token with one store, then concurrently call consume
(or the consume method name used in RefreshStore) from both stores and assert
that exactly one call succeeds and the other fails; mirror the
concurrency/assertion pattern used in the existing ticket-store concurrency test
to ensure atomicity across connections.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 99b16c71-5ea9-4adc-9b27-0d3cb706fd98

📥 Commits

Reviewing files that changed from the base of the PR and between adfdafa and d2bf47a.

📒 Files selected for processing (2)
  • tests/unit/api/auth/test_refresh_store.py
  • web/src/utils/csrf.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (6)
  • GitHub Check: Test (Python 3.14)
  • GitHub Check: Dashboard Test
  • GitHub Check: Build Web
  • GitHub Check: Build Backend
  • GitHub Check: Dependency Review
  • GitHub Check: Analyze (python)
🧰 Additional context used
📓 Path-based instructions (4)
web/src/**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (web/CLAUDE.md)

web/src/**/*.{ts,tsx,js,jsx}: Always use createLogger from @/lib/logger -- never bare console.warn/console.error/console.debug in application code
Logger variable name must always be const log (e.g. const log = createLogger('module-name'))
Pass dynamic/untrusted values as separate arguments to logger methods (not interpolated into the message string) so they go through sanitizeArg
Attacker-controlled fields inside structured objects must be wrapped in sanitizeForLog() before embedding in log calls

Files:

  • web/src/utils/csrf.ts
web/src/**/*.{ts,tsx}

📄 CodeRabbit inference engine (web/CLAUDE.md)

web/src/**/*.{ts,tsx}: Use Tailwind semantic classes (text-foreground, bg-card, text-accent, text-success, bg-danger, etc.) or CSS variables (var(--so-*)) for colors; NEVER hardcode hex values in .tsx/.ts files
Use font-sans or font-mono (Geist tokens) for typography; NEVER set fontFamily directly in .tsx/.ts files
Use density-aware tokens (p-card, gap-section-gap, gap-grid-gap) or standard Tailwind spacing; NEVER hardcode pixel values for layout spacing in components
Use token variables (var(--so-shadow-card-hover), border-border, border-bright) for shadows and borders; NEVER hardcode values in .tsx/.ts files
Use @/lib/motion presets for Framer Motion transition durations; NEVER hardcode transition durations
CSS side-effect imports in TypeScript 6 require type declarations -- add /// <reference types="vite/client" /> at the top of files with CSS imports

web/src/**/*.{ts,tsx}: Always reuse existing components from web/src/components/ui/ before creating new ones in React dashboard code
Never hardcode hex colors, font-family, pixel spacing, or Framer Motion transitions in web dashboard code - use design tokens and @/lib/motion presets
Web dashboard: Node.js 22+, TypeScript 6.0+, use React 19, react-router, shadcn/ui, Base UI, Tailwind CSS 4, Zustand, @tanstack/react-query, Framer Motion, Storybook 10
TypeScript strict mode for web dashboard with zero ESLint warnings

Files:

  • web/src/utils/csrf.ts
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: No from __future__ import annotations - Python 3.14 has PEP 649 native lazy annotations
Use except A, B: syntax (no parentheses) for exception handling in Python 3.14 per PEP 758 - ruff enforces this
Use 88-character line length (enforced by ruff) in Python code

Files:

  • tests/unit/api/auth/test_refresh_store.py
tests/**/*.py

⚙️ CodeRabbit configuration file

Test files do not require Google-style docstrings on classes or functions -- ruff D rules are only enforced on src/. A bare @settings() decorator with no arguments on Hypothesis property tests is a no-op and should not be suggested -- the HYPOTHESIS_PROFILE env var controls example counts via registered profiles, which @given() honors automatically.

Files:

  • tests/unit/api/auth/test_refresh_store.py
🔇 Additional comments (6)
web/src/utils/csrf.ts (1)

1-35: LGTM! Solid CSRF token utility with proper error handling.

The implementation correctly:

  • Preserves CSRF tokens containing = by using slice(eqIdx + 1) instead of splitting
  • Wraps decodeURIComponent in try-catch to fail closed on malformed cookies
  • Uses createLogger with const log naming convention per coding guidelines
  • Passes the error as a separate argument to the logger for proper sanitization

The fail-closed behavior (returning null for missing/malformed cookies, causing the server to return 403) is the correct security posture for CSRF validation.

tests/unit/api/auth/test_refresh_store.py (5)

35-48: LGTM!

Test correctly verifies token creation and retrieval through the consume path.


52-88: LGTM!

Core consume behavior is thoroughly tested: used flag, replay protection, nonexistent tokens, and expiration.


90-120: Excellent coverage of is_session_revoked callback paths!

These tests directly address the second past review comment by covering both the revoked session rejection path and the non-revoked session success path. The implementation correctly verifies that:

  • Tokens belonging to revoked sessions are rejected (lines 90-102)
  • Tokens with valid sessions pass the revocation check (lines 104-119)

122-170: LGTM!

Revocation tests correctly verify scoped invalidation: revoke_by_session affects only the target session's tokens (lines 123-150), and revoke_by_user affects only the target user's tokens (lines 152-169). The negative verification (ensuring unaffected tokens remain usable) strengthens the test coverage.


172-206: LGTM!

Cleanup test correctly verifies selective expiration removal while preserving used tokens for replay detection and leaving active tokens consumable. The three-token scenario provides comprehensive coverage of the cleanup logic.

Comment on lines +19 to +32
@pytest.fixture
async def db() -> AsyncGenerator[aiosqlite.Connection]:
conn = await aiosqlite.connect(":memory:")
try:
conn.row_factory = aiosqlite.Row
await apply_schema(conn)
yield conn
finally:
await conn.close()


@pytest.fixture
async def store(db: aiosqlite.Connection) -> RefreshStore:
return RefreshStore(db)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if a concurrent atomicity test was added elsewhere
rg -n "concurrent.*consum|atomic.*consum|TaskGroup.*consum" --type=py tests/

Repository: Aureliolo/synthorg

Length of output: 169


Add a concurrent atomicity test for RefreshStore.

The :memory: fixture in lines 19-32 prevents testing atomic consumption across multiple concurrent connections. Add a file-backed test that uses two independent connections to verify that only one consumer can successfully consume a single-use refresh token, following the pattern used in test_ticket_store.py:117.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/api/auth/test_refresh_store.py` around lines 19 - 32, Add a new
test that verifies atomic consumption of a single-use refresh token using two
independent DB connections instead of the in-memory fixture: create a temporary
file-backed SQLite DB (e.g., via tmp_path or tempfile), open two separate
aiosqlite.connect(...) connections, apply_schema on one connection, instantiate
two RefreshStore instances (one per connection), insert a single-use refresh
token with one store, then concurrently call consume (or the consume method name
used in RefreshStore) from both stores and assert that exactly one call succeeds
and the other fails; mirror the concurrency/assertion pattern used in the
existing ticket-store concurrency test to ensure atomicity across connections.

@Aureliolo Aureliolo merged commit d3022c7 into main Apr 7, 2026
32 of 34 checks passed
@Aureliolo Aureliolo deleted the feat/enterprise-auth branch April 7, 2026 10:17
@Aureliolo Aureliolo temporarily deployed to cloudflare-preview April 7, 2026 10:17 — with GitHub Actions Inactive
Aureliolo added a commit that referenced this pull request Apr 7, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.6.4](v0.6.3...v0.6.4)
(2026-04-07)


### Features

* analytics and metrics runtime pipeline
([#226](#226),
[#225](#225),
[#227](#227),
[#224](#224))
([#1127](#1127))
([ec57641](ec57641))
* engine intelligence -- quality signals, health monitoring, trajectory
scoring, coordination metrics
([#1099](#1099))
([aac2029](aac2029)),
closes [#697](#697)
[#707](#707)
[#705](#705)
[#703](#703)
* enterprise-grade auth -- HttpOnly cookie sessions, CSRF, lockout,
session limits
([#1102](#1102))
([d3022c7](d3022c7)),
closes [#1068](#1068)
* implement core tool categories and granular sub-constraints
([#1101](#1101))
([0611b53](0611b53)),
closes [#1034](#1034)
[#220](#220)
* memory evolution -- GraphRAG/consistency research +
SelfEditingMemoryStrategy
([#1036](#1036),
[#208](#208))
([#1129](#1129))
([a9acda3](a9acda3))
* security hardening -- sandbox, risk override, SSRF self-heal, DAST fix
([#1100](#1100))
([31e7273](31e7273)),
closes [#1098](#1098)
[#696](#696)
[#222](#222)
[#671](#671)


### Bug Fixes

* harden agent identity versioning post-review
([#1128](#1128))
([8eb2859](8eb2859)),
closes [#1076](#1076)


### Documentation

* engine architecture research
([#688](#688)
[#690](#690)
[#848](#848)
[#687](#687))
([#1114](#1114))
([59b31f9](59b31f9))


### Maintenance

* add .claudeignore and split CLAUDE.md for token optimization
([#1112](#1112))
([b0fbd18](b0fbd18))
* bump github.com/sigstore/protobuf-specs from 0.5.0 to 0.5.1 in /cli in
the all group
([#1106](#1106))
([73089c9](73089c9))
* bump jsdom from 29.0.1 to 29.0.2 in /site in the all group
([#1107](#1107))
([8e99dce](8e99dce))
* bump jsdom from 29.0.1 to 29.0.2 in /web in the all group
([#1108](#1108))
([ce8c749](ce8c749))
* bump python from `fb83750` to `6869258` in /docker/backend in the all
group ([#1104](#1104))
([4911726](4911726))
* bump python from `fb83750` to `6869258` in /docker/web in the all
group ([#1103](#1103))
([87bdf09](87bdf09))
* bump the all group across 1 directory with 4 updates
([#1111](#1111))
([f702464](f702464))
* bump the all group in /docker/sandbox with 2 updates
([#1105](#1105))
([05a91ca](05a91ca))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: enterprise-grade auth -- HttpOnly cookie sessions, CSRF protection, concurrent session control

2 participants