Skip to content

feat: Sprint 29A-D — 27 multi-model audit fixes (security, reliabilit… - #5

Merged
rafaelob merged 1 commit into
mainfrom
sprint-29-audit-fixes
Mar 10, 2026
Merged

feat: Sprint 29A-D — 27 multi-model audit fixes (security, reliabilit…#5
rafaelob merged 1 commit into
mainfrom
sprint-29-audit-fixes

Conversation

@rafaelob

@rafaelob rafaelob commented Mar 4, 2026

Copy link
Copy Markdown
Owner

…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

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Accessibility improvement
  • Performance improvement
  • Refactoring (no functional changes)

Accessibility Impact

  • No accessibility impact
  • Improves accessibility (describe how)
  • New UI elements have proper ARIA labels and keyboard navigation
  • Tested with screen reader
  • WCAG AAA contrast ratios verified

Testing

  • Backend tests pass (uv run pytest)
  • Agent tests pass (cd agents && uv run pytest)
  • Frontend tests pass (pnpm test)
  • New tests added for this change

Quality Gates

  • uv run ruff check . — clean
  • uv run mypy . — clean
  • pnpm lint — clean
  • pnpm typecheck — clean
  • Docker Compose builds and runs (docker compose up -d --build)

Screenshots

If UI changes, include before/after screenshots.

Related Issues

Closes #(issue number)

…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>
Copilot AI review requested due to automatic review settings March 4, 2026 21:34

Copilot AI left a comment

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.

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': '' },

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

@codex revise essa sugestão.

Comment on lines 26 to +31
/**
* 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> {

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

@codex revise essa sugestão.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Summary

  • Repositioned the JSDoc blocks in setup-wizard.tsx so the mapper comment now correctly documents toSetupPayload, and the component-level JSDoc now directly precedes SetupWizard, 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_pr with 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

View task →

Comment on lines 166 to +207
@@ -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)

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

@codex revise essa sugestão.

Comment on lines 439 to +442
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

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

@codex revise essa sugestão.

Comment on lines +416 to +461
# 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()

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

@codex revise essa sugestão.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Summary

Testing

  • python -m py_compile runtime/ailine_runtime/api/middleware/tenant_context.py

View task →

Comment on lines +99 to +107
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

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

@codex revise essa sugestão.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Summary

  • Tightened tenant isolation in trace mutations by making append_node and update_run always require exact teacher_id equality 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 TraceStore tests to use the current get(..., teacher_id=...) contract consistently and added a regression test that verifies mutation calls with teacher_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 f750e75 and created a follow-up PR via the make_pr tool.

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

View task →

Comment on lines +178 to +179
// Exponential backoff: 3s, 6s, 12s, 24s, 48s
const delay = retryMs * Math.pow(2, attempt - 1)

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

@codex revise essa sugestão.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +432 to +435
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment on lines 43 to +46
_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.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment on lines +180 to +183
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, delay)
signal?.addEventListener('abort', () => {
clearTimeout(timer)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment on lines +66 to +68
for handler in self._handlers.get(event_type, []):
try:
await handler(data)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

@rafaelob
rafaelob merged commit eccabc0 into main Mar 10, 2026
5 of 13 checks passed
@rafaelob
rafaelob deleted the sprint-29-audit-fixes branch March 10, 2026 13:02
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.

2 participants