Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,36 @@ jobs:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
run: flyctl deploy . --config fly.toml --remote-only

deploy-electric-proxy:
name: Deploy Electric Proxy to Cloudflare
runs-on: ubuntu-latest
environment: production

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.3

- name: Cache dependencies
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}

- name: Install dependencies
run: bun install --frozen

- name: Deploy Worker
working-directory: apps/electric-proxy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: bunx wrangler deploy
Comment on lines +451 to +479
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the electric-proxy app directory and wrangler configuration exist

echo "=== Checking apps/electric-proxy directory ==="
if [ -d "apps/electric-proxy" ]; then
  echo "Directory exists"
  ls -la apps/electric-proxy/
else
  echo "ERROR: apps/electric-proxy directory not found"
  exit 1
fi

echo ""
echo "=== Checking for wrangler.toml ==="
if [ -f "apps/electric-proxy/wrangler.toml" ]; then
  echo "wrangler.toml found:"
  cat apps/electric-proxy/wrangler.toml
else
  echo "ERROR: wrangler.toml not found in apps/electric-proxy"
  exit 1
fi

Repository: superset-sh/superset

Length of output: 638


🏁 Script executed:

# Search for any documentation or setup instructions related to secrets
echo "=== Searching for secret setup documentation ==="
find . -type f \( -name "*.md" -o -name "*.txt" \) -exec grep -l "CLOUDFLARE\|electric-proxy\|secret" {} \; | head -20

Repository: superset-sh/superset

Length of output: 170


🏁 Script executed:

# Check if there are any GitHub workflow files that reference secret setup
echo "=== Checking for secret setup references in workflow files ==="
rg -i "cloudflare.*secret|electric.*url|electric.*secret" .github/workflows/ -A 2 -B 2

Repository: superset-sh/superset

Length of output: 4899


🏁 Script executed:

# Look for any .github/environments or secrets documentation
echo "=== Checking for GitHub environment configuration ==="
if [ -d ".github/environments" ]; then
  echo "Found .github/environments:"
  find .github/environments -type f | xargs cat
fi

# Check for any documentation about setting up secrets
find . -type f -name "*.md" | xargs grep -l "production environment" 2>/dev/null | head -5

Repository: superset-sh/superset

Length of output: 117


🏁 Script executed:

cat apps/electric-proxy/wrangler.jsonc

Repository: superset-sh/superset

Length of output: 155


🏁 Script executed:

cat apps/electric-proxy/.dev.vars.example

Repository: superset-sh/superset

Length of output: 458


🏁 Script executed:

# Check the src directory structure to understand what the Worker does
ls -la apps/electric-proxy/src/

Repository: superset-sh/superset

Length of output: 467


🏁 Script executed:

# Look for any production environment configuration in wrangler files
find apps/electric-proxy -name "wrangler*" -exec cat {} \;

Repository: superset-sh/superset

Length of output: 155


🏁 Script executed:

# Check if there are comments or setup docs in the electric-proxy package.json or README
cat apps/electric-proxy/package.json

Repository: superset-sh/superset

Length of output: 612


🏁 Script executed:

# Search for any README or docs in the electric-proxy directory
find apps/electric-proxy -type f \( -name "README*" -o -name "*.md" \) -exec cat {} \;

Repository: superset-sh/superset

Length of output: 46


🏁 Script executed:

# Check the index.ts to see how environment variables are being used
head -50 apps/electric-proxy/src/index.ts

Repository: superset-sh/superset

Length of output: 1642


🏁 Script executed:

cat apps/electric-proxy/src/types.ts

Repository: superset-sh/superset

Length of output: 252


🏁 Script executed:

# Check if there's any environment binding configuration in wrangler.jsonc
# that might define secrets at deployment time
git log --oneline -n 5 -- apps/electric-proxy/wrangler.jsonc 2>/dev/null || echo "No git history available"

Repository: superset-sh/superset

Length of output: 148


🏁 Script executed:

# Search for how other Cloudflare Worker deployments handle secrets in this repo
rg "wrangler deploy" .github/workflows/ -B 5 -A 5

Repository: superset-sh/superset

Length of output: 866


🏁 Script executed:

# Check if there's environment binding configuration later in wrangler.jsonc or if it's complete
wc -l apps/electric-proxy/wrangler.jsonc

