fix: race condition hardening across auth, counters, and data fetching (#102)

* fix: prevent race conditions in token operations and auth flows

Backend:
- Refresh token rotation: use atomic UPDATE...WHERE revoked_at IS NULL
  to prevent concurrent refresh requests from both succeeding
- Account invite codes: SELECT FOR UPDATE to prevent double-spend
- Platform invite codes: SELECT FOR UPDATE to prevent double-spend
- Password reset tokens: SELECT FOR UPDATE to prevent double-use
- Email verification tokens: SELECT FOR UPDATE to prevent double-use

Frontend:
- Token refresh subscriber arrays: swap before iterating so a throwing
  callback doesn't leave the queue in a dirty state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: atomic counters, plan limit re-check, and double-submit guard

Backend:
- Tree usage_count: use SQL-level UPDATE (Tree.usage_count + 1) instead
  of Python-level increment to prevent lost updates under concurrency
- Tag usage_count: same SQL-level atomic increment/decrement in both
  create_tree and update_tree (delete_tree already used this pattern)
- Plan tree limit: re-check count after db.flush() to close the TOCTOU
  window where two concurrent creates could both pass the pre-check

Frontend:
- TreeEditorPage: add isSaving early-return guard inside handleSaveDraft
  and handlePublish callbacks so Ctrl+S can't bypass the button disabled
  prop and fire duplicate save requests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: prevent stale API responses from overwriting newer data

- SessionHistoryPage: move loadSessions into effect with cancelled flag
  so rapid filter/tab changes discard outdated responses
- TreeLibraryPage: add request ID ref to loadTrees so stale responses
  from previous filter selections are discarded
- QuickStartPage: add request ID ref to debounced search so out-of-order
  responses don't overwrite newer search results

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add flexible intake design — deferred variables + prepared sessions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #102.
This commit is contained in:
chihlasm
2026-03-10 01:57:22 -04:00
committed by GitHub
parent 5095b0d8df
commit 4727106141
9 changed files with 305 additions and 98 deletions

View File

@@ -60,7 +60,59 @@ export function SessionHistoryPage() {
// Load sessions when filters change
useEffect(() => {
let cancelled = false
const loadSessions = async () => {
setIsLoading(true)
try {
const params: Record<string, string | boolean> = {}
// Tab filter (all/active/completed)
if (filter !== 'all') {
params.completed = filter === 'completed'
}
// Search/filter params
if (filters.ticketNumber) {
params.ticket_number = filters.ticketNumber
}
if (filters.clientName) {
params.client_name = filters.clientName
}
if (filters.treeName) {
params.tree_name = filters.treeName
}
// Date range params
if (filters.dateRange?.from) {
const fromDate = filters.dateRange.from
const toDate = filters.dateRange.to || filters.dateRange.from
if (filters.dateType === 'started') {
params.started_after = fromDate.toISOString()
params.started_before = toDate.toISOString()
} else {
params.completed_after = fromDate.toISOString()
params.completed_before = toDate.toISOString()
}
}
const sessionsData = await sessionsApi.list({ ...params, size: 51 })
if (cancelled) return
const truncated = sessionsData.length > 50
setHasMore(truncated)
setSessions(truncated ? sessionsData.slice(0, 50) : sessionsData)
} catch (err) {
if (cancelled) return
toast.error('Failed to load sessions')
console.error(err)
} finally {
if (!cancelled) setIsLoading(false)
}
}
loadSessions()
return () => { cancelled = true }
}, [filter, filters])
// Update URL params when filters change
@@ -79,53 +131,6 @@ export function SessionHistoryPage() {
setSearchParams(params, { replace: true })
}, [filters, setSearchParams])
const loadSessions = async () => {
setIsLoading(true)
try {
const params: Record<string, string | boolean> = {}
// Tab filter (all/active/completed)
if (filter !== 'all') {
params.completed = filter === 'completed'
}
// Search/filter params
if (filters.ticketNumber) {
params.ticket_number = filters.ticketNumber
}
if (filters.clientName) {
params.client_name = filters.clientName
}
if (filters.treeName) {
params.tree_name = filters.treeName
}
// Date range params
if (filters.dateRange?.from) {
const fromDate = filters.dateRange.from
const toDate = filters.dateRange.to || filters.dateRange.from
if (filters.dateType === 'started') {
params.started_after = fromDate.toISOString()
params.started_before = toDate.toISOString()
} else {
params.completed_after = fromDate.toISOString()
params.completed_before = toDate.toISOString()
}
}
const sessionsData = await sessionsApi.list({ ...params, size: 51 })
const truncated = sessionsData.length > 50
setHasMore(truncated)
setSessions(truncated ? sessionsData.slice(0, 50) : sessionsData)
} catch (err) {
toast.error('Failed to load sessions')
console.error(err)
} finally {
setIsLoading(false)
}
}
const handleFilterChange = (newFilters: SessionFilterState) => {
setFilters(newFilters)
}