Skip to content

Implement wisp#61

Merged
creatorcluster merged 5 commits into
creatorcluster:mainfrom
Coder-soft:main
Jul 6, 2026
Merged

Implement wisp#61
creatorcluster merged 5 commits into
creatorcluster:mainfrom
Coder-soft:main

Conversation

@Coder-soft

Copy link
Copy Markdown

No description provided.

- Install @renderdragonorg/wisp v0.1.1
- Initialize wisp SDK in main.tsx with convex URL
- Bind supabase auth state to wisp identity in AuthProvider
- Add convex backend (schema, events, stats, http, crons, dashboard)
- Fix upsertMachine to update meta fields on existing machines
- Expose __wisp on window for console debugging
- Add WISP_SECRET auth check on /ingest endpoint with custom SDK transport
- Fix ip-api.com geo lookup to use HTTPS instead of HTTP
- Fix getMachineStats crash on machines with zero sessions
- Fix computeDailyStats to use by_startedAt index instead of filter
- Fix searchMachines to use search index instead of full table scan
- Add search_machineId index on machines table in schema
- Add x-wisp-token to CORS allowed headers
…ctor

- Remove PostHogProvider and posthog-js dependency (replaced by wisp analytics)
- Remove posthog.captureException from ErrorBoundary
- Refactor AdBlockDetector to use generic CDN endpoints instead of PostHog
- Fix Radix DialogContent aria-describedby warning by explicitly setting undefined
- Add by_open_lastActivityAt composite index on sessions
- closeStaleSessions now uses index for both endedAt eq and lastActivityAt lt, removing the post-filter scan
@vercel

vercel Bot commented Jul 6, 2026

Copy link
Copy Markdown

@Coder-soft is attempting to deploy a commit to the yamura3's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in: 8 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: CHILL

Plan: Pro

Run ID: 1d3e667b-7431-4099-8e4f-6d96ef538ac2

📥 Commits

Reviewing files that changed from the base of the PR and between 18c7cc9 and 6d4023c.

⛔ Files ignored due to path filters (8)
  • convex/_generated/ai/ai-files.state.json is excluded by !**/_generated/**
  • convex/_generated/ai/guidelines.md is excluded by !**/_generated/**
  • convex/_generated/api.d.ts is excluded by !**/_generated/**
  • convex/_generated/api.js is excluded by !**/_generated/**
  • convex/_generated/dataModel.d.ts is excluded by !**/_generated/**
  • convex/_generated/server.d.ts is excluded by !**/_generated/**
  • convex/_generated/server.js is excluded by !**/_generated/**
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (13)
  • convex/crons.ts
  • convex/dashboard.ts
  • convex/events.ts
  • convex/http.ts
  • convex/schema.ts
  • convex/stats.ts
  • package.json
  • pnpm-workspace.yaml
  • src/components/AdBlockDetector.tsx
  • src/components/ErrorBoundary.tsx
  • src/components/ui/dialog.tsx
  • src/main.tsx
  • src/providers/AuthProvider.tsx
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@greptile-apps

greptile-apps Bot commented Jul 6, 2026

Copy link
Copy Markdown

Greptile Summary

This PR integrates the @renderdragonorg/wisp analytics SDK into the frontend and adds the full Convex backend to receive, store, and aggregate analytics events (sessions, machines, pageviews, errors, daily rollups).

  • Frontend (src/main.tsx, AuthProvider.tsx): Initialises Wisp with either a custom HTTP transport (token-authenticated) or the default Convex transport; Supabase auth state is bound to Wisp identity via bindSupabase.
  • Convex backend (events.ts, http.ts, schema.ts, dashboard.ts, stats.ts, crons.ts): New /ingest HTTP endpoint receives batched events, resolves geo data via Cloudflare headers or ip-api.com, and writes them via internal mutations; cron jobs close stale sessions and compute daily aggregates; dashboard queries surface sessions, top pages, error breakdowns, and overview totals.
  • New components: AdBlockDetector warns users when ad-block tools are active, and ErrorBoundary provides a basic fallback UI for uncaught React errors.

Confidence Score: 3/5

The ingest pipeline has three overlapping holes that together mean any visitor can write arbitrary analytics data: the shared secret is visible in the browser bundle, the public recordBatch mutation bypasses the HTTP route entirely, and a missing env var leaves the endpoint fully open.

Three distinct auth gaps all affect the same ingest path. The VITE_WISP_SECRET issue alone makes the token scheme inoperative, and the public recordBatch mutation provides a second independent bypass. These need to be addressed before the analytics data can be trusted.

src/main.tsx (client-exposed secret), convex/http.ts (open-by-default auth), and convex/events.ts (public mutation bypassing auth) all require changes before merging.

Security Review

  • Client-exposed auth token (src/main.tsx): VITE_WISP_SECRET is a Vite env var inlined into the browser bundle, making the token check in isAuthorized ineffective.
  • Auth bypass via public mutation (convex/events.ts): recordBatch is a public Convex mutation; any Convex SDK client can call it directly, bypassing the HTTP /ingest auth layer entirely.
  • Open ingest endpoint when secret is unset (convex/http.ts): isAuthorized returns true when WISP_SECRET is not configured, leaving the endpoint unauthenticated in fresh deployments.
  • IP address forwarded to third-party (convex/http.ts): Raw visitor IPs are sent to ip-api.com, which may conflict with privacy obligations depending on jurisdiction.

Important Files Changed

Filename Overview
src/main.tsx Initializes the @renderdragonorg/wisp analytics SDK; VITE_WISP_SECRET is a client-side env var visible in the browser bundle, undermining the token auth scheme.
convex/http.ts HTTP ingest endpoint with optional token auth; auth is bypassed entirely when WISP_SECRET is unset, and IP is forwarded to a third-party geo API.
convex/events.ts Defines recordBatch as a public mutation, allowing direct Convex client calls that bypass the HTTP auth layer; should be internalMutation.
convex/dashboard.ts Dashboard queries with multiple unbounded .collect() calls; getOverview fallback uses .filter() (table scan) instead of the available by_startedAt index; upper timestamp bounds missing in several pageview queries.
convex/stats.ts Cron-triggered internal mutations for closing stale sessions and computing daily stats; logic is sound though uses .collect() which could stress transaction limits at scale.
convex/schema.ts Well-structured schema with appropriate indexes for all query access patterns; no issues found.
convex/crons.ts Defines two cron jobs (session cleanup every 10 min, daily stats at 00:15 UTC); correctly uses internal references and crons.interval/crons.cron methods.
src/providers/AuthProvider.tsx Binds Supabase auth state to Wisp identity via bindSupabase; cleanup is handled correctly in useEffect return.
src/components/AdBlockDetector.tsx New component that probes Google Analytics URLs to detect ad blockers and prompts users; logic is intentional and cleanup is handled.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Browser as Browser (Wisp SDK)
    participant Ingest as Convex HTTP /ingest
    participant Auth as isAuthorized()
    participant Geo as resolveGeo()
    participant CF as Cloudflare Headers
    participant IPAPI as ip-api.com
    participant Mutation as events.recordBatchWithGeo (internal)
    participant DB as Convex DB
    participant Cron as Cron Jobs

    Browser->>Ingest: "POST /ingest {events[]} + x-wisp-token"
    Ingest->>Auth: check x-wisp-token vs WISP_SECRET
    alt secret unset — always passes
        Auth-->>Ingest: true
    else token matches
        Auth-->>Ingest: true
    end
    Ingest->>Geo: resolveGeo(request)
    Geo->>CF: read cf-ipcountry / cf-region / cf-ipcity
    alt CF headers complete
        CF-->>Geo: country, region, city
    else incomplete
        Geo->>IPAPI: "GET /json/{ip}"
        IPAPI-->>Geo: country, regionName, city
    end
    Geo-->>Ingest: "{ip, country, region, city}"
    Ingest->>Mutation: "ctx.runMutation(recordBatchWithGeo, {events, geo})"
    Mutation->>DB: upsert machines, upsert sessions, insert events
    DB-->>Mutation: ok
    Mutation-->>Ingest: "{inserted: N}"
    Ingest-->>Browser: 204 No Content

    loop Every 10 min
        Cron->>DB: close stale sessions
    end
    loop Daily at 00:15 UTC
        Cron->>DB: compute and upsert dailyStats row
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Browser as Browser (Wisp SDK)
    participant Ingest as Convex HTTP /ingest
    participant Auth as isAuthorized()
    participant Geo as resolveGeo()
    participant CF as Cloudflare Headers
    participant IPAPI as ip-api.com
    participant Mutation as events.recordBatchWithGeo (internal)
    participant DB as Convex DB
    participant Cron as Cron Jobs

    Browser->>Ingest: "POST /ingest {events[]} + x-wisp-token"
    Ingest->>Auth: check x-wisp-token vs WISP_SECRET
    alt secret unset — always passes
        Auth-->>Ingest: true
    else token matches
        Auth-->>Ingest: true
    end
    Ingest->>Geo: resolveGeo(request)
    Geo->>CF: read cf-ipcountry / cf-region / cf-ipcity
    alt CF headers complete
        CF-->>Geo: country, region, city
    else incomplete
        Geo->>IPAPI: "GET /json/{ip}"
        IPAPI-->>Geo: country, regionName, city
    end
    Geo-->>Ingest: "{ip, country, region, city}"
    Ingest->>Mutation: "ctx.runMutation(recordBatchWithGeo, {events, geo})"
    Mutation->>DB: upsert machines, upsert sessions, insert events
    DB-->>Mutation: ok
    Mutation-->>Ingest: "{inserted: N}"
    Ingest-->>Browser: 204 No Content

    loop Every 10 min
        Cron->>DB: close stale sessions
    end
    loop Daily at 00:15 UTC
        Cron->>DB: compute and upsert dailyStats row
    end
Loading

Comments Outside Diff (1)

  1. convex/dashboard.ts, line 944-949 (link)

    P2 Table scan with .filter()by_startedAt index is available but unused

    The fallback branch of getOverview fetches sessions using .filter() on startedAt, which scans the entire sessions table. The schema already defines a by_startedAt index; using .withIndex("by_startedAt", q => q.gte("startedAt", dayStart).lt("startedAt", dayEnd)) would read only the rows in range.

Reviews (1): Last reviewed commit: "fix: eliminate filter scan in closeStale..." | Re-trigger Greptile

Comment thread src/main.tsx
const options = {
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
const convexUrl = import.meta.env.VITE_CONVEX_URL;
const wispSecret = import.meta.env.VITE_WISP_SECRET;

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 security VITE_ prefix exposes the secret in the client bundle

Any variable prefixed with VITE_ is inlined into the browser-side JavaScript bundle at build time — it is visible in plain text in DevTools, not encrypted. This means VITE_WISP_SECRET is readable by every user of the app, which completely negates the token-based auth in isAuthorized. An attacker who opens DevTools can copy the value and call /ingest directly with arbitrary payloads.

Comment thread convex/http.ts
Comment on lines +9 to +11
function isAuthorized(request: Request): boolean {
if (!WISP_SECRET) return true;
return request.headers.get("x-wisp-token") === WISP_SECRET;

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 security Open ingest endpoint when WISP_SECRET is unset

When WISP_SECRET is not configured as a Convex environment variable, isAuthorized returns true unconditionally — any caller on the internet can POST to /ingest and inject arbitrary session and event data. This is easy to miss in a fresh deployment before the env var is added.

Comment thread convex/events.ts
Comment on lines +125 to +164
export const recordBatch = mutation({
args: { events: v.array(eventValidator) },
handler: async (ctx, { events }) => {
// Process oldest-first so ordering-dependent bookkeeping (session creation, counters) is correct.
const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp);

for (const event of sorted) {
const isBookkeeping = event.name === "session_start";

await upsertMachine(ctx, {
machineId: event.machineId,
userId: event.userId,
timestamp: event.timestamp,
meta: isBookkeeping ? event.payload : undefined,
});

await upsertSession(ctx, {
sessionId: event.sessionId,
machineId: event.machineId,
userId: event.userId,
timestamp: event.timestamp,
url: event.url,
isError: event.type === "error",
});

await ctx.db.insert("events", {
sessionId: event.sessionId,
machineId: event.machineId,
userId: event.userId,
type: event.type,
name: event.name,
payload: event.payload,
url: event.url,
timestamp: event.timestamp,
});
}

return { inserted: sorted.length };
},
});

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 security recordBatch is a public mutation — bypasses the HTTP auth layer entirely

The HTTP /ingest route calls the internal mutation recordBatchWithGeo, which is correctly gated behind isAuthorized. However, recordBatch is exported as a public Convex mutation, meaning any Convex client (the SDK, convex dev console, a script) can call api.events.recordBatch directly without going through the HTTP route — no token needed. This lets anyone inject arbitrary events. Changing it to internalMutation would close the gap.

Comment thread convex/dashboard.ts
Comment on lines +306 to +312
async function countPageViews(ctx: QueryCtx, dayStart: number, dayEnd: number): Promise<number> {
const pvs = await ctx.db
.query("events")
.withIndex("by_type_time", (q) => q.eq("type", "pageview").gte("timestamp", dayStart))
.collect();
return pvs.filter((e) => e.timestamp < dayEnd).length;
}

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 Upper bound not applied in the index — post-filters in JS

countPageViews (and the same pattern in getTopPages, getPageVisitors, getPageViewsOverTime) queries with only .gte("timestamp", dayStart) in the index and then discards rows where timestamp >= dayEnd in JavaScript. This reads every pageview from dayStart to the present. Adding .lte("timestamp", dayEnd) inside the withIndex callback would limit the read to the target range.

Comment thread convex/http.ts
Comment on lines +74 to +92
if (ip) {
try {
const res = await fetch(
`https://ip-api.com/json/${ip}?fields=country,regionName,city`,
{ signal: AbortSignal.timeout(2000) }
);
if (res.ok) {
const data = await res.json() as { country?: string; regionName?: string; city?: string };
return {
ip,
country: cfCountry ?? data.country ?? undefined,
region: cfRegion ?? data.regionName ?? undefined,
city: cfCity ?? data.city ?? undefined,
};
}
} catch {
// Timeout or network error — return Cloudflare data (even partial) silently
}
}

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 Raw client IP forwarded to a third-party geo service

The extracted IP address (which may include the visitor's real IP via cf-connecting-ip) is sent to ip-api.com in the URL path, which is logged by that service. Depending on the applicable jurisdiction and the site's privacy policy, routing user IP addresses to an external API without disclosure may be a compliance concern (GDPR, CCPA).

- getOverview fallback: use by_startedAt index instead of filter
- countPageViews: add .lt(timestamp, dayEnd) to index seek
- getTopPages: add .lt(timestamp, dayEnd) to index seek, remove post-filter
- getPageVisitors: add .lt(timestamp, dayEnd) to index seek
- getPageViewsOverTime: add .lt(timestamp, dayEnd) to index seek
All pageview queries now constrain both bounds at the index level, eliminating post-filter full scans.
@creatorcluster creatorcluster merged commit 34e4985 into creatorcluster:main Jul 6, 2026
1 of 3 checks passed
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