Repository: superset-sh/superset

Length of output: 100


🏁 Script executed:

# Look for any secret setup commands in the deploy-production workflow for electric-proxy
rg -A 20 "deploy-electric-proxy:" .github/workflows/deploy-production.yml | head -40

Repository: superset-sh/superset

Length of output: 589


🏁 Script executed:

# Check if there's a separate wrangler.toml (in addition to jsonc)
find apps/electric-proxy -name "wrangler.*"

Repository: superset-sh/superset

Length of output: 98


🏁 Script executed:

# Verify what the deploy command does - check if wrangler reads from environment automatically
rg "AUTH_URL|ELECTRIC_" apps/electric-proxy/ --type ts

Repository: superset-sh/superset

Length of output: 1181


🏁 Script executed:

# Check how AUTH_URL is currently being handled - is it in wrangler.jsonc with bindings?
cat -n apps/electric-proxy/wrangler.jsonc

Repository: superset-sh/superset

Length of output: 190


🏁 Script executed:

# Check if there's any environment variable binding or secret definition we missed
grep -r "env\|vars\|secret" apps/electric-proxy/ --include="*.jsonc" --include="*.json" --include="*.toml" --include="*.ts" 2>/dev/null | grep -v node_modules

Repository: superset-sh/superset

Length of output: 1188


🏁 Script executed:

# Look at the full deploy-production.yml to see if there's any environment setup elsewhere for this job
sed -n '451,485p' .github/workflows/deploy-production.yml

Repository: superset-sh/superset

Length of output: 1014


Add required Worker environment variables to the deployment command.

The wrangler.jsonc file (not wrangler.toml) exists and is valid, but it lacks environment bindings. The deploy step must pass Worker environment variables during deployment since they are required at runtime. The Worker code requires AUTH_URL (mandatory) and expects optional variables like ELECTRIC_URL and ELECTRIC_SECRET defined in the Env interface.

Update the deploy step to either:

  1. Add environment variable bindings to wrangler.jsonc with a [env.production] section, or
  2. Pass the variables as environment to the deploy command:
env:
  CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
  CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
  AUTH_URL: ${{ secrets.AUTH_URL }}
  ELECTRIC_URL: ${{ secrets.ELECTRIC_URL }}
  ELECTRIC_SECRET: ${{ secrets.ELECTRIC_SECRET }}
run: bunx wrangler deploy

Ensure the corresponding GitHub secrets (AUTH_URL, ELECTRIC_URL, ELECTRIC_SECRET) are configured in the production environment.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/deploy-production.yml around lines 451 - 479, The deploy
step in the deploy-electric-proxy job currently does not provide required Worker
env vars; update the Deploy Worker step (or wrangler.jsonc) so the Cloudflare
Worker receives AUTH_URL (required) and optional ELECTRIC_URL and
ELECTRIC_SECRET at deploy/runtime; either add an [env.production] section with
those bindings to wrangler.jsonc or add AUTH_URL, ELECTRIC_URL and
ELECTRIC_SECRET to the Deploy Worker step's env block alongside
CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID so the Worker’s Env interface
variables are available at runtime when bunx wrangler deploy is run.


deploy-docs:
name: Deploy Docs to Vercel
runs-on: ubuntu-latest
Expand Down
15 changes: 13 additions & 2 deletions .superset/lib/setup/steps.sh
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,7 @@ step_write_env() {
# Offsets: +0 web, +1 api, +2 marketing, +3 admin, +4 docs,
# +5 desktop vite, +6 notifications, +7 streams, +8 streams internal, +9 electric,
# +10 caddy (HTTP/2 reverse proxy for API electric endpoint), +11 code inspector,
# +12 desktop automation (CDP)
# +12 desktop automation (CDP), +13 wrangler (electric-proxy worker)
local BASE=$SUPERSET_PORT_BASE

# App ports (fixed offsets from base)
Expand All @@ -439,6 +439,7 @@ step_write_env() {
local CADDY_ELECTRIC_PORT=$((BASE + 10))
local CODE_INSPECTOR_PORT=$((BASE + 11))
local DESKTOP_AUTOMATION_PORT=$((BASE + 12))
local WRANGLER_PORT=$((BASE + 13))

echo ""
echo "# Workspace Ports (allocated from SUPERSET_PORT_BASE=$BASE, range=20)"
Expand All @@ -456,6 +457,7 @@ step_write_env() {
write_env_var "CADDY_ELECTRIC_PORT" "$CADDY_ELECTRIC_PORT"
write_env_var "CODE_INSPECTOR_PORT" "$CODE_INSPECTOR_PORT"
write_env_var "DESKTOP_AUTOMATION_PORT" "$DESKTOP_AUTOMATION_PORT"
write_env_var "WRANGLER_PORT" "$WRANGLER_PORT"
echo ""
echo "# Cross-app URLs (overrides from root .env)"
write_env_var "NEXT_PUBLIC_API_URL" "http://localhost:$API_PORT"
Expand Down Expand Up @@ -510,12 +512,21 @@ step_write_env() {
{ "port": $STREAMS_INTERNAL_PORT, "label": "Streams Internal" },
{ "port": $ELECTRIC_PORT, "label": "Electric" },
{ "port": $CADDY_ELECTRIC_PORT, "label": "Caddy Electric" },
{ "port": $CODE_INSPECTOR_PORT, "label": "Code Inspector" }
{ "port": $CODE_INSPECTOR_PORT, "label": "Code Inspector" },
{ "port": $WRANGLER_PORT, "label": "Electric Proxy (Wrangler)" }
]
}
PORTSJSON
success "Port name mapping written to .superset/ports.json"

cat > apps/electric-proxy/.dev.vars <<DEVVARS
AUTH_URL=http://localhost:$API_PORT
ELECTRIC_CLOUD_URL=${ELECTRIC_CLOUD_URL:-https://api.electric-sql.cloud}
ELECTRIC_SOURCE_ID=${ELECTRIC_SOURCE_ID:-}
ELECTRIC_SOURCE_SECRET=${ELECTRIC_SOURCE_SECRET:-}
DEVVARS
success "Electric proxy .dev.vars written"

return 0
}

Expand Down
67 changes: 35 additions & 32 deletions apps/api/src/app/api/electric/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,45 +3,50 @@ import { auth } from "@superset/auth/server";
import { env } from "@/env";
import { buildWhereClause } from "./utils";

interface AuthInfo {
userId: string;
organizationIds: string[];
}

async function authenticate(request: Request): Promise<AuthInfo | null> {
const bearer = request.headers.get("Authorization");
if (bearer?.startsWith("Bearer ")) {
const token = bearer.slice(7);
try {
const { payload } = await auth.api.verifyJWT({ body: { token } });
if (payload?.sub && Array.isArray(payload.organizationIds)) {
return {
userId: payload.sub,
organizationIds: payload.organizationIds as string[],
};
Comment on lines +16 to +21
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

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "route.ts" | grep -E "electric.*path" | head -5

Repository: superset-sh/superset

Length of output: 114


🏁 Script executed:

git ls-files | grep -E "apps/api/src/app/api/electric.*route\.ts"

Repository: superset-sh/superset

Length of output: 112


🏁 Script executed:

find . -path "*/apps/api/src/app/api/electric/*" -name "*.ts" -type f

Repository: superset-sh/superset

Length of output: 165


🏁 Script executed:

cat -n ./apps/api/src/app/api/electric/[...path]/route.ts

Repository: superset-sh/superset

Length of output: 4632


Validate JWT claim types before constructing AuthInfo.

Line 20–22 use unchecked casts without runtime narrowing. Although payload?.sub is checked for truthiness on line 18, it could be any truthy non-string value. Similarly, payload.email and payload.organizationIds array elements lack type validation. This violates the type safety guideline and could lead to incorrect behavior or failures downstream.

Add runtime type checks for sub (string), email (string), and filter organizationIds entries to ensure all are strings:

Proposed fix
 		try {
 			const { payload } = await auth.api.verifyJWT({ body: { token } });
-			if (payload?.sub && Array.isArray(payload.organizationIds)) {
+			const userId = typeof payload?.sub === "string" ? payload.sub : null;
+			const email = typeof payload?.email === "string" ? payload.email : "";
+			const organizationIds = Array.isArray(payload?.organizationIds)
+				? payload.organizationIds.filter(
+						(id): id is string => typeof id === "string",
+					)
+				: [];
+			if (userId) {
 				return {
-					userId: payload.sub,
-					email: (payload.email as string) ?? "",
-					organizationIds: payload.organizationIds as string[],
+					userId,
+					email,
+					organizationIds,
 				};
 			}
 		} catch {}
📝 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
const { payload } = await auth.api.verifyJWT({ body: { token } });
if (payload?.sub && Array.isArray(payload.organizationIds)) {
return {
userId: payload.sub,
email: (payload.email as string) ?? "",
organizationIds: payload.organizationIds as string[],
};
const { payload } = await auth.api.verifyJWT({ body: { token } });
const userId = typeof payload?.sub === "string" ? payload.sub : null;
const email = typeof payload?.email === "string" ? payload.email : "";
const organizationIds = Array.isArray(payload?.organizationIds)
? payload.organizationIds.filter(
(id): id is string => typeof id === "string",
)
: [];
if (userId) {
return {
userId,
email,
organizationIds,
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/app/api/electric/`[...path]/route.ts around lines 17 - 23, The
returned AuthInfo is constructed from unchecked payload fields; add runtime type
guards after auth.api.verifyJWT to ensure payload.sub is a string before
assigning to userId, ensure payload.email is a string (otherwise set email to
empty string), and ensure payload.organizationIds is an array and map/filter it
to a string[] by keeping only elements with typeof === "string"; update the
object returned (the block that currently returns userId: payload.sub, email:
(payload.email as string) ?? "", organizationIds: payload.organizationIds as
string[]) to use these validated values so downstream code sees correct types.

}
} catch {}
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Mar 3, 2026

Choose a reason for hiding this comment

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

P2: Empty catch {} silently swallows JWT verification errors, making auth failures invisible in production. Log at least a warning with context so you can trace issues like expired tokens or misconfigured secrets.

(Based on your team's feedback about avoiding empty catch blocks that hide failures.)

View Feedback: 1 2

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/app/api/electric/[...path]/route.ts, line 25:

<comment>Empty `catch {}` silently swallows JWT verification errors, making auth failures invisible in production. Log at least a warning with context so you can trace issues like expired tokens or misconfigured secrets.

(Based on your team's feedback about avoiding empty catch blocks that hide failures.) </comment>

<file context>
@@ -3,32 +3,56 @@ import { auth } from "@superset/auth/server";
+					organizationIds: payload.organizationIds as string[],
+				};
+			}
+		} catch {}
+	}
+
</file context>
Suggested change
} catch {}
} catch (err) {
console.warn("[electric] JWT verification failed", err);
}
Fix with Cubic

}

const sessionData = await auth.api.getSession({ headers: request.headers });
if (!sessionData?.user) return null;
Comment on lines +12 to +27
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

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "route.ts" | grep -E "electric.*route\.ts"

Repository: superset-sh/superset

Length of output: 114


🏁 Script executed:

git ls-files | grep "electric.*route\.ts"

Repository: superset-sh/superset

Length of output: 112


🏁 Script executed:

fd "route.ts" apps/api

Repository: superset-sh/superset

Length of output: 1650


🏁 Script executed:

cat -n apps/api/src/app/api/electric/[...path]/route.ts

Repository: superset-sh/superset

Length of output: 4632


🏁 Script executed:

# Check for Biome configuration and linting
cat biome.json 2>/dev/null || cat .biomeignore 2>/dev/null || echo "No Biome config found"

Repository: superset-sh/superset

Length of output: 85


🏁 Script executed:

# Check if there are any comments or tests explaining the auth fallback behavior
rg -A 5 -B 5 "authenticate.*request" apps/api/src/app/api/electric/[...path]/route.ts

Repository: superset-sh/superset

Length of output: 701


🏁 Script executed:

# Search for similar patterns of empty catch blocks in TypeScript files
rg "catch\s*\{\s*\}" --type ts -C 2 apps/api/src/app/api/electric/

Repository: superset-sh/superset

Length of output: 337


Return null when Bearer token verification fails—do not fall back to session auth.

If an Authorization header with a Bearer token is provided, that signals explicit token-based authentication. When JWT verification fails, silently catching the error and falling back to session auth creates credential-precedence ambiguity. An invalid or malformed token should reject authentication entirely, not degrade to session validation.

Proposed fix
 	if (bearer?.startsWith("Bearer ")) {
 		const token = bearer.slice(7);
 		try {
 			const { payload } = await auth.api.verifyJWT({ body: { token } });
 			if (payload?.sub && Array.isArray(payload.organizationIds)) {
 				return {
 					userId: payload.sub,
 					email: (payload.email as string) ?? "",
 					organizationIds: payload.organizationIds as string[],
 				};
 			}
-		} catch {}
+		} catch {
+			return null;
+		}
+		return null;
 	}
📝 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
const bearer = request.headers.get("Authorization");
if (bearer?.startsWith("Bearer ")) {
const token = bearer.slice(7);
try {
const { payload } = await auth.api.verifyJWT({ body: { token } });
if (payload?.sub && Array.isArray(payload.organizationIds)) {
return {
userId: payload.sub,
email: (payload.email as string) ?? "",
organizationIds: payload.organizationIds as string[],
};
}
} catch {}
}
const sessionData = await auth.api.getSession({ headers: request.headers });
if (!sessionData?.user) return null;
const bearer = request.headers.get("Authorization");
if (bearer?.startsWith("Bearer ")) {
const token = bearer.slice(7);
try {
const { payload } = await auth.api.verifyJWT({ body: { token } });
if (payload?.sub && Array.isArray(payload.organizationIds)) {
return {
userId: payload.sub,
email: (payload.email as string) ?? "",
organizationIds: payload.organizationIds as string[],
};
}
} catch {
return null;
}
return null;
}
const sessionData = await auth.api.getSession({ headers: request.headers });
if (!sessionData?.user) return null;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/app/api/electric/`[...path]/route.ts around lines 13 - 29, If an
Authorization header containing a Bearer token is present, do not fall back to
session auth when JWT verification fails; detect bearer
(bearer?.startsWith("Bearer ")), extract token (token = bearer.slice(7)), call
auth.api.verifyJWT({ body: { token } }) and if verification throws or payload is
missing required fields, immediately return null instead of proceeding to
auth.api.getSession; only call auth.api.getSession when no Authorization Bearer
header exists. Ensure references to verifyJWT, getSession, bearer, token, and
sessionData are updated accordingly.

return {
userId: sessionData.user.id,
organizationIds: sessionData.session.organizationIds ?? [],
};
}

export async function GET(request: Request): Promise<Response> {
const sessionData = await auth.api.getSession({
headers: request.headers,
});
if (!sessionData?.user) {
const authInfo = await authenticate(request);
if (!authInfo) {
return new Response("Unauthorized", { status: 401 });
}

const url = new URL(request.url);

// Use client-sent organizationId, falling back to session for older clients.
// TODO(2026-02-26): Remove activeOrganizationId fallback once all clients send organizationId param.
const organizationId =
url.searchParams.get("organizationId") ??
sessionData.session.activeOrganizationId;
const allowedOrgIds = sessionData.session.organizationIds ?? [];
const organizationId = url.searchParams.get("organizationId");

if (organizationId && !allowedOrgIds.includes(organizationId)) {
if (organizationId && !authInfo.organizationIds.includes(organizationId)) {
return new Response("Not a member of this organization", { status: 403 });
}
Comment on lines +42 to 46
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, check the structure and find the relevant files
find apps/api/src/app/api/electric -type f -name "*.ts" -o -name "*.tsx" | head -20

Repository: superset-sh/superset

Length of output: 161


🏁 Script executed:

#!/bin/bash
# Check if utils.ts exists and examine buildWhereClause
if [ -f "apps/api/src/app/api/electric/[...path]/utils.ts" ]; then
  echo "=== utils.ts found ==="
  cat "apps/api/src/app/api/electric/[...path]/utils.ts"
else
  echo "=== Searching for utils.ts with fd ==="
  fd "utils" apps/api/src/app/api/electric/
fi

Repository: superset-sh/superset

Length of output: 3743


🏁 Script executed:

#!/bin/bash
# Search for buildWhereClause function definition
rg -n "buildWhereClause" apps/api/src/app/api/electric/ -A 15 -B 2

Repository: superset-sh/superset

Length of output: 4364


🏁 Script executed:

#!/bin/bash
# Also check the route.ts file to see the full context
cat -n "apps/api/src/app/api/electric/[...path]/route.ts"

Repository: superset-sh/superset

Length of output: 3734


🏁 Script executed:

#!/bin/bash
# Check if there are any tests or documentation about organizationId requirement
rg -i "organizationId.*required\|optional" apps/api/src/app/api/electric/ -B 2 -A 2

Repository: superset-sh/superset

Length of output: 46


🏁 Script executed:

#!/bin/bash
# Check if there's any validation requiring organizationId elsewhere
rg -n "organizationId" apps/api/src/app/api/electric/\[...path\]/route.ts -B 3 -A 3

Repository: superset-sh/superset

Length of output: 1168


Require organizationId validation for all endpoints.

When organizationId is missing from query params, the validation check (line 44) is entirely skipped. The code then passes an empty string to buildWhereClause (line 64). This is problematic because:

  1. For auth.organizations table: The endpoint ignores the empty organizationId parameter and returns all organizations the authenticated user belongs to, bypassing organization scoping.
  2. For other tables: Behavior is inconsistent—some return empty results with organizationId = "", but the lack of explicit validation means scoping is not enforced at the handler level.

All endpoints should require explicit organizationId validation before calling buildWhereClause, ensuring proper organization scoping regardless of query parameters.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/app/api/electric/`[...path]/route.ts around lines 42 - 46, The
handler currently skips validation when organizationId is missing and later
passes an empty string into buildWhereClause; change the logic to require an
explicit organizationId before proceeding: immediately return a 400 (Bad
Request) if url.searchParams.get("organizationId") is null/empty, then verify
membership using authInfo.organizationIds.includes(organizationId) and return
403 if not a member; only after both checks pass call buildWhereClause with the
validated organizationId. Reference symbols: the organizationId variable from
url.searchParams, authInfo.organizationIds.includes(...), and buildWhereClause.


const useCloud =
env.ELECTRIC_SOURCE_ID &&
env.ELECTRIC_SOURCE_SECRET &&
(request.headers.get("x-electric-backend") === "cloud" ||
sessionData.user.email?.endsWith("@superset.sh"));

const originUrl = useCloud
? new URL("/v1/shape", "https://api.electric-sql.cloud")
: new URL(env.ELECTRIC_URL);

if (useCloud) {
// biome-ignore lint/style/noNonNullAssertion: guarded by useCloud check above
originUrl.searchParams.set("source_id", env.ELECTRIC_SOURCE_ID!);
// biome-ignore lint/style/noNonNullAssertion: guarded by useCloud check above
originUrl.searchParams.set("secret", env.ELECTRIC_SOURCE_SECRET!);
} else {
originUrl.searchParams.set("secret", env.ELECTRIC_SECRET);
}
const originUrl = new URL(env.ELECTRIC_URL);
originUrl.searchParams.set("secret", env.ELECTRIC_SECRET);

url.searchParams.forEach((value, key) => {
if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) {
Expand All @@ -57,7 +62,7 @@ export async function GET(request: Request): Promise<Response> {
const whereClause = await buildWhereClause(
tableName,
organizationId ?? "",
sessionData.user.id,
authInfo.userId,
);
if (!whereClause) {
return new Response(`Unknown table: ${tableName}`, { status: 400 });
Expand Down Expand Up @@ -86,8 +91,6 @@ export async function GET(request: Request): Promise<Response> {
const response = await fetch(originUrl.toString());

const headers = new Headers(response.headers);
headers.append("Vary", "Authorization, X-Electric-Backend");

if (headers.get("content-encoding")) {
headers.delete("content-encoding");
headers.delete("content-length");
Expand Down
11 changes: 0 additions & 11 deletions apps/api/vercel.json

This file was deleted.

2 changes: 1 addition & 1 deletion apps/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export default defineConfig({
),
"process.env.NEXT_PUBLIC_ELECTRIC_URL": defineEnv(
process.env.NEXT_PUBLIC_ELECTRIC_URL,
"https://api.superset.sh/api/electric",
"https://electric-proxy.avi-6ac.workers.dev",
),
"process.env.NEXT_PUBLIC_DOCS_URL": defineEnv(
process.env.NEXT_PUBLIC_DOCS_URL,
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/env.main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const env = createEnv({
NEXT_PUBLIC_STREAMS_URL: z.url().default("https://streams.superset.sh"),
NEXT_PUBLIC_ELECTRIC_URL: z
.url()
.default("https://api.superset.sh/api/electric"),
.default("https://electric-proxy.avi-6ac.workers.dev"),
NEXT_PUBLIC_WEB_URL: z.url().default("https://app.superset.sh"),
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
NEXT_PUBLIC_POSTHOG_HOST: z.string().default("https://us.i.posthog.com"),
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/env.renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const envSchema = z.object({
NEXT_PUBLIC_WEB_URL: z.url().default("https://app.superset.sh"),
NEXT_PUBLIC_ELECTRIC_URL: z
.url()
.default("https://api.superset.sh/api/electric"),
.default("https://electric-proxy.avi-6ac.workers.dev"),
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
NEXT_PUBLIC_POSTHOG_HOST: z.string().default("https://us.i.posthog.com"),
SENTRY_DSN_DESKTOP: z.string().optional(),
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/renderer/lib/auth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { auth } from "@superset/auth/server";
import {
apiKeyClient,
customSessionClient,
jwtClient,
organizationClient,
} from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
Expand All @@ -18,6 +19,16 @@ export function getAuthToken(): string | null {
return authToken;
}

let jwt: string | null = null;

export function setJwt(token: string | null) {
jwt = token;
}

export function getJwt(): string | null {
return jwt;
}

/**
* Better Auth client for Electron desktop app.
*
Expand All @@ -31,6 +42,7 @@ export const authClient = createAuthClient({
customSessionClient<typeof auth>(),
stripeClient({ subscription: true }),
apiKeyClient(),
jwtClient(),
],
fetchOptions: {
credentials: "include",
Expand All @@ -40,5 +52,11 @@ export const authClient = createAuthClient({
context.headers.set("Authorization", `Bearer ${token}`);
}
},
onResponse: async (context) => {
const token = context.response.headers.get("set-auth-jwt");
if (token) {
setJwt(token);
}
},
},
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type ReactNode, useEffect, useState } from "react";
import { authClient, setAuthToken } from "renderer/lib/auth-client";
import { authClient, setAuthToken, setJwt } from "renderer/lib/auth-client";
import { SupersetLogo } from "renderer/routes/sign-in/components/SupersetLogo/SupersetLogo";
import { electronTrpc } from "../../lib/electron-trpc";

Expand Down Expand Up @@ -31,6 +31,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
err,
);
}
try {
const res = await authClient.token();
if (res.data?.token) {
setJwt(res.data.token);
}
} catch (err) {
console.warn(
"[AuthProvider] JWT fetch failed during hydration",
err,
);
}
}
}
if (!cancelled) {
Expand Down Expand Up @@ -61,6 +72,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setIsHydrated(true);
} else if (data === null) {
setAuthToken(null);
setJwt(null);
try {
await refetchSession();
} catch (err) {
Expand All @@ -73,6 +85,26 @@ export function AuthProvider({ children }: { children: ReactNode }) {
},
});

useEffect(() => {
if (!isHydrated) return;

const refreshJwt = () =>
authClient
.token()
.then((res) => {
if (res.data?.token) {
setJwt(res.data.token);
}
})
.catch((err: unknown) => {
console.warn("[AuthProvider] JWT refresh failed", err);
});

refreshJwt();
const interval = setInterval(refreshJwt, 50 * 60 * 1000);
return () => clearInterval(interval);
}, [isHydrated]);

if (!isHydrated) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-background">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";

const preloadCollectionsMock = mock(() => Promise.resolve());

mock.module("posthog-js/react", () => ({
useFeatureFlagEnabled: mock(() => false),
}));

mock.module("./collections", () => ({
getCollections: mock(() => ({})),
preloadCollections: preloadCollectionsMock,
setElectricUrl: mock(),
}));

const { preloadActiveOrganizationCollections } = await import(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { FEATURE_FLAGS } from "@superset/shared/constants";
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Mar 3, 2026

Choose a reason for hiding this comment

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

P1: Setting the Electric URL in useEffect is too late for collections that are created during render/module initialization, so the feature flag may not actually switch all collections to the cloud endpoint.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx, line 36:

<comment>Setting the Electric URL in `useEffect` is too late for collections that are created during render/module initialization, so the feature flag may not actually switch all collections to the cloud endpoint.</comment>

<file context>
@@ -20,9 +26,16 @@ const CollectionsContext = createContext<CollectionsContextType | null>(null);
+
+	useEffect(() => {
+		if (useElectricCloud) {
+			setElectricUrl(env.NEXT_PUBLIC_ELECTRIC_URL);
+		}
+	}, [useElectricCloud]);
</file context>
Fix with Cubic

import { useFeatureFlagEnabled } from "posthog-js/react";
import {
createContext,
type ReactNode,
Expand All @@ -10,7 +12,11 @@ import {
import { env } from "renderer/env.renderer";
import { authClient } from "renderer/lib/auth-client";
import { MOCK_ORG_ID } from "shared/constants";
import { getCollections, preloadCollections } from "./collections";
import {
getCollections,
preloadCollections,
setElectricUrl,
} from "./collections";

type CollectionsContextType = ReturnType<typeof getCollections> & {
switchOrganization: (organizationId: string) => Promise<void>;
Expand All @@ -34,9 +40,16 @@ export function preloadActiveOrganizationCollections(

export function CollectionsProvider({ children }: { children: ReactNode }) {
const { data: session, refetch: refetchSession } = authClient.useSession();
const useElectricCloud = useFeatureFlagEnabled(FEATURE_FLAGS.ELECTRIC_CLOUD);
const activeOrganizationId = env.SKIP_ENV_VALIDATION
? MOCK_ORG_ID
: session?.session?.activeOrganizationId;

useEffect(() => {
if (useElectricCloud) {
setElectricUrl(env.NEXT_PUBLIC_ELECTRIC_URL);
}
}, [useElectricCloud]);
Comment on lines +48 to +52
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:

#!/bin/bash

# First, let's find and examine the collections.ts file
fd "collections.ts" apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/

# Check the setElectricUrl function
rg -n "setElectricUrl" apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/ -A 5 -B 2

# Check how electricUrl is used
rg -n "electricUrl" apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/ -A 2 -B 2

Repository: superset-sh/superset

Length of output: 13404


🏁 Script executed:

#!/bin/bash

# Find where getCollections is called in the CollectionsProvider
rg -n "getCollections" apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx -B 2 -A 2

# Check the getCollections function implementation and how it uses electricUrl
rg -n "export function getCollections" apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts -A 30

# Check if shapes are created lazily or at module load time
rg -n "const shapes" apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts -B 2 -A 20

Repository: superset-sh/superset

Length of output: 997


🏁 Script executed:

#!/bin/bash

# Get full context around line 74 to see what hook/function contains it
sed -n '60,90p' apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx

# Find and examine createOrgCollections
rg -n "function createOrgCollections" apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts -A 40

Repository: superset-sh/superset

Length of output: 2244


Potential race condition: Electric shapes may be created before URL is updated.

When activeOrganizationId is available, the useMemo immediately creates Electric shapes with the current electricUrl. If this happens before the useEffect calls setElectricUrl(), the shapes will use the default API URL instead of the Electric Cloud URL, and preloadCollections will start subscriptions against the wrong endpoint. Since electricUrl is captured when shape objects are created, updating it later won't affect already-initialized shapes.

Ensure setElectricUrl() is called during initialization before getCollections() is invoked, or add electricUrl to the dependencies of the collections useMemo to recreate shapes when the URL updates.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx`
around lines 34 - 38, The Electric shapes are being created in the collections
useMemo with the current electricUrl before useEffect can call setElectricUrl,
causing a race where shapes (and preloadCollections/getCollections
subscriptions) use the wrong endpoint; fix by ensuring electricUrl is set before
shapes are created — either move the setElectricUrl call to run synchronously
during initialization (so it executes before the useMemo that builds shapes), or
add electricUrl to the dependency array of the collections useMemo so shapes are
recreated when electricUrl changes; update references to useEffect,
setElectricUrl, electricUrl, useMemo that builds
getCollections/preloadCollections, and activeOrganizationId/useElectricCloud
accordingly to avoid stale captured values.

const [isSwitching, setIsSwitching] = useState(false);

const switchOrganization = useCallback(
Expand Down
Loading