From c42e2684242c7b65c74a64304de9526e2ae9309a Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 21 Apr 2026 20:35:38 -0700 Subject: [PATCH] chore(api): remove legacy Vercel electric proxy The Vercel-hosted `/api/electric/*` route is no longer used by supported desktop clients. Bumps minimum desktop version to 1.5.0 (the first release after 2026-04-10) so any lingering client on a pre-1.5.0 build is forced to update before it can heartbeat against an endpoint that's about to disappear. Drops the route, its env vars (ELECTRIC_URL/SECRET/SOURCE_*), `@electric-sql/client` dep, Electric CORS headers, and the matching env passthroughs in the Vercel deploy workflows. The Cloudflare worker (apps/electric-proxy) and Fly-hosted Electric instance stay intact. --- .github/workflows/deploy-preview.yml | 4 - .github/workflows/deploy-production.yml | 4 - apps/api/package.json | 1 - apps/api/src/app/api/desktop/version/route.ts | 2 +- .../src/app/api/electric/[...path]/route.ts | 104 ---------- .../src/app/api/electric/[...path]/utils.ts | 187 ------------------ apps/api/src/env.ts | 4 - apps/api/src/proxy.ts | 9 +- bun.lock | 1 - 9 files changed, 2 insertions(+), 314 deletions(-) delete mode 100644 apps/api/src/app/api/electric/[...path]/route.ts delete mode 100644 apps/api/src/app/api/electric/[...path]/utils.ts diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index d9c417d30d5..772641b28b5 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -160,8 +160,6 @@ jobs: QSTASH_URL: ${{ secrets.QSTASH_URL }} QSTASH_CURRENT_SIGNING_KEY: ${{ secrets.QSTASH_CURRENT_SIGNING_KEY }} QSTASH_NEXT_SIGNING_KEY: ${{ secrets.QSTASH_NEXT_SIGNING_KEY }} - ELECTRIC_URL: ${{ secrets.ELECTRIC_URL }} - ELECTRIC_SECRET: ${{ secrets.ELECTRIC_SECRET }} DURABLE_STREAMS_URL: ${{ secrets.DURABLE_STREAMS_URL }} DURABLE_STREAMS_SECRET: ${{ secrets.DURABLE_STREAMS_SECRET }} STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} @@ -214,8 +212,6 @@ jobs: --env QSTASH_URL=$QSTASH_URL \ --env QSTASH_CURRENT_SIGNING_KEY=$QSTASH_CURRENT_SIGNING_KEY \ --env QSTASH_NEXT_SIGNING_KEY=$QSTASH_NEXT_SIGNING_KEY \ - --env ELECTRIC_URL=$ELECTRIC_URL \ - --env ELECTRIC_SECRET=$ELECTRIC_SECRET \ --env DURABLE_STREAMS_URL=$DURABLE_STREAMS_URL \ --env DURABLE_STREAMS_SECRET=$DURABLE_STREAMS_SECRET \ --env STRIPE_SECRET_KEY=$STRIPE_SECRET_KEY \ diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 8c2b8a5e0bf..23efd918d66 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -111,8 +111,6 @@ jobs: QSTASH_URL: ${{ secrets.QSTASH_URL }} QSTASH_CURRENT_SIGNING_KEY: ${{ secrets.QSTASH_CURRENT_SIGNING_KEY }} QSTASH_NEXT_SIGNING_KEY: ${{ secrets.QSTASH_NEXT_SIGNING_KEY }} - ELECTRIC_URL: ${{ secrets.ELECTRIC_URL }} - ELECTRIC_SECRET: ${{ secrets.ELECTRIC_SECRET }} DURABLE_STREAMS_URL: ${{ secrets.DURABLE_STREAMS_URL }} DURABLE_STREAMS_SECRET: ${{ secrets.DURABLE_STREAMS_SECRET }} STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} @@ -165,8 +163,6 @@ jobs: --env QSTASH_URL=$QSTASH_URL \ --env QSTASH_CURRENT_SIGNING_KEY=$QSTASH_CURRENT_SIGNING_KEY \ --env QSTASH_NEXT_SIGNING_KEY=$QSTASH_NEXT_SIGNING_KEY \ - --env ELECTRIC_URL=$ELECTRIC_URL \ - --env ELECTRIC_SECRET=$ELECTRIC_SECRET \ --env DURABLE_STREAMS_URL=$DURABLE_STREAMS_URL \ --env DURABLE_STREAMS_SECRET=$DURABLE_STREAMS_SECRET \ --env STRIPE_SECRET_KEY=$STRIPE_SECRET_KEY \ diff --git a/apps/api/package.json b/apps/api/package.json index cfddb896133..06419d3d03a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,7 +14,6 @@ "@anthropic-ai/sdk": "^0.78.0", "@better-auth/oauth-provider": "1.6.5", "@durable-streams/client": "^0.2.3", - "@electric-sql/client": "1.5.15", "@linear/sdk": "^68.1.0", "@modelcontextprotocol/sdk": "^1.26.0", "@octokit/app": "^16.1.2", diff --git a/apps/api/src/app/api/desktop/version/route.ts b/apps/api/src/app/api/desktop/version/route.ts index 01751c8d186..2e6dff66c7e 100644 --- a/apps/api/src/app/api/desktop/version/route.ts +++ b/apps/api/src/app/api/desktop/version/route.ts @@ -1,4 +1,4 @@ -const MINIMUM_DESKTOP_VERSION = "0.0.48"; +const MINIMUM_DESKTOP_VERSION = "1.5.0"; /** * Used to force the desktop app to update, in cases where we can't support diff --git a/apps/api/src/app/api/electric/[...path]/route.ts b/apps/api/src/app/api/electric/[...path]/route.ts deleted file mode 100644 index 21e552bfcab..00000000000 --- a/apps/api/src/app/api/electric/[...path]/route.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from "@electric-sql/client"; -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 { - 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[], - }; - } - } catch {} - } - - const sessionData = await auth.api.getSession({ headers: request.headers }); - if (!sessionData?.user) return null; - return { - userId: sessionData.user.id, - organizationIds: sessionData.session.organizationIds ?? [], - }; -} - -export async function GET(request: Request): Promise { - const authInfo = await authenticate(request); - if (!authInfo) { - return new Response("Unauthorized", { status: 401 }); - } - - const url = new URL(request.url); - - const organizationId = url.searchParams.get("organizationId"); - - if (organizationId && !authInfo.organizationIds.includes(organizationId)) { - return new Response("Not a member of this organization", { status: 403 }); - } - - 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)) { - originUrl.searchParams.set(key, value); - } - }); - - const tableName = url.searchParams.get("table"); - if (!tableName) { - return new Response("Missing table parameter", { status: 400 }); - } - - const whereClause = await buildWhereClause( - tableName, - organizationId ?? "", - authInfo.userId, - ); - if (!whereClause) { - return new Response(`Unknown table: ${tableName}`, { status: 400 }); - } - - originUrl.searchParams.set("table", tableName); - originUrl.searchParams.set("where", whereClause.fragment); - whereClause.params.forEach((value, index) => { - originUrl.searchParams.set(`params[${index + 1}]`, String(value)); - }); - - if (tableName === "auth.apikeys") { - originUrl.searchParams.set( - "columns", - "id,name,start,created_at,last_request", - ); - } - - if (tableName === "integration_connections") { - originUrl.searchParams.set( - "columns", - "id,organization_id,connected_by_user_id,provider,token_expires_at,external_org_id,external_org_name,config,created_at,updated_at", - ); - } - - const response = await fetch(originUrl.toString()); - - const headers = new Headers(response.headers); - if (headers.get("content-encoding")) { - headers.delete("content-encoding"); - headers.delete("content-length"); - } - - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); -} diff --git a/apps/api/src/app/api/electric/[...path]/utils.ts b/apps/api/src/app/api/electric/[...path]/utils.ts deleted file mode 100644 index 7206700204a..00000000000 --- a/apps/api/src/app/api/electric/[...path]/utils.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { db } from "@superset/db/client"; -import { - agentCommands, - chatSessions, - devicePresence, - githubPullRequests, - githubRepositories, - integrationConnections, - invitations, - members, - organizations, - projects, - sessionHosts, - subscriptions, - taskStatuses, - tasks, - v2Clients, - v2Hosts, - v2Projects, - v2UsersHosts, - v2Workspaces, - workspaces, -} from "@superset/db/schema"; -import { eq, inArray, sql } from "drizzle-orm"; -import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; -import { QueryBuilder } from "drizzle-orm/pg-core"; - -export type AllowedTable = - | "tasks" - | "task_statuses" - | "projects" - | "v2_hosts" - | "v2_clients" - | "v2_projects" - | "v2_users_hosts" - | "v2_workspaces" - | "auth.members" - | "auth.organizations" - | "auth.users" - | "auth.invitations" - | "auth.apikeys" - | "device_presence" - | "agent_commands" - | "integration_connections" - | "subscriptions" - | "workspaces" - | "chat_sessions" - | "session_hosts" - | "github_repositories" - | "github_pull_requests"; - -interface WhereClause { - fragment: string; - params: unknown[]; -} - -function build(table: PgTable, column: PgColumn, id: string): WhereClause { - const whereExpr = eq(sql`${sql.identifier(column.name)}`, id); - const qb = new QueryBuilder(); - const { sql: query, params } = qb - .select() - .from(table) - .where(whereExpr) - .toSQL(); - const fragment = query.replace(/^select .* from .* where\s+/i, ""); - return { fragment, params }; -} - -export async function buildWhereClause( - tableName: string, - organizationId: string, - userId: string, -): Promise { - switch (tableName) { - case "tasks": - return build(tasks, tasks.organizationId, organizationId); - - case "task_statuses": - return build(taskStatuses, taskStatuses.organizationId, organizationId); - - case "projects": - return build(projects, projects.organizationId, organizationId); - - case "v2_projects": - return build(v2Projects, v2Projects.organizationId, organizationId); - - case "v2_hosts": - return build(v2Hosts, v2Hosts.organizationId, organizationId); - - case "v2_clients": - return build(v2Clients, v2Clients.organizationId, organizationId); - - case "v2_users_hosts": - return build(v2UsersHosts, v2UsersHosts.organizationId, organizationId); - - case "v2_workspaces": - return build(v2Workspaces, v2Workspaces.organizationId, organizationId); - - case "auth.members": - return build(members, members.organizationId, organizationId); - - case "auth.invitations": - return build(invitations, invitations.organizationId, organizationId); - - case "auth.organizations": { - // Use the authenticated user's ID to find their organizations - const userMemberships = await db.query.members.findMany({ - where: eq(members.userId, userId), - columns: { organizationId: true }, - }); - - if (userMemberships.length === 0) { - return { fragment: "1 = 0", params: [] }; - } - - const orgIds = [...new Set(userMemberships.map((m) => m.organizationId))]; - const whereExpr = inArray( - sql`${sql.identifier(organizations.id.name)}`, - orgIds, - ); - const qb = new QueryBuilder(); - const { sql: query, params } = qb - .select() - .from(organizations) - .where(whereExpr) - .toSQL(); - const fragment = query.replace(/^select .* from .* where\s+/i, ""); - return { fragment, params }; - } - - case "auth.users": { - const fragment = `$1 = ANY("organization_ids")`; - return { fragment, params: [organizationId] }; - } - - case "device_presence": - return build( - devicePresence, - devicePresence.organizationId, - organizationId, - ); - - case "agent_commands": - return build(agentCommands, agentCommands.organizationId, organizationId); - - case "auth.apikeys": { - const fragment = `"metadata" LIKE '%"organizationId":"' || $1 || '"%'`; - return { fragment, params: [organizationId] }; - } - - case "integration_connections": - return build( - integrationConnections, - integrationConnections.organizationId, - organizationId, - ); - - case "subscriptions": - return build(subscriptions, subscriptions.referenceId, organizationId); - - case "workspaces": - return build(workspaces, workspaces.organizationId, organizationId); - - case "chat_sessions": - return build(chatSessions, chatSessions.organizationId, organizationId); - - case "session_hosts": - return build(sessionHosts, sessionHosts.organizationId, organizationId); - - case "github_repositories": - return build( - githubRepositories, - githubRepositories.organizationId, - organizationId, - ); - - case "github_pull_requests": - return build( - githubPullRequests, - githubPullRequests.organizationId, - organizationId, - ); - - default: - return null; - } -} diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index e627182b53e..117ed25046a 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -10,10 +10,6 @@ export const env = createEnv({ server: { DATABASE_URL: z.string(), DATABASE_URL_UNPOOLED: z.string(), - ELECTRIC_URL: z.string().url(), - ELECTRIC_SECRET: z.string().min(16), - ELECTRIC_SOURCE_ID: z.string().optional(), - ELECTRIC_SOURCE_SECRET: z.string().optional(), BLOB_READ_WRITE_TOKEN: z.string(), GOOGLE_CLIENT_ID: z.string().min(1), GOOGLE_CLIENT_SECRET: z.string().min(1), diff --git a/apps/api/src/proxy.ts b/apps/api/src/proxy.ts index 8e28b5829e2..6451123950c 100644 --- a/apps/api/src/proxy.ts +++ b/apps/api/src/proxy.ts @@ -29,15 +29,8 @@ function getCorsHeaders(origin: string | null, deploymentOrigin: string) { "Access-Control-Allow-Origin": isAllowed ? origin : "", "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", "Access-Control-Allow-Headers": - "Content-Type, Authorization, x-trpc-source, trpc-accept, X-Electric-Backend, Producer-Id, Producer-Epoch, Producer-Seq, Stream-Closed", + "Content-Type, Authorization, x-trpc-source, trpc-accept, Producer-Id, Producer-Epoch, Producer-Seq, Stream-Closed", "Access-Control-Expose-Headers": [ - // Electric sync headers - "electric-offset", - "electric-handle", - "electric-schema", - "electric-cursor", - "electric-chunk-last-offset", - "electric-up-to-date", // Durable stream headers "Stream-Next-Offset", "Stream-Cursor", diff --git a/bun.lock b/bun.lock index 432bc0a2bd6..a72771c7a2b 100644 --- a/bun.lock +++ b/bun.lock @@ -62,7 +62,6 @@ "@anthropic-ai/sdk": "^0.78.0", "@better-auth/oauth-provider": "1.6.5", "@durable-streams/client": "^0.2.3", - "@electric-sql/client": "1.5.15", "@linear/sdk": "^68.1.0", "@modelcontextprotocol/sdk": "^1.26.0", "@octokit/app": "^16.1.2",