feat: Sprint 29A-D — 27 multi-model audit fixes (security, reliabilit… - #5
Conversation
…y, contracts, polish) P0 Critical (29A): migration composite FK ordering, embedding dim=1536, JWT revocation fail-closed with LRU cache, tenant-scoped trace/evidence stores, setup token log redaction, aria-live SSE chat fix. High Reliability (29B): PBKDF2 async offload, JWT startup validation, CircuitBreaker 3-state machine (half-open probe), Redis PubSub real subscribe, SSE client reconnection with exponential backoff, React.memo ChatMessageBubble. Contract Alignment (29C): setup wizard endpoint/payload fix, idempotency guard, privacy panel auth+abort, safe defaults (demo=0, dev=false), GEMINI_API_KEY alias. Medium Polish (29D): SSE terminal ordering+backpressure, async tutor I/O, auth-first in tutor routes, login form cleanup, api.ts type safety, composite DB indexes, accessibility store cleanup. Deferred: D3 (auth.py DI refactor) — follow-up sprint. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This is a large multi-category sprint PR (Sprint 29A-D) addressing security hardening, reliability improvements, contract alignment, and medium-priority polish across the AiLine platform's backend (Python/FastAPI) and frontend (React/TypeScript) codebases.
Changes:
- Security & reliability: JWT revocation fail-closed with in-memory LRU cache, tenant-scoped trace/evidence stores, PBKDF2 async offload, CircuitBreaker 3-state machine (half-open probe), Redis PubSub real subscribe, migration composite FK ordering fix
- Contract alignment: Setup wizard endpoint/payload mapping, idempotency guard for plan streams, privacy panel auth+abort controller, safe env defaults (demo=0, dev=false), GEMINI_API_KEY alias, embedding dims=1536
- Polish: SSE client reconnection with exponential backoff, React.memo for ChatMessageBubble, anyio async I/O in tutor routes, auth-first route pattern, login form cleanup, accessibility store cleanup, composite DB indexes
Reviewed changes
Copilot reviewed 25 out of 25 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
runtime/tests/test_redis_event_bus.py |
Updated tests to await bus.subscribe() after async API change |
runtime/ailine_runtime/shared/trace_store.py |
Made teacher_id required in get(), added optional tenant checks to append_node/update_run |
runtime/ailine_runtime/shared/observability_store.py |
Added tenant-keyed evidence storage with (teacher_id, run_id) tuple keys |
runtime/ailine_runtime/shared/config.py |
Embedding dims default 1536, GEMINI_API_KEY alias, JWT private key validation |
runtime/ailine_runtime/api/routers/tutors.py |
Added auth dependency and anyio thread offload for sync I/O |
runtime/ailine_runtime/api/routers/setup_service.py |
Setup token log redaction, embedding default_dims corrected to 1536 |
runtime/ailine_runtime/api/routers/plans_stream.py |
Idempotency guard, done event for heartbeat, teacher_id passed to trace updates |
runtime/ailine_runtime/api/routers/observability.py |
teacher_id passed to get_standards_evidence for tenant scoping |
runtime/ailine_runtime/api/routers/auth.py |
PBKDF2 hashing/verification offloaded to thread pool |
runtime/ailine_runtime/api/middleware/tenant_context.py |
JTI blacklist fail-closed with LRU in-memory cache |
runtime/ailine_runtime/adapters/events/redis_bus.py |
Real PubSub subscription with background listener task |
runtime/ailine_runtime/adapters/db/alembic/versions/2026_02_12_0001_initial_schema.py |
Composite FK unique constraint for courses moved to initial migration |
runtime/ailine_runtime/adapters/db/alembic/versions/2026_02_27_0006_fix_composite_fk_unique_constraints.py |
Removed duplicate courses constraint now handled in 0001 |
runtime/ailine_runtime/adapters/db/alembic/versions/2026_03_04_0007_composite_indexes.py |
New composite indexes for common query patterns |
frontend/src/stores/accessibility-store.ts |
Moved _mqCleanup out of store to module-level, added exported cleanupAccessibilityStore() |
frontend/src/lib/sse-fetch.ts |
SSE reconnection with exponential backoff, Last-Event-ID tracking, retry field support |
frontend/src/lib/api.ts |
Replaced as unknown as cast with proper as AuthUser type import |
frontend/src/components/tutor/tutor-chat.tsx |
Removed aria-live from scroll container (moved elsewhere), smooth scroll only when not streaming |
frontend/src/components/tutor/chat-message-bubble.tsx |
Wrapped with React.memo to avoid unnecessary re-renders during streaming |
frontend/src/components/setup/setup-wizard.tsx |
Fixed API endpoint, added toSetupPayload snake_case mapper, safe defaults |
frontend/src/components/privacy/privacy-panel.tsx |
Added auth headers and AbortController cleanup to all fetch calls |
frontend/src/app/[locale]/login/page.tsx |
Removed stray getAuthHeaders() from login request (login doesn't need auth) |
docker-compose.yml |
Added migrations service, shared YAML anchors, safe defaults, resource limits |
agents/ailine_agents/resilience.py |
Full 3-state (closed/open/half-open) circuit breaker implementation |
.env.example |
Updated defaults: AILINE_DEV_MODE=false, AILINE_DEMO_MODE=0, dims=1536, GOOGLE_API_KEY |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify(config), | ||
| headers: { 'Content-Type': 'application/json', 'X-Setup-Token': '' }, |
There was a problem hiding this comment.
The X-Setup-Token header is hard-coded to an empty string (''). Since require_setup_token on the backend uses hmac.compare_digest(x_setup_token, expected), an empty token will always be rejected with a 403, making the setup wizard non-functional. The actual setup token should be read from a state variable or user input in the wizard UI, not hard-coded to an empty string.
| /** | ||
| * Main setup wizard orchestrator. | ||
| * Manages step navigation, state, validation, and API calls. | ||
| */ | ||
| /** Map camelCase setup config to snake_case for the backend API. */ | ||
| function toSetupPayload(config: SetupConfig): Record<string, unknown> { |
There was a problem hiding this comment.
The module-level comment block for the SetupWizard component is malformed. The JSDoc comment for SetupWizard (/** Map camelCase setup config to snake_case... */) and the function comment above it (/** Main setup wizard orchestrator... */) overlap on adjacent lines (30-31) rather than each preceding its own declaration. The helper function toSetupPayload appears before SetupWizard but the comment block intended for SetupWizard ends up as a documentation block for toSetupPayload instead.
There was a problem hiding this comment.
Summary
- Repositioned the JSDoc blocks in
setup-wizard.tsxso the mapper comment now correctly documentstoSetupPayload, and the component-level JSDoc now directly precedesSetupWizard, fixing the reviewer’s line-31 documentation association issue. frontend/src/components/setup/setup-wizard.tsxL26-L59 - Committed the fix on the current branch with message:
fix(setup-wizard): correct JSDoc association for SetupWizard. - Created a follow-up PR via
make_prwith title: “fix: align SetupWizard JSDoc with correct declaration”.
Testing
- ✅
pnpm -C frontend exec eslint src/components/setup/setup-wizard.tsx - ✅
curl -I https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html | head
| @@ -198,12 +199,12 @@ class TurnFlagIn(BaseModel): | |||
| @router.post("/{tutor_id}/sessions/{session_id}/flag") | |||
| async def tutor_flag_turn(tutor_id: str, session_id: str, body: TurnFlagIn): | |||
| """Flag a specific turn in a tutor conversation for review.""" | |||
| spec = load_tutor_spec(tutor_id) | |||
| spec = await anyio.to_thread.run_sync(load_tutor_spec, tutor_id) | |||
| if not spec: | |||
| raise HTTPException(status_code=404, detail="Tutor not found") | |||
| ctx = require_tenant_access(spec.teacher_id, action="flag", resource="tutor turn") | |||
|
|
|||
| session = load_session(session_id) | |||
| session = await anyio.to_thread.run_sync(load_session, session_id) | |||
There was a problem hiding this comment.
The tutor_session_transcript and tutor_flag_turn endpoints do not add teacher_id: str = Depends(require_authenticated) to their signatures despite the PR description stating "auth-first in tutor routes." All other endpoints in this file were updated to include authentication, but these two are missing it. Without the Depends(require_authenticated) parameter, require_tenant_access inside them calls get_current_teacher_id() which relies on the middleware's context var — this may work via middleware, but the routes are inconsistent with the other updated endpoints and rely on implicit auth rather than explicit FastAPI dependencies.
| container = getattr(getattr(request.app, "state", None), "container", None) | ||
| if container is None: | ||
| return False | ||
| logger.warning("jti_blacklist_check_failed_closed", jti=jti, reason="no_container") | ||
| return True # Fail-closed |
There was a problem hiding this comment.
The _is_jti_blacklisted function now fails closed (returns True, treating the token as blacklisted) when Redis is unavailable — including at startup when the container hasn't loaded yet. This means that any request made before the application's container fully initializes (where container is None) will have all JWTs treated as revoked, potentially causing all authenticated requests to fail with 401 until the container is ready. This is a significant availability concern in production deployments, especially during rolling restarts or slow startup.
| # In-memory LRU fallback cache for JTI blacklist when Redis is down | ||
| _REVOCATION_CACHE: dict[str, float] = {} | ||
| _REVOCATION_CACHE_MAX = 1000 | ||
| _REVOCATION_CACHE_TTL = 300.0 # 5 minutes | ||
|
|
||
|
|
||
| async def _is_jti_blacklisted(request: Request, jti: str) -> bool: | ||
| """Check if a JWT ID (jti) has been revoked via Redis blacklist. | ||
|
|
||
| Returns False when Redis is unavailable (fail-open for availability). | ||
| The blacklist key format is ``jti_blacklist:{jti}`` with a TTL matching | ||
| the token's remaining lifetime (set by POST /auth/logout). | ||
| Fail-closed: returns True (treat as blacklisted) when Redis is | ||
| unavailable, to prevent accepting potentially revoked tokens. | ||
| Uses an in-memory LRU cache as fallback for known-good JTIs. | ||
| """ | ||
| # F-258: Use public EventBus.get_redis_client() instead of private _redis | ||
| import time as _time | ||
|
|
||
| # Check in-memory cache first (known-good JTIs) | ||
| cached_ts = _REVOCATION_CACHE.get(jti) | ||
| if cached_ts is not None: | ||
| if _time.monotonic() - cached_ts < _REVOCATION_CACHE_TTL: | ||
| return False # Recently verified as not blacklisted | ||
| else: | ||
| del _REVOCATION_CACHE[jti] | ||
|
|
||
| container = getattr(getattr(request.app, "state", None), "container", None) | ||
| if container is None: | ||
| return False | ||
| logger.warning("jti_blacklist_check_failed_closed", jti=jti, reason="no_container") | ||
| return True # Fail-closed | ||
| event_bus = getattr(container, "event_bus", None) | ||
| if event_bus is None: | ||
| return False | ||
| logger.warning("jti_blacklist_check_failed_closed", jti=jti, reason="no_event_bus") | ||
| return True # Fail-closed | ||
| redis_client = await event_bus.get_redis_client() | ||
| if redis_client is None: | ||
| return False | ||
| logger.warning("jti_blacklist_check_failed_closed", jti=jti, reason="no_redis_client") | ||
| return True # Fail-closed | ||
| try: | ||
| result = await redis_client.get(f"jti_blacklist:{jti}") | ||
| return result is not None | ||
| except Exception: | ||
| # Fail-open: if Redis is down, allow the request through | ||
| logger.warning("jti_blacklist_check_failed", jti=jti) | ||
| if result is not None: | ||
| return True # Token IS blacklisted | ||
|
|
||
| # Cache as known-good | ||
| if len(_REVOCATION_CACHE) >= _REVOCATION_CACHE_MAX: | ||
| # Evict oldest entry | ||
| oldest_key = min(_REVOCATION_CACHE, key=_REVOCATION_CACHE.get) # type: ignore[arg-type] | ||
| del _REVOCATION_CACHE[oldest_key] | ||
| _REVOCATION_CACHE[jti] = _time.monotonic() |
There was a problem hiding this comment.
The _REVOCATION_CACHE is a module-level plain dict that is accessed concurrently by async request handlers without any locking. Read-modify-write operations on it (checking len, evicting the oldest entry, and writing) are not atomic. Under high concurrency, two coroutines could both observe len(_REVOCATION_CACHE) >= _REVOCATION_CACHE_MAX and both try to evict and write, or the dict could be structurally modified during iteration of _REVOCATION_CACHE.get, leading to potential race conditions. An asyncio.Lock should protect these operations.
There was a problem hiding this comment.
Summary
- Added
asyncioimport and introduced a module-level_REVOCATION_CACHE_LOCK = asyncio.Lock()so the in-memory JTI fallback cache has explicit async concurrency protection. runtime/ailine_runtime/api/middleware/tenant_context.pyL42-L44 runtime/ailine_runtime/api/middleware/tenant_context.pyL417-L422 - Wrapped the cache read/TTL-expiry logic in
_is_jti_blacklistedwith the lock and switched expiry removal topop(jti, None)to keep that mutation safe under concurrent handlers. runtime/ailine_runtime/api/middleware/tenant_context.pyL433-L440 - Wrapped cache eviction/write (
lencheck, oldest-key eviction, insert) in the same lock so the LRU-like update path is atomic across concurrent requests. runtime/ailine_runtime/api/middleware/tenant_context.pyL458-L465 - Committed the change and created a follow-up PR via
make_prwith a summary of this reviewer-suggested fix.
Testing
- ✅
python -m py_compile runtime/ailine_runtime/api/middleware/tenant_context.py
| trace = self._traces[run_id] | ||
| if teacher_id and trace.teacher_id and trace.teacher_id != teacher_id: | ||
| logger.warning( | ||
| "append_node tenant mismatch run_id=%s expected=%s got=%s — ignored", | ||
| run_id, | ||
| trace.teacher_id, | ||
| teacher_id, | ||
| ) | ||
| return |
There was a problem hiding this comment.
The append_node and update_run methods check if teacher_id and trace.teacher_id and trace.teacher_id != teacher_id: before blocking the operation. This means the tenant check is silently skipped when either the provided teacher_id is empty ("") or the stored trace.teacher_id is empty. An attacker could call these methods with teacher_id="" and bypass tenant isolation entirely. Since the caller in plans_stream.py always passes a non-empty teacher_id, this is likely a defense-in-depth gap, but it is inconsistent with the strengthened get() method which now always enforces the check.
There was a problem hiding this comment.
Summary
- Tightened tenant isolation in trace mutations by making
append_nodeandupdate_runalways require exactteacher_idequality with the stored trace tenant, which closes the empty-string bypass path noted in the review thread. runtime/ailine_runtime/shared/trace_store.pyL92-L141 - Updated
TraceStoretests to use the currentget(..., teacher_id=...)contract consistently and added a regression test that verifies mutation calls withteacher_id=""are rejected when the stored trace belongs to another tenant. runtime/tests/test_trace_store.pyL41-L183 - Committed the changes on the current branch as
f750e75and created a follow-up PR via themake_prtool.
Testing
- ✅
uv run ruff check runtime/ailine_runtime/shared/trace_store.py runtime/tests/test_trace_store.py - ✅
uv run python -m py_compile runtime/ailine_runtime/shared/trace_store.py runtime/tests/test_trace_store.py ⚠️ uv run pytest runtime/tests/test_trace_store.py -q(fails in this environment due to missing dependency:sqlalchemy)- ✅
curl -sL 'https://duckduckgo.com/html/?q=python+tenant+isolation+empty+string+check' | head -n 10
| // Exponential backoff: 3s, 6s, 12s, 24s, 48s | ||
| const delay = retryMs * Math.pow(2, attempt - 1) |
There was a problem hiding this comment.
The exponential backoff for retries computes the delay using retryMs * Math.pow(2, attempt - 1). The first retry (attempt=1) results in 3000 * 1 = 3s, but the comment says "Exponential backoff: 3s, 6s, 12s, 24s, 48s". The maximum is 3000 * Math.pow(2, 4) = 48000ms = 48s. There is no cap or jitter applied — for 5 retries this totals ~93s of wait time. While this is unlikely to be a hard bug, there is no maximum delay cap, and without jitter, multiple clients reconnecting after a server restart will cause a thundering herd. Consider capping the delay (e.g., Math.min(delay, 30000)) and adding jitter.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 94b03f5308
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| cached_ts = _REVOCATION_CACHE.get(jti) | ||
| if cached_ts is not None: | ||
| if _time.monotonic() - cached_ts < _REVOCATION_CACHE_TTL: | ||
| return False # Recently verified as not blacklisted |
There was a problem hiding this comment.
Revalidate cached JTIs before accepting token
Returning early from the in-memory “known-good” cache means a token that was validated once can still be accepted for up to 5 minutes after logout revokes its JTI in Redis. In practice: request 1 caches jti as good, /auth/logout blacklists it, and subsequent requests skip Redis here and remain authorized until _REVOCATION_CACHE_TTL expires, which undermines revocation guarantees.
Useful? React with 👍 / 👎.
| _SETUP_TOKEN = secrets.token_urlsafe(32) | ||
| import sys | ||
| print( | ||
| f"\n{'=' * 60}\n" | ||
| f" AILINE SETUP TOKEN: {_SETUP_TOKEN}\n" | ||
| f" Use this in the X-Setup-Token header for /setup/apply\n" | ||
| f"{'=' * 60}\n", | ||
| file=sys.stderr, | ||
| flush=True, | ||
| _log.info( | ||
| "setup_token_generated", | ||
| msg="Setup token generated. Retrieve via server admin or set AILINE_SETUP_TOKEN env var.", |
There was a problem hiding this comment.
Surface generated setup token to operators
When AILINE_SETUP_TOKEN is not set, this path generates a random setup token but no longer prints or persists it anywhere retrievable, while require_setup_token still enforces that exact token for /setup/apply and /setup/validate. On a fresh deployment this can block setup entirely because clients cannot discover the required header value.
Useful? React with 👍 / 👎.
| await new Promise<void>((resolve) => { | ||
| const timer = setTimeout(resolve, delay) | ||
| signal?.addEventListener('abort', () => { | ||
| clearTimeout(timer) |
There was a problem hiding this comment.
Exit backoff wait immediately when signal is already aborted
If onerror aborts the controller before this delay block runs, setTimeout is still scheduled and the abort listener is attached too late to fire, so the function can stall for the full backoff interval before returning. This makes cancellation and “stop retrying” behavior laggy (up to tens of seconds) instead of immediate.
Useful? React with 👍 / 👎.
| for handler in self._handlers.get(event_type, []): | ||
| try: | ||
| await handler(data) |
There was a problem hiding this comment.
Remove duplicate local dispatch in Redis listener path
With the new PubSub listener, handlers are now invoked once directly in publish and again when the same message is consumed from Redis, so same-process subscribers can process every event twice. That can duplicate side effects (e.g., writes or notifications) whenever a node both subscribes and publishes on the same event type.
Useful? React with 👍 / 👎.
…y, contracts, polish)
P0 Critical (29A): migration composite FK ordering, embedding dim=1536, JWT revocation fail-closed with LRU cache, tenant-scoped trace/evidence stores, setup token log redaction, aria-live SSE chat fix.
High Reliability (29B): PBKDF2 async offload, JWT startup validation, CircuitBreaker 3-state machine (half-open probe), Redis PubSub real subscribe, SSE client reconnection with exponential backoff, React.memo ChatMessageBubble.
Contract Alignment (29C): setup wizard endpoint/payload fix, idempotency guard, privacy panel auth+abort, safe defaults (demo=0, dev=false), GEMINI_API_KEY alias.
Medium Polish (29D): SSE terminal ordering+backpressure, async tutor I/O, auth-first in tutor routes, login form cleanup, api.ts type safety, composite DB indexes, accessibility store cleanup.
Deferred: D3 (auth.py DI refactor) — follow-up sprint.
Description
Brief description of the changes and their motivation.
Type of Change
Accessibility Impact
Testing
uv run pytest)cd agents && uv run pytest)pnpm test)Quality Gates
uv run ruff check .— cleanuv run mypy .— cleanpnpm lint— cleanpnpm typecheck— cleandocker compose up -d --build)Screenshots
If UI changes, include before/after screenshots.
Related Issues
Closes #(issue number)