From 61cc08b58ff6ff8f862f16aa0597010090e0efaf Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 12 Feb 2026 22:32:16 -0800 Subject: [PATCH 1/3] feat: move Electric proxy to Cloudflare Worker with JWT auth Move Electric SQL shape proxy from Next.js API route to a Cloudflare Worker for edge auth via JWKS, request collapsing, and reduced API server load. - Create apps/electric-worker with JWT verification (jose + JWKS) - Re-enable /token endpoint in better-auth with definePayload containing organizationIds claim - Desktop client fetches + caches JWT for Electric, refreshes on 55-minute interval - Collections use new NEXT_PUBLIC_ELECTRIC_URL + JWT token - Hardcoded WHERE clauses in worker (no Drizzle dependency) - Existing API route preserved for backward compatibility --- .gitignore | 3 + apps/desktop/electron.vite.config.ts | 4 + apps/desktop/src/renderer/env.renderer.ts | 2 + apps/desktop/src/renderer/index.html | 4 +- apps/desktop/src/renderer/lib/auth-client.ts | 9 + .../providers/AuthProvider/AuthProvider.tsx | 89 +++++++++- .../CollectionsProvider/collections.ts | 10 +- apps/desktop/vite/helpers.ts | 5 + apps/electric-worker/package.json | 20 +++ apps/electric-worker/src/auth.ts | 46 ++++++ apps/electric-worker/src/index.ts | 154 ++++++++++++++++++ apps/electric-worker/src/where-clauses.ts | 67 ++++++++ apps/electric-worker/tsconfig.json | 15 ++ apps/electric-worker/wrangler.jsonc | 11 ++ bun.lock | 126 ++++++++++++++ packages/auth/src/server.ts | 17 +- 16 files changed, 571 insertions(+), 11 deletions(-) create mode 100644 apps/electric-worker/package.json create mode 100644 apps/electric-worker/src/auth.ts create mode 100644 apps/electric-worker/src/index.ts create mode 100644 apps/electric-worker/src/where-clauses.ts create mode 100644 apps/electric-worker/tsconfig.json create mode 100644 apps/electric-worker/wrangler.jsonc 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/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..f515f786fe1 100644 --- a/apps/desktop/src/renderer/lib/auth-client.ts +++ b/apps/desktop/src/renderer/lib/auth-client.ts @@ -9,6 +9,7 @@ import { createAuthClient } from "better-auth/react"; import { env } from "renderer/env.renderer"; let authToken: string | null = null; +let electricToken: string | null = null; export function setAuthToken(token: string | null) { authToken = token; @@ -18,6 +19,14 @@ export function getAuthToken(): string | null { return authToken; } +export function setElectricToken(token: string | null) { + electricToken = token; +} + +export function getElectricToken(): string | null { + return electricToken; +} + /** * Better Auth client for Electron desktop app. * diff --git a/apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx b/apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx index 59b1ff6ac18..2f0fac13b09 100644 --- a/apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx +++ b/apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx @@ -1,8 +1,22 @@ import { Spinner } from "@superset/ui/spinner"; -import { type ReactNode, useEffect, useState } from "react"; -import { authClient, setAuthToken } from "renderer/lib/auth-client"; +import { + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { env } from "renderer/env.renderer"; +import { + authClient, + getAuthToken, + setAuthToken, + setElectricToken, +} from "renderer/lib/auth-client"; import { electronTrpc } from "../../lib/electron-trpc"; +const TOKEN_REFRESH_INTERVAL = 55 * 60 * 1000; // 55 minutes (JWT expires in 1h) + /** * AuthProvider: Manages token synchronization between memory and encrypted disk storage. * @@ -10,9 +24,11 @@ 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 + * 4. Fetch and periodically refresh an Electric JWT for edge-authenticated sync */ export function AuthProvider({ children }: { children: ReactNode }) { const [isHydrated, setIsHydrated] = useState(false); + const refreshTimerRef = useRef | null>(null); // Get session refetch to bust cache when token changes const { refetch: refetchSession } = authClient.useSession(); @@ -24,6 +40,55 @@ export function AuthProvider({ children }: { children: ReactNode }) { refetchOnReconnect: false, }); + const fetchElectricToken = useCallback(async () => { + const sessionToken = getAuthToken(); + if (!sessionToken) return; + + try { + const response = await fetch( + `${env.NEXT_PUBLIC_API_URL}/api/auth/token`, + { + method: "GET", + headers: { + Authorization: `Bearer ${sessionToken}`, + }, + }, + ); + + if (!response.ok) { + console.error( + "[auth/electric-token] Failed to fetch JWT:", + response.status, + ); + return; + } + + const data = (await response.json()) as { token?: string }; + if (data.token) { + setElectricToken(data.token); + } + } catch (error) { + console.error("[auth/electric-token] Error fetching JWT:", error); + } + }, []); + + const startTokenRefresh = useCallback(() => { + if (refreshTimerRef.current) { + clearInterval(refreshTimerRef.current); + } + refreshTimerRef.current = setInterval( + fetchElectricToken, + TOKEN_REFRESH_INTERVAL, + ); + }, [fetchElectricToken]); + + const stopTokenRefresh = useCallback(() => { + if (refreshTimerRef.current) { + clearInterval(refreshTimerRef.current); + refreshTimerRef.current = null; + } + }, []); + useEffect(() => { if (!isSuccess || isHydrated) return; @@ -32,11 +97,19 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (!isExpired) { setAuthToken(storedToken.token); refetchSession().catch(() => {}); + fetchElectricToken().then(startTokenRefresh); } } setIsHydrated(true); - }, [storedToken, isSuccess, isHydrated, refetchSession]); + }, [ + storedToken, + isSuccess, + isHydrated, + refetchSession, + fetchElectricToken, + startTokenRefresh, + ]); // Listen for auth events from main process (new auth or sign-out only, not hydration) electronTrpc.auth.onTokenChanged.useSubscription(undefined, { @@ -44,18 +117,28 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (data?.token && data?.expiresAt) { // New authentication - clear old session state first, then set new token setAuthToken(null); + setElectricToken(null); + stopTokenRefresh(); await authClient.signOut({ fetchOptions: { throw: false } }); setAuthToken(data.token); setIsHydrated(true); refetchSession(); + fetchElectricToken().then(startTokenRefresh); } else if (data === null) { // Sign-out setAuthToken(null); + setElectricToken(null); + stopTokenRefresh(); refetchSession(); } }, }); + // Cleanup refresh timer on unmount + useEffect(() => { + return () => stopTokenRefresh(); + }, [stopTokenRefresh]); + // 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..57988b22086 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,12 @@ 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 { getAuthToken, getElectricToken } 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`; interface OrgCollections { tasks: Collection; @@ -63,7 +63,7 @@ const organizationsCollection = createCollection( params: { table: "auth.organizations" }, headers: { Authorization: () => { - const token = getAuthToken(); + const token = getElectricToken(); return token ? `Bearer ${token}` : ""; }, }, @@ -91,7 +91,7 @@ const apiKeysCollection = createCollection( params: { table: "auth.apikeys" }, headers: { Authorization: () => { - const token = getAuthToken(); + const token = getElectricToken(); return token ? `Bearer ${token}` : ""; }, }, @@ -104,7 +104,7 @@ const apiKeysCollection = createCollection( function createOrgCollections(organizationId: string): OrgCollections { const headers = { Authorization: () => { - const token = getAuthToken(); + const token = getElectricToken(); return token ? `Bearer ${token}` : ""; }, }; diff --git a/apps/desktop/vite/helpers.ts b/apps/desktop/vite/helpers.ts index 7753d7b2dda..5d3a872a2c8 100644 --- a/apps/desktop/vite/helpers.ts +++ b/apps/desktop/vite/helpers.ts @@ -70,6 +70,11 @@ export function htmlEnvTransformPlugin(): Plugin { /%NEXT_PUBLIC_API_URL%/g, process.env.NEXT_PUBLIC_API_URL || "https://api.superset.sh", ) + .replace( + /%NEXT_PUBLIC_ELECTRIC_URL%/g, + process.env.NEXT_PUBLIC_ELECTRIC_URL || + "https://electric.superset.sh", + ) .replace( /%NEXT_PUBLIC_STREAMS_URL%/g, process.env.NEXT_PUBLIC_STREAMS_URL || "https://streams.superset.sh", diff --git a/apps/electric-worker/package.json b/apps/electric-worker/package.json new file mode 100644 index 00000000000..3958883e575 --- /dev/null +++ b/apps/electric-worker/package.json @@ -0,0 +1,20 @@ +{ + "name": "@superset/electric-worker", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "build": "wrangler deploy --dry-run --outdir=dist", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "jose": "^6.1.3" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250214.0", + "typescript": "^5.9.3", + "wrangler": "^4.14.4" + } +} diff --git a/apps/electric-worker/src/auth.ts b/apps/electric-worker/src/auth.ts new file mode 100644 index 00000000000..e752874ed28 --- /dev/null +++ b/apps/electric-worker/src/auth.ts @@ -0,0 +1,46 @@ +import { createRemoteJWKSet, jwtVerify } from "jose"; + +interface VerifiedClaims { + userId: string; + organizationIds: string[]; +} + +let jwks: ReturnType | null = null; + +function getJWKS(jwksUrl: string): ReturnType { + if (!jwks) { + jwks = createRemoteJWKSet(new URL(jwksUrl)); + } + return jwks; +} + +export async function verifyJWT({ + token, + jwksUrl, + issuer, + audience, +}: { + token: string; + jwksUrl: string; + issuer: string; + audience: string; +}): Promise { + const keySet = getJWKS(jwksUrl); + + const { payload } = await jwtVerify(token, keySet, { + issuer, + audience, + }); + + const userId = payload.sub; + if (!userId) { + throw new Error("Missing sub claim"); + } + + const organizationIds = payload.organizationIds; + if (!Array.isArray(organizationIds)) { + throw new Error("Missing organizationIds claim"); + } + + return { userId, organizationIds: organizationIds as string[] }; +} diff --git a/apps/electric-worker/src/index.ts b/apps/electric-worker/src/index.ts new file mode 100644 index 00000000000..d9e899c5781 --- /dev/null +++ b/apps/electric-worker/src/index.ts @@ -0,0 +1,154 @@ +import { verifyJWT } from "./auth"; +import { buildWhereClause } from "./where-clauses"; + +export interface Env { + JWKS_URL: string; + JWT_ISSUER: string; + JWT_AUDIENCE: string; + ELECTRIC_URL: string; + ELECTRIC_SECRET: string; +} + +const ELECTRIC_PROTOCOL_PARAMS = new Set([ + "live", + "live_sse", + "handle", + "offset", + "cursor", + "expired_handle", + "log", + "subset__where", + "subset__limit", + "subset__offset", + "subset__order_by", + "subset__params", + "subset__where_expr", + "subset__order_by_expr", + "cache-buster", +]); + +const CORS_HEADERS: Record = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Authorization, Content-Type", + "Access-Control-Expose-Headers": + "electric-handle, electric-offset, electric-cursor, electric-schema, electric-chunk-last-offset, electric-up-to-date", +}; + +function corsResponse(status: number, body: string): Response { + return new Response(body, { status, headers: CORS_HEADERS }); +} + +export default { + async fetch(request: Request, env: Env): Promise { + // Handle CORS preflight + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + + if (request.method !== "GET") { + return corsResponse(405, "Method not allowed"); + } + + const url = new URL(request.url); + + // Only handle /v1/shape + if (!url.pathname.startsWith("/v1/shape")) { + return corsResponse(404, "Not found"); + } + + // Extract and verify JWT + const authHeader = request.headers.get("Authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return corsResponse(401, "Missing or invalid Authorization header"); + } + + const token = authHeader.slice(7); + let claims: { userId: string; organizationIds: string[] }; + try { + claims = await verifyJWT({ + token, + jwksUrl: env.JWKS_URL, + issuer: env.JWT_ISSUER, + audience: env.JWT_AUDIENCE, + }); + } catch (error) { + console.error("[auth/verify] JWT verification failed:", error); + return corsResponse(401, "Invalid token"); + } + + // Validate request params + const table = url.searchParams.get("table"); + if (!table) { + return corsResponse(400, "Missing table parameter"); + } + + const organizationId = url.searchParams.get("organizationId") ?? ""; + + // For tables that require an org, verify membership + if (table !== "auth.organizations" && table !== "auth.apikeys") { + if (!organizationId) { + return corsResponse(400, "Missing organizationId parameter"); + } + if (!claims.organizationIds.includes(organizationId)) { + return corsResponse(403, "Not a member of this organization"); + } + } + + // Build WHERE clause + const whereClause = buildWhereClause({ + table, + organizationId, + userId: claims.userId, + organizationIds: claims.organizationIds, + }); + + if (!whereClause) { + return corsResponse(400, `Unknown table: ${table}`); + } + + // Build upstream Electric URL + const originUrl = new URL(env.ELECTRIC_URL); + originUrl.searchParams.set("secret", env.ELECTRIC_SECRET); + originUrl.searchParams.set("table", table); + originUrl.searchParams.set("where", whereClause.fragment); + + for (let i = 0; i < whereClause.params.length; i++) { + originUrl.searchParams.set(`params[${i + 1}]`, whereClause.params[i]); + } + + if (whereClause.columns) { + originUrl.searchParams.set("columns", whereClause.columns); + } + + // Forward Electric protocol params + for (const [key, value] of url.searchParams) { + if (ELECTRIC_PROTOCOL_PARAMS.has(key)) { + originUrl.searchParams.set(key, value); + } + } + + // Proxy to Electric + const response = await fetch(originUrl.toString()); + + const headers = new Headers(response.headers); + headers.set("Vary", "Authorization"); + + // Strip content-encoding since we're not passing through compressed body + if (headers.has("content-encoding")) { + headers.delete("content-encoding"); + headers.delete("content-length"); + } + + // Add CORS headers + for (const [key, value] of Object.entries(CORS_HEADERS)) { + headers.set(key, value); + } + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + }, +}; diff --git a/apps/electric-worker/src/where-clauses.ts b/apps/electric-worker/src/where-clauses.ts new file mode 100644 index 00000000000..82d5f4f49b0 --- /dev/null +++ b/apps/electric-worker/src/where-clauses.ts @@ -0,0 +1,67 @@ +interface WhereClause { + fragment: string; + params: string[]; + columns?: string; +} + +export function buildWhereClause({ + table, + organizationId, + userId, + organizationIds, +}: { + table: string; + organizationId: string; + userId: string; + organizationIds: string[]; +}): WhereClause | null { + switch (table) { + case "tasks": + case "task_statuses": + case "repositories": + case "auth.members": + case "auth.invitations": + case "device_presence": + case "agent_commands": + case "integration_connections": + return { + fragment: '"organization_id" = $1', + params: [organizationId], + }; + + case "subscriptions": + return { + fragment: '"reference_id" = $1', + params: [organizationId], + }; + + case "auth.apikeys": + return { + fragment: '"user_id" = $1', + params: [userId], + columns: "id,name,start,created_at,last_request", + }; + + case "auth.users": + return { + fragment: '$1 = ANY("organization_ids")', + params: [organizationId], + }; + + case "auth.organizations": { + if (organizationIds.length === 0) { + return { fragment: "1 = 0", params: [] }; + } + const placeholders = organizationIds + .map((_, i) => `$${i + 1}`) + .join(", "); + return { + fragment: `"id" IN (${placeholders})`, + params: organizationIds, + }; + } + + default: + return null; + } +} diff --git a/apps/electric-worker/tsconfig.json b/apps/electric-worker/tsconfig.json new file mode 100644 index 00000000000..3bd43fd4e02 --- /dev/null +++ b/apps/electric-worker/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/electric-worker/wrangler.jsonc b/apps/electric-worker/wrangler.jsonc new file mode 100644 index 00000000000..10f75223e31 --- /dev/null +++ b/apps/electric-worker/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "name": "electric-proxy", + "main": "src/index.ts", + "compatibility_date": "2025-01-01", + "vars": { + "JWKS_URL": "https://api.superset.sh/api/auth/jwks", + "JWT_ISSUER": "https://api.superset.sh", + "JWT_AUDIENCE": "https://api.superset.sh" + } + // ELECTRIC_URL and ELECTRIC_SECRET are set via `wrangler secret put` +} diff --git a/bun.lock b/bun.lock index 2336561270c..98976a7ca5d 100644 --- a/bun.lock +++ b/bun.lock @@ -314,6 +314,18 @@ "typescript": "^5.9.3", }, }, + "apps/electric-worker": { + "name": "@superset/electric-worker", + "version": "0.1.0", + "dependencies": { + "jose": "^6.1.3", + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250214.0", + "typescript": "^5.9.3", + "wrangler": "^4.14.4", + }, + }, "apps/marketing": { "name": "@superset/marketing", "version": "0.1.0", @@ -1021,6 +1033,22 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.15", "", { "os": "win32", "cpu": "x64" }, "sha512-kDZr/hgg+igo5Emi0LcjlgfkoGZtgIpJKhnvKTRmMBv6FF/3SDyEV4khBwqNebZIyMZTzvpca9sQNSXJ39pI2A=="], + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], + + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.12.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20260115.0" }, "optionalPeers": ["workerd"] }, "sha512-tP/Wi+40aBJovonSNJSsS7aFJY0xjuckKplmzDs2Xat06BJ68B6iG7YDUWXJL8gNn0gqW7YC5WhlYhO3QbugQA=="], + + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260212.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-kLxuYutk88Wlo7edp8mlkN68TgZZ9237SUnuX9kNaD5jcOdblUqiBctMRZeRcPsuoX/3g2t0vS4ga02NBEVRNg=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260212.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fqoqQWMA1D0ZzDOD8sp0allREM2M8GHdpxMXQ8EdZpZ70z5bJbJ9Vr4qe35++FNIZJspsDHfTw3Xm/M4ELm/dQ=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260212.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bCSQoZzDzV5MSh4ueWo1DgmOn4Hf3QBu4Yo3eQFXA2llYFIu/sZgRtkEehw1X2/SY5Sn6O0EMCqxJYRf82Wdeg=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260212.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-GPvp1iiKQodtbUDi6OmR5I0vD75lawB54tdYGtmypuHC7ZOI2WhBmhb3wCxgnQNOG1z7mhCQrzRCoqrKwYbVWQ=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260212.0", "", { "os": "win32", "cpu": "x64" }, "sha512-wHRI218Xn4ndgWJCUHH4Zx0YlU5q/o6OmcxXkcw95tJOsQn4lDrhppioPh4eScxJZALf2X+ODeZcyQTCq5exGw=="], + + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260212.0", "", {}, "sha512-ZK+e8T/2tWBCrE8PoAi9oqTxcBen9Apq+dxbsy1R5LFVdB6M4pY+oP49OFuHTTezrvNXbyvmzbf/vjtrCPGdNg=="], + "@code-inspector/core": ["@code-inspector/core@1.4.0", "", { "dependencies": { "@vue/compiler-dom": "^3.5.13", "chalk": "^4.1.1", "dotenv": "^16.1.4", "launch-ide": "1.4.0", "portfinder": "^1.0.28" } }, "sha512-LLBRdu0+Y+Ai5FXmRJtn72qTx//tfooxEeI9LFSL9G0H2T0k+cyFvg91njlkPQIDM2IA59N5pz5Jkx1zbTgC4A=="], "@code-inspector/esbuild": ["@code-inspector/esbuild@1.4.0", "", { "dependencies": { "@code-inspector/core": "1.4.0" } }, "sha512-dCdGoG8av2bXy76Bb4wfQKGF2aXQC8Es3PYOs8uh07ksgkwYtfJ4xDHM9Rki2MdDvmnm4/ey8UF0lFRCght9dg=="], @@ -1033,6 +1061,8 @@ "@code-inspector/webpack": ["@code-inspector/webpack@1.4.0", "", { "dependencies": { "@code-inspector/core": "1.4.0" } }, "sha512-ClUGCuowdLx36cTVPrz9E3TTP0WlTn3mSw4XPEQ9XWcEQpixFtImBxIRpC4xyztkYENTfEWZDy7mKxE4lthmDQ=="], + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], @@ -1523,6 +1553,12 @@ "@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="], + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], + + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], + + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + "@posthog/core": ["@posthog/core@1.9.0", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-j7KSWxJTUtNyKynLt/p0hfip/3I46dWU2dk+pt7dKRoz2l5CYueHuHK4EO7Wlgno5yo1HO4sc4s30MXMTICHJw=="], "@prisma/instrumentation": ["@prisma/instrumentation@7.2.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-Rh9Z4x5kEj1OdARd7U18AtVrnL6rmLSI0qYShaB4W7Wx5BKbgzndWF+QnuzMb7GLfVdlT5aYCXoPQVYuYtVu0g=="], @@ -1943,6 +1979,8 @@ "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], + "@speed-highlight/core": ["@speed-highlight/core@1.2.14", "", {}, "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], @@ -1963,6 +2001,8 @@ "@superset/durable-session": ["@superset/durable-session@workspace:packages/durable-session"], + "@superset/electric-worker": ["@superset/electric-worker@workspace:apps/electric-worker"], + "@superset/email": ["@superset/email@workspace:packages/email"], "@superset/local-db": ["@superset/local-db@workspace:packages/local-db"], @@ -2605,6 +2645,8 @@ "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + "blueimp-md5": ["blueimp-md5@2.19.0", "", {}, "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w=="], "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], @@ -3019,6 +3061,8 @@ "error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="], + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -3881,6 +3925,8 @@ "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + "miniflare": ["miniflare@4.20260212.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", "workerd": "1.20260212.0", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-Lgxq83EuR2q/0/DAVOSGXhXS1V7GDB04HVggoPsenQng8sqEDR3hO4FigIw5ZI2Sv2X7kIc30NCzGHJlCFIYWg=="], + "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -4835,6 +4881,8 @@ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="], "unicode-match-property-ecmascript": ["unicode-match-property-ecmascript@2.0.0", "", { "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" } }, "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q=="], @@ -4981,6 +5029,10 @@ "wonka": ["wonka@6.3.5", "", {}, "sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw=="], + "workerd": ["workerd@1.20260212.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260212.0", "@cloudflare/workerd-darwin-arm64": "1.20260212.0", "@cloudflare/workerd-linux-64": "1.20260212.0", "@cloudflare/workerd-linux-arm64": "1.20260212.0", "@cloudflare/workerd-windows-64": "1.20260212.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-4B9BoZUzKSRv3pVZGEPh7OX+Q817hpUqAUtz5O0TxJVqo4OsYJAUA/sY177Q5ha/twjT9KaJt2DtQzE+oyCOzw=="], + + "wrangler": ["wrangler@4.65.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.12.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260212.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260212.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260212.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-R+n3o3tlGzLK9I4fGocPReOuvcnjhtOL2aCVKkHMeuEwt9pPbOO4FxJtx/ec5cIUG/otRyJnfQGCAr9DplBVng=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -5015,6 +5067,10 @@ "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], + + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], @@ -5045,6 +5101,8 @@ "@code-inspector/vite/chalk": ["chalk@4.1.1", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg=="], + "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + "@develar/schema-utils/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "@develar/schema-utils/ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], @@ -5191,6 +5249,12 @@ "@opentelemetry/instrumentation-pg/@types/pg": ["@types/pg@8.15.6", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ=="], + "@poppinss/colors/kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "@poppinss/dumper/@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + + "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + "@prisma/instrumentation/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.207.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.207.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA=="], "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -5581,6 +5645,10 @@ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "miniflare/undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="], + + "miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "minipass-fetch/minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -5799,12 +5867,18 @@ "whatwg-url-without-unicode/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "wrangler/path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "xcode/uuid": ["uuid@7.0.3", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="], + "youch/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@babel/highlight/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], @@ -7165,6 +7239,58 @@ "webpack/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/packages/auth/src/server.ts b/packages/auth/src/server.ts index 3aada7f57b1..6522e21219b 100644 --- a/packages/auth/src/server.ts +++ b/packages/auth/src/server.ts @@ -49,7 +49,7 @@ const NOTIFY_SLACK_URL = `${env.NEXT_PUBLIC_API_URL}/api/integrations/stripe/job export const auth = betterAuth({ baseURL: env.NEXT_PUBLIC_API_URL, secret: env.BETTER_AUTH_SECRET, - disabledPaths: ["/token"], + disabledPaths: [], database: drizzleAdapter(db, { provider: "pg", usePlural: true, @@ -131,6 +131,21 @@ export const auth = betterAuth({ issuer: env.NEXT_PUBLIC_API_URL, audience: env.NEXT_PUBLIC_API_URL, expirationTime: "1h", + definePayload: async ({ + user, + }: { + user: { id: string; email: string }; + session: Record; + }) => { + const userMemberships = await db.query.members.findMany({ + where: eq(members.userId, user.id), + columns: { organizationId: true }, + }); + const organizationIds = [ + ...new Set(userMemberships.map((m) => m.organizationId)), + ]; + return { sub: user.id, email: user.email, organizationIds }; + }, }, }), oauthProvider({ From 508af1b92e6f034779ab5eccc5e40803ccd8e2df Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 12 Feb 2026 22:41:26 -0800 Subject: [PATCH 2/3] rename electric-worker to electric-proxy --- apps/{electric-worker => electric-proxy}/package.json | 2 +- apps/{electric-worker => electric-proxy}/src/auth.ts | 0 apps/{electric-worker => electric-proxy}/src/index.ts | 0 .../src/where-clauses.ts | 0 apps/{electric-worker => electric-proxy}/tsconfig.json | 0 apps/{electric-worker => electric-proxy}/wrangler.jsonc | 0 bun.lock | 6 +++--- 7 files changed, 4 insertions(+), 4 deletions(-) rename apps/{electric-worker => electric-proxy}/package.json (91%) rename apps/{electric-worker => electric-proxy}/src/auth.ts (100%) rename apps/{electric-worker => electric-proxy}/src/index.ts (100%) rename apps/{electric-worker => electric-proxy}/src/where-clauses.ts (100%) rename apps/{electric-worker => electric-proxy}/tsconfig.json (100%) rename apps/{electric-worker => electric-proxy}/wrangler.jsonc (100%) diff --git a/apps/electric-worker/package.json b/apps/electric-proxy/package.json similarity index 91% rename from apps/electric-worker/package.json rename to apps/electric-proxy/package.json index 3958883e575..4364d85eb16 100644 --- a/apps/electric-worker/package.json +++ b/apps/electric-proxy/package.json @@ -1,5 +1,5 @@ { - "name": "@superset/electric-worker", + "name": "@superset/electric-proxy", "version": "0.1.0", "private": true, "type": "module", diff --git a/apps/electric-worker/src/auth.ts b/apps/electric-proxy/src/auth.ts similarity index 100% rename from apps/electric-worker/src/auth.ts rename to apps/electric-proxy/src/auth.ts diff --git a/apps/electric-worker/src/index.ts b/apps/electric-proxy/src/index.ts similarity index 100% rename from apps/electric-worker/src/index.ts rename to apps/electric-proxy/src/index.ts diff --git a/apps/electric-worker/src/where-clauses.ts b/apps/electric-proxy/src/where-clauses.ts similarity index 100% rename from apps/electric-worker/src/where-clauses.ts rename to apps/electric-proxy/src/where-clauses.ts diff --git a/apps/electric-worker/tsconfig.json b/apps/electric-proxy/tsconfig.json similarity index 100% rename from apps/electric-worker/tsconfig.json rename to apps/electric-proxy/tsconfig.json diff --git a/apps/electric-worker/wrangler.jsonc b/apps/electric-proxy/wrangler.jsonc similarity index 100% rename from apps/electric-worker/wrangler.jsonc rename to apps/electric-proxy/wrangler.jsonc diff --git a/bun.lock b/bun.lock index 98976a7ca5d..caf60e653df 100644 --- a/bun.lock +++ b/bun.lock @@ -314,8 +314,8 @@ "typescript": "^5.9.3", }, }, - "apps/electric-worker": { - "name": "@superset/electric-worker", + "apps/electric-proxy": { + "name": "@superset/electric-proxy", "version": "0.1.0", "dependencies": { "jose": "^6.1.3", @@ -2001,7 +2001,7 @@ "@superset/durable-session": ["@superset/durable-session@workspace:packages/durable-session"], - "@superset/electric-worker": ["@superset/electric-worker@workspace:apps/electric-worker"], + "@superset/electric-proxy": ["@superset/electric-proxy@workspace:apps/electric-proxy"], "@superset/email": ["@superset/email@workspace:packages/email"], From 5783fb45139717e2fdfc07ada2e63010dbb8d9ba Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 12 Feb 2026 23:25:11 -0800 Subject: [PATCH 3/3] simplify Electric auth: inline JWT headers, scope API keys to org - Remove getElectricToken wrapper, call authClient.token() directly in Electric async headers - Move apiKeys from singleton to per-org collection (filter by metadata->>'organizationId' instead of userId) - Remove userId from Worker auth since it's no longer needed - Clean up AuthProvider (remove Container/token-caching leftovers) --- .../src/app/api/electric/[...path]/utils.ts | 7 +- apps/desktop/src/renderer/lib/auth-client.ts | 11 +-- .../providers/AuthProvider/AuthProvider.tsx | 99 ++----------------- .../CollectionsProvider/collections.ts | 74 +++++++------- apps/electric-proxy/src/auth.ts | 8 +- apps/electric-proxy/src/index.ts | 20 +--- apps/electric-proxy/src/where-clauses.ts | 6 +- apps/electric-proxy/wrangler.jsonc | 2 +- 8 files changed, 57 insertions(+), 170 deletions(-) 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/src/renderer/lib/auth-client.ts b/apps/desktop/src/renderer/lib/auth-client.ts index f515f786fe1..f377197b887 100644 --- a/apps/desktop/src/renderer/lib/auth-client.ts +++ b/apps/desktop/src/renderer/lib/auth-client.ts @@ -3,13 +3,13 @@ import type { auth } from "@superset/auth/server"; import { apiKeyClient, customSessionClient, + jwtClient, organizationClient, } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; import { env } from "renderer/env.renderer"; let authToken: string | null = null; -let electricToken: string | null = null; export function setAuthToken(token: string | null) { authToken = token; @@ -19,14 +19,6 @@ export function getAuthToken(): string | null { return authToken; } -export function setElectricToken(token: string | null) { - electricToken = token; -} - -export function getElectricToken(): string | null { - return electricToken; -} - /** * Better Auth client for Electron desktop app. * @@ -40,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 2f0fac13b09..e4648f42d34 100644 --- a/apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx +++ b/apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx @@ -1,22 +1,8 @@ import { Spinner } from "@superset/ui/spinner"; -import { - type ReactNode, - useCallback, - useEffect, - useRef, - useState, -} from "react"; -import { env } from "renderer/env.renderer"; -import { - authClient, - getAuthToken, - setAuthToken, - setElectricToken, -} from "renderer/lib/auth-client"; +import { type ReactNode, useEffect, useState } from "react"; +import { authClient, setAuthToken } from "renderer/lib/auth-client"; import { electronTrpc } from "../../lib/electron-trpc"; -const TOKEN_REFRESH_INTERVAL = 55 * 60 * 1000; // 55 minutes (JWT expires in 1h) - /** * AuthProvider: Manages token synchronization between memory and encrypted disk storage. * @@ -24,71 +10,20 @@ const TOKEN_REFRESH_INTERVAL = 55 * 60 * 1000; // 55 minutes (JWT expires in 1h) * 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 - * 4. Fetch and periodically refresh an Electric JWT for edge-authenticated sync + * + * 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); - const refreshTimerRef = useRef | null>(null); - - // 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, refetchOnReconnect: false, }); - const fetchElectricToken = useCallback(async () => { - const sessionToken = getAuthToken(); - if (!sessionToken) return; - - try { - const response = await fetch( - `${env.NEXT_PUBLIC_API_URL}/api/auth/token`, - { - method: "GET", - headers: { - Authorization: `Bearer ${sessionToken}`, - }, - }, - ); - - if (!response.ok) { - console.error( - "[auth/electric-token] Failed to fetch JWT:", - response.status, - ); - return; - } - - const data = (await response.json()) as { token?: string }; - if (data.token) { - setElectricToken(data.token); - } - } catch (error) { - console.error("[auth/electric-token] Error fetching JWT:", error); - } - }, []); - - const startTokenRefresh = useCallback(() => { - if (refreshTimerRef.current) { - clearInterval(refreshTimerRef.current); - } - refreshTimerRef.current = setInterval( - fetchElectricToken, - TOKEN_REFRESH_INTERVAL, - ); - }, [fetchElectricToken]); - - const stopTokenRefresh = useCallback(() => { - if (refreshTimerRef.current) { - clearInterval(refreshTimerRef.current); - refreshTimerRef.current = null; - } - }, []); - useEffect(() => { if (!isSuccess || isHydrated) return; @@ -97,49 +32,27 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (!isExpired) { setAuthToken(storedToken.token); refetchSession().catch(() => {}); - fetchElectricToken().then(startTokenRefresh); } } setIsHydrated(true); - }, [ - storedToken, - isSuccess, - isHydrated, - refetchSession, - fetchElectricToken, - startTokenRefresh, - ]); + }, [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); - setElectricToken(null); - stopTokenRefresh(); await authClient.signOut({ fetchOptions: { throw: false } }); setAuthToken(data.token); setIsHydrated(true); refetchSession(); - fetchElectricToken().then(startTokenRefresh); } else if (data === null) { - // Sign-out setAuthToken(null); - setElectricToken(null); - stopTokenRefresh(); refetchSession(); } }, }); - // Cleanup refresh timer on unmount - useEffect(() => { - return () => stopTokenRefresh(); - }, [stopTokenRefresh]); - - // 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 57988b22086..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,13 +18,23 @@ 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, getElectricToken } 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_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; taskStatuses: 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 = getElectricToken(); - 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 = getElectricToken(); - 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 = getElectricToken(); - 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).preload(), @@ -363,6 +362,5 @@ export function getCollections(organizationId: string) { return { ...orgCollections, organizations: organizationsCollection, - apiKeys: apiKeysCollection, }; } diff --git a/apps/electric-proxy/src/auth.ts b/apps/electric-proxy/src/auth.ts index e752874ed28..5267146ad76 100644 --- a/apps/electric-proxy/src/auth.ts +++ b/apps/electric-proxy/src/auth.ts @@ -1,7 +1,6 @@ import { createRemoteJWKSet, jwtVerify } from "jose"; interface VerifiedClaims { - userId: string; organizationIds: string[]; } @@ -32,15 +31,10 @@ export async function verifyJWT({ audience, }); - const userId = payload.sub; - if (!userId) { - throw new Error("Missing sub claim"); - } - const organizationIds = payload.organizationIds; if (!Array.isArray(organizationIds)) { throw new Error("Missing organizationIds claim"); } - return { userId, organizationIds: organizationIds as string[] }; + return { organizationIds: organizationIds as string[] }; } diff --git a/apps/electric-proxy/src/index.ts b/apps/electric-proxy/src/index.ts index d9e899c5781..130969e6276 100644 --- a/apps/electric-proxy/src/index.ts +++ b/apps/electric-proxy/src/index.ts @@ -41,7 +41,6 @@ function corsResponse(status: number, body: string): Response { export default { async fetch(request: Request, env: Env): Promise { - // Handle CORS preflight if (request.method === "OPTIONS") { return new Response(null, { status: 204, headers: CORS_HEADERS }); } @@ -52,19 +51,17 @@ export default { const url = new URL(request.url); - // Only handle /v1/shape if (!url.pathname.startsWith("/v1/shape")) { return corsResponse(404, "Not found"); } - // Extract and verify JWT const authHeader = request.headers.get("Authorization"); if (!authHeader?.startsWith("Bearer ")) { return corsResponse(401, "Missing or invalid Authorization header"); } const token = authHeader.slice(7); - let claims: { userId: string; organizationIds: string[] }; + let claims: { organizationIds: string[] }; try { claims = await verifyJWT({ token, @@ -77,7 +74,6 @@ export default { return corsResponse(401, "Invalid token"); } - // Validate request params const table = url.searchParams.get("table"); if (!table) { return corsResponse(400, "Missing table parameter"); @@ -85,8 +81,7 @@ export default { const organizationId = url.searchParams.get("organizationId") ?? ""; - // For tables that require an org, verify membership - if (table !== "auth.organizations" && table !== "auth.apikeys") { + if (table !== "auth.organizations") { if (!organizationId) { return corsResponse(400, "Missing organizationId parameter"); } @@ -95,11 +90,9 @@ export default { } } - // Build WHERE clause const whereClause = buildWhereClause({ table, organizationId, - userId: claims.userId, organizationIds: claims.organizationIds, }); @@ -107,7 +100,6 @@ export default { return corsResponse(400, `Unknown table: ${table}`); } - // Build upstream Electric URL const originUrl = new URL(env.ELECTRIC_URL); originUrl.searchParams.set("secret", env.ELECTRIC_SECRET); originUrl.searchParams.set("table", table); @@ -121,26 +113,24 @@ export default { originUrl.searchParams.set("columns", whereClause.columns); } - // Forward Electric protocol params for (const [key, value] of url.searchParams) { if (ELECTRIC_PROTOCOL_PARAMS.has(key)) { originUrl.searchParams.set(key, value); } } - // Proxy to Electric - const response = await fetch(originUrl.toString()); + const response = await fetch(originUrl.toString(), { + cf: { cacheEverything: true }, + }); const headers = new Headers(response.headers); headers.set("Vary", "Authorization"); - // Strip content-encoding since we're not passing through compressed body if (headers.has("content-encoding")) { headers.delete("content-encoding"); headers.delete("content-length"); } - // Add CORS headers for (const [key, value] of Object.entries(CORS_HEADERS)) { headers.set(key, value); } diff --git a/apps/electric-proxy/src/where-clauses.ts b/apps/electric-proxy/src/where-clauses.ts index 82d5f4f49b0..7c8475dfa29 100644 --- a/apps/electric-proxy/src/where-clauses.ts +++ b/apps/electric-proxy/src/where-clauses.ts @@ -7,12 +7,10 @@ interface WhereClause { export function buildWhereClause({ table, organizationId, - userId, organizationIds, }: { table: string; organizationId: string; - userId: string; organizationIds: string[]; }): WhereClause | null { switch (table) { @@ -37,8 +35,8 @@ export function buildWhereClause({ case "auth.apikeys": return { - fragment: '"user_id" = $1', - params: [userId], + fragment: `"metadata"::jsonb->>'organizationId' = $1`, + params: [organizationId], columns: "id,name,start,created_at,last_request", }; diff --git a/apps/electric-proxy/wrangler.jsonc b/apps/electric-proxy/wrangler.jsonc index 10f75223e31..6f6d2c268fc 100644 --- a/apps/electric-proxy/wrangler.jsonc +++ b/apps/electric-proxy/wrangler.jsonc @@ -1,5 +1,6 @@ { "name": "electric-proxy", + "account_id": "6ac34a81594ea52ff7ed2f24b9108e41", "main": "src/index.ts", "compatibility_date": "2025-01-01", "vars": { @@ -7,5 +8,4 @@ "JWT_ISSUER": "https://api.superset.sh", "JWT_AUDIENCE": "https://api.superset.sh" } - // ELECTRIC_URL and ELECTRIC_SECRET are set via `wrangler secret put` }