Skip to content

fix: address codebase-analysis findings across API, SDKs, MCP, and dashboard#276

Merged
duyet merged 14 commits into
mainfrom
claude/codebase-analysis-issues-ypsx13
Jul 3, 2026
Merged

fix: address codebase-analysis findings across API, SDKs, MCP, and dashboard#276
duyet merged 14 commits into
mainfrom
claude/codebase-analysis-issues-ypsx13

Conversation

@duyet

@duyet duyet commented Jul 2, 2026

Copy link
Copy Markdown
Owner

Fixes the 22 issues filed from the deep codebase analysis. Quality gates green locally: Biome clean, tsc clean, API vitest 353 passed (+12 new), TS SDK 17 passed, MCP 11 passed, python-sdk 57 passed.

Security — API

Correctness / reliability — API

SDK / MCP / shared / dashboard

Commits are grouped by subsystem, each with the repo's dual co-author trailers.

🤖 Generated with Claude Code


Generated by Claude Code

duyetbot and others added 13 commits July 2, 2026 12:46
…ault

Derive a per-user org discriminator (personal:${clerkUserId}) when a Clerk
session has no active organization, and drop the "default" fallbacks in
clerk-session.ts and routes/projects.ts. Previously every org-less user
collapsed into one shared sentinel org, allowing cross-tenant reads between
unrelated personal accounts. Adds a test proving user A cannot see user B's
org-less projects.

Closes #254

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>
Add a shared url-safety helper that requires https and rejects loopback,
private, link-local and cloud-metadata hosts (127/8, 10/8, 172.16/12,
192.168/16, 169.254/16, ::1, fc00::/7, localhost). Enforced in the
Create/Update webhook schemas at registration and again in lib/webhook.ts
immediately before delivery (defense in depth).

Closes #255

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>
Add a partial unique index on active leases (project_id, state_key) WHERE
released_at IS NULL and turn acquire into a guarded conditional insert that
catches the unique violation and returns LEASE_CONFLICT, replacing the racy
check-then-insert. Expired leases are released inline before insert so a fresh
lease can still be acquired. Migration regenerated via drizzle-kit.

Closes #256

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>
The public analytics routes mounted only apiKeyAuth + rateLimit, so any valid
key (even one scoped to state:write only) could read aggregate analytics. Add
requireScope("analytics:read").

Closes #257

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>
Add an explicit ESCAPE '\\' clause so backslash-escaped wildcards are treated
as literals, and stop escaping '[' (a literal in SQLite LIKE, not a wildcard).
Paginate on a composite (updated_at, id) cursor with matching ORDER BY so rows
sharing a timestamp are neither skipped nor duplicated, mirroring
listConversations.

Closes #258
Closes #261

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>
Mirror listConversations so claims sharing a created_at are not skipped or
duplicated across pages; ORDER BY now tie-breaks on id and the cursor encodes
both parts.

Refs #261

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>
GET /conversations/:id loaded all messages unbounded and unordered. Order by
(created_at, rowid) and cap at 1000 to prevent an unbounded load, keeping the
response shape unchanged.

Closes #260

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>
deleteProject only removed 5 tables, orphaning state, claims, capability
tokens, webhooks, domains and OAuth artifacts (and leaving credentials usable
since D1 does not enforce FK cascade by default). Explicitly delete every
project-child table and also return capability-token hashes so their auth-cache
entries are invalidated alongside API keys.

Closes #262

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>
Verify the stored client_secret hash (constant-time) for confidential clients
using client_secret_basic or client_secret_post at the token endpoint; public
(none + PKCE) clients keep working, and unknown clients still surface
invalid_grant via the grant handlers. Add per-IP rate limiting to
POST /api/oauth/register.

Closes #263
Closes #264

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>
The Workers KV sliding-window limiter is a non-atomic read-modify-write and is
bypassable under burst concurrency, so it stays opt-in behind USE_SLIDING_WINDOW
while the atomic D1 fixed-window limiter remains the default path. Document the
limitation and recommend a Durable Object / CF Rate Limiting binding for an
atomic sliding window.

