Skip to content

feat: move Electric proxy to Cloudflare Worker with JWT auth#1457

Merged
saddlepaddle merged 3 commits into
mainfrom
satya/electric-worker
Feb 13, 2026
Merged

feat: move Electric proxy to Cloudflare Worker with JWT auth#1457
saddlepaddle merged 3 commits into
mainfrom
satya/electric-worker

Conversation

@saddlepaddle
Copy link
Copy Markdown
Collaborator

@saddlepaddle saddlepaddle commented Feb 13, 2026

Summary

  • New Cloudflare Worker (apps/electric-worker/) — proxies Electric shape requests at the edge with JWT verification via JWKS (zero DB calls per request)
  • JWT auth flow — re-enables better-auth /token endpoint with organizationIds claim; desktop client fetches + caches JWT, refreshes every 55 min
  • Desktop client wired up — collections use NEXT_PUBLIC_ELECTRIC_URL + JWT token instead of session token through API route
  • Backward compatible — existing API route (apps/api/src/app/api/electric/) untouched for older clients

Architecture

Desktop Client
    │  Authorization: Bearer <JWT>
    │  ?table=tasks&organizationId=org-123
    ▼
Cloudflare Worker (apps/electric-worker)
    │ 1. Verify JWT via JWKS (cached at edge)
    │ 2. Check organizationId ∈ jwt.organizationIds
    │ 3. Build WHERE clause (hardcoded patterns)
    │ 4. Proxy to Electric with ELECTRIC_SECRET
    ▼
Electric Server

Files changed

Area Files
Worker apps/electric-worker/ (new) — auth.ts, index.ts, where-clauses.ts, wrangler.jsonc
Auth packages/auth/src/server.ts — re-enable /token, add definePayload with org claims
Desktop auth-client.ts, AuthProvider.tsx, collections.ts, env.renderer.ts, electron.vite.config.ts, index.html, helpers.ts
Infra .gitignore — add .wrangler

Follow-up

  • SUPER-286 — org-scoped API keys (blocked on better-auth referenceId support)

Test plan

  • POST /api/auth/token returns JWT with organizationIds claim
  • Worker accepts valid JWT + correct org → returns shape data
  • Worker rejects wrong org → 403
  • Worker rejects no auth → 401
  • Lint, typecheck, tests all pass
  • End-to-end: desktop app connects through worker, shapes sync
  • Token refresh works across 1h boundary

Summary by CodeRabbit

  • New Features

    • Per-organization API key display and tighter per-org data filtering
    • Electric service URL configurable at build time; CSP updated to allow Electric connections
  • Authentication

    • JWT-based verification enhanced to include organization IDs in tokens
    • Immediate renderer startup (removed initial loading gating)
  • Infrastructure

    • New proxy service to securely validate JWTs and forward Electric shape requests
  • Chores

    • Updated ignore rules to exclude Wrangler artifacts

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
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 13, 2026

📝 Walkthrough

Walkthrough

Adds a Cloudflare Worker (electric-proxy) that validates JWTs via JWKS and proxies organization-scoped Electric shape requests; includes new JWT payload with organizationIds on the auth server, desktop app env/CSP and auth-client updates, and per-organization apiKeys support in the desktop collections provider.

Changes

Cohort / File(s) Summary
Git & Build config
/.gitignore , apps/electric-proxy/package.json, apps/electric-proxy/tsconfig.json, apps/electric-proxy/wrangler.jsonc
Ignored Wrangler artifacts; added electric-proxy package manifest and TypeScript/Wrangler configs for a Cloudflare Worker.
Desktop build & env
apps/desktop/electron.vite.config.ts, apps/desktop/src/renderer/env.renderer.ts, apps/desktop/vite/helpers.ts
Introduce NEXT_PUBLIC_ELECTRIC_URL into Vite defines, Zod env schema, and HTML env replacement.
Desktop CSP / HTML
apps/desktop/src/renderer/index.html
Updated Content-Security-Policy connect-src to include NEXT_PUBLIC_ELECTRIC_URL for Electric connections.
Desktop auth plumbing
apps/desktop/src/renderer/lib/auth-client.ts, apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx
Integrated jwtClient plugin into authClient and removed hydration gating spinner so children render immediately (token lifecycle largely preserved).
Collections & apiKeys
apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts
Added ApiKeyDisplay schema and per-organization apiKeys collection; switched collection requests to async Authorization via authClient.token() and changed Electric shape base URL to NEXT_PUBLIC_ELECTRIC_URL.
Auth server (JWT payload)
packages/auth/src/server.ts
Re-enabled /token path in disabledPaths and added definePayload to JWT config to include sub, email, and organizationIds derived from user memberships.
Electric proxy code
apps/electric-proxy/src/index.ts, apps/electric-proxy/src/auth.ts, apps/electric-proxy/src/where-clauses.ts
New Cloudflare Worker: verifies JWTs using JWKS, enforces organization-scoped WHERE clauses, validates query params, and proxies shape requests to Electric (adds Env interface and fetch handler).
Renderer API util change
apps/api/src/app/api/electric/[...path]/utils.ts
Changed auth.apikeys case to filter using apikey metadata JSONB ->>'organizationId' (parameterized by organizationId) instead of querying by userId.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Desktop Client
    participant Proxy as Electric Proxy (Worker)
    participant JWKS as JWKS Provider
    participant Electric as Electric DB

    Client->>Proxy: GET /v1/shape?table=T&organizationId=O\nAuthorization: Bearer <jwt>
    Proxy->>JWKS: GET JWKS (jwks_url)
    JWKS-->>Proxy: JWKS keys
    Proxy->>Proxy: jwtVerify(token, keys)\nextract organizationIds
    Proxy->>Proxy: buildWhereClause(table=T, organizationId=O, organizationIds)
    Proxy->>Electric: GET /v1/shape?table=T&where=...&secret=ELECTRIC_SECRET
    Electric-->>Proxy: 200 + body
    Proxy-->>Client: Response (CORS + Vary: Authorization)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped through keys and worker fog,

