diff --git a/.gitignore b/.gitignore
index 66e7915d143..9181c70b7f5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -78,5 +78,8 @@ apps/desktop/resources/bin/win32-x64/
# Streams data
apps/streams/data/
+# Wrangler
+.wrangler
+
# Generated by setup.sh
Caddyfile
diff --git a/apps/api/src/app/api/electric/[...path]/utils.ts b/apps/api/src/app/api/electric/[...path]/utils.ts
index 68a4ca82479..280551d59fb 100644
--- a/apps/api/src/app/api/electric/[...path]/utils.ts
+++ b/apps/api/src/app/api/electric/[...path]/utils.ts
@@ -1,7 +1,6 @@
import { db } from "@superset/db/client";
import {
agentCommands,
- apikeys,
devicePresence,
integrationConnections,
invitations,
@@ -109,8 +108,10 @@ export async function buildWhereClause(
case "agent_commands":
return build(agentCommands, agentCommands.organizationId, organizationId);
- case "auth.apikeys":
- return build(apikeys, apikeys.userId, userId);
+ case "auth.apikeys": {
+ const fragment = `"metadata"::jsonb->>'organizationId' = $1`;
+ return { fragment, params: [organizationId] };
+ }
case "integration_connections":
return build(
diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts
index 2dd2d0045fc..f7a8f4ab444 100644
--- a/apps/desktop/electron.vite.config.ts
+++ b/apps/desktop/electron.vite.config.ts
@@ -158,6 +158,10 @@ export default defineConfig({
process.env.NEXT_PUBLIC_WEB_URL,
"https://app.superset.sh",
),
+ "process.env.NEXT_PUBLIC_ELECTRIC_URL": defineEnv(
+ process.env.NEXT_PUBLIC_ELECTRIC_URL,
+ "https://electric.superset.sh",
+ ),
"process.env.NEXT_PUBLIC_DOCS_URL": defineEnv(
process.env.NEXT_PUBLIC_DOCS_URL,
"https://docs.superset.sh",
diff --git a/apps/desktop/src/renderer/env.renderer.ts b/apps/desktop/src/renderer/env.renderer.ts
index 0e41dea4a6e..61f349ba236 100644
--- a/apps/desktop/src/renderer/env.renderer.ts
+++ b/apps/desktop/src/renderer/env.renderer.ts
@@ -17,6 +17,7 @@ const envSchema = z.object({
.default("development"),
NEXT_PUBLIC_API_URL: z.url().default("https://api.superset.sh"),
NEXT_PUBLIC_WEB_URL: z.url().default("https://app.superset.sh"),
+ NEXT_PUBLIC_ELECTRIC_URL: z.url().default("https://electric.superset.sh"),
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(),
@@ -33,6 +34,7 @@ const rawEnv = {
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NEXT_PUBLIC_WEB_URL: process.env.NEXT_PUBLIC_WEB_URL,
+ NEXT_PUBLIC_ELECTRIC_URL: process.env.NEXT_PUBLIC_ELECTRIC_URL,
NEXT_PUBLIC_POSTHOG_KEY: import.meta.env.NEXT_PUBLIC_POSTHOG_KEY as
| string
| undefined,
diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html
index 6ce8ca61e19..7179263467b 100644
--- a/apps/desktop/src/renderer/index.html
+++ b/apps/desktop/src/renderer/index.html
@@ -11,11 +11,11 @@
- default-src 'self': Only allow resources from same origin
- script-src 'self' 'wasm-unsafe-eval' https://*.posthog.com: Allow scripts from same origin + WebAssembly (for xterm ImageAddon) + PostHog
- style-src 'self' 'unsafe-inline': Allow styles from same origin + inline (needed for CSS-in-JS)
- - connect-src 'self' ws: wss: %NEXT_PUBLIC_API_URL% %NEXT_PUBLIC_STREAMS_URL% https://*.posthog.com https://*.sentry.io sentry-ipc:: Allow WebSocket + API + Streams server + PostHog + Sentry
+ - connect-src 'self' ws: wss: %NEXT_PUBLIC_API_URL% %NEXT_PUBLIC_ELECTRIC_URL% %NEXT_PUBLIC_STREAMS_URL% https://*.posthog.com https://*.sentry.io sentry-ipc:: Allow WebSocket + API + Electric worker + Streams server + PostHog + Sentry
- img-src 'self' data: %NEXT_PUBLIC_API_URL% https://*.public.blob.vercel-storage.com https://github.com https://avatars.githubusercontent.com https://models.dev: Allow images from same origin + data URIs + API (Linear image proxy) + Vercel blob storage + GitHub avatars + model provider logos
- font-src 'self': Allow fonts from same origin
-->
-
+
diff --git a/apps/desktop/src/renderer/lib/auth-client.ts b/apps/desktop/src/renderer/lib/auth-client.ts
index e898d589360..f377197b887 100644
--- a/apps/desktop/src/renderer/lib/auth-client.ts
+++ b/apps/desktop/src/renderer/lib/auth-client.ts
@@ -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";
@@ -31,6 +32,7 @@ export const authClient = createAuthClient({
customSessionClient(),
stripeClient({ subscription: true }),
apiKeyClient(),
+ jwtClient(),
],
fetchOptions: {
credentials: "include",
diff --git a/apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx b/apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx
index 59b1ff6ac18..e4648f42d34 100644
--- a/apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx
+++ b/apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx
@@ -10,14 +10,14 @@ import { electronTrpc } from "../../lib/electron-trpc";
* 1. Load token from disk on mount
* 2. If valid (not expired), set in memory and validate session in background
* 3. Render children immediately without blocking on network
+ *
+ * Electric JWT tokens are fetched on-demand via async headers in collections.ts
+ * using authClient.token() from better-auth's JWT plugin.
*/
export function AuthProvider({ children }: { children: ReactNode }) {
const [isHydrated, setIsHydrated] = useState(false);
-
- // Get session refetch to bust cache when token changes
const { refetch: refetchSession } = authClient.useSession();
- // Initial hydration: Load token from disk
const { data: storedToken, isSuccess } =
electronTrpc.auth.getStoredToken.useQuery(undefined, {
refetchOnWindowFocus: false,
@@ -38,25 +38,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setIsHydrated(true);
}, [storedToken, isSuccess, isHydrated, refetchSession]);
- // Listen for auth events from main process (new auth or sign-out only, not hydration)
electronTrpc.auth.onTokenChanged.useSubscription(undefined, {
onData: async (data) => {
if (data?.token && data?.expiresAt) {
- // New authentication - clear old session state first, then set new token
setAuthToken(null);
await authClient.signOut({ fetchOptions: { throw: false } });
setAuthToken(data.token);
setIsHydrated(true);
refetchSession();
} else if (data === null) {
- // Sign-out
setAuthToken(null);
refetchSession();
}
},
});
- // Show loading spinner until initial hydration completes
if (!isHydrated) {
return (
diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts
index 6dfb832f15b..6055fda0526 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts
+++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts
@@ -18,12 +18,22 @@ import type { Collection } from "@tanstack/react-db";
import { createCollection } from "@tanstack/react-db";
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import { env } from "renderer/env.renderer";
-import { getAuthToken } from "renderer/lib/auth-client";
+import { authClient, getAuthToken } from "renderer/lib/auth-client";
import superjson from "superjson";
import { z } from "zod";
const columnMapper = snakeCamelMapper();
-const electricUrl = `${env.NEXT_PUBLIC_API_URL}/api/electric/v1/shape`;
+const electricUrl = `${env.NEXT_PUBLIC_ELECTRIC_URL}/v1/shape`;
+
+const apiKeyDisplaySchema = z.object({
+ id: z.string(),
+ name: z.string().nullable(),
+ start: z.string().nullable(),
+ createdAt: z.coerce.date(),
+ lastRequest: z.coerce.date().nullable(),
+});
+
+type ApiKeyDisplay = z.infer
;
interface OrgCollections {
tasks: Collection;
@@ -36,6 +46,7 @@ interface OrgCollections {
devicePresence: Collection;
integrationConnections: Collection;
subscriptions: Collection;
+ apiKeys: Collection;
}
// Per-org collections cache
@@ -62,37 +73,9 @@ const organizationsCollection = createCollection(
url: electricUrl,
params: { table: "auth.organizations" },
headers: {
- Authorization: () => {
- const token = getAuthToken();
- return token ? `Bearer ${token}` : "";
- },
- },
- columnMapper,
- },
- getKey: (item) => item.id,
- }),
-);
-
-const apiKeyDisplaySchema = z.object({
- id: z.string(),
- name: z.string().nullable(),
- start: z.string().nullable(),
- createdAt: z.coerce.date(),
- lastRequest: z.coerce.date().nullable(),
-});
-
-type ApiKeyDisplay = z.infer;
-
-const apiKeysCollection = createCollection(
- electricCollectionOptions({
- id: "apikeys",
- shapeOptions: {
- url: electricUrl,
- params: { table: "auth.apikeys" },
- headers: {
- Authorization: () => {
- const token = getAuthToken();
- return token ? `Bearer ${token}` : "";
+ Authorization: async () => {
+ const { data } = await authClient.token();
+ return data?.token ? `Bearer ${data.token}` : "";
},
},
columnMapper,
@@ -103,9 +86,9 @@ const apiKeysCollection = createCollection(
function createOrgCollections(organizationId: string): OrgCollections {
const headers = {
- Authorization: () => {
- const token = getAuthToken();
- return token ? `Bearer ${token}` : "";
+ Authorization: async () => {
+ const { data } = await authClient.token();
+ return data?.token ? `Bearer ${data.token}` : "";
},
};
@@ -313,6 +296,22 @@ function createOrgCollections(organizationId: string): OrgCollections {
}),
);
+ const apiKeys = createCollection(
+ electricCollectionOptions({
+ id: `apikeys-${organizationId}`,
+ shapeOptions: {
+ url: electricUrl,
+ params: {
+ table: "auth.apikeys",
+ organizationId,
+ },
+ headers,
+ columnMapper,
+ },
+ getKey: (item) => item.id,
+ }),
+ );
+
return {
tasks,
taskStatuses,
@@ -324,6 +323,7 @@ function createOrgCollections(organizationId: string): OrgCollections {
devicePresence,
integrationConnections,
subscriptions,
+ apiKeys,
};
}
@@ -335,8 +335,7 @@ function createOrgCollections(organizationId: string): OrgCollections {
export async function preloadCollections(
organizationId: string,
): Promise {
- const { organizations, apiKeys, ...orgCollections } =
- getCollections(organizationId);
+ const { organizations, ...orgCollections } = getCollections(organizationId);
await Promise.allSettled(
Object.values(orgCollections).map((c) =>
(c as Collection