Skip to content

refactor(desktop): remove Cloudflare Electric proxy, revert to API proxy#1501

Merged
saddlepaddle merged 6 commits into
mainfrom
fix-electric-token
Feb 15, 2026
Merged

refactor(desktop): remove Cloudflare Electric proxy, revert to API proxy#1501
saddlepaddle merged 6 commits into
mainfrom
fix-electric-token

Conversation

@saddlepaddle
Copy link
Copy Markdown
Collaborator

@saddlepaddle saddlepaddle commented Feb 14, 2026

Summary

  • Remove the Cloudflare Worker Electric proxy (apps/electric-proxy/) and all JWT-based auth for Electric SQL
  • Revert to routing Electric requests through the Vercel API proxy (/api/electric/) with session-based bearer auth
  • Remove NEXT_PUBLIC_ELECTRIC_URL env var, Caddyfile, dev:caddy script, and all related config from Vite, CSP, CI, turbo, and setup.sh
  • Clean up auth-client.ts (remove jwtClient, getJwt/setJwt) and AuthProvider (remove useJwtRefresh hook)

Test plan

  • bun run typecheck --filter=@superset/desktop passes
  • bun run lint:fix clean
  • bun test — 1213 pass, 0 fail
  • Verify Electric sync works in dev (collections load via API proxy)
  • Verify production deployment routes Electric through Vercel API

Summary by CodeRabbit

  • Chores
    • Removed the standalone proxy service and associated config/artifacts.
    • Updated the default Electric API endpoint to https://api.superset.sh/api/electric.
    • Simplified auth token/header handling for collection requests.
    • Removed proxy-specific build/env settings for the macOS desktop build and dev tooling.
    • Updated Content Security Policy wording to reflect the consolidated proxy/API setup.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 14, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR removes the electric-proxy Cloudflare Worker and its JWT auth/where-clause logic, centralizes Authorization header handling in desktop collections, and updates NEXT_PUBLIC_ELECTRIC_URL defaults and related build/setup configuration to point directly at the Electric API endpoint.

Changes

Cohort / File(s) Summary
Auth Client & Provider
apps/desktop/src/renderer/lib/auth-client.ts, apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx
Removed jwtClient import/usage and trimmed JWT-related comments; auth still uses getAuthToken; public APIs unchanged.
Collections Header Management
apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts
Added centralized electricHeaders that synchronously reads getAuthToken and supplies Bearer header across organizations and per-org collections; removed per-call async token header construction and authClient import.
Environment & Build Defaults
apps/desktop/electron.vite.config.ts, apps/desktop/src/renderer/env.renderer.ts, apps/desktop/vite/helpers.ts
Changed default NEXT_PUBLIC_ELECTRIC_URL from https://electric.superset.sh to https://api.superset.sh/api/electric (and origin resolution in helper).
HTML & CSP
apps/desktop/src/renderer/index.html
Updated CSP comment and meta tag text from "Electric worker" to "Electric proxy" in connect-src comment/meta.
Build, Dev & Turbo Config
.github/workflows/build-desktop.yml, package.json, turbo.jsonc
Removed NEXT_PUBLIC_ELECTRIC_URL from macOS build env, removed electric-proxy from dev script filter, and removed ELECTRIC_PROXY_PORT from globalPassThroughEnv.
Electric Proxy Removal
apps/electric-proxy/...
apps/electric-proxy/package.json, apps/electric-proxy/tsconfig.json, apps/electric-proxy/wrangler.jsonc, apps/electric-proxy/src/index.ts, apps/electric-proxy/src/auth.ts, apps/electric-proxy/src/env.ts, apps/electric-proxy/src/where-clauses.ts
Deleted the entire electric-proxy app: worker entry, JWT verification (verifyJWT), Env interface, buildWhereClause logic, configs and package metadata.
Local Setup Scripts
.superset/lib/setup/steps.sh
Removed ELECTRIC_PROXY_PORT allocation and dev var generation; updated Caddy reverse_proxy to target API port and adjusted related outputs/comments.

Sequence Diagram(s)

sequenceDiagram
    participant Desktop as Desktop App
    participant Proxy as electric-proxy (Worker)
    participant JWKS as JWKS
    participant Electric as Electric API

    Desktop->>Proxy: GET /v1/shape?table=... (Authorization: Bearer <token>)
    Proxy->>JWKS: verifyJWT(token)
    JWKS-->>Proxy: claims (organizationIds)
    Proxy->>Electric: GET /v1/shape?... (with WHERE clause & optional secret)
    Electric-->>Proxy: shape data
    Proxy-->>Desktop: proxied shape data (Vary: Authorization)
Loading
sequenceDiagram
    participant Desktop as Desktop App
    participant Electric as Electric API

    Desktop->>Electric: GET /api/electric/v1/shape?table=... (Authorization: Bearer <token> via electricHeaders)
    Electric-->>Desktop: shape data
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • #1464: Touches the same electric-proxy Env/interfaces and index behavior that this PR removes.
  • #1457: Introduced the electric-proxy and JWT integration that this PR deletes.
  • #543: Related desktop auth-client/token handling changes overlapping with this PR.

Poem

🐇 I nibbled at the proxy's root,
Removed the worker, dug the route.
Tokens now in one small stash,
Direct to API — no extra dash.
A hop, a twitch, the code feels light. 🥕

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main objective: removing the Cloudflare Electric proxy and reverting to API-based routing, which is the primary change across all modified files.
Description check ✅ Passed The PR description is comprehensive, including a clear summary of changes, test plan results, and remaining verification tasks. It addresses the key objectives despite being less structured than the template.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix-electric-token

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 and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts (1)

332-335: ⚠️ Potential issue | 🟡 Minor

Stale JSDoc: references getAuthToken() but Electric headers now use getJwt().

Proposed fix
 /**
  * Get collections for an organization, creating them if needed.
  * Collections are cached per org for instant switching.
- * Auth token is read dynamically via getAuthToken() - no need to pass it.
+ * JWT is read dynamically via getJwt() for Electric sync headers.
+ * API client uses getAuthToken() for tRPC calls.
  */
🤖 Fix all issues with AI agents
In `@apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx`:
- Around line 36-52: The onTokenChanged subscription handler
(electronTrpc.auth.onTokenChanged.useSubscription) resets auth state and calls
fetchJwt() on token rotation but never re-enables the JWT rendering gate; add a
call to setNeedsJwt(true) in the branch where data?.token && data?.expiresAt is
handled (before calling fetchJwt()) so the gate (needsJwt && !isJwtReady)
prevents children rendering until the JWT is cached; update the handler near
setAuthToken/clearJwt/setIsHydrated/refetchSession to call setNeedsJwt(true)
prior to fetchJwt().

In
`@apps/desktop/src/renderer/providers/AuthProvider/hooks/useJwtRefresh/useJwtRefresh.ts`:
- Around line 46-48: The catch block in useJwtRefresh currently only calls
setJwt(null) when fetchJwt throws, leaving isReady false and causing
AuthProvider (isJwtReady) to hang; update the catch handler in the fetchJwt flow
to also call setIsReady(true) (and keep setJwt(null)) so the initial failure
still flips readiness; locate the error path around fetchJwt, setJwt and
setIsReady in useJwtRefresh and add the setIsReady(true) call in that catch
branch.
- Around line 38-44: The refresh loop can spin when expiresAt - Date.now() -
JWT_REFRESH_BUFFER_MS <= 0; update the logic in useJwtRefresh so the computed
refreshIn for scheduling fetchJwt is floored to a sensible minimum (e.g.,
MIN_REFRESH_DELAY_MS = 30_000) instead of allowing 0; specifically, after
computing refreshIn use something like const delay = Math.max(refreshIn,
MIN_REFRESH_DELAY_MS) and pass delay to setTimeout, keeping the existing
clearTimeout(refreshTimerRef.current) and using fetchJwt as before (symbols:
expiresAt, JWT_REFRESH_BUFFER_MS, refreshIn, refreshTimerRef.current, fetchJwt).

In
`@apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts`:
- Around line 69-74: The current electricHeaders.Authorization function builds
an Authorization string that becomes "Bearer " when getJwt() returns null;
update the electricHeaders implementation so Authorization returns a proper
bearer string only when a token exists (e.g., return `Bearer ${token}` when
token is truthy) and otherwise return an empty value acceptable to the Electric
client (prefer undefined or empty string depending on client support) to avoid
sending the malformed "Bearer " header; locate and change the Authorization
arrow function in electricHeaders and adjust to return token ? `Bearer ${token}`
: undefined (or empty string if the client requires it).
🧹 Nitpick comments (1)
apps/desktop/src/renderer/providers/AuthProvider/hooks/useJwtRefresh/useJwtRefresh.ts (1)

7-14: atob does not handle base64url encoding used by JWTs.

JWTs use base64url (with -/_ instead of +// and no padding). atob expects standard base64 and will throw for payloads containing those characters. The try/catch provides a safe fallback (returns null, skipping auto-refresh), so this won't crash — but refresh scheduling would silently stop working for affected tokens.

A small normalization before decoding would make this robust:

Proposed fix
 function parseJwtExp(token: string): number | null {
 	try {
-		const payload = JSON.parse(atob(token.split(".")[1]));
+		const base64Url = token.split(".")[1];
+		const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
+		const payload = JSON.parse(atob(base64));
 		return typeof payload.exp === "number" ? payload.exp * 1000 : null;
 	} catch {

Comment on lines 36 to 52
electronTrpc.auth.onTokenChanged.useSubscription(undefined, {
onData: async (data) => {
if (data?.token && data?.expiresAt) {
setAuthToken(null);
clearJwt();
await authClient.signOut({ fetchOptions: { throw: false } });
setAuthToken(data.token);
setIsHydrated(true);
refetchSession();
fetchJwt();
} else if (data === null) {
setAuthToken(null);
clearJwt();
refetchSession();
}
},
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

needsJwt is not set to true in the onTokenChanged handler, so JWT gating won't re-engage on token rotation.

When a new token arrives via the subscription (lines 38-46), fetchJwt() is called but setNeedsJwt(true) is not. Since the rendering gate on line 54 is needsJwt && !isJwtReady, children may render briefly before the JWT is cached — meaning collections could issue requests with a null JWT during that window.

If this is intentional (tolerable brief gap during token rotation), a short comment would help future readers. Otherwise, adding setNeedsJwt(true) before fetchJwt() would re-engage the gate.

🤖 Prompt for AI Agents
In `@apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx` around
lines 36 - 52, The onTokenChanged subscription handler
(electronTrpc.auth.onTokenChanged.useSubscription) resets auth state and calls
fetchJwt() on token rotation but never re-enables the JWT rendering gate; add a
call to setNeedsJwt(true) in the branch where data?.token && data?.expiresAt is
handled (before calling fetchJwt()) so the gate (needsJwt && !isJwtReady)
prevents children rendering until the JWT is cached; update the handler near
setAuthToken/clearJwt/setIsHydrated/refetchSession to call setNeedsJwt(true)
prior to fetchJwt().

Comment on lines +38 to +44
if (expiresAt) {
const refreshIn = Math.max(
expiresAt - Date.now() - JWT_REFRESH_BUFFER_MS,
0,
);
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = setTimeout(fetchJwt, refreshIn);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Tight refresh loop when token lifetime ≤ JWT_REFRESH_BUFFER_MS.

If the server issues tokens with a lifetime shorter than 5 minutes, refreshIn computes to 0 every time, and setTimeout(fetchJwt, 0) fires immediately — creating a hot loop of token requests. Consider adding a minimum delay floor (e.g., 30 seconds) to prevent this.

Proposed fix
+			const MIN_REFRESH_INTERVAL_MS = 30_000;
 			const refreshIn = Math.max(
 				expiresAt - Date.now() - JWT_REFRESH_BUFFER_MS,
-				0,
+				MIN_REFRESH_INTERVAL_MS,
 			);
🤖 Prompt for AI Agents
In
`@apps/desktop/src/renderer/providers/AuthProvider/hooks/useJwtRefresh/useJwtRefresh.ts`
around lines 38 - 44, The refresh loop can spin when expiresAt - Date.now() -
JWT_REFRESH_BUFFER_MS <= 0; update the logic in useJwtRefresh so the computed
refreshIn for scheduling fetchJwt is floored to a sensible minimum (e.g.,
MIN_REFRESH_DELAY_MS = 30_000) instead of allowing 0; specifically, after
computing refreshIn use something like const delay = Math.max(refreshIn,
MIN_REFRESH_DELAY_MS) and pass delay to setTimeout, keeping the existing
clearTimeout(refreshTimerRef.current) and using fetchJwt as before (symbols:
expiresAt, JWT_REFRESH_BUFFER_MS, refreshIn, refreshTimerRef.current, fetchJwt).

Comment on lines +46 to +48
} catch {
setJwt(null);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

isReady is never set to true if the initial fetchJwt throws, causing an infinite spinner.

The catch block calls setJwt(null) but omits setIsReady(true). Since AuthProvider gates rendering on isJwtReady (which maps to isReady), a network error during the first token fetch will leave the app stuck on the loading spinner indefinitely.

Compare with lines 28-30 where the "no token" path correctly sets isReady.

Proposed fix
 		} catch {
 			setJwt(null);
+			setIsReady(true);
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch {
setJwt(null);
}
} catch {
setJwt(null);
setIsReady(true);
}
🤖 Prompt for AI Agents
In
`@apps/desktop/src/renderer/providers/AuthProvider/hooks/useJwtRefresh/useJwtRefresh.ts`
around lines 46 - 48, The catch block in useJwtRefresh currently only calls
setJwt(null) when fetchJwt throws, leaving isReady false and causing
AuthProvider (isJwtReady) to hang; update the catch handler in the fetchJwt flow
to also call setIsReady(true) (and keep setJwt(null)) so the initial failure
still flips readiness; locate the error path around fetchJwt, setJwt and
setIsReady in useJwtRefresh and add the setIsReady(true) call in that catch
branch.

@saddlepaddle saddlepaddle changed the title fix(desktop): cache Electric JWT to prevent token spam refactor(desktop): remove Cloudflare Electric proxy, revert to API proxy Feb 14, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
.superset/setup.sh (1)

119-121: ⚠️ Potential issue | 🟡 Minor

Stale Caddy dependency check — consider removing.

Lines 119-121 still warn when caddy is not found ("HTTP/2 proxy for Electric won't work"), but this PR removes the Caddy-based Electric proxy entirely. This warning is now misleading for developers setting up their workspace.

Proposed fix
-  if ! command -v caddy &> /dev/null; then
-    warn "caddy not found — HTTP/2 proxy for Electric won't work (Run: brew install caddy && caddy trust)"
-  fi

Each of 12+ Electric collections was independently calling
authClient.token() (a network request) on every sync cycle.
Centralizes JWT lifecycle in a useJwtRefresh hook mounted in
AuthProvider, with synchronous getJwt() for collections to read.
Remove the Cloudflare Worker Electric proxy and all JWT-based auth for
Electric SQL. Revert to routing Electric requests through the Vercel API
proxy (/api/electric/) with session-based bearer auth.

- Delete apps/electric-proxy/ package entirely
- Remove JWT token caching (useJwtRefresh hook, getJwt/setJwt, jwtClient)
- Revert AuthProvider to pre-JWT state
- Point collections at NEXT_PUBLIC_API_URL/api/electric/ instead of
  NEXT_PUBLIC_ELECTRIC_URL
- Remove NEXT_PUBLIC_ELECTRIC_URL from env, Vite config, CSP, CI, turbo
- Remove dev:caddy script (no longer needed)
- Clean up setup.sh (remove electric-proxy .dev.vars, Caddyfile gen,
  CADDY_ELECTRIC_PORT/ELECTRIC_PROXY_PORT)
Caddy is needed for HTTP/2 in local dev to avoid the browser's 6-connection
limit with 10+ Electric SSE streams. Update it to proxy to the API server
(which handles session auth) instead of the removed Cloudflare Worker.
Client code shouldn't know the proxy path. The env var now points
directly at the Electric endpoint so collections.ts just appends
/v1/shape, matching the old pattern.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In
`@apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts`:
- Line 26: The current construction of electricUrl by concatenating
env.NEXT_PUBLIC_ELECTRIC_URL risks double paths; update collections.ts to build
the URL safely using the URL API (e.g., set electricUrl = new
URL("/api/electric/v1/shape", env.NEXT_PUBLIC_ELECTRIC_URL).href) and/or tighten
the env schema for NEXT_PUBLIC_ELECTRIC_URL to be origin-only (validate via new
URL(envVal).origin === envVal or a zod refine) so you never append to a value
that already contains a path; reference electricUrl and
env.NEXT_PUBLIC_ELECTRIC_URL when making these changes.
🧹 Nitpick comments (1)
apps/desktop/src/renderer/index.html (1)

14-18: %NEXT_PUBLIC_ELECTRIC_URL% in CSP is now redundant with %NEXT_PUBLIC_API_URL%.

Since NEXT_PUBLIC_ELECTRIC_URL now defaults to the same origin as NEXT_PUBLIC_API_URL (https://api.superset.sh), the separate CSP placeholder is redundant. It's harmless, but you could simplify by removing it from connect-src if Electric requests always go through the API origin going forward.


const columnMapper = snakeCamelMapper();
const electricUrl = `${env.NEXT_PUBLIC_ELECTRIC_URL}/v1/shape`;
const electricUrl = `${env.NEXT_PUBLIC_ELECTRIC_URL}/api/electric/v1/shape`;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n "NEXT_PUBLIC_ELECTRIC_URL" --type-add 'env:*.env*' -g '*.env*' -g '*.ts' -g '*.tsx' -g '*.json' -g '*.sh' -C2

Repository: superset-sh/superset

Length of output: 2502


🏁 Script executed:

# Also check if there are any env template files or documentation
find . -type f \( -name ".env*" -o -name "*.example" -o -name "*env*template*" \) | head -20

Repository: superset-sh/superset

Length of output: 78


🏁 Script executed:

# Check the collections.ts file to see the full context
cat -n apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts | head -40

Repository: superset-sh/superset

Length of output: 1598


Add validation to ensure NEXT_PUBLIC_ELECTRIC_URL is origin-only or use safe URL construction.

The env var is validated only as z.url() with default "https://api.superset.sh", but this allows misconfiguration with a path (e.g., https://api.superset.sh/api). Appending /api/electric/v1/shape directly would then produce a malformed URL like https://api.superset.sh/api/api/electric/v1/shape. Either restrict validation to origin-only values or use safe URL construction (e.g., new URL("/api/electric/v1/shape", env.NEXT_PUBLIC_ELECTRIC_URL).href).

🤖 Prompt for AI Agents
In
`@apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts`
at line 26, The current construction of electricUrl by concatenating
env.NEXT_PUBLIC_ELECTRIC_URL risks double paths; update collections.ts to build
the URL safely using the URL API (e.g., set electricUrl = new
URL("/api/electric/v1/shape", env.NEXT_PUBLIC_ELECTRIC_URL).href) and/or tighten
the env schema for NEXT_PUBLIC_ELECTRIC_URL to be origin-only (validate via new
URL(envVal).origin === envVal or a zod refine) so you never append to a value
that already contains a path; reference electricUrl and
env.NEXT_PUBLIC_ELECTRIC_URL when making these changes.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 15, 2026

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch
  • ✅ Electric Fly.io app
  • ✅ Streams Fly.io app

Thank you for your contribution! 🎉

CSP connect-src with a path doesn't match sub-paths in Chromium.
Extract just the origin (scheme+host+port) for CSP whitelisting.
@saddlepaddle saddlepaddle merged commit d2a5c7c into main Feb 15, 2026
15 checks passed
@Kitenite Kitenite deleted the fix-electric-token branch February 16, 2026 00:04
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.

1 participant