Verified each bouncing log,
Clauses neat, the rows take flight,
Electric hums through JWT night,
Hooray — a proxy, swift and bright! ✨

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately and concisely summarizes the primary change: moving Electric proxy to Cloudflare Worker with JWT authentication, which aligns with the main objectives of the PR.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering summary, architecture, files changed, test plan, and follow-up items, though it uses a custom format rather than the template.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch satya/electric-worker

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 13, 2026

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

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

Thank you for your contribution! 🎉

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

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

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

348-351: ⚠️ Potential issue | 🟡 Minor

Stale doc comment — references getAuthToken() but Electric shapes now use getElectricToken().

Line 350 says "Auth token is read dynamically via getAuthToken()" which is no longer accurate for Electric shape requests.

📝 Suggested fix
- * Auth token is read dynamically via getAuthToken() - no need to pass it.
+ * Auth tokens are read dynamically via getElectricToken() (for Electric shapes)
+ * and getAuthToken() (for tRPC API calls) - no need to pass them.
🤖 Fix all issues with AI agents
In
`@apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts`:
- Around line 64-68: Update organizationsCollection, apiKeysCollection, and the
collections created in createOrgCollections to include an onError handler that
detects a 401 Unauthorized error, fetches or refreshes the JWT (via
getElectricToken or the appropriate refresh helper), and returns an object like
{ headers: { Authorization: `Bearer ${newToken}` } } so the client will retry
the request with the new token; attach this onError alongside the existing
headers function that currently returns "" when getElectricToken() is null so
that 401s are recovered and the sync stream can resume.

In `@apps/electric-worker/src/auth.ts`:
- Around line 40-45: The code currently only checks
Array.isArray(payload.organizationIds) but doesn't ensure each element is a
string; update the validation in the auth.ts logic that reads
payload.organizationIds to verify every element is typeof "string" (e.g., using
Array.prototype.every) and throw a clear Error like "organizationIds must be an
array of strings" if any element is not a string; return organizationIds as
string[] only after this check so downstream includes() usage in index.ts is
safe from tampered/malformed JWT data.