Closes #259

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>
TS SDK: restrict automatic retries to idempotent methods (or POSTs carrying an
Idempotency-Key), honor Retry-After on 429 with jittered backoff, and page
LangGraph list() across cursors up to the caller's limit. MCP: add a
configurable AGENTSTATE_TIMEOUT_MS AbortController timeout to apiRequest. Shared:
reconcile ListResponse<T> to the real { data, pagination: { limit, next_cursor } }
shape. Add @langchain/core as an optional peer dependency.

Closes #269
Closes #270
Closes #271
Closes #272
Closes #273
Closes #274
Closes #275

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>
Split the langgraph checkpoint-saver imports into separate try/except blocks so
a missing AsyncBaseCheckpointSaver no longer stubs out BaseCheckpointSaver.
Handle 400/403 explicitly and wrap all non-2xx responses in AgentStateError with
the parsed { error: { code, message } } body. Add a timeout constructor param
(~30s) passed to httpx. Unify the default base URL, page LangGraph list() across
cursors, restrict retries to idempotent methods, and honor Retry-After + jitter.

Closes #265
Closes #267
Closes #268
Refs #269
Refs #270
Refs #272
Refs #274

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>
Remove the loaded ref that defeated the effect deps array (stale messages
persisted when switching conversations) and add an ignore cleanup flag to drop
out-of-order async responses from a previous conversation.

Closes #266

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>

@sourcery-ai sourcery-ai 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.

Sorry @duyet, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@duyet, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 278c2a9a-80e9-4be7-aaea-57510322e1f0

📥 Commits

Reviewing files that changed from the base of the PR and between ac52fb5 and d9a2519.

⛔ Files ignored due to path filters (8)
  • packages/sdk/dist/ai-sdk.d.ts is excluded by !**/dist/**
  • packages/sdk/dist/ai-sdk.mjs is excluded by !**/dist/**
  • packages/sdk/dist/index.d.ts is excluded by !**/dist/**
  • packages/sdk/dist/index.js is excluded by !**/dist/**
  • packages/sdk/dist/index.mjs is excluded by !**/dist/**
  • packages/sdk/dist/langgraph.d.ts is excluded by !**/dist/**
  • packages/sdk/dist/langgraph.js is excluded by !**/dist/**
  • packages/sdk/dist/langgraph.mjs is excluded by !**/dist/**
📒 Files selected for processing (38)
  • packages/api/drizzle/0008_amusing_nightshade.sql
  • packages/api/drizzle/meta/0008_snapshot.json
  • packages/api/drizzle/meta/_journal.json
  • packages/api/src/db/schema.ts
  • packages/api/src/lib/clerk-session.ts
  • packages/api/src/lib/url-safety.ts
  • packages/api/src/lib/validation.ts
  • packages/api/src/lib/webhook.ts
  • packages/api/src/middleware/rate-limit.ts
  • packages/api/src/routes/analytics-public.ts
  • packages/api/src/routes/conversations/crud.ts
  • packages/api/src/routes/conversations/search.ts
  • packages/api/src/routes/oauth/index.ts
  • packages/api/src/routes/projects.ts
  • packages/api/src/services/claims.ts
  • packages/api/src/services/conversation-search.ts
  • packages/api/src/services/leases.ts
  • packages/api/src/services/oauth.ts
  • packages/api/src/services/projects.ts
  • packages/api/test/analytics-public.test.ts
  • packages/api/test/clerk-dashboard-auth.test.ts
  • packages/api/test/clerk-jwt.ts
  • packages/api/test/oauth.test.ts
  • packages/api/test/setup.ts
  • packages/api/test/webhooks.test.ts
  • packages/dashboard/src/components/dashboard/conversations/_use-messages.ts
  • packages/mcp/src/index.ts
  • packages/python-sdk/agentstate/client.py
  • packages/python-sdk/agentstate/exceptions.py
  • packages/python-sdk/agentstate/langgraph.py
  • packages/python-sdk/tests/test_client.py
  • packages/python-sdk/tests/test_langgraph.py
  • packages/sdk/package.json
  • packages/sdk/src/index.ts
  • packages/sdk/src/langgraph.ts
  • packages/sdk/tests/langgraph.test.ts
  • packages/sdk/tests/retry.test.ts
  • packages/shared/src/index.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/codebase-analysis-issues-ypsx13

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces several security and robustness enhancements across the API, TypeScript SDK, and Python SDK. Key changes include adding SSRF guards for webhooks, isolating personal accounts into per-user internal organizations, implementing client secret verification for confidential OAuth clients, and adding robust retry logic with exponential backoff and jitter to the SDKs. Feedback from the review suggests hardening the SSRF blocklist with CGNAT and site-local IPv6 ranges, preventing client ID enumeration in OAuth authentication, resetting the dashboard's message state immediately when switching conversations to avoid stale UI rendering, and clamping the maximum retry delay in both SDKs to prevent application hangs from large Retry-After headers.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +34 to +43
function isBlockedIPv4(octets: [number, number, number, number]): boolean {
const [a, b] = octets;
if (a === 0) return true; // 0.0.0.0/8 (includes unspecified)
if (a === 127) return true; // 127.0.0.0/8 loopback
if (a === 10) return true; // 10.0.0.0/8 private
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local (+ metadata)
return false;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

security-high high

The SSRF protection blocks standard private ranges but misses the Carrier-Grade NAT (CGNAT) range 100.64.0.0/10 (RFC 6598). This range is commonly used in cloud environments, Tailscale/ZeroTier networks, and internal Kubernetes clusters, and is a known vector for SSRF bypasses. Adding a check for this range hardens the SSRF guard.

Suggested change
function isBlockedIPv4(octets: [number, number, number, number]): boolean {
const [a, b] = octets;
if (a === 0) return true; // 0.0.0.0/8 (includes unspecified)
if (a === 127) return true; // 127.0.0.0/8 loopback
if (a === 10) return true; // 10.0.0.0/8 private
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local (+ metadata)
return false;
}
function isBlockedIPv4(octets: [number, number, number, number]): boolean {
const [a, b] = octets;
if (a === 0) return true; // 0.0.0.0/8 (includes unspecified)
if (a === 127) return true; // 127.0.0.0/8 loopback
if (a === 10) return true; // 10.0.0.0/8 private
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local (+ metadata)
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT (RFC 6598)
return false;
}

Comment on lines +61 to +66
const firstHextet = h.split(":")[0];
if (firstHextet.length > 0) {
// fc00::/7 → first byte 0xfc or 0xfd; fe80::/10 → fe80..febf.
if (firstHextet.startsWith("fc") || firstHextet.startsWith("fd")) return true;
if (/^fe[89ab]/.test(firstHextet)) return true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

security-high high

The IPv6 blocklist covers fe80::/10 (link-local) but misses fec0::/10 (site-local), which is deprecated but still private/local and can be used for SSRF. We can expand the regex to /^fe[8-f]/ to cover both link-local and site-local ranges.

Suggested change
const firstHextet = h.split(":")[0];
if (firstHextet.length > 0) {
// fc00::/7 → first byte 0xfc or 0xfd; fe80::/10 → fe80..febf.
if (firstHextet.startsWith("fc") || firstHextet.startsWith("fd")) return true;
if (/^fe[89ab]/.test(firstHextet)) return true;
}
const firstHextet = h.split(":")[0];
if (firstHextet.length > 0) {
// fc00::/7 → first byte 0xfc or 0xfd; fe80::/10 → fe80..febf; fec0::/10 → fec0..feff (site-local)
if (firstHextet.startsWith("fc") || firstHextet.startsWith("fd")) return true;
if (/^fe[8-f]/.test(firstHextet)) return true;
}

Comment on lines +137 to +140
const client = await getClient(db, clientId);
if (!client) {
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

security-medium medium

If the client is not found, returning early (success) allows client ID enumeration. An attacker can distinguish between a valid client ID (which returns 401 invalid_client when the secret is wrong) and an invalid client ID (which returns 400 invalid_grant or similar). Throwing invalid_client for unknown client IDs is more compliant with RFC 6749 §5.2 and prevents enumeration.

Suggested change
const client = await getClient(db, clientId);
if (!client) {
return;
}
const client = await getClient(db, clientId);
if (!client) {
throw new OAuthError("invalid_client", "invalid client credentials", 401);
}

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.

Applied the other five suggestions (CGNAT + IPv6 site-local SSRF ranges, retry-delay clamp in both SDKs, dashboard message reset). Leaving this one as-is intentionally: returning invalid_grant for an unknown/mismatched client_id at the token endpoint is a deliberate design here (see the docstring on authenticateClient) and is asserted by the existing tests (oauth.test.ts — "rejects a refresh token redeemed with the wrong client_id", etc.). The grant handlers bind the client to the code/refresh-token and surface invalid_grant, so an unknown client can't complete a grant regardless. The residual enumeration signal is minor and overriding the maintainer's tested error-semantics choice on this isn't warranted; happy to revisit if you'd prefer strict RFC 6749 §5.2 invalid_client for unknown clients.


Generated by Claude Code

Comment on lines 18 to 21
useEffect(() => {
if (loaded.current) return;
loaded.current = true;
let ignore = false;
(async () => {
setLoading(true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

When switching between conversations, the messages state is not reset immediately. This causes the UI to display the messages of the previous conversation while the new one is loading. Resetting messages to null at the start of the effect prevents this stale data display.

Suggested change
useEffect(() => {
if (loaded.current) return;
loaded.current = true;
let ignore = false;
(async () => {
setLoading(true);
useEffect(() => {
let ignore = false;
setMessages(null);
(async () => {
setLoading(true);

Comment thread packages/sdk/src/index.ts
Comment on lines 350 to 357
if (attempt > 0) {
const delay = this.retryDelayMs * 2 ** (attempt - 1);
const backoff = this.retryDelayMs * 2 ** (attempt - 1);
// Full jitter on the exponential backoff to avoid thundering herds.
const jittered = backoff + Math.floor(Math.random() * backoff);
const delay = nextDelayMs ?? jittered;
nextDelayMs = null;
await new Promise((resolve) => setTimeout(resolve, delay));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

If the server returns an extremely large Retry-After header (e.g., due to misconfiguration or long rate limit windows), the client will sleep for that entire duration, potentially hanging the application or exceeding execution limits (especially in serverless environments like Cloudflare Workers). Clamping the maximum retry delay to a reasonable value (e.g., 30 seconds) prevents this.

Suggested change
if (attempt > 0) {
const delay = this.retryDelayMs * 2 ** (attempt - 1);
const backoff = this.retryDelayMs * 2 ** (attempt - 1);
// Full jitter on the exponential backoff to avoid thundering herds.
const jittered = backoff + Math.floor(Math.random() * backoff);
const delay = nextDelayMs ?? jittered;
nextDelayMs = null;
await new Promise((resolve) => setTimeout(resolve, delay));
}
if (attempt > 0) {
const backoff = this.retryDelayMs * 2 ** (attempt - 1);
// Full jitter on the exponential backoff to avoid thundering herds.
const jittered = backoff + Math.floor(Math.random() * backoff);
const delay = Math.min(nextDelayMs ?? jittered, 30000);
nextDelayMs = null;
await new Promise((resolve) => setTimeout(resolve, delay));
}

Comment on lines +149 to +153
if retry_after is not None:
base = retry_after
else:
base = self.retry_delay_ms * (2 ** attempt) / 1000.0
return base + random.uniform(0.0, base * 0.5)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Similar to the TypeScript SDK, if the server returns an extremely large Retry-After header, the client will sleep for that entire duration, blocking the thread. Clamping the maximum retry delay to a reasonable value (e.g., 30.0 seconds) prevents hanging the client application.

Suggested change
if retry_after is not None:
base = retry_after
else:
base = self.retry_delay_ms * (2 ** attempt) / 1000.0
return base + random.uniform(0.0, base * 0.5)
if retry_after is not None:
base = retry_after
else:
base = self.retry_delay_ms * (2 ** attempt) / 1000.0
delay = base + random.uniform(0.0, base * 0.5)
return min(delay, 30.0)

Address review feedback on #276:
- url-safety: block CGNAT 100.64.0.0/10 (RFC 6598) and IPv6 site-local
  fec0::/10 in addition to the existing private/link-local ranges.
- SDK + python-sdk: clamp the retry delay to 30s so a misconfigured or
  huge Retry-After header can't hang the client.
- dashboard useMessages: clear messages immediately on conversation
  switch so the panel doesn't show the previous conversation while loading.

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants