feat(analytics): account leaderboard with tracked affiliates - #646
feat(analytics): account leaderboard with tracked affiliates#646mezotv wants to merge 10 commits into
Conversation
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
Comp AI code review complete — 2 issues found. Commit |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
4 Skipped Deployments
|
|
Capy auto-review is paused for this organization because the usage-cycle auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews. |
|
React Doctor found 6 new issues in 4 files · 6 warnings · score 77 / 100 (Needs work) · 0 fixed · vs 6 warnings
Reviewed by React Doctor for commit |
Greptile SummaryAdds an organization-scoped account leaderboard combining connected and tracked X accounts, including tracking mutations, immediate and scheduled ingestion, Tinybird window aggregation, and dashboard UI.
Confidence Score: 4/5The tracked-account-only experience and tracked-row detail behavior need to be fixed before merging because key advertised flows are currently unreachable or permanently misleading. The page derives its empty state and detail payload solely from connected accounts, which prevents organizations without OAuth connections from rendering the leaderboard and prevents tracked entries from ever showing their synchronized lifetime statistics. Files Needing Attention: apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx and apps/dashboard/src/components/analytics/leaderboard-card.tsx Important Files Changed
Sequence DiagramsequenceDiagram
participant User
participant UI as LeaderboardCard
participant RPC as Analytics oRPC
participant X as X API
participant DB as Postgres
participant TB as Tinybird
User->>UI: "Track @affiliate"
UI->>RPC: trackAccount(org, username)
RPC->>X: Resolve username
X-->>RPC: Account metadata
RPC->>DB: Insert tracked_social_accounts
RPC->>X: Fetch account and posts
RPC->>TB: Ingest dimensions, stats, and posts
RPC-->>UI: Tracking succeeded
UI->>RPC: leaderboard(org, days)
RPC->>DB: Load connected and tracked accounts
RPC->>TB: Query current and previous windows
RPC-->>UI: Ranked entries
|
| <LeaderboardCard | ||
| accountDetails={accounts} | ||
| organizationId={organizationId} |
There was a problem hiding this comment.
Tracked rows never receive details
When a user expands a tracked-only account after synchronization, accountDetails still contains only the connected-account overview. The username lookup therefore always misses and permanently displays “Lifetime stats appear after this account's first sync” instead of the synchronized account statistics.
There was a problem hiding this comment.
4 issues found across 3 files.
Prompt for AI agents (all issues)
Check whether each issue below is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it, and use sub-agents to handle independent issues in parallel.
<file name="apps/dashboard/src/lib/orpc/routers/analytics.ts">
<issue n="1" at="apps/dashboard/src/lib/orpc/routers/analytics.ts:58-480" severity="MEDIUM">trackAccount endpoint calls shared paid Twitter API with no rate limiting — The `trackAccount` procedure (L417) is authenticated and org-membership-checked, but has no rate limiting before invoking expensive external API calls. Every invocation calls `resolveTwitterAccount(input.username)` (L426) — a Twitter API v2 call using the shared app-level `TWITTER_BEARER_TOKEN` — BEFORE the existing-account dedup checks at L432/L448. If the account is new, it then triggers `syncTrackedAccountNow` (L480), whose body (L58-63) calls `collectTwitterRows`, which performs a batched `fetchTwitterUsersBatch` call plus `fetchUserTweets` for the user (up to TWITTER_TIMELINE_MAX_PAGES=5 paginated timeline requests, per apps/dashboard/src/lib/analytics/twitter-sync.ts and constants.ts). So a single trackAccount call burns 1-7+ Twitter API requests against a token shared across ALL organizations on the platform. Because the bearer token is app-level (not per-org), any authenticated member of any organization can repeatedly POST trackAccount with arbitrary usernames (Zod only constrains length<=30) to exhaust the shared Twitter API rate-limit/quota for every tenant simultaneously — a cross-tenant availability/cost-abuse impact. The call happens even for usernames that are already tracked (resolveTwitterAccount runs before the early-return at L448), so an attacker need not find new accounts to amplify the abuse. No framework-level rate limiting wraps this handler (the only gating is auth + membership). Fix: Add per-user/per-org rate limiting to the trackAccount (and any sync-triggering) procedure, e.g. an oRPC middleware or Upstash/Redis sliding-window limiter keyed on the authenticated user id and organizationId. Also reorder the handler to check the existing-connected/existing-tracked dedup (DB-only) BEFORE calling resolveTwitterAccount, so already-tracked usernames don't consume a Twitter API call. Consider a separate background job (queue) for the initial sync rather than doing it inline in the request.</issue>
<issue n="2" at="apps/dashboard/src/lib/orpc/routers/analytics.ts:448-467" severity="MEDIUM">Concurrent trackAccount requests for the same username can 500 on unique-index conflict — In `trackAccount`, the existence check `db.query.trackedSocialAccounts.findFirst` (L448) and the subsequent `db.insert` (L467) are not atomic. Two concurrent requests for the same new Twitter handle will both observe no existing tracked row, both proceed past the checks, and both attempt an insert. The second insert collides with the `trackedSocialAccounts_org_provider_account_uidx` unique index and throws a database constraint error, which propagates as an unhandled 500 rather than gracefully returning the existing trackedAccountId (the way the single-request path does at L452-455). The unique index prevents data corruption, so this is a correctness/UX bug rather than a security issue. Fix: Catch the unique-constraint violation on insert and re-read the existing row to return its id (idempotent behavior), or use an upsert (`onConflictDoNothing().returning()`) against the unique index, mirroring the existing-tracked early-return path.</issue>
</file>
<file name="packages/db/migrations/meta/0062_snapshot.json">
<issue n="3" at="packages/db/migrations/meta/0062_snapshot.json:36-71" severity="MEDIUM">OAuth access/refresh/id tokens stored as plaintext in accounts table, inconsistent with codebase-wide at-rest encryption — The `accounts` table (Better Auth account-linking table) stores third-party OAuth credentials as plain `text` columns: `access_token` (snapshot line 36), `refresh_token` (line 42), and `id_token` (line 48), with no `encrypted_` prefix and no indication of at-rest encryption. This is confirmed against the schema source in `packages/db/src/schema.ts:183-185` where `accessToken`/`refreshToken`/`idToken` are defined as plain `text()`. This directly conflicts with the codebase's own deliberate convention of encrypting every other integration secret at rest, as evidenced by the `encrypted_` column naming used throughout the same schema: `linear_integrations.encrypted_access_token`/`encrypted_webhook_secret`, `slack_integrations.encrypted_bot_token`, `granola_integrations.encrypted_api_key`, `github_integrations.encrypted_token`, and the app's own OAuth server tables (`oauth_clients.encrypted_tokens`, `oauth_authorization_codes.encrypted_*`). Because the `accounts` table holds LIVE, immediately-usable OAuth tokens for external identity providers (Google, GitHub, etc.), a separate database-compromise vector (SQL injection elsewhere in the app, a leaked DB backup, exposed DB credentials, or an insider with read access) would expose these tokens in cleartext, enabling account takeover across every linked provider for every user, plus PII disclosure from `id_token` payloads. The same compromise of the `*_integrations` tables would NOT yield usable secrets because those are encrypted at rest. Note: the `password` column (line 72) is not the concern — Better Auth stores a password hash there, not a plaintext password. Caveat: this is a generated snapshot reflecting the schema; the underlying decision lives in `schema.ts` and the Better Auth library's default account storage, which writes tokens directly to these columns without application-level encryption. Fix: Encrypt the `access_token`, `refresh_token`, and `id_token` columns at rest using the same encryption utility already used for the `encrypted_*` columns in the integration tables. If Better Auth writes these columns directly and cannot be easily intercepted, consider either (a) post-processing tokens through a Better Auth hook/database adapter to encrypt before write and decrypt on read, (b) enabling database-level encryption (e.g., Postgres TDE / pgcrypto) for the `accounts` table, or (c) at minimum, shortening token retention and periodically purging `access_token`/`refresh_token` once they are no longer needed for active sessions. Apply the encryption retroactively to existing rows via a migration.</issue>
</file>
<file name="apps/dashboard/src/workflows/steps/social-analytics-steps.ts">
<issue n="4" at="apps/dashboard/src/workflows/steps/social-analytics-steps.ts:40-83" severity="MEDIUM">Tracked-only social accounts always synced to Tinybird with verified=false — In `listSyncableAccounts`, the query against `trackedSocialAccounts` (lines 40-53) selects id, organizationId, provider, providerAccountId, username, displayName, profileImageUrl but omits the `verified` column. The `tracked_social_accounts` table does have a `verified` column (per migration 0062_brave_menace.sql), and the `trackAccount` handler in lib/orpc/routers/analytics.ts populates it from the Twitter API (`verified: resolved.verified`). However, the `trackedOnly` mapping at line 83 hardcodes `verified: false`. These accounts flow through `snapshotAccountDimensions` -> `buildAccountRow` (which sets `verified: account.verified`) and are ingested into Tinybird as account dimensions. As a result, every tracked-only (non-OAuth-connected) account is persisted to the analytics warehouse with `verified=false` regardless of its real verification status. This is inconsistent with the `leaderboard` handler, which reads `verified`/`verifiedType` from the same `trackedSocialAccounts` table and returns the correct value. Any analytics view or downstream consumer relying on Tinybird's `verified` dimension will incorrectly display tracked accounts as unverified. This is a data-correctness logic bug, not a security vulnerability. Note: the `connected` accounts query (line 30) correctly selects `verified` and the `connected` mapping uses `verified: account.verified ?? false`, so only tracked-only accounts are affected. Fix: Add `verified: true` to the `trackedSocialAccounts.findMany` columns list in `listSyncableAccounts`, and change the `trackedOnly` mapping to use `verified: account.verified ?? false` instead of the hardcoded `false`. If the Tinybird `SocialAccountRow` datasource supports a verified-type field and it is desired, also select `verifiedType: true` and propagate it.</issue>
</file>
Commit b0b9c20 · Posted by Comp AI Code Reviews.
| }); | ||
|
|
||
| try { | ||
| await syncTrackedAccountNow({ |
There was a problem hiding this comment.
MEDIUM: trackAccount endpoint calls shared paid Twitter API with no rate limiting
The trackAccount procedure (L417) is authenticated and org-membership-checked, but has no rate limiting before invoking expensive external API calls. Every invocation calls resolveTwitterAccount(input.username) (L426) — a Twitter API v2 call using the shared app-level TWITTER_BEARER_TOKEN — BEFORE the existing-account dedup checks at L432/L448. If the account is new, it then triggers syncTrackedAccountNow (L480), whose body (L58-63) calls collectTwitterRows, which performs a batched fetchTwitterUsersBatch call plus fetchUserTweets for the user (up to TWITTER_TIMELINE_MAX_PAGES=5 paginated timeline requests, per apps/dashboard/src/lib/analytics/twitter-sync.ts and constants.ts). So a single trackAccount call burns 1-7+ Twitter API requests against a token shared across ALL organizations on the platform.
Because the bearer token is app-level (not per-org), any authenticated member of any organization can repeatedly POST trackAccount with arbitrary usernames (Zod only constrains length<=30) to exhaust the shared Twitter API rate-limit/quota for every tenant simultaneously — a cross-tenant availability/cost-abuse impact. The call happens even for usernames that are already tracked (resolveTwitterAccount runs before the early-return at L448), so an attacker need not find new accounts to amplify the abuse. No framework-level rate limiting wraps this handler (the only gating is auth + membership).
Suggestion: Add per-user/per-org rate limiting to the trackAccount (and any sync-triggering) procedure, e.g. an oRPC middleware or Upstash/Redis sliding-window limiter keyed on the authenticated user id and organizationId. Also reorder the handler to check the existing-connected/existing-tracked dedup (DB-only) BEFORE calling resolveTwitterAccount, so already-tracked usernames don't consume a Twitter API call. Consider a separate background job (queue) for the initial sync rather than doing it inline in the request.
Prompt for AI agents
Check whether this issue is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it.
<issue at="apps/dashboard/src/lib/orpc/routers/analytics.ts:58-480" severity="MEDIUM">trackAccount endpoint calls shared paid Twitter API with no rate limiting — The `trackAccount` procedure (L417) is authenticated and org-membership-checked, but has no rate limiting before invoking expensive external API calls. Every invocation calls `resolveTwitterAccount(input.username)` (L426) — a Twitter API v2 call using the shared app-level `TWITTER_BEARER_TOKEN` — BEFORE the existing-account dedup checks at L432/L448. If the account is new, it then triggers `syncTrackedAccountNow` (L480), whose body (L58-63) calls `collectTwitterRows`, which performs a batched `fetchTwitterUsersBatch` call plus `fetchUserTweets` for the user (up to TWITTER_TIMELINE_MAX_PAGES=5 paginated timeline requests, per apps/dashboard/src/lib/analytics/twitter-sync.ts and constants.ts). So a single trackAccount call burns 1-7+ Twitter API requests against a token shared across ALL organizations on the platform. Because the bearer token is app-level (not per-org), any authenticated member of any organization can repeatedly POST trackAccount with arbitrary usernames (Zod only constrains length<=30) to exhaust the shared Twitter API rate-limit/quota for every tenant simultaneously — a cross-tenant availability/cost-abuse impact. The call happens even for usernames that are already tracked (resolveTwitterAccount runs before the early-return at L448), so an attacker need not find new accounts to amplify the abuse. No framework-level rate limiting wraps this handler (the only gating is auth + membership). Fix: Add per-user/per-org rate limiting to the trackAccount (and any sync-triggering) procedure, e.g. an oRPC middleware or Upstash/Redis sliding-window limiter keyed on the authenticated user id and organizationId. Also reorder the handler to check the existing-connected/existing-tracked dedup (DB-only) BEFORE calling resolveTwitterAccount, so already-tracked usernames don't consume a Twitter API call. Consider a separate background job (queue) for the initial sync rather than doing it inline in the request.</issue>
Commit b0b9c20.
| "name": "access_token", | ||
| "type": "text", | ||
| "primaryKey": false, | ||
| "notNull": false | ||
| }, | ||
| "refresh_token": { | ||
| "name": "refresh_token", | ||
| "type": "text", | ||
| "primaryKey": false, | ||
| "notNull": false | ||
| }, | ||
| "id_token": { | ||
| "name": "id_token", | ||
| "type": "text", | ||
| "primaryKey": false, | ||
| "notNull": false | ||
| }, | ||
| "access_token_expires_at": { | ||
| "name": "access_token_expires_at", | ||
| "type": "timestamp", | ||
| "primaryKey": false, | ||
| "notNull": false | ||
| }, | ||
| "refresh_token_expires_at": { | ||
| "name": "refresh_token_expires_at", | ||
| "type": "timestamp", | ||
| "primaryKey": false, | ||
| "notNull": false | ||
| }, | ||
| "scope": { | ||
| "name": "scope", | ||
| "type": "text", | ||
| "primaryKey": false, | ||
| "notNull": false | ||
| }, | ||
| "password": { |
There was a problem hiding this comment.
MEDIUM: OAuth access/refresh/id tokens stored as plaintext in accounts table, inconsistent with codebase-wide at-rest encryption
The accounts table (Better Auth account-linking table) stores third-party OAuth credentials as plain text columns: access_token (snapshot line 36), refresh_token (line 42), and id_token (line 48), with no encrypted_ prefix and no indication of at-rest encryption. This is confirmed against the schema source in packages/db/src/schema.ts:183-185 where accessToken/refreshToken/idToken are defined as plain text(). This directly conflicts with the codebase's own deliberate convention of encrypting every other integration secret at rest, as evidenced by the encrypted_ column naming used throughout the same schema: linear_integrations.encrypted_access_token/encrypted_webhook_secret, slack_integrations.encrypted_bot_token, granola_integrations.encrypted_api_key, github_integrations.encrypted_token, and the app's own OAuth server tables (oauth_clients.encrypted_tokens, oauth_authorization_codes.encrypted_*). Because the accounts table holds LIVE, immediately-usable OAuth tokens for external identity providers (Google, GitHub, etc.), a separate database-compromise vector (SQL injection elsewhere in the app, a leaked DB backup, exposed DB credentials, or an insider with read access) would expose these tokens in cleartext, enabling account takeover across every linked provider for every user, plus PII disclosure from id_token payloads. The same compromise of the *_integrations tables would NOT yield usable secrets because those are encrypted at rest. Note: the password column (line 72) is not the concern — Better Auth stores a password hash there, not a plaintext password. Caveat: this is a generated snapshot reflecting the schema; the underlying decision lives in schema.ts and the Better Auth library's default account storage, which writes tokens directly to these columns without application-level encryption.
Suggestion: Encrypt the access_token, refresh_token, and id_token columns at rest using the same encryption utility already used for the encrypted_* columns in the integration tables. If Better Auth writes these columns directly and cannot be easily intercepted, consider either (a) post-processing tokens through a Better Auth hook/database adapter to encrypt before write and decrypt on read, (b) enabling database-level encryption (e.g., Postgres TDE / pgcrypto) for the accounts table, or (c) at minimum, shortening token retention and periodically purging access_token/refresh_token once they are no longer needed for active sessions. Apply the encryption retroactively to existing rows via a migration.
Prompt for AI agents
Check whether this issue is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it.
<issue at="packages/db/migrations/meta/0062_snapshot.json:36-71" severity="MEDIUM">OAuth access/refresh/id tokens stored as plaintext in accounts table, inconsistent with codebase-wide at-rest encryption — The `accounts` table (Better Auth account-linking table) stores third-party OAuth credentials as plain `text` columns: `access_token` (snapshot line 36), `refresh_token` (line 42), and `id_token` (line 48), with no `encrypted_` prefix and no indication of at-rest encryption. This is confirmed against the schema source in `packages/db/src/schema.ts:183-185` where `accessToken`/`refreshToken`/`idToken` are defined as plain `text()`. This directly conflicts with the codebase's own deliberate convention of encrypting every other integration secret at rest, as evidenced by the `encrypted_` column naming used throughout the same schema: `linear_integrations.encrypted_access_token`/`encrypted_webhook_secret`, `slack_integrations.encrypted_bot_token`, `granola_integrations.encrypted_api_key`, `github_integrations.encrypted_token`, and the app's own OAuth server tables (`oauth_clients.encrypted_tokens`, `oauth_authorization_codes.encrypted_*`). Because the `accounts` table holds LIVE, immediately-usable OAuth tokens for external identity providers (Google, GitHub, etc.), a separate database-compromise vector (SQL injection elsewhere in the app, a leaked DB backup, exposed DB credentials, or an insider with read access) would expose these tokens in cleartext, enabling account takeover across every linked provider for every user, plus PII disclosure from `id_token` payloads. The same compromise of the `*_integrations` tables would NOT yield usable secrets because those are encrypted at rest. Note: the `password` column (line 72) is not the concern — Better Auth stores a password hash there, not a plaintext password. Caveat: this is a generated snapshot reflecting the schema; the underlying decision lives in `schema.ts` and the Better Auth library's default account storage, which writes tokens directly to these columns without application-level encryption. Fix: Encrypt the `access_token`, `refresh_token`, and `id_token` columns at rest using the same encryption utility already used for the `encrypted_*` columns in the integration tables. If Better Auth writes these columns directly and cannot be easily intercepted, consider either (a) post-processing tokens through a Better Auth hook/database adapter to encrypt before write and decrypt on read, (b) enabling database-level encryption (e.g., Postgres TDE / pgcrypto) for the `accounts` table, or (c) at minimum, shortening token retention and periodically purging `access_token`/`refresh_token` once they are no longer needed for active sessions. Apply the encryption retroactively to existing rows via a migration.</issue>
Commit b0b9c20.
| const existingTracked = await db.query.trackedSocialAccounts.findFirst({ | ||
| columns: { id: true }, | ||
| where: and( | ||
| eq(trackedSocialAccounts.organizationId, input.organizationId), | ||
| eq(trackedSocialAccounts.provider, "twitter"), | ||
| eq( | ||
| trackedSocialAccounts.providerAccountId, | ||
| resolved.providerAccountId | ||
| ) | ||
| ), | ||
| }); | ||
| if (existingTracked) { | ||
| return { | ||
| trackedAccountId: existingTracked.id, | ||
| username: resolved.username, | ||
| }; | ||
| } | ||
|
|
||
| const trackedAccountId = crypto.randomUUID(); | ||
| await db.insert(trackedSocialAccounts).values({ |
There was a problem hiding this comment.
MEDIUM: Concurrent trackAccount requests for the same username can 500 on unique-index conflict
In trackAccount, the existence check db.query.trackedSocialAccounts.findFirst (L448) and the subsequent db.insert (L467) are not atomic. Two concurrent requests for the same new Twitter handle will both observe no existing tracked row, both proceed past the checks, and both attempt an insert. The second insert collides with the trackedSocialAccounts_org_provider_account_uidx unique index and throws a database constraint error, which propagates as an unhandled 500 rather than gracefully returning the existing trackedAccountId (the way the single-request path does at L452-455). The unique index prevents data corruption, so this is a correctness/UX bug rather than a security issue.
Suggestion: Catch the unique-constraint violation on insert and re-read the existing row to return its id (idempotent behavior), or use an upsert (onConflictDoNothing().returning()) against the unique index, mirroring the existing-tracked early-return path.
Prompt for AI agents
Check whether this issue is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it.
<issue at="apps/dashboard/src/lib/orpc/routers/analytics.ts:448-467" severity="MEDIUM">Concurrent trackAccount requests for the same username can 500 on unique-index conflict — In `trackAccount`, the existence check `db.query.trackedSocialAccounts.findFirst` (L448) and the subsequent `db.insert` (L467) are not atomic. Two concurrent requests for the same new Twitter handle will both observe no existing tracked row, both proceed past the checks, and both attempt an insert. The second insert collides with the `trackedSocialAccounts_org_provider_account_uidx` unique index and throws a database constraint error, which propagates as an unhandled 500 rather than gracefully returning the existing trackedAccountId (the way the single-request path does at L452-455). The unique index prevents data corruption, so this is a correctness/UX bug rather than a security issue. Fix: Catch the unique-constraint violation on insert and re-read the existing row to return its id (idempotent behavior), or use an upsert (`onConflictDoNothing().returning()`) against the unique index, mirroring the existing-tracked early-return path.</issue>
Commit b0b9c20.
| ) || | ||
| connectedKeys.has( | ||
| `${account.organizationId}:${account.provider}:@${account.username.toLowerCase()}` | ||
| ) |
There was a problem hiding this comment.
MEDIUM: Tracked-only social accounts always synced to Tinybird with verified=false
In listSyncableAccounts, the query against trackedSocialAccounts (lines 40-53) selects id, organizationId, provider, providerAccountId, username, displayName, profileImageUrl but omits the verified column. The tracked_social_accounts table does have a verified column (per migration 0062_brave_menace.sql), and the trackAccount handler in lib/orpc/routers/analytics.ts populates it from the Twitter API (verified: resolved.verified). However, the trackedOnly mapping at line 83 hardcodes verified: false. These accounts flow through snapshotAccountDimensions -> buildAccountRow (which sets verified: account.verified) and are ingested into Tinybird as account dimensions. As a result, every tracked-only (non-OAuth-connected) account is persisted to the analytics warehouse with verified=false regardless of its real verification status. This is inconsistent with the leaderboard handler, which reads verified/verifiedType from the same trackedSocialAccounts table and returns the correct value. Any analytics view or downstream consumer relying on Tinybird's verified dimension will incorrectly display tracked accounts as unverified. This is a data-correctness logic bug, not a security vulnerability. Note: the connected accounts query (line 30) correctly selects verified and the connected mapping uses verified: account.verified ?? false, so only tracked-only accounts are affected.
Suggestion: Add verified: true to the trackedSocialAccounts.findMany columns list in listSyncableAccounts, and change the trackedOnly mapping to use verified: account.verified ?? false instead of the hardcoded false. If the Tinybird SocialAccountRow datasource supports a verified-type field and it is desired, also select verifiedType: true and propagate it.
Prompt for AI agents
Check whether this issue is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it.
<issue at="apps/dashboard/src/workflows/steps/social-analytics-steps.ts:40-83" severity="MEDIUM">Tracked-only social accounts always synced to Tinybird with verified=false — In `listSyncableAccounts`, the query against `trackedSocialAccounts` (lines 40-53) selects id, organizationId, provider, providerAccountId, username, displayName, profileImageUrl but omits the `verified` column. The `tracked_social_accounts` table does have a `verified` column (per migration 0062_brave_menace.sql), and the `trackAccount` handler in lib/orpc/routers/analytics.ts populates it from the Twitter API (`verified: resolved.verified`). However, the `trackedOnly` mapping at line 83 hardcodes `verified: false`. These accounts flow through `snapshotAccountDimensions` -> `buildAccountRow` (which sets `verified: account.verified`) and are ingested into Tinybird as account dimensions. As a result, every tracked-only (non-OAuth-connected) account is persisted to the analytics warehouse with `verified=false` regardless of its real verification status. This is inconsistent with the `leaderboard` handler, which reads `verified`/`verifiedType` from the same `trackedSocialAccounts` table and returns the correct value. Any analytics view or downstream consumer relying on Tinybird's `verified` dimension will incorrectly display tracked accounts as unverified. This is a data-correctness logic bug, not a security vulnerability. Note: the `connected` accounts query (line 30) correctly selects `verified` and the `connected` mapping uses `verified: account.verified ?? false`, so only tracked-only accounts are affected. Fix: Add `verified: true` to the `trackedSocialAccounts.findMany` columns list in `listSyncableAccounts`, and change the `trackedOnly` mapping to use `verified: account.verified ?? false` instead of the hardcoded `false`. If the Tinybird `SocialAccountRow` datasource supports a verified-type field and it is desired, also select `verifiedType: true` and propagate it.</issue>
Commit b0b9c20.
There was a problem hiding this comment.
No blocking issues found across the changed files.
Commit f3c6cbe · Posted by Comp AI Code Reviews.
There was a problem hiding this comment.
No blocking issues found across the changed files.
Commit b6805a1 · Posted by Comp AI Code Reviews.
There was a problem hiding this comment.
1 issue found across 1 file.
Prompt for AI agents (all issues)
Check whether each issue below is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it, and use sub-agents to handle independent issues in parallel.
<file name="packages/analytics/src/cache/query-cache.ts">
<issue n="1" at="packages/analytics/src/cache/query-cache.ts:43-94" severity="MEDIUM">Silent error swallowing in bumpAnalyticsVersions can serve stale analytics data for up to 6 hours — In `bumpAnalyticsVersions` (lines 79-95), the Redis pipeline that increments per-organization cache version keys is wrapped with `Effect.ignore` (line 94). If the Redis pipeline fails (network error, rate limit, timeout, etc.), the error is silently swallowed and the version counter is never incremented. Since `cachedQuery` uses the version number as a cache-buster in the cache key (line 61), a failed version bump means the query cache will continue serving stale data until the TTL expires (QUERY_CACHE_TTL_SECONDS = 21,600 seconds = 6 hours, per constants/cache.ts). This occurs right after successful data ingestion into Tinybird — `ingestRows` in client.ts awaits `bumpAnalyticsVersions` (line 93) after a successful ingest, but a silent failure there leaves the cache pointing at the pre-ingestion version. The `readVersion` function (lines 35-46) has a related issue: on Redis GET failure it falls back to `INITIAL_CACHE_VERSION = 0` via `Effect.orElseSucceed(() => null)` (line 43) and `version ?? INITIAL_CACHE_VERSION` (line 44), which could cause queries to read a version-0 cache key that may contain very stale or orphaned data. While this is a data-freshness/reliability concern rather than a direct security vulnerability (cached data remains scoped to the correct organization via params), it could cause significant user-facing analytics discrepancies after data ingestion events, with stale data persisting for hours. Fix: Log errors from the version bump pipeline instead of silently ignoring them with `Effect.ignore`, so operators can detect cache invalidation failures. Consider adding retry logic with exponential backoff for the version bump, or using a shorter fallback TTL when version bump failures are detected. For `readVersion`, consider treating Redis GET failures as a cache miss (forcing a fresh fetch) rather than falling back to version 0, which risks serving stale cached entries.</issue>
</file>
Commit c737f8b · Posted by Comp AI Code Reviews.
| Effect.orElseSucceed(() => null), | ||
| Effect.map((version) => version ?? INITIAL_CACHE_VERSION) | ||
| ); | ||
| } | ||
|
|
||
| export function cachedQuery<TResult>( | ||
| options: CachedQueryOptions<TResult> | ||
| ): Promise<TResult> { | ||
| const redis = getAnalyticsRedis(); | ||
| if (!redis) { | ||
| return options.fetch(); | ||
| } | ||
| const program = Effect.gen(function* () { | ||
| const version = yield* readVersion( | ||
| redis, | ||
| options.scope, | ||
| options.organizationId | ||
| ); | ||
| const key = `${QUERY_CACHE_KEY_PREFIX}:${options.scope}:${version}:${options.pipe}:${stableParams(options.params)}`; | ||
| const hit = yield* Effect.tryPromise(() => redis.get<TResult>(key)).pipe( | ||
| Effect.orElseSucceed(() => null) | ||
| ); | ||
| if (hit !== null) { | ||
| return hit; | ||
| } | ||
| const fresh = yield* Effect.tryPromise(() => options.fetch()); | ||
| if (fresh !== null) { | ||
| yield* Effect.tryPromise(() => | ||
| redis.set(key, toJsonSafe(fresh), { ex: QUERY_CACHE_TTL_SECONDS }) | ||
| ).pipe(Effect.ignore); | ||
| } | ||
| return fresh; | ||
| }); | ||
| return Effect.runPromise(program); | ||
| } | ||
|
|
||
| export function bumpAnalyticsVersions( | ||
| scope: AnalyticsCacheScope, | ||
| organizationIds: ReadonlyArray<string | null> | ||
| ): Promise<void> { | ||
| const redis = getAnalyticsRedis(); | ||
| const keys = [...new Set(organizationIds.map((id) => versionKey(scope, id)))]; | ||
| if (!redis || keys.length === 0) { | ||
| return Promise.resolve(); | ||
| } | ||
| const program = Effect.tryPromise(() => { | ||
| const pipeline = redis.pipeline(); | ||
| for (const key of keys) { | ||
| pipeline.incr(key); | ||
| } | ||
| return pipeline.exec(); | ||
| }).pipe(Effect.ignore); |
There was a problem hiding this comment.
MEDIUM: Silent error swallowing in bumpAnalyticsVersions can serve stale analytics data for up to 6 hours
In bumpAnalyticsVersions (lines 79-95), the Redis pipeline that increments per-organization cache version keys is wrapped with Effect.ignore (line 94). If the Redis pipeline fails (network error, rate limit, timeout, etc.), the error is silently swallowed and the version counter is never incremented. Since cachedQuery uses the version number as a cache-buster in the cache key (line 61), a failed version bump means the query cache will continue serving stale data until the TTL expires (QUERY_CACHE_TTL_SECONDS = 21,600 seconds = 6 hours, per constants/cache.ts). This occurs right after successful data ingestion into Tinybird — ingestRows in client.ts awaits bumpAnalyticsVersions (line 93) after a successful ingest, but a silent failure there leaves the cache pointing at the pre-ingestion version. The readVersion function (lines 35-46) has a related issue: on Redis GET failure it falls back to INITIAL_CACHE_VERSION = 0 via Effect.orElseSucceed(() => null) (line 43) and version ?? INITIAL_CACHE_VERSION (line 44), which could cause queries to read a version-0 cache key that may contain very stale or orphaned data. While this is a data-freshness/reliability concern rather than a direct security vulnerability (cached data remains scoped to the correct organization via params), it could cause significant user-facing analytics discrepancies after data ingestion events, with stale data persisting for hours.
Suggestion: Log errors from the version bump pipeline instead of silently ignoring them with Effect.ignore, so operators can detect cache invalidation failures. Consider adding retry logic with exponential backoff for the version bump, or using a shorter fallback TTL when version bump failures are detected. For readVersion, consider treating Redis GET failures as a cache miss (forcing a fresh fetch) rather than falling back to version 0, which risks serving stale cached entries.
Prompt for AI agents
Check whether this issue is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it.
<issue at="packages/analytics/src/cache/query-cache.ts:43-94" severity="MEDIUM">Silent error swallowing in bumpAnalyticsVersions can serve stale analytics data for up to 6 hours — In `bumpAnalyticsVersions` (lines 79-95), the Redis pipeline that increments per-organization cache version keys is wrapped with `Effect.ignore` (line 94). If the Redis pipeline fails (network error, rate limit, timeout, etc.), the error is silently swallowed and the version counter is never incremented. Since `cachedQuery` uses the version number as a cache-buster in the cache key (line 61), a failed version bump means the query cache will continue serving stale data until the TTL expires (QUERY_CACHE_TTL_SECONDS = 21,600 seconds = 6 hours, per constants/cache.ts). This occurs right after successful data ingestion into Tinybird — `ingestRows` in client.ts awaits `bumpAnalyticsVersions` (line 93) after a successful ingest, but a silent failure there leaves the cache pointing at the pre-ingestion version. The `readVersion` function (lines 35-46) has a related issue: on Redis GET failure it falls back to `INITIAL_CACHE_VERSION = 0` via `Effect.orElseSucceed(() => null)` (line 43) and `version ?? INITIAL_CACHE_VERSION` (line 44), which could cause queries to read a version-0 cache key that may contain very stale or orphaned data. While this is a data-freshness/reliability concern rather than a direct security vulnerability (cached data remains scoped to the correct organization via params), it could cause significant user-facing analytics discrepancies after data ingestion events, with stale data persisting for hours. Fix: Log errors from the version bump pipeline instead of silently ignoring them with `Effect.ignore`, so operators can detect cache invalidation failures. Consider adding retry logic with exponential backoff for the version bump, or using a shorter fallback TTL when version bump failures are detected. For `readVersion`, consider treating Redis GET failures as a cache miss (forcing a fresh fetch) rather than falling back to version 0, which risks serving stale cached entries.</issue>
Commit c737f8b.
There was a problem hiding this comment.
2 issues found across 1 file.
Prompt for AI agents (all issues)
Check whether each issue below is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it, and use sub-agents to handle independent issues in parallel.
<file name="apps/dashboard/src/lib/orpc/routers/analytics.ts">
<issue n="1" at="apps/dashboard/src/lib/orpc/routers/analytics.ts:24-498" severity="MEDIUM">trackAccount and previewTrackAccount call paid Twitter API without rate limiting — The `trackAccount` and `previewTrackAccount` procedures are `authorizedProcedure` handlers (auth-only; no rate limiting) that call the paid/rate-limited Twitter (X) API. `previewTrackAccount` calls `resolveTwitterAccount(input.username)` → `twitterAppFetch('https://api.x.com/2/users/by/username/...')` using the app bearer token. `trackAccount` does the same lookup AND, after inserting a row, calls `syncTrackedAccountNow`, which runs `collectTwitterRows`: a `fetchTwitterUsersBatch` call plus, for each matched account, `fetchUserTweets` that paginates up to `TWITTER_TIMELINE_MAX_PAGES` (5) × `TWITTER_TIMELINE_MAX_RESULTS` (100) = up to 500 timeline tweets per invocation — all billed against the shared `TWITTER_BEARER_TOKEN` quota. Any authenticated user of any org can hammer `analytics.trackAccount` / `analytics.previewTrackAccount` in a tight loop (the only throttle is Twitter's own per-app v2 limit) to exhaust the global Twitter API quota for the whole deployment and/or enumerate X handles. This contrasts with sibling endpoints in the same app that DO rate-limit equivalent Twitter calls: `routers/brand.ts` wraps `fetchTweet` with `ratelimit.fetchTweet` (30/min) and tweet imports with `ratelimit.importTweets` (20/min). The analytics router has no such guard. The `username` input is only constrained by `trackAccountInputSchema` (1–30 chars), not by any per-user/per-org rate limit. Fix: Apply an Upstash rate limit (e.g. `ratelimit.fetchTweet`-style sliding window) keyed by `context.user.id` (and/or `input.organizationId`) at the top of `previewTrackAccount` and `trackAccount`, before calling `resolveTwitterAccount`/`syncTrackedAccountNow`. Consider a tighter limit for `trackAccount` since it also fans out into timeline pagination. Reuse the existing `ratelimit` utility in `src/utils/ratelimit.ts` for consistency with the rest of the app.</issue>
<issue n="2" at="apps/dashboard/src/lib/orpc/routers/analytics.ts:18-500" severity="MEDIUM">trackAccount always ingests verified=false into analytics, ignoring resolved verification status — In `trackAccount`, after resolving the Twitter account, the DB insert correctly stores `verified: resolved.verified` and `verifiedType: resolved.verifiedType`. But the immediately-following `syncTrackedAccountNow` call builds a `SyncableSocialAccount` literal with `verified: false` hardcoded (line ~498, the object passed to `syncTrackedAccountNow`), even though `resolved.verified` is in scope. `buildAccountRow` (rows.ts) then writes that `verified: false` into the `social_accounts` Tinybird datasource row. So every freshly-tracked account's first analytics ingest records `verified: false` regardless of the account's actual X verification status, and the leaderboard/overview `verified` field derived from those rows will be wrong until a later sync corrects it. This is a data-correctness bug, not a security issue. Fix: Pass `verified: resolved.verified` (and `verifiedType`) into the `SyncableSocialAccount` object passed to `syncTrackedAccountNow`, matching the values written to the DB.</issue>
</file>
Commit 143412a · Posted by Comp AI Code Reviews.
| }; | ||
| } | ||
|
|
||
| const trackedAccountId = crypto.randomUUID(); |
There was a problem hiding this comment.
MEDIUM: trackAccount and previewTrackAccount call paid Twitter API without rate limiting
The trackAccount and previewTrackAccount procedures are authorizedProcedure handlers (auth-only; no rate limiting) that call the paid/rate-limited Twitter (X) API. previewTrackAccount calls resolveTwitterAccount(input.username) → twitterAppFetch('https://api.x.com/2/users/by/username/...') using the app bearer token. trackAccount does the same lookup AND, after inserting a row, calls syncTrackedAccountNow, which runs collectTwitterRows: a fetchTwitterUsersBatch call plus, for each matched account, fetchUserTweets that paginates up to TWITTER_TIMELINE_MAX_PAGES (5) × TWITTER_TIMELINE_MAX_RESULTS (100) = up to 500 timeline tweets per invocation — all billed against the shared TWITTER_BEARER_TOKEN quota. Any authenticated user of any org can hammer analytics.trackAccount / analytics.previewTrackAccount in a tight loop (the only throttle is Twitter's own per-app v2 limit) to exhaust the global Twitter API quota for the whole deployment and/or enumerate X handles. This contrasts with sibling endpoints in the same app that DO rate-limit equivalent Twitter calls: routers/brand.ts wraps fetchTweet with ratelimit.fetchTweet (30/min) and tweet imports with ratelimit.importTweets (20/min). The analytics router has no such guard. The username input is only constrained by trackAccountInputSchema (1–30 chars), not by any per-user/per-org rate limit.
Suggestion: Apply an Upstash rate limit (e.g. ratelimit.fetchTweet-style sliding window) keyed by context.user.id (and/or input.organizationId) at the top of previewTrackAccount and trackAccount, before calling resolveTwitterAccount/syncTrackedAccountNow. Consider a tighter limit for trackAccount since it also fans out into timeline pagination. Reuse the existing ratelimit utility in src/utils/ratelimit.ts for consistency with the rest of the app.
Prompt for AI agents
Check whether this issue is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it.
<issue at="apps/dashboard/src/lib/orpc/routers/analytics.ts:24-498" severity="MEDIUM">trackAccount and previewTrackAccount call paid Twitter API without rate limiting — The `trackAccount` and `previewTrackAccount` procedures are `authorizedProcedure` handlers (auth-only; no rate limiting) that call the paid/rate-limited Twitter (X) API. `previewTrackAccount` calls `resolveTwitterAccount(input.username)` → `twitterAppFetch('https://api.x.com/2/users/by/username/...')` using the app bearer token. `trackAccount` does the same lookup AND, after inserting a row, calls `syncTrackedAccountNow`, which runs `collectTwitterRows`: a `fetchTwitterUsersBatch` call plus, for each matched account, `fetchUserTweets` that paginates up to `TWITTER_TIMELINE_MAX_PAGES` (5) × `TWITTER_TIMELINE_MAX_RESULTS` (100) = up to 500 timeline tweets per invocation — all billed against the shared `TWITTER_BEARER_TOKEN` quota. Any authenticated user of any org can hammer `analytics.trackAccount` / `analytics.previewTrackAccount` in a tight loop (the only throttle is Twitter's own per-app v2 limit) to exhaust the global Twitter API quota for the whole deployment and/or enumerate X handles. This contrasts with sibling endpoints in the same app that DO rate-limit equivalent Twitter calls: `routers/brand.ts` wraps `fetchTweet` with `ratelimit.fetchTweet` (30/min) and tweet imports with `ratelimit.importTweets` (20/min). The analytics router has no such guard. The `username` input is only constrained by `trackAccountInputSchema` (1–30 chars), not by any per-user/per-org rate limit. Fix: Apply an Upstash rate limit (e.g. `ratelimit.fetchTweet`-style sliding window) keyed by `context.user.id` (and/or `input.organizationId`) at the top of `previewTrackAccount` and `trackAccount`, before calling `resolveTwitterAccount`/`syncTrackedAccountNow`. Consider a tighter limit for `trackAccount` since it also fans out into timeline pagination. Reuse the existing `ratelimit` utility in `src/utils/ratelimit.ts` for consistency with the rest of the app.</issue>
Commit 143412a.
|
|
||
| const trackedAccountId = crypto.randomUUID(); | ||
| await db.insert(trackedSocialAccounts).values({ | ||
| id: trackedAccountId, |
There was a problem hiding this comment.
MEDIUM: trackAccount always ingests verified=false into analytics, ignoring resolved verification status
In trackAccount, after resolving the Twitter account, the DB insert correctly stores verified: resolved.verified and verifiedType: resolved.verifiedType. But the immediately-following syncTrackedAccountNow call builds a SyncableSocialAccount literal with verified: false hardcoded (line ~498, the object passed to syncTrackedAccountNow), even though resolved.verified is in scope. buildAccountRow (rows.ts) then writes that verified: false into the social_accounts Tinybird datasource row. So every freshly-tracked account's first analytics ingest records verified: false regardless of the account's actual X verification status, and the leaderboard/overview verified field derived from those rows will be wrong until a later sync corrects it. This is a data-correctness bug, not a security issue.
Suggestion: Pass verified: resolved.verified (and verifiedType) into the SyncableSocialAccount object passed to syncTrackedAccountNow, matching the values written to the DB.
Prompt for AI agents
Check whether this issue is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it.
<issue at="apps/dashboard/src/lib/orpc/routers/analytics.ts:18-500" severity="MEDIUM">trackAccount always ingests verified=false into analytics, ignoring resolved verification status — In `trackAccount`, after resolving the Twitter account, the DB insert correctly stores `verified: resolved.verified` and `verifiedType: resolved.verifiedType`. But the immediately-following `syncTrackedAccountNow` call builds a `SyncableSocialAccount` literal with `verified: false` hardcoded (line ~498, the object passed to `syncTrackedAccountNow`), even though `resolved.verified` is in scope. `buildAccountRow` (rows.ts) then writes that `verified: false` into the `social_accounts` Tinybird datasource row. So every freshly-tracked account's first analytics ingest records `verified: false` regardless of the account's actual X verification status, and the leaderboard/overview `verified` field derived from those rows will be wrong until a later sync corrects it. This is a data-correctness bug, not a security issue. Fix: Pass `verified: resolved.verified` (and `verifiedType`) into the `SyncableSocialAccount` object passed to `syncTrackedAccountNow`, matching the values written to the DB.</issue>
Commit 143412a.
Remove the track button and dialog, drop the rank-change column, and pin the window select to the standard module header height so row lines align across modules.
0a9d6ba to
92389da
Compare
There was a problem hiding this comment.
No blocking issues found across the changed files.
1 other commit review still in progress for this PR — findings may follow.
Commit 0a9d6ba · Posted by Comp AI Code Reviews.
There was a problem hiding this comment.
2 issues found across 1 file.
Prompt for AI agents (all issues)
Check whether each issue below is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it, and use sub-agents to handle independent issues in parallel.
<file name="apps/dashboard/src/lib/orpc/routers/analytics.ts">
<issue n="1" at="apps/dashboard/src/lib/orpc/routers/analytics.ts:232-291" severity="MEDIUM">Missing rate limiting on trackAccount and previewTrackAccount handlers — The `trackAccount` and `previewTrackAccount` handlers call the Twitter API (`resolveTwitterAccount`) without any rate limiting. More significantly, `trackAccount` calls `syncTrackedAccountNow` which invokes `collectTwitterRows` — this makes a batch Twitter user lookup API call AND then for each account calls `fetchUserTweets` which paginates up to `TWITTER_TIMELINE_MAX_PAGES` (5) pages of `TWITTER_TIMELINE_MAX_RESULTS` (100) tweets each, followed by multiple Tinybird ingest calls (`ingestSocialAccounts`, `ingestSocialAccountStats`, `ingestSocialPosts`, `ingestSocialPostStats`). This is a significantly expensive operation. Other routers in the same codebase consistently rate-limit similar expensive API operations: `brand.ts` rate-limits `importTweets` (20/1m) and `fetchTweet` (30/1m), `github.ts` rate-limits `githubProbe` (30/1m), `integrations.ts` rate-limits `mcpConnection` (10/1m), and `onboarding.ts` rate-limits `onboardingAgent` (2/10m). The analytics router has no rate limiting despite calling the same Twitter API. An authenticated organization member could repeatedly call `trackAccount` with different usernames to exhaust the shared Twitter Bearer Token API quota (which is shared across the entire deployment) and Tinybird ingest quota, causing denial of service for other organizations' analytics features. The `previewTrackAccount` handler is lighter (single Twitter API call) but still uncapped. The `assertOrganizationAccess` check only verifies membership — it does not limit call frequency. Fix: Add rate limiting using the existing `ratelimit` utility, keyed by `input.organizationId` (and optionally the user ID), similar to how `brand.ts` rate-limits `importTweets` and `fetchTweet`. For example: `const { success } = await ratelimit.fetchTweet.limit(input.organizationId); if (!success) throw tooManyRequests(...)`. Consider a stricter limit for `trackAccount` since it triggers a full sync (multiple Twitter API calls + Tinybird ingests) than for `previewTrackAccount` (single lookup).</issue>
<issue n="2" at="apps/dashboard/src/lib/orpc/routers/analytics.ts:273-287" severity="MEDIUM">Race condition in trackAccount between existence check and insert — In the `trackAccount` handler, after checking `existingTracked` (findFirst on trackedSocialAccounts) and finding no match, the handler proceeds to `db.insert(trackedSocialAccounts).values(...)`. If two concurrent requests for the same Twitter account arrive, both could pass the `existingTracked` check before either inserts, leading to a unique constraint violation on the `trackedSocialAccounts_org_provider_account_uidx` index. The error would propagate as an unhandled 500 error to the client rather than the expected idempotent `{ trackedAccountId, username }` response. This is not a security issue (the unique index prevents duplicate data), but it is a UX/correctness bug. The handler does not catch the unique constraint error to retry the lookup or return the existing record. Fix: Wrap the insert in a try/catch that handles the unique constraint violation (e.g., `onConflictDoNothing` with a subsequent findFirst, or catch the error and re-query for the existing record). Drizzle ORM supports `.onConflictDoNothing()` which could be used here.</issue>
</file>
Commit 92389da · Posted by Comp AI Code Reviews.
Adds the account leaderboard so an org can rank the accounts it posts from alongside affiliate accounts it only tracks.
tracked_social_accountstable (own migration) for X accounts tracked without an OAuth connection.account_leaderboardTinybird pipe: per-account posts/interactions/impressions for a trailing window plus the window before it, so rank movement is computable.analytics.leaderboard/trackAccount/untrackAccount; tracking an account resolves it through the X API and does an immediate first sync.LeaderboardCardon the analytics page with a new "Accounts" section and 7/30 day windows.Stack
Summary by cubic
Adds an account leaderboard that ranks connected and tracked X accounts by interactions, plus a 30‑day impressions share donut. Speeds up analytics with a
@upstash/rediscache over Tinybird and purges data when accounts are untracked.New Features
@notra/uidither‑kit pie/radar chart primitives.Migration
tracked_social_accounts(with unique org/provider/account index).account_leaderboardto aggregate current and prior window totals.Written for commit 92389da. Summary will update on new commits.
Summary by Comp AI
2 issues found.
Written for commit
92389da. New commits will trigger a re-review. Generated by Comp AI.