In `@apps/electric-worker/src/index.ts`:
- Around line 55-58: The current check using
url.pathname.startsWith("/v1/shape") is too permissive and will match unintended
routes; update the condition in index.ts to require an exact match (use
url.pathname === "/v1/shape") so only the exact /v1/shape path is handled and
all other paths return corsResponse(404, "Not found"); locate the check that
references url.pathname.startsWith and replace it with an equality comparison
(or explicit validation if you need to allow a single optional trailing slash)
inside the same request handling block.
- Around line 131-152: The upstream fetch to Electric (the line calling
fetch(originUrl.toString())) should be wrapped in a try/catch so network errors
are logged with context before being propagated or handled; update the block
around fetch(originUrl.toString()) to catch any thrown error, log the error
along with the originUrl (using the existing logger, e.g., processLogger or the
module's logger), and then either rethrow or return a controlled error Response
(e.g., 502) while still applying the same CORS_HEADERS and header cleanup logic
(headers set/Delete and Vary header) so callers receive consistent headers;
ensure you reference originUrl, the fetch call, CORS_HEADERS, and the Response
creation (new Response(...)) when making the change.

In `@packages/auth/src/server.ts`:
- Around line 134-139: The definePayload callback signature is incorrect for
better-auth v1.4.18 — change the parameter from a destructured top-level shape
to a single object parameter that contains both user and session (i.e., accept
({ user, session }) instead of ({ user }: { user... })), and update or remove
the explicit type annotation so it matches the plugin API (use types like User &
Record<string, any> and Session & Record<string, any> for user and session
respectively, or drop the annotation to rely on inference); modify the
definePayload function declaration accordingly so the runtime signature and
types align with the JWT plugin.
🧹 Nitpick comments (6)
apps/electric-worker/wrangler.jsonc (1)

4-10: Consider adding environment overrides for staging/dev.

The vars block hardcodes production URLs, meaning wrangler dev will validate JWTs against the production JWKS endpoint. If you have (or plan to have) a staging environment, consider adding [env.staging] or [env.dev] overrides so local development and preview deployments can use non-production auth endpoints.

apps/electric-worker/src/where-clauses.ts (1)

1-5: Consider exporting WhereClause for downstream type safety.

The WhereClause interface is used as the return type of the exported buildWhereClause function but isn't itself exported. If index.ts or tests ever need to explicitly type a variable holding the result, they'd have to rely on type inference or duplicate the type.

Proposed fix
-interface WhereClause {
+export interface WhereClause {
apps/electric-worker/src/index.ts (1)

30-36: Add Access-Control-Max-Age to reduce preflight overhead.

Without Access-Control-Max-Age, browsers will send a CORS preflight on every request. Since the desktop client makes frequent shape requests, caching the preflight result avoids unnecessary round-trips.

Proposed fix
 const CORS_HEADERS: Record<string, string> = {
 	"Access-Control-Allow-Origin": "*",
 	"Access-Control-Allow-Methods": "GET, OPTIONS",
 	"Access-Control-Allow-Headers": "Authorization, Content-Type",
+	"Access-Control-Max-Age": "86400",
 	"Access-Control-Expose-Headers":
 		"electric-handle, electric-offset, electric-cursor, electric-schema, electric-chunk-last-offset, electric-up-to-date",
 };
apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx (2)

66-68: Validate the /api/auth/token response shape instead of a bare cast.

The response from an external endpoint is cast with as { token?: string } without validation. If the endpoint ever returns an unexpected shape, data.token would silently be undefined and the electric token would never be set.

♻️ Suggested fix using Zod
+const electricTokenResponseSchema = z.object({ token: z.string() });
+
 const fetchElectricToken = useCallback(async () => {
   ...
-  const data = (await response.json()) as { token?: string };
-  if (data.token) {
-    setElectricToken(data.token);
-  }
+  const parsed = electricTokenResponseSchema.safeParse(await response.json());
+  if (parsed.success) {
+    setElectricToken(parsed.data.token);
+  } else {
+    console.error("[auth/electric-token] Unexpected response shape");
+  }

As per coding guidelines: "Validate external API data as untrusted by handling missing fields, unknown enums, and unexpected shapes with tolerant parsing and explicit fallbacks" and "Use Zod schemas for validating tRPC inputs and API route bodies at boundaries".


43-73: Electric token fetch failure during hydration is silent — user may run without sync indefinitely.

If fetchElectricToken fails during hydration (line 100), the interval still starts (good), but there's no feedback mechanism if repeated refreshes also fail. Consider adding a retry-with-backoff or at minimum a warning log after consecutive failures so the issue is observable.

Also applies to: 92-112

packages/auth/src/server.ts (1)

134-148: Duplicate membership-query logic with customSession (line 448).

Both definePayload and the customSession callback independently query members by userId and deduplicate organizationIds via Set. Consider extracting a shared helper (e.g., getOrganizationIdsForUser(userId)) to keep this in one place.

Also a couple of secondary points:

  1. PII in JWTemail is embedded in the (base64-encoded, not encrypted) token. This is common for OIDC-style JWTs but ensure tokens aren't logged or stored in places where email exposure creates a compliance risk.
  2. No error handling — if the DB query on line 140 throws, the token endpoint returns an opaque 500. A try/catch with a console.error("[auth/definePayload] …") would improve debuggability.
♻️ Example shared helper extraction
+// Near top of file or in a utils module
+async function getOrganizationIdsForUser(userId: string): Promise<string[]> {
+  const userMemberships = await db.query.members.findMany({
+    where: eq(members.userId, userId),
+    columns: { organizationId: true },
+  });
+  return [...new Set(userMemberships.map((m) => m.organizationId))];
+}

Then use it in definePayload:

 definePayload: async ({
   user,
 }: {
   user: { id: string; email: string };
   session: Record<string, unknown>;
 }) => {
-  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)),
-  ];
+  const organizationIds = await getOrganizationIdsForUser(user.id);
   return { sub: user.id, email: user.email, organizationIds };
 },

Comment on lines 64 to 68
headers: {
Authorization: () => {
const token = getAuthToken();
const token = getElectricToken();
return token ? `Bearer ${token}` : "";
},
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:

# First, let's look at the collections.ts file to understand the context
cat -n apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts | head -120

Repository: superset-sh/superset

Length of output: 4170


🏁 Script executed:

# Search for Electric client initialization and configuration
rg -n "electric\|Electric" apps/desktop/src/renderer --type=ts -C3 | head -100

Repository: superset-sh/superset

Length of output: 46


🏁 Script executed:

# Search for AuthProvider implementation and token loading
fd -H "AuthProvider" apps/desktop --type=ts --type=tsx

Repository: superset-sh/superset

Length of output: 233


🏁 Script executed:

# Look for retry, backoff, or error handling patterns in Electric shape usage
rg -n "shapes\|sync\|retry\|error.*electric|Electric.*error" apps/desktop/src --type=ts -C2

Repository: superset-sh/superset

Length of output: 477


🏁 Script executed:

# Check the package.json or lock file for Electric version to understand what features are available
rg -n "electric-sql|@electric-sql" apps/desktop/package.json

Repository: superset-sh/superset

Length of output: 101


🏁 Script executed:

# Find and read the auth-client implementation
find apps/desktop -name "auth-client*" -type f

Repository: superset-sh/superset

Length of output: 108


🏁 Script executed:

# Search for AuthProvider implementation
find apps/desktop -name "*AuthProvider*" -type f

Repository: superset-sh/superset

Length of output: 129


🏁 Script executed:

# Search for getElectricToken and getAuthToken implementations
rg -n "getElectricToken|getAuthToken|fetchElectricToken" apps/desktop/src/renderer --type=ts --type=tsx -B2 -A10

Repository: superset-sh/superset

Length of output: 91


🏁 Script executed:

# Look for Electric client error handling or onError handlers
rg -n "onError|error.*handler|retry|backoff" apps/desktop/src/renderer -A3 | head -80

Repository: superset-sh/superset

Length of output: 9387


🏁 Script executed:

# Check for Electric Shape or collection error handling patterns
rg -n "shapes?\.|sync|error" apps/desktop/src/renderer/lib --type=ts --type=tsx -C2

Repository: superset-sh/superset

Length of output: 91


🏁 Script executed:

# Read auth-client implementation
cat -n apps/desktop/src/renderer/lib/auth-client.ts

Repository: superset-sh/superset

Length of output: 1734


🏁 Script executed:

# Read AuthProvider implementation
cat -n apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx | head -150

Repository: superset-sh/superset

Length of output: 5105


🏁 Script executed:

# Search for where electricCollectionOptions comes from and how it handles errors
rg -n "electricCollectionOptions" apps/desktop/src --type=js -A5 -B2

Repository: superset-sh/superset

Length of output: 46


🏁 Script executed:

# Look for any Electric-related configuration or error handling
rg -n "@tanstack/electric-db-collection\|Electric\|collection.*error" apps/desktop/src -A3 -B2

Repository: superset-sh/superset

Length of output: 46


🏁 Script executed:

# Check if there's any retry logic in the shapes or collection setup
rg -n "shape.*retry\|shape.*error\|collection.*retry\|collection.*error" apps/desktop/src -A3

Repository: superset-sh/superset

Length of output: 46


🌐 Web query:

Electric SQL v1.5.2 client 401 unauthorized retry behavior backoff

💡 Result:

In ElectricSQL TypeScript client (@electric-sql/client), a 401 Unauthorized is treated as a non-retryable 4xx error:

  • No automatic retry/backoff is applied for 401s. Automatic retries with exponential backoff are only for 5xx, network errors, and 429 rate limits. [1]
  • On a 401, the stream will stop/throw unless you provide a stream-level onError handler. In onError, you typically refresh your token and return { headers: ... } (returning any object, even {}, tells the client to retry; returning undefined stops permanently). [1]
  • If you do retry after a 401 by returning new headers from onError, recent client versions include a fix so the retry preserves the current offset (rather than resetting and re-emitting duplicates). [2]

Sources:
[1] @electric-sql/client README (UNPKG) — error handling and retry/backoff behavior, including 401 via onError. (app.unpkg.com)
[2] Electric repo release note describing the offset-preservation fix when retrying after onError (e.g., 401). (newreleases.io)

Citations:


🌐 Web query:

@electric-sql/client shape sync error handling retry

💡 Result:

In @electric-sql/client, shape sync errors are handled at the ShapeStream level, with built-in automatic retries and an optional onError hook to control whether syncing continues.

  • Automatic retry (built-in): the client automatically retries network errors, 5xx, and 429 responses using exponential backoff (with jitter). Your onError handler is called only after these retries are exhausted, or immediately for non-retryable errors (typically 4xx). [1]
  • onError controls “retry vs stop”:
    • Return an object to keep syncing / retry (can also update request inputs):
      • {} retry with same params/headers
      • { headers: ... } retry with new headers (e.g., refreshed auth token)
      • { params: ... } retry with new shape params
      • { params, headers } retry with both
    • Return undefined / no return to stop the stream permanently. [1]
  • Per-subscriber error callback: stream.subscribe(onMessages, onError) can observe errors, but cannot control retry behavior (use stream-level onError for that). [1]
  • Server-directed backoff (Retry-After) note: the Electric sync service has had fixes related to ensuring clients can read Retry-After during overload (503) via CORS exposure; without it, browsers could force clients to fall back to purely exponential backoff. [2]

Sources:
[1] @electric-sql/client README (ShapeStream “Error Handling”) via unpkg. (app.unpkg.com)
[2] Electric sync-service release notes mentioning Retry-After CORS exposure fix. (github.com)

Citations:


🏁 Script executed:

# Check if there are any useEffect guards or error handling around Electric collection usage in the collections provider
rg -n "organizationsCollection\|apiKeysCollection\|useEffect" apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider -A5 -B2

Repository: superset-sh/superset

Length of output: 46


🏁 Script executed:

# Check for any error boundaries or recovery mechanisms for Electric shapes
rg -n "catch\|error\|try\|fallback\|retry" apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider -A3

Repository: superset-sh/superset

Length of output: 46


🏁 Script executed:

# Look at how collections are used - check if they're accessed conditionally based on token availability
rg -n "getElectricToken\|electricToken" apps/desktop/src/renderer/routes/_authenticated -A3 -B3

Repository: superset-sh/superset

Length of output: 46


🏁 Script executed:

# Final check: does `@tanstack/electric-db-collection` or electricCollectionOptions add any default error handling?
rg -n "@tanstack/electric-db-collection\|electricCollectionOptions" apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts -B5 -A15 | head -50

Repository: superset-sh/superset

Length of output: 46


🏁 Script executed:

# Check if there's a parent component that guards Electric collection access
cat -n apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/*.tsx | head -100

Repository: superset-sh/superset

Length of output: 3079


Add onError handler to recover from 401 Unauthorized when JWT is not yet available.

The authorization header function returns "" when getElectricToken() is null, causing a 401 response. The Electric client (@electric-sql/client v1.5.2) does not automatically retry 401 errors—only 5xx, network errors, and 429 responses trigger automatic backoff. Without an onError handler, the sync stream stops permanently and does not recover once the token is fetched.

Add an onError handler to each shape that detects 401 errors, refreshes the token, and returns { headers: { Authorization: ... } } to trigger a retry with the updated token. This applies to all three instances: organizationsCollection, apiKeysCollection, and the collections in createOrgCollections.

🤖 Prompt for AI Agents
In
`@apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts`
around lines 64 - 68, Update organizationsCollection, apiKeysCollection, and the
collections created in createOrgCollections to include an onError handler that
detects a 401 Unauthorized error, fetches or refreshes the JWT (via
getElectricToken or the appropriate refresh helper), and returns an object like
{ headers: { Authorization: `Bearer ${newToken}` } } so the client will retry
the request with the new token; attach this onError alongside the existing
headers function that currently returns "" when getElectricToken() is null so
that 401s are recovered and the sync stream can resume.

Comment thread apps/electric-proxy/src/auth.ts Outdated
Comment on lines +40 to +45
const organizationIds = payload.organizationIds;
if (!Array.isArray(organizationIds)) {
throw new Error("Missing organizationIds claim");
}

return { userId, organizationIds: organizationIds as string[] };
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

Validate that organizationIds elements are strings.

Array.isArray confirms the value is an array but doesn't validate element types. A tampered or misconfigured JWT could carry non-string elements, making the downstream includes() check in index.ts silently fail or behave unexpectedly. As per coding guidelines, external API data should be treated as untrusted.

🛡️ Proposed fix
 	const organizationIds = payload.organizationIds;
-	if (!Array.isArray(organizationIds)) {
+	if (
+		!Array.isArray(organizationIds) ||
+		!organizationIds.every((id): id is string => typeof id === "string")
+	) {
 		throw new Error("Missing organizationIds claim");
 	}
 
-	return { userId, organizationIds: organizationIds as string[] };
+	return { userId, organizationIds };
📝 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 organizationIds = payload.organizationIds;
if (!Array.isArray(organizationIds)) {
throw new Error("Missing organizationIds claim");
}
return { userId, organizationIds: organizationIds as string[] };
const organizationIds = payload.organizationIds;
if (
!Array.isArray(organizationIds) ||
!organizationIds.every((id): id is string => typeof id === "string")
) {
throw new Error("Missing organizationIds claim");
}
return { userId, organizationIds };
🤖 Prompt for AI Agents
In `@apps/electric-worker/src/auth.ts` around lines 40 - 45, The code currently
only checks Array.isArray(payload.organizationIds) but doesn't ensure each
element is a string; update the validation in the auth.ts logic that reads
payload.organizationIds to verify every element is typeof "string" (e.g., using
Array.prototype.every) and throw a clear Error like "organizationIds must be an
array of strings" if any element is not a string; return organizationIds as
string[] only after this check so downstream includes() usage in index.ts is
safe from tampered/malformed JWT data.

Comment thread apps/electric-proxy/src/index.ts Outdated
Comment on lines +55 to +58
// Only handle /v1/shape
if (!url.pathname.startsWith("/v1/shape")) {
return corsResponse(404, "Not found");
}
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

Path matching with startsWith is overly broad.

url.pathname.startsWith("/v1/shape") will also match paths like /v1/shapeXYZ or /v1/shape/anything. Electric's API endpoint is exactly /v1/shape.

Proposed fix
-		if (!url.pathname.startsWith("/v1/shape")) {
+		if (url.pathname !== "/v1/shape") {
 			return corsResponse(404, "Not found");
 		}
📝 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
// Only handle /v1/shape
if (!url.pathname.startsWith("/v1/shape")) {
return corsResponse(404, "Not found");
}
// Only handle /v1/shape
if (url.pathname !== "/v1/shape") {
return corsResponse(404, "Not found");
}
🤖 Prompt for AI Agents
In `@apps/electric-worker/src/index.ts` around lines 55 - 58, The current check
using url.pathname.startsWith("/v1/shape") is too permissive and will match
unintended routes; update the condition in index.ts to require an exact match
(use url.pathname === "/v1/shape") so only the exact /v1/shape path is handled
and all other paths return corsResponse(404, "Not found"); locate the check that
references url.pathname.startsWith and replace it with an equality comparison
(or explicit validation if you need to allow a single optional trailing slash)
inside the same request handling block.

Comment thread apps/electric-proxy/src/index.ts Outdated
Comment on lines +131 to +152
// 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,
});
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

Wrap upstream fetch in try/catch for observability on Electric failures.

If the upstream Electric service is unreachable or returns a network error, the fetch will throw an unhandled exception, producing a generic 500 with no logged context. As per coding guidelines, errors should be logged with context before rethrowing or handling.

Proposed fix
 		// Proxy to Electric
-		const response = await fetch(originUrl.toString());
+		let response: Response;
+		try {
+			response = await fetch(originUrl.toString());
+		} catch (error) {
+			console.error("[electric/proxy] Upstream request failed:", error);
+			return corsResponse(502, "Upstream service unavailable");
+		}
🤖 Prompt for AI Agents
In `@apps/electric-worker/src/index.ts` around lines 131 - 152, The upstream fetch
to Electric (the line calling fetch(originUrl.toString())) should be wrapped in
a try/catch so network errors are logged with context before being propagated or
handled; update the block around fetch(originUrl.toString()) to catch any thrown
error, log the error along with the originUrl (using the existing logger, e.g.,
processLogger or the module's logger), and then either rethrow or return a
controlled error Response (e.g., 502) while still applying the same CORS_HEADERS
and header cleanup logic (headers set/Delete and Vary header) so callers receive
consistent headers; ensure you reference originUrl, the fetch call,
CORS_HEADERS, and the Response creation (new Response(...)) when making the
change.

Comment on lines +134 to +139
definePayload: async ({
user,
}: {
user: { id: string; email: string };
session: Record<string, unknown>;
}) => {
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

🌐 Web query:

better-auth 1.4.18 JWT plugin definePayload callback signature

💡 Result:

In better-auth v1.4.18, the JWT plugin option is typed as:

import type { Awaitable } from "@better-auth/core";
import type { JWTPayload } from "jose";

definePayload?: (
  session: {
    user: User & Record<string, any>;
    session: Session & Record<string, any>;
  }
) => Awaitable<JWTPayload>;

It’s invoked with the full Better Auth session object (ctx.context.session!) when /token (or the middleware header) is generated. [1] [2]


🌐 Web query:

better-auth JWT plugin documentation definePayload

💡 Result:

In Better Auth’s JWT plugin, definePayload is the option used to customize what goes into the JWT payload (by default, the full user object is included). Example from the docs: [1]

import { betterAuth } from "better-auth";
import { jwt } from "better-auth/plugins";

export const auth = betterAuth({
  plugins: [
    jwt({
      jwt: {
        definePayload: ({ user }) => ({
          id: user.id,
          email: user.email,
          role: user.role,
        }),
      },
    }),
  ],
});

Source: Better Auth JWT plugin docs (see “Modify JWT payload”). [1]

References

Citations:


🌐 Web query:

better-auth GitHub source code JWT plugin definePayload

💡 Result:

In better-auth, the JWT plugin’s definePayload hook is used inside getJwtToken().

  • Source file (GitHub): packages/better-auth/src/plugins/jwt/sign.ts
  • Where it’s applied: getJwtToken() sets payload to either the full session.user (default) or the result of options.jwt.definePayload(session), then passes it into signJWT(). (raw.githubusercontent.com)

The corresponding docs section (“Modify JWT payload”) shows how to configure it via:

jwt({
  jwt: {
    definePayload: ({ user }) => ({ id: user.id, email: user.email, role: user.role })
  }
})
``` ([better-auth.com](https://www.better-auth.com/docs/plugins/jwt))

Citations:

- 1: https://raw.githubusercontent.com/better-auth/better-auth/cd567659b8b6c78bf90843fd7218446a4ca6aa9b/packages/better-auth/src/plugins/jwt/sign.ts
- 2: https://www.better-auth.com/docs/plugins/jwt

---



</details>

**Fix `definePayload` callback signature to match better-auth 1.4.18 API.**

The type annotation is incorrect. better-auth v1.4.18's JWT plugin passes a single `session` object with shape `{ user: User & Record<string, any>; session: Session & Record<string, any> }`, but the code annotates as if the destructured parameters are at the top level.

Either remove the explicit type annotation and rely on inference, or correct it to match the actual signature:

```ts
definePayload: async ({ user, session: sessionData }: { user: User & Record<string, any>; session: Session & Record<string, any> }) => {
🤖 Prompt for AI Agents
In `@packages/auth/src/server.ts` around lines 134 - 139, The definePayload
callback signature is incorrect for better-auth v1.4.18 — change the parameter
from a destructured top-level shape to a single object parameter that contains
both user and session (i.e., accept ({ user, session }) instead of ({ user }: {
user... })), and update or remove the explicit type annotation so it matches the
plugin API (use types like User & Record<string, any> and Session &
Record<string, any> for user and session respectively, or drop the annotation to
rely on inference); modify the definePayload function declaration accordingly so
the runtime signature and types align with the JWT plugin.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/electric-proxy/src/index.ts`:
- Around line 134-136: The Vary header is being overwritten by
headers.set("Vary", "Authorization") which drops upstream values like
"Accept-Encoding"; update the logic that builds the Headers object (the local
variable headers created from response.headers) to read any existing Vary value,
append "Authorization" if not already present (comma-separating and trimming),
and then set the combined, de-duplicated string back on headers (use
headers.get("Vary") to read and headers.set("Vary", combined) to write) so
existing cache directives are preserved.
🧹 Nitpick comments (3)
apps/electric-proxy/src/auth.ts (1)

8-15: JWKS cache doesn't account for URL changes.

getJWKS caches the first createRemoteJWKSet result and returns it for all subsequent calls regardless of the jwksUrl argument. If the env JWKS_URL ever differs across deployments sharing the same isolate (unlikely but possible during config rollouts), stale keys would be used.

A simple fix is to key the cache on the URL:

Proposed fix
-let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
+let jwks: { url: string; keySet: ReturnType<typeof createRemoteJWKSet> } | null = null;
 
 function getJWKS(jwksUrl: string): ReturnType<typeof createRemoteJWKSet> {
-	if (!jwks) {
-		jwks = createRemoteJWKSet(new URL(jwksUrl));
+	if (!jwks || jwks.url !== jwksUrl) {
+		jwks = { url: jwksUrl, keySet: createRemoteJWKSet(new URL(jwksUrl)) };
 	}
-	return jwks;
+	return jwks.keySet;
 }
apps/electric-proxy/src/where-clauses.ts (1)

1-5: Consider exporting WhereClause for consumers.

The WhereClause interface isn't exported, so index.ts relies on type inference for the return value. Exporting it would allow callers to explicitly type variables if needed and improve discoverability.

apps/electric-proxy/src/index.ts (1)

106-108: User-supplied table value reflected in error response.

Unknown table: ${table} echoes untrusted input directly into the response body. While this isn't an HTML context (no XSS risk in a plain-text API response), it's still good practice to avoid reflecting raw input. Consider a generic message instead.

Proposed fix
-		return corsResponse(400, `Unknown table: ${table}`);
+		return corsResponse(400, "Unsupported table");

Comment on lines +134 to +136
const headers = new Headers(response.headers);
headers.set("Vary", "Authorization");

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

Vary header is overwritten, not appended.

headers.set("Vary", "Authorization") replaces any existing Vary header from the Electric upstream (e.g., Vary: Accept-Encoding). This can break intermediate cache behavior, causing incorrect shared-cache hits.

Proposed fix
-		headers.set("Vary", "Authorization");
+		headers.append("Vary", "Authorization");
📝 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 headers = new Headers(response.headers);
headers.set("Vary", "Authorization");
const headers = new Headers(response.headers);
headers.append("Vary", "Authorization");
🤖 Prompt for AI Agents
In `@apps/electric-proxy/src/index.ts` around lines 134 - 136, The Vary header is
being overwritten by headers.set("Vary", "Authorization") which drops upstream
values like "Accept-Encoding"; update the logic that builds the Headers object
(the local variable headers created from response.headers) to read any existing
Vary value, append "Authorization" if not already present (comma-separating and
trimming), and then set the combined, de-duplicated string back on headers (use
headers.get("Vary") to read and headers.set("Vary", combined) to write) so
existing cache directives are preserved.

- 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)
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In
`@apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts`:
- Around line 76-79: The async Authorization header function currently calls
authClient.token() without error handling so a rejection will throw and kill the
Electric stream; wrap the call to authClient.token() in try/catch and on error
return a safe fallback (either an empty string "" or the stale token from
getAuthToken()) instead of letting the exception propagate; update the same
pattern in the other Authorization header occurrence (the second async header
block) so both places use the try/catch fallback to allow the stream to hit its
onError/retry path.
🧹 Nitpick comments (2)
apps/electric-proxy/src/auth.ts (1)

7-14: JWKS cache ignores URL changes across requests.

The module-scoped jwks singleton is created for the first jwksUrl seen and never invalidated. In a Cloudflare Worker isolate (which persists across requests), if jwksUrl ever differs between invocations — e.g., during a config migration or in a multi-tenant setup — stale keys will be served silently.

A simple fix is to key the cache on the URL:

Proposed fix
-let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
+let jwksCache: { url: string; keySet: ReturnType<typeof createRemoteJWKSet> } | null = null;

 function getJWKS(jwksUrl: string): ReturnType<typeof createRemoteJWKSet> {
-	if (!jwks) {
-		jwks = createRemoteJWKSet(new URL(jwksUrl));
+	if (!jwksCache || jwksCache.url !== jwksUrl) {
+		jwksCache = { url: jwksUrl, keySet: createRemoteJWKSet(new URL(jwksUrl)) };
 	}
-	return jwks;
+	return jwksCache.keySet;
 }
apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts (1)

28-36: apiKeyDisplaySchema is defined but only used for type inference — consider if runtime validation is needed.

If Electric returns unexpected shapes (schema drift, missing columns), runtime parsing via apiKeyDisplaySchema.parse() would catch it early. Currently the schema is only used as z.infer<...>. This is fine if you trust the data source, but flagging since the guidelines recommend validating external data.

Comment on lines +76 to 79
Authorization: async () => {
const { data } = await authClient.token();
return data?.token ? `Bearer ${data.token}` : "";
},
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

Unhandled rejection from authClient.token() will permanently kill the shape stream.

If authClient.token() rejects (e.g., network failure, auth server unreachable), the async header function throws and the Electric stream has no way to recover. Wrap in try/catch and return "" (or the stale token via getAuthToken()) as a fallback so the stream can still attempt the request and hit the onError retry path.

♻️ Suggested helper extraction (also reduces duplication)
+async function getElectricAuthHeader(): Promise<string> {
+	try {
+		const { data } = await authClient.token();
+		if (data?.token) return `Bearer ${data.token}`;
+	} catch (e) {
+		console.error("[electric/auth] token fetch failed", e);
+	}
+	// Fall back to session bearer token so the request still fires
+	const fallback = getAuthToken();
+	return fallback ? `Bearer ${fallback}` : "";
+}
+
 const organizationsCollection = createCollection(
 	electricCollectionOptions<SelectOrganization>({
 		id: "organizations",
 		shapeOptions: {
 			url: electricUrl,
 			params: { table: "auth.organizations" },
 			headers: {
-				Authorization: async () => {
-					const { data } = await authClient.token();
-					return data?.token ? `Bearer ${data.token}` : "";
-				},
+				Authorization: getElectricAuthHeader,
 			},
 			columnMapper,
 		},
 		getKey: (item) => item.id,
 	}),
 );
 
 function createOrgCollections(organizationId: string): OrgCollections {
 	const headers = {
-		Authorization: async () => {
-			const { data } = await authClient.token();
-			return data?.token ? `Bearer ${data.token}` : "";
-		},
+		Authorization: getElectricAuthHeader,
 	};

Also applies to: 89-92

🤖 Prompt for AI Agents
In
`@apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts`
around lines 76 - 79, The async Authorization header function currently calls
authClient.token() without error handling so a rejection will throw and kill the
Electric stream; wrap the call to authClient.token() in try/catch and on error
return a safe fallback (either an empty string "" or the stale token from
getAuthToken()) instead of letting the exception propagate; update the same
pattern in the other Authorization header occurrence (the second async header
block) so both places use the try/catch fallback to allow the stream to hit its
onError/retry path.

@saddlepaddle saddlepaddle merged commit 390d946 into main Feb 13, 2026
14 of 15 checks passed
@Kitenite Kitenite deleted the satya/electric-worker branch February 16, 2026 00:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant