diff --git a/.env.example b/.env.example
index 55017296cf9..2d7f1a9c197 100644
--- a/.env.example
+++ b/.env.example
@@ -1,38 +1,73 @@
-
-
# =============================================================================
-# ROOT SUPERSET ENV
-# Shared by all worktrees via direnv
+# SUPERSET LOCAL DEVELOPMENT ENV
+# Copied to .env by `bun setup:local`.
+#
+# Keep .env machine-local and untracked. Blank optional integration keys are
+# intentional in the local profile; the app should boot without third-party
+# credentials and degrade only when those specific features are exercised.
# =============================================================================
# -----------------------------------------------------------------------------
-# Neon Organization Credentials
+# Local Contributor Profile
# -----------------------------------------------------------------------------
-NEON_ORG_ID=
-NEON_PROJECT_ID=
-NEON_API_KEY=
+# Required for fresh-clone local development without third-party credentials.
+SUPERSET_PROFILE=local
+
+# Keep desktop dev state separate from production / canary installs.
+# `bun setup:local` rewrites this to local-dev-.
+SUPERSET_WORKSPACE_NAME=local-dev
+
+# -----------------------------------------------------------------------------
+# Local Database
+# -----------------------------------------------------------------------------
+# `bun setup:local` rewrites these to a database name derived from the
+# worktree path, so multiple local worktrees can share one Postgres container
+# without sharing schema/auth state.
+SUPERSET_LOCAL_DATABASE_NAME=superset
+DATABASE_URL=postgres://superset:superset@localhost:5433/superset
+DATABASE_URL_UNPOOLED=postgres://superset:superset@localhost:5433/superset
# -----------------------------------------------------------------------------
-# Neon Database Credentials (Production)
+# Local Ports
# -----------------------------------------------------------------------------
-DATABASE_URL=
-DATABASE_URL_UNPOOLED=
+WEB_PORT=4640
+API_PORT=4641
+DESKTOP_VITE_PORT=4645
+DESKTOP_NOTIFICATIONS_PORT=4646
+ELECTRIC_PORT=4649
+CADDY_ELECTRIC_PORT=4650
+WRANGLER_PORT=4652
# -----------------------------------------------------------------------------
-# Cross-App URLs (Local Dev)
+# Local App URLs
# -----------------------------------------------------------------------------
-NEXT_PUBLIC_API_URL=http://localhost:3001
-NEXT_PUBLIC_WEB_URL=http://localhost:3000
+NEXT_PUBLIC_API_URL=http://localhost:4641
+NEXT_PUBLIC_WEB_URL=http://localhost:4640
NEXT_PUBLIC_ADMIN_URL=http://localhost:3003
NEXT_PUBLIC_MARKETING_URL=http://localhost:3002
NEXT_PUBLIC_DOCS_URL=http://localhost:3004
+NEXT_PUBLIC_ELECTRIC_URL=https://localhost:4650
# -----------------------------------------------------------------------------
-# Better Auth
+# Local Auth Defaults
# -----------------------------------------------------------------------------
-BETTER_AUTH_SECRET=
+BETTER_AUTH_SECRET=local_dev_secret_not_for_production
NEXT_PUBLIC_COOKIE_DOMAIN=localhost
+# =============================================================================
+# OPTIONAL INTEGRATION CREDENTIALS
+# Leave these blank for the default local contributor flow.
+# =============================================================================
+
+# -----------------------------------------------------------------------------
+# Neon Organization Credentials
+# -----------------------------------------------------------------------------
+# Only needed for Neon-backed/internal workflows. The local setup script uses the
+# Docker Postgres URLs above.
+NEON_ORG_ID=
+NEON_PROJECT_ID=
+NEON_API_KEY=
+
# -----------------------------------------------------------------------------
# OAuth Credentials (for Desktop App direct auth)
# -----------------------------------------------------------------------------
@@ -99,6 +134,7 @@ KV_REST_API_TOKEN=
# QStash API token (used for background job scheduling in packages/trpc and apps/api)
QSTASH_TOKEN=
+QSTASH_URL=
# QStash webhook signature verification keys (used to verify webhook requests)
QSTASH_CURRENT_SIGNING_KEY=
diff --git a/Caddyfile.example b/Caddyfile.example
index b1043c33d78..719e224ae8d 100644
--- a/Caddyfile.example
+++ b/Caddyfile.example
@@ -2,14 +2,14 @@
# Avoids browser's 6-connection limit when using 10+ Electric streams.
#
# Copy to Caddyfile: cp Caddyfile.example Caddyfile
-# Install caddy: brew install caddy (macOS) / see https://caddyserver.com/docs/install
+# Install caddy: brew install caddy
{
auto_https disable_redirects
}
-https://localhost:3010 {
- reverse_proxy localhost:3001 {
+https://localhost:{$CADDY_ELECTRIC_PORT} {
+ reverse_proxy localhost:{$WRANGLER_PORT} {
flush_interval -1
}
}
diff --git a/README.md b/README.md
index e1d935232ec..478d5dea679 100644
--- a/README.md
+++ b/README.md
@@ -71,7 +71,7 @@ If it runs in a terminal, it runs on Superset
| Requirement | Details |
|:------------|:--------|
-| **OS** | macOS (Windows/Linux untested) |
+| **OS** | macOS |
| **Runtime** | [Bun](https://bun.sh/) v1.0+ |
| **Version Control** | Git 2.20+ |
| **GitHub CLI** | [gh](https://cli.github.com/) |
@@ -85,57 +85,27 @@ If it runs in a terminal, it runs on Superset
### Build from Source
-
-Click to expand build instructions
+For a complete contributor workflow that boots a fresh clone with no third-party credentials (Neon / OAuth / Stripe / Resend keys are all optional), follow **[Local Development](docs/LOCAL_DEVELOPMENT.md)**.
-**1. Clone the repository**
+Short version:
```bash
git clone https://github.com/superset-sh/superset.git
cd superset
-```
-
-**2. Set up environment variables** (choose one):
-
-Option A: Full setup
-```bash
-cp .env.example .env
-# Edit .env and fill in the values
-```
-
-Option B: Skip env validation (for quick local testing)
-```bash
-cp .env.example .env
-echo 'SKIP_ENV_VALIDATION=1' >> .env
-```
-
-**3. Set up Caddy** (reverse proxy for Electric SQL streams):
-
-```bash
-# Install caddy: brew install caddy (macOS) or see https://caddyserver.com/docs/install
-cp Caddyfile.example Caddyfile
-
-# Without this, Chromium rejects https://localhost:* with ERR_CERT_AUTHORITY_INVALID.
-# Prompts for sudo once.
-caddy trust
-```
-
-**4. Install dependencies and run**
-
-```bash
bun install
-bun run dev
+bun setup:local
+bun dev
```
-**5. Build the desktop app**
+`bun setup:local` copies local examples, assigns this worktree its own local database and desktop state, starts Docker Postgres/Electric, trusts Caddy's local CA, and runs migrations. It does not overwrite internal `.env` files. The desktop window opens auto-signed-in as `admin@local.test`, with state isolated under `~/.superset-local-dev-*` so source builds do not reuse production or canary desktop state. See [Local Development](docs/LOCAL_DEVELOPMENT.md) for details and troubleshooting.
+
+To build a distributable desktop app:
```bash
bun run build
open apps/desktop/release
```
-
-
## Keyboard Shortcuts
All shortcuts are customizable via **Settings > Keyboard Shortcuts** (`⌘/`). See [full documentation](https://docs.superset.sh/keyboard-shortcuts).
diff --git a/apps/admin/src/env.ts b/apps/admin/src/env.ts
index 0601dc61f69..2d9ea4aba6d 100644
--- a/apps/admin/src/env.ts
+++ b/apps/admin/src/env.ts
@@ -1,7 +1,10 @@
+import { shouldSkipEnvValidation } from "@superset/shared/deployment-profile";
import { createEnv } from "@t3-oss/env-nextjs";
import { vercel } from "@t3-oss/env-nextjs/presets-zod";
import { z } from "zod";
+const skipValidation = shouldSkipEnvValidation();
+
export const env = createEnv({
extends: [vercel()],
shared: {
@@ -49,6 +52,6 @@ export const env = createEnv({
NEXT_PUBLIC_SENTRY_ENVIRONMENT: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT,
},
- skipValidation: !!process.env.SKIP_ENV_VALIDATION,
+ skipValidation,
emptyStringAsUndefined: true,
});
diff --git a/apps/api/package.json b/apps/api/package.json
index 2fb057d5453..77c73628b5c 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -8,6 +8,7 @@
"clean": "git clean -xdf .cache .next .turbo node_modules",
"dev": "dotenv -e ../../.env -- sh -c 'exec next dev --port ${API_PORT:-3001}'",
"start": "next start --port 3001",
+ "test": "bun test",
"typecheck": "tsc --noEmit"
},
"dependencies": {
diff --git a/apps/api/src/app/api/automations/evaluate/route.ts b/apps/api/src/app/api/automations/evaluate/route.ts
index f1c6d7f5a85..7b14e4c2f1d 100644
--- a/apps/api/src/app/api/automations/evaluate/route.ts
+++ b/apps/api/src/app/api/automations/evaluate/route.ts
@@ -2,17 +2,14 @@ import { dbWs } from "@superset/db/client";
import { automations, subscriptions } from "@superset/db/schema";
import { ACTIVE_SUBSCRIPTION_STATUSES } from "@superset/shared/billing";
import { nextOccurrenceAfter } from "@superset/shared/rrule";
-import { Client, Receiver } from "@upstash/qstash";
+import { Receiver } from "@upstash/qstash";
import { and, eq, exists, inArray, lte, ne, sql } from "drizzle-orm";
import { env } from "@/env";
+import { qstash } from "@/lib/qstash";
export const dynamic = "force-dynamic";
-const qstash = new Client({
- token: env.QSTASH_TOKEN,
- baseUrl: env.QSTASH_URL,
-});
const receiver = new Receiver({
currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY,
nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY,
@@ -73,6 +70,13 @@ export async function POST(request: Request): Promise {
return Response.json({ enqueued: 0 });
}
+ if (!qstash) {
+ return Response.json(
+ { error: "QSTASH_TOKEN is required to enqueue automation jobs" },
+ { status: 503 },
+ );
+ }
+
await qstash.batchJSON(
due.map((automation) => {
const scheduledFor = bucketToMinute(automation.nextRunAt);
diff --git a/apps/api/src/app/api/github/callback/route.ts b/apps/api/src/app/api/github/callback/route.ts
index 94021159083..6e220e14217 100644
--- a/apps/api/src/app/api/github/callback/route.ts
+++ b/apps/api/src/app/api/github/callback/route.ts
@@ -1,14 +1,12 @@
import { db } from "@superset/db/client";
import { githubInstallations, members } from "@superset/db/schema";
-import { Client } from "@upstash/qstash";
import { and, eq } from "drizzle-orm";
import { env } from "@/env";
import { verifySignedState } from "@/lib/oauth-state";
+import { qstash, requireQstash } from "@/lib/qstash";
import { githubApp } from "../octokit";
-const qstash = new Client({ token: env.QSTASH_TOKEN });
-
/**
* Callback handler for GitHub App installation.
* GitHub redirects here after the user installs/configures the app.
@@ -122,14 +120,28 @@ export async function GET(request: Request) {
// Queue initial sync job
try {
- await qstash.publishJSON({
- url: `${env.NEXT_PUBLIC_API_URL}/api/github/jobs/initial-sync`,
- body: {
- installationDbId: savedInstallation.id,
- organizationId,
- },
- retries: 3,
- });
+ const syncUrl = `${env.NEXT_PUBLIC_API_URL}/api/github/jobs/initial-sync`;
+ const syncBody = {
+ installationDbId: savedInstallation.id,
+ organizationId,
+ };
+
+ if (env.NODE_ENV === "development" && !qstash) {
+ const response = await fetch(syncUrl, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(syncBody),
+ });
+ if (!response.ok) {
+ throw new Error(`Local sync request failed: ${response.status}`);
+ }
+ } else {
+ await requireQstash("github/callback").publishJSON({
+ url: syncUrl,
+ body: syncBody,
+ retries: 3,
+ });
+ }
} catch (error) {
console.error(
"[github/callback] Failed to queue initial sync job:",
diff --git a/apps/api/src/app/api/health/route.ts b/apps/api/src/app/api/health/route.ts
new file mode 100644
index 00000000000..9b3588d69ae
--- /dev/null
+++ b/apps/api/src/app/api/health/route.ts
@@ -0,0 +1,19 @@
+import {
+ getDeploymentProfile,
+ isStrictProfile,
+} from "@superset/shared/deployment-profile";
+import { NextResponse } from "next/server";
+import { getIntegrationStatuses } from "../../../lib/integration-status";
+
+export function GET() {
+ const profile = getDeploymentProfile();
+ if (isStrictProfile(profile)) {
+ return NextResponse.json({ ok: true });
+ }
+
+ return NextResponse.json({
+ ok: true,
+ profile,
+ integrations: getIntegrationStatuses(),
+ });
+}
diff --git a/apps/api/src/app/api/integrations/linear/callback/route.ts b/apps/api/src/app/api/integrations/linear/callback/route.ts
index 5b090f03a54..3a770bd097d 100644
--- a/apps/api/src/app/api/integrations/linear/callback/route.ts
+++ b/apps/api/src/app/api/integrations/linear/callback/route.ts
@@ -2,18 +2,16 @@ import { LinearClient } from "@linear/sdk";
import { db } from "@superset/db/client";
import { integrationConnections, members, users } from "@superset/db/schema";
import { linearTokenResponseSchema } from "@superset/trpc/integrations/linear";
-import { Client } from "@upstash/qstash";
import { and, eq, isNull, ne } from "drizzle-orm";
import { env } from "@/env";
import { verifySignedState } from "@/lib/oauth-state";
+import { qstash, requireQstash } from "@/lib/qstash";
const UNIQUE_VIOLATION = "23505";
const ACTIVE_LINKAGE_INDEX =
"integration_connections_provider_external_org_active_unique";
-const qstash = new Client({ token: env.QSTASH_TOKEN });
-
export async function GET(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
@@ -149,11 +147,25 @@ export async function GET(request: Request) {
}
try {
- await qstash.publishJSON({
- url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/linear/jobs/initial-sync`,
- body: { organizationId, creatorUserId: userId },
- retries: 3,
- });
+ const syncUrl = `${env.NEXT_PUBLIC_API_URL}/api/integrations/linear/jobs/initial-sync`;
+ const syncBody = { organizationId, creatorUserId: userId };
+
+ if (env.NODE_ENV === "development" && !qstash) {
+ const response = await fetch(syncUrl, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(syncBody),
+ });
+ if (!response.ok) {
+ throw new Error(`Local sync request failed: ${response.status}`);
+ }
+ } else {
+ await requireQstash("linear/callback").publishJSON({
+ url: syncUrl,
+ body: syncBody,
+ retries: 3,
+ });
+ }
} catch (error) {
console.error("Failed to queue initial sync job:", error);
return Response.redirect(
diff --git a/apps/api/src/app/api/integrations/slack/events/route.ts b/apps/api/src/app/api/integrations/slack/events/route.ts
index 8706cb59ae9..ea098d5ca78 100644
--- a/apps/api/src/app/api/integrations/slack/events/route.ts
+++ b/apps/api/src/app/api/integrations/slack/events/route.ts
@@ -1,14 +1,12 @@
import type { LinkSharedEvent, SlackEvent } from "@slack/types";
-import { Client } from "@upstash/qstash";
import { env } from "@/env";
+import { qstash } from "@/lib/qstash";
import { verifySlackSignature } from "../verify-signature";
import { processAppHomeOpened } from "./process-app-home-opened";
import { processEntityDetails } from "./process-entity-details";
import { processLinkShared } from "./process-link-shared";
-const qstash = new Client({ token: env.QSTASH_TOKEN });
-
type SlackEventEnvelope = {
type?: string;
challenge?: string;
@@ -79,15 +77,21 @@ export async function POST(request: Request) {
if (event.type === "app_mention") {
try {
- await qstash.publishJSON({
- url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/jobs/process-mention`,
- body: {
- event,
- teamId: team_id,
- eventId: event_id,
- },
- retries: 3,
- });
+ if (qstash) {
+ await qstash.publishJSON({
+ url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/jobs/process-mention`,
+ body: {
+ event,
+ teamId: team_id,
+ eventId: event_id,
+ },
+ retries: 3,
+ });
+ } else {
+ console.warn(
+ "[slack/events] Skipping mention job; QStash is not configured",
+ );
+ }
} catch (error) {
console.error("[slack/events] Failed to queue mention job:", error);
}
@@ -109,15 +113,21 @@ export async function POST(request: Request) {
}
try {
- await qstash.publishJSON({
- url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/jobs/process-assistant-message`,
- body: {
- event: messageEvent,
- teamId: team_id,
- eventId: event_id,
- },
- retries: 3,
- });
+ if (qstash) {
+ await qstash.publishJSON({
+ url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/jobs/process-assistant-message`,
+ body: {
+ event: messageEvent,
+ teamId: team_id,
+ eventId: event_id,
+ },
+ retries: 3,
+ });
+ } else {
+ console.warn(
+ "[slack/events] Skipping assistant message job; QStash is not configured",
+ );
+ }
} catch (error) {
console.error(
"[slack/events] Failed to queue assistant message job:",
diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts
index 117ed25046a..cc3c98b3923 100644
--- a/apps/api/src/env.ts
+++ b/apps/api/src/env.ts
@@ -1,6 +1,9 @@
+import { shouldSkipEnvValidation } from "@superset/shared/deployment-profile";
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
+const skipValidation = shouldSkipEnvValidation();
+
export const env = createEnv({
shared: {
NODE_ENV: z
@@ -70,5 +73,5 @@ export const env = createEnv({
NEXT_PUBLIC_SENTRY_ENVIRONMENT: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT,
},
emptyStringAsUndefined: true,
- skipValidation: !!process.env.SKIP_ENV_VALIDATION,
+ skipValidation,
});
diff --git a/apps/api/src/instrumentation.ts b/apps/api/src/instrumentation.ts
index 5a4e9039446..125499e5b0a 100644
--- a/apps/api/src/instrumentation.ts
+++ b/apps/api/src/instrumentation.ts
@@ -1,8 +1,10 @@
import * as Sentry from "@sentry/nextjs";
+import { logBootSummary } from "./lib/boot-summary";
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("../sentry.server.config");
+ logBootSummary();
}
if (process.env.NEXT_RUNTIME === "edge") {
diff --git a/apps/api/src/lib/analytics.ts b/apps/api/src/lib/analytics.ts
index aa8d66fce76..ae446bf440d 100644
--- a/apps/api/src/lib/analytics.ts
+++ b/apps/api/src/lib/analytics.ts
@@ -1,8 +1,43 @@
+import type { EventMessage } from "posthog-node";
import { PostHog } from "posthog-node";
import { env } from "@/env";
-export const posthog = new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, {
- host: env.NEXT_PUBLIC_POSTHOG_HOST,
- flushAt: 1,
- flushInterval: 0,
-});
+type FeatureFlagValue = Parameters[2];
+type FeatureFlagOptions = Parameters[2];
+type FeatureFlagPayloadOptions = Parameters<
+ PostHog["getFeatureFlagPayload"]
+>[3];
+type FeatureFlagResult = Awaited>;
+type FeatureFlagPayload = Awaited>;
+
+interface AnalyticsClient {
+ capture: (props: EventMessage) => void;
+ getFeatureFlag: (
+ key: string,
+ distinctId: string,
+ options?: FeatureFlagOptions,
+ ) => Promise;
+ getFeatureFlagPayload: (
+ key: string,
+ distinctId: string,
+ matchValue?: FeatureFlagValue,
+ options?: FeatureFlagPayloadOptions,
+ ) => Promise;
+}
+
+const disabled: AnalyticsClient = {
+ capture: () => {},
+ getFeatureFlag: async () => undefined,
+ getFeatureFlagPayload: async () => undefined,
+};
+
+function createAnalyticsClient(): AnalyticsClient {
+ if (!env.NEXT_PUBLIC_POSTHOG_KEY) return disabled;
+ return new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, {
+ host: env.NEXT_PUBLIC_POSTHOG_HOST,
+ flushAt: 1,
+ flushInterval: 0,
+ });
+}
+
+export const posthog = createAnalyticsClient();
diff --git a/apps/api/src/lib/boot-summary.ts b/apps/api/src/lib/boot-summary.ts
new file mode 100644
index 00000000000..e8ecfc44ad7
--- /dev/null
+++ b/apps/api/src/lib/boot-summary.ts
@@ -0,0 +1,32 @@
+import {
+ getDeploymentProfile,
+ isStrictProfile,
+} from "@superset/shared/deployment-profile";
+import { getMissingIntegrations } from "./integration-status";
+
+let logged = false;
+
+export function logBootSummary(): void {
+ if (logged) return;
+ logged = true;
+
+ const profile = getDeploymentProfile();
+ const missing = getMissingIntegrations();
+
+ if (isStrictProfile(profile)) {
+ console.log(`[superset] profile=${profile} (strict)`);
+ return;
+ }
+
+ console.log(`[superset] profile=${profile} (lenient)`);
+ if (missing.length === 0) {
+ console.log("[superset] all integrations configured");
+ return;
+ }
+ console.log(
+ `[superset] disabled features (set the listed env var(s) to enable):`,
+ );
+ for (const { label, envVars } of missing) {
+ console.log(` - ${label.padEnd(28)} ${envVars.join(", ")}`);
+ }
+}
diff --git a/apps/api/src/lib/integration-status.test.ts b/apps/api/src/lib/integration-status.test.ts
new file mode 100644
index 00000000000..3c29ba092f2
--- /dev/null
+++ b/apps/api/src/lib/integration-status.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "bun:test";
+
+import {
+ getIntegrationStatuses,
+ getMissingIntegrations,
+} from "./integration-status";
+
+describe("integration status", () => {
+ it("reports configured and missing integrations from the supplied env", () => {
+ const statuses = getIntegrationStatuses({
+ STRIPE_SECRET_KEY: "sk_test",
+ RESEND_API_KEY: "",
+ NEXT_PUBLIC_POSTHOG_KEY: undefined,
+ });
+
+ expect(statuses.stripe).toBe("configured");
+ expect(statuses.resend).toBe("missing");
+ expect(statuses.posthog).toBe("missing");
+ expect(statuses["upstash-kv"]).toBe("missing");
+ expect(statuses.blob).toBe("missing");
+ });
+
+ it("requires all env vars for multi-key integrations", () => {
+ expect(
+ getIntegrationStatuses({
+ KV_REST_API_URL: "https://example.upstash.io",
+ })["upstash-kv"],
+ ).toBe("missing");
+
+ expect(
+ getIntegrationStatuses({
+ KV_REST_API_URL: "https://example.upstash.io",
+ KV_REST_API_TOKEN: "token",
+ })["upstash-kv"],
+ ).toBe("configured");
+ });
+
+ it("returns missing integrations with display labels for boot output", () => {
+ const missing = getMissingIntegrations({
+ STRIPE_SECRET_KEY: "sk_test",
+ });
+
+ expect(missing.some(({ key }) => key === "stripe")).toBe(false);
+ expect(missing).toContainEqual({
+ key: "blob",
+ label: "vercel-blob (uploads)",
+ envVars: ["BLOB_READ_WRITE_TOKEN"],
+ });
+ });
+});
diff --git a/apps/api/src/lib/integration-status.ts b/apps/api/src/lib/integration-status.ts
new file mode 100644
index 00000000000..ceb07171a3f
--- /dev/null
+++ b/apps/api/src/lib/integration-status.ts
@@ -0,0 +1,51 @@
+export const INTEGRATIONS = [
+ { key: "stripe", label: "stripe", envVars: ["STRIPE_SECRET_KEY"] },
+ { key: "resend", label: "resend (email)", envVars: ["RESEND_API_KEY"] },
+ {
+ key: "posthog",
+ label: "posthog (telemetry)",
+ envVars: ["NEXT_PUBLIC_POSTHOG_KEY"],
+ },
+ { key: "sentry", label: "sentry", envVars: ["NEXT_PUBLIC_SENTRY_DSN_API"] },
+ { key: "github-app", label: "github-app", envVars: ["GH_APP_ID"] },
+ { key: "github-oauth", label: "github-oauth", envVars: ["GH_CLIENT_ID"] },
+ { key: "google-oauth", label: "google-oauth", envVars: ["GOOGLE_CLIENT_ID"] },
+ { key: "linear", label: "linear", envVars: ["LINEAR_CLIENT_ID"] },
+ { key: "slack", label: "slack", envVars: ["SLACK_CLIENT_ID"] },
+ { key: "qstash", label: "qstash (jobs)", envVars: ["QSTASH_TOKEN"] },
+ {
+ key: "upstash-kv",
+ label: "upstash-kv (rate limit)",
+ envVars: ["KV_REST_API_URL", "KV_REST_API_TOKEN"],
+ },
+ {
+ key: "blob",
+ label: "vercel-blob (uploads)",
+ envVars: ["BLOB_READ_WRITE_TOKEN"],
+ },
+ { key: "anthropic", label: "anthropic", envVars: ["ANTHROPIC_API_KEY"] },
+ { key: "tavily", label: "tavily (search)", envVars: ["TAVILY_API_KEY"] },
+] as const;
+
+export type IntegrationKey = (typeof INTEGRATIONS)[number]["key"];
+export type Integration = (typeof INTEGRATIONS)[number];
+export type IntegrationStatus = "configured" | "missing";
+
+export function getIntegrationStatuses(
+ envSource: Record = process.env,
+): Record {
+ return Object.fromEntries(
+ INTEGRATIONS.map(({ key, envVars }) => [
+ key,
+ envVars.every((envVar) => envSource[envVar]) ? "configured" : "missing",
+ ]),
+ ) as Record;
+}
+
+export function getMissingIntegrations(
+ envSource: Record = process.env,
+): Integration[] {
+ return INTEGRATIONS.filter(({ envVars }) =>
+ envVars.some((envVar) => !envSource[envVar]),
+ );
+}
diff --git a/apps/api/src/lib/qstash.ts b/apps/api/src/lib/qstash.ts
new file mode 100644
index 00000000000..b826b638086
--- /dev/null
+++ b/apps/api/src/lib/qstash.ts
@@ -0,0 +1,20 @@
+import { Client } from "@upstash/qstash";
+
+import { env } from "@/env";
+
+export const qstash = env.QSTASH_TOKEN
+ ? new Client({
+ token: env.QSTASH_TOKEN,
+ ...(env.QSTASH_URL ? { baseUrl: env.QSTASH_URL } : {}),
+ })
+ : null;
+
+export function requireQstash(context: string) {
+ if (!qstash) {
+ throw new Error(
+ `[${context}] QSTASH_TOKEN is required to enqueue background jobs`,
+ );
+ }
+
+ return qstash;
+}
diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts
index f11759ceaa2..add0f28f908 100644
--- a/apps/desktop/electron.vite.config.ts
+++ b/apps/desktop/electron.vite.config.ts
@@ -13,6 +13,7 @@ import { mainExternalizedDependencies } from "./runtime-dependencies";
import {
copyResourcesPlugin,
defineEnv,
+ devOrProdUrl,
devPath,
htmlEnvTransformPlugin,
} from "./vite/helpers";
@@ -53,17 +54,26 @@ export default defineConfig({
process.env.SKIP_ENV_VALIDATION,
"",
),
- "process.env.NEXT_PUBLIC_API_URL": defineEnv(
- process.env.NEXT_PUBLIC_API_URL,
- "https://api.superset.sh",
+ "process.env.NEXT_PUBLIC_API_URL": JSON.stringify(
+ devOrProdUrl(
+ "NEXT_PUBLIC_API_URL",
+ "http://localhost:4641",
+ "https://api.superset.sh",
+ ),
),
- "process.env.NEXT_PUBLIC_STREAMS_URL": defineEnv(
- process.env.NEXT_PUBLIC_STREAMS_URL,
- "https://streams.superset.sh",
+ "process.env.NEXT_PUBLIC_STREAMS_URL": JSON.stringify(
+ devOrProdUrl(
+ "NEXT_PUBLIC_STREAMS_URL",
+ "http://localhost:4647",
+ "https://streams.superset.sh",
+ ),
),
- "process.env.NEXT_PUBLIC_WEB_URL": defineEnv(
- process.env.NEXT_PUBLIC_WEB_URL,
- "https://app.superset.sh",
+ "process.env.NEXT_PUBLIC_WEB_URL": JSON.stringify(
+ devOrProdUrl(
+ "NEXT_PUBLIC_WEB_URL",
+ "http://localhost:4640",
+ "https://app.superset.sh",
+ ),
),
"process.env.NEXT_PUBLIC_MARKETING_URL": defineEnv(
process.env.NEXT_PUBLIC_MARKETING_URL,
@@ -76,7 +86,16 @@ export default defineConfig({
"process.env.SENTRY_DSN_DESKTOP": defineEnv(
process.env.SENTRY_DSN_DESKTOP,
),
- "process.env.RELAY_URL": defineEnv(process.env.RELAY_URL),
+ "process.env.RELAY_URL": JSON.stringify(
+ devOrProdUrl(
+ "RELAY_URL",
+ "http://localhost:4653",
+ "https://relay.superset.sh",
+ ),
+ ),
+ "process.env.SUPERSET_EXPLICIT_RELAY_URL": defineEnv(
+ process.env.RELAY_URL ? "1" : "",
+ ),
// Must match renderer for analytics in main process
"process.env.NEXT_PUBLIC_POSTHOG_KEY": defineEnv(
process.env.NEXT_PUBLIC_POSTHOG_KEY,
@@ -169,22 +188,35 @@ export default defineConfig({
process.env.SKIP_ENV_VALIDATION,
"",
),
+ "process.env.SUPERSET_PROFILE": defineEnv(
+ process.env.SUPERSET_PROFILE,
+ "",
+ ),
"process.platform": defineEnv(process.platform),
- "process.env.NEXT_PUBLIC_API_URL": defineEnv(
- process.env.NEXT_PUBLIC_API_URL,
- "https://api.superset.sh",
+ "process.env.NEXT_PUBLIC_API_URL": JSON.stringify(
+ devOrProdUrl(
+ "NEXT_PUBLIC_API_URL",
+ "http://localhost:4641",
+ "https://api.superset.sh",
+ ),
),
- "process.env.NEXT_PUBLIC_WEB_URL": defineEnv(
- process.env.NEXT_PUBLIC_WEB_URL,
- "https://app.superset.sh",
+ "process.env.NEXT_PUBLIC_WEB_URL": JSON.stringify(
+ devOrProdUrl(
+ "NEXT_PUBLIC_WEB_URL",
+ "http://localhost:4640",
+ "https://app.superset.sh",
+ ),
),
"process.env.NEXT_PUBLIC_MARKETING_URL": defineEnv(
process.env.NEXT_PUBLIC_MARKETING_URL,
"https://superset.sh",
),
- "process.env.NEXT_PUBLIC_ELECTRIC_URL": defineEnv(
- process.env.NEXT_PUBLIC_ELECTRIC_URL,
- "https://electric-proxy.avi-6ac.workers.dev",
+ "process.env.NEXT_PUBLIC_ELECTRIC_URL": JSON.stringify(
+ devOrProdUrl(
+ "NEXT_PUBLIC_ELECTRIC_URL",
+ "https://localhost:4650",
+ "https://electric-proxy.avi-6ac.workers.dev",
+ ),
),
"process.env.NEXT_PUBLIC_DOCS_URL": defineEnv(
process.env.NEXT_PUBLIC_DOCS_URL,
@@ -200,7 +232,16 @@ export default defineConfig({
"import.meta.env.SENTRY_DSN_DESKTOP": defineEnv(
process.env.SENTRY_DSN_DESKTOP,
),
- "process.env.RELAY_URL": defineEnv(process.env.RELAY_URL),
+ "process.env.RELAY_URL": JSON.stringify(
+ devOrProdUrl(
+ "RELAY_URL",
+ "http://localhost:4653",
+ "https://relay.superset.sh",
+ ),
+ ),
+ "process.env.SUPERSET_EXPLICIT_RELAY_URL": defineEnv(
+ process.env.RELAY_URL ? "1" : "",
+ ),
"process.env.STREAMS_URL": defineEnv(
process.env.STREAMS_URL,
"https://superset-stream.fly.dev",
diff --git a/apps/desktop/scripts/clean-launch-services.ts b/apps/desktop/scripts/clean-launch-services.ts
index 8f8dda03557..d60686b3f2f 100644
--- a/apps/desktop/scripts/clean-launch-services.ts
+++ b/apps/desktop/scripts/clean-launch-services.ts
@@ -12,6 +12,14 @@ import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { resolve } from "node:path";
+import { config } from "dotenv";
+
+// Let contributor-local .env opt out before touching global Launch Services.
+config({
+ path: resolve(import.meta.dirname, "../../../.env"),
+ override: true,
+ quiet: true,
+});
if (process.platform !== "darwin") {
process.exit(0);
@@ -22,6 +30,11 @@ if (process.env.NODE_ENV !== "development") {
process.exit(0);
}
+if (process.env.SUPERSET_PROFILE === "local") {
+ console.log("[clean-launch-services] Skipping - local profile");
+ process.exit(0);
+}
+
const LSREGISTER =
"/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
const WORKTREE_BASE = resolve(homedir(), ".superset/worktrees");
diff --git a/apps/desktop/scripts/patch-dev-protocol.ts b/apps/desktop/scripts/patch-dev-protocol.ts
index dc89ff36fe0..871905fdfbc 100644
--- a/apps/desktop/scripts/patch-dev-protocol.ts
+++ b/apps/desktop/scripts/patch-dev-protocol.ts
@@ -175,6 +175,11 @@ export function main() {
process.exit(0);
}
+ if (process.env.SUPERSET_PROFILE === "local") {
+ console.log("[patch-dev-protocol] Skipping - local profile");
+ process.exit(0);
+ }
+
// Prefer path-derived name so stale .env values never override the active worktree.
const { bundleDisplayWorkspaceName, workspaceName, worktreePath } =
resolveWorkspaceIdentity({
diff --git a/apps/desktop/src/main/env.main.ts b/apps/desktop/src/main/env.main.ts
index 13fe2e1af5a..6fa5939b502 100644
--- a/apps/desktop/src/main/env.main.ts
+++ b/apps/desktop/src/main/env.main.ts
@@ -9,23 +9,106 @@
import { createEnv } from "@t3-oss/env-core";
import { z } from "zod/v4";
+// NOTE: deployment-profile checks are inlined here rather than imported from
+// @superset/shared/deployment-profile because electron.vite.config.ts does
+// `await import("./src/main/env.main")` at config-load time, which Node's ESM
+// loader handles directly (no Vite transform) — and Node can't load `.ts`
+// files from sibling workspace packages. Keep this in sync with shared/.
+function isTruthyFlag(value: string | undefined): boolean {
+ return value === "1" || value === "true";
+}
+
+type MainDeploymentProfile = "cloud" | "local" | "ci" | "internal";
+const VALID_PROFILES: MainDeploymentProfile[] = [
+ "cloud",
+ "local",
+ "ci",
+ "internal",
+];
+
+function getExplicitProfile(): MainDeploymentProfile | undefined {
+ const explicitProfile = process.env.SUPERSET_PROFILE;
+ if (!explicitProfile) return undefined;
+ if (VALID_PROFILES.includes(explicitProfile as MainDeploymentProfile)) {
+ return explicitProfile as MainDeploymentProfile;
+ }
+ throw new Error(
+ `Invalid SUPERSET_PROFILE="${explicitProfile}". Expected one of: ${VALID_PROFILES.join(
+ ", ",
+ )}.`,
+ );
+}
+
+function getDeploymentProfile(): MainDeploymentProfile {
+ if (isTruthyFlag(process.env.VERCEL) || process.env.VERCEL_ENV) {
+ return "cloud";
+ }
+ const explicitProfile = getExplicitProfile();
+ if (explicitProfile) return explicitProfile;
+ if (isTruthyFlag(process.env.CI)) return "ci";
+ return "internal";
+}
+
+export const deploymentProfile = getDeploymentProfile();
+const isStrict =
+ deploymentProfile === "cloud" || deploymentProfile === "internal";
+const skipValidation =
+ !isStrict || isTruthyFlag(process.env.SKIP_ENV_VALIDATION);
+
export const env = createEnv({
server: {
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
- NEXT_PUBLIC_API_URL: z.url().default("https://api.superset.sh"),
- NEXT_PUBLIC_STREAMS_URL: z.url().default("https://streams.superset.sh"),
+ // In dev builds (NODE_ENV=development) the URL defaults switch to
+ // localhost so fresh-clone local contributors never silently sync
+ // against hosted production endpoints.
+ NEXT_PUBLIC_API_URL: z
+ .url()
+ .default(
+ process.env.NODE_ENV === "development"
+ ? "http://localhost:4641"
+ : "https://api.superset.sh",
+ ),
+ NEXT_PUBLIC_STREAMS_URL: z
+ .url()
+ .default(
+ process.env.NODE_ENV === "development"
+ ? "http://localhost:4647"
+ : "https://streams.superset.sh",
+ ),
NEXT_PUBLIC_ELECTRIC_URL: z
.url()
- .default("https://electric-proxy.avi-6ac.workers.dev"),
- NEXT_PUBLIC_WEB_URL: z.url().default("https://app.superset.sh"),
+ .default(
+ process.env.NODE_ENV === "development"
+ ? "https://localhost:4650"
+ : "https://electric-proxy.avi-6ac.workers.dev",
+ ),
+ NEXT_PUBLIC_WEB_URL: z
+ .url()
+ .default(
+ process.env.NODE_ENV === "development"
+ ? "http://localhost:4640"
+ : "https://app.superset.sh",
+ ),
NEXT_PUBLIC_MARKETING_URL: z.url().default("https://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(),
- STREAMS_URL: z.url().default("https://superset-stream.fly.dev"),
- RELAY_URL: z.url().default("https://relay.superset.sh"),
+ STREAMS_URL: z
+ .url()
+ .default(
+ process.env.NODE_ENV === "development"
+ ? "http://localhost:4647"
+ : "https://superset-stream.fly.dev",
+ ),
+ RELAY_URL: z
+ .url()
+ .default(
+ process.env.NODE_ENV === "development"
+ ? "http://localhost:4653"
+ : "https://relay.superset.sh",
+ ),
},
runtimeEnv: {
@@ -45,9 +128,7 @@ export const env = createEnv({
RELAY_URL: process.env.RELAY_URL,
},
emptyStringAsUndefined: true,
- // Only allow skipping validation in development (never in production)
- skipValidation:
- process.env.NODE_ENV === "development" && !!process.env.SKIP_ENV_VALIDATION,
+ skipValidation,
// Main process runs in trusted Node.js environment
isServer: true,
diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts
index 181fde3e22e..ad5053c31d5 100644
--- a/apps/desktop/src/main/index.ts
+++ b/apps/desktop/src/main/index.ts
@@ -1,6 +1,7 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { settings } from "@superset/local-db";
+import { isLocalProfile } from "@superset/shared/deployment-profile";
import {
app,
BrowserWindow,
@@ -28,6 +29,7 @@ import { initAppState } from "./lib/app-state";
import { requestAppleEventsAccess } from "./lib/apple-events-permission";
import { isUpdateReadyToInstall, setupAutoUpdater } from "./lib/auto-updater";
import { installBundledCliShim } from "./lib/bundled-cli";
+import { ensureDevAuthToken } from "./lib/dev-auto-sign-in";
import { resolveDevWorkspaceName } from "./lib/dev-workspace-name";
import { setWorkspaceDockIcon } from "./lib/dock-icon";
import { loadWebviewBrowserExtension } from "./lib/extensions";
@@ -55,6 +57,13 @@ import { MainWindow } from "./windows/main";
console.log("[main] Local database ready:", !!localDb);
const IS_DEV = process.env.NODE_ENV === "development";
+// Local profile only: expose Chrome DevTools Protocol for headless testing
+// (e.g. import/host-service checks). Skip in internal / cloud profiles.
+if (IS_DEV && isLocalProfile()) {
+ app.commandLine.appendSwitch("remote-debugging-port", "9333");
+ app.commandLine.appendSwitch("remote-allow-origins", "*");
+}
+
void applyShellEnvToProcess().catch((error) => {
console.error("[main] Failed to apply shell environment:", error);
});
@@ -418,6 +427,14 @@ if (!gotTheLock) {
console.error("[main] Failed to install bundled CLI shim:", error);
}
+ // Local profile only: auto-sign-in as the seed admin if no token is on disk.
+ // Fire-and-forget — the function polls the API for readiness internally
+ // (Turbo starts services concurrently, the API may still be compiling).
+ // AuthProvider in the renderer subscribes to auth.onTokenChanged and
+ // will re-hydrate when the token lands, so window creation doesn't
+ // block on this.
+ void ensureDevAuthToken();
+
// Discover and adopt host-services that survived a previous quit
// before the tray initializes, so it shows accurate status immediately.
await getHostServiceCoordinator().discoverAll();
diff --git a/apps/desktop/src/main/lib/dev-auto-sign-in.ts b/apps/desktop/src/main/lib/dev-auto-sign-in.ts
new file mode 100644
index 00000000000..10c33969d04
--- /dev/null
+++ b/apps/desktop/src/main/lib/dev-auto-sign-in.ts
@@ -0,0 +1,165 @@
+import { isLocalProfile } from "@superset/shared/deployment-profile";
+import { env as mainEnv } from "main/env.main";
+import {
+ loadToken,
+ saveToken,
+} from "../../lib/trpc/routers/auth/utils/auth-functions";
+
+const DEV_EMAIL = "admin@local.test";
+const DEV_PASSWORD = "supersetdev";
+const DEV_NAME = "Local Admin";
+const TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30;
+const DEV_AUTH_TIMEOUT_MS = 8000;
+
+// API may take a few seconds to compile on first dev launch (Turbo starts
+// services concurrently). Poll /api/auth/ok before giving up.
+const HEALTH_POLL_INTERVAL_MS = 1000;
+const HEALTH_POLL_TIMEOUT_MS = 60_000;
+
+interface SignInResponse {
+ token?: string;
+ user?: { id: string };
+}
+
+interface AuthErrorBody {
+ code?: string;
+ message?: string;
+}
+
+interface SessionResponse {
+ user?: unknown;
+}
+
+async function postAuth(
+ path: string,
+ body: Record,
+): Promise<{ ok: boolean; status: number; data: T | AuthErrorBody }> {
+ const res = await fetch(`${mainEnv.NEXT_PUBLIC_API_URL}${path}`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Origin: mainEnv.NEXT_PUBLIC_API_URL,
+ },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(DEV_AUTH_TIMEOUT_MS),
+ });
+ const data = (await res.json().catch(() => ({}))) as T | AuthErrorBody;
+ return { ok: res.ok, status: res.status, data };
+}
+
+async function waitForApiReady(): Promise {
+ const start = Date.now();
+ const url = `${mainEnv.NEXT_PUBLIC_API_URL}/api/auth/ok`;
+ while (Date.now() - start < HEALTH_POLL_TIMEOUT_MS) {
+ try {
+ const res = await fetch(url, {
+ signal: AbortSignal.timeout(2000),
+ });
+ if (res.ok) return true;
+ } catch {
+ // connection refused / timeout — keep polling
+ }
+ await new Promise((r) => setTimeout(r, HEALTH_POLL_INTERVAL_MS));
+ }
+ return false;
+}
+
+async function isStoredTokenValid(token: string): Promise {
+ try {
+ const res = await fetch(
+ `${mainEnv.NEXT_PUBLIC_API_URL}/api/auth/get-session`,
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ signal: AbortSignal.timeout(DEV_AUTH_TIMEOUT_MS),
+ },
+ );
+ if (!res.ok) return false;
+
+ const data = (await res.json().catch(() => null)) as SessionResponse | null;
+ return !!data?.user;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Dev-only: in the local profile, sign in (or sign up) as the seed
+ * admin user and persist the token so the renderer's AuthProvider can
+ * hydrate normally — no special renderer code.
+ *
+ * Polls the API for readiness before attempting sign-in (Turbo starts
+ * services concurrently and the API may still be compiling on first
+ * launch). Best-effort: failure is logged but doesn't crash boot.
+ */
+export async function ensureDevAuthToken(): Promise {
+ // Local profile only — internal devs, self-hosters, and prod all use real auth.
+ if (!isLocalProfile()) return;
+
+ const stored = await loadToken();
+ if (stored.token && stored.expiresAt) {
+ const expiresAt = new Date(stored.expiresAt);
+ const isExpired =
+ Number.isNaN(expiresAt.getTime()) || expiresAt < new Date();
+ if (!isExpired) {
+ const ready = await waitForApiReady();
+ if (!ready) {
+ console.warn(
+ `[dev-auto-sign-in] API at ${mainEnv.NEXT_PUBLIC_API_URL} did not respond within ${HEALTH_POLL_TIMEOUT_MS}ms — skipping. Use the sign-in form once the API is up.`,
+ );
+ return;
+ }
+
+ if (await isStoredTokenValid(stored.token)) return;
+
+ console.log("[dev-auto-sign-in] stored token is stale; refreshing");
+ }
+ }
+
+ const ready = await waitForApiReady();
+ if (!ready) {
+ console.warn(
+ `[dev-auto-sign-in] API at ${mainEnv.NEXT_PUBLIC_API_URL} did not respond within ${HEALTH_POLL_TIMEOUT_MS}ms — skipping. Use the sign-in form once the API is up.`,
+ );
+ return;
+ }
+
+ try {
+ let signIn = await postAuth("/api/auth/sign-in/email", {
+ email: DEV_EMAIL,
+ password: DEV_PASSWORD,
+ });
+
+ const errBody = signIn.data as AuthErrorBody;
+ if (!signIn.ok && errBody.code === "INVALID_EMAIL_OR_PASSWORD") {
+ const signUp = await postAuth("/api/auth/sign-up/email", {
+ email: DEV_EMAIL,
+ password: DEV_PASSWORD,
+ name: DEV_NAME,
+ });
+ if (!signUp.ok) {
+ const e = signUp.data as AuthErrorBody;
+ throw new Error(`dev sign-up failed (${signUp.status}): ${e.message}`);
+ }
+ signIn = await postAuth("/api/auth/sign-in/email", {
+ email: DEV_EMAIL,
+ password: DEV_PASSWORD,
+ });
+ }
+
+ if (!signIn.ok) {
+ const e = signIn.data as AuthErrorBody;
+ throw new Error(`dev sign-in failed (${signIn.status}): ${e.message}`);
+ }
+ const token = (signIn.data as SignInResponse).token;
+ if (!token) throw new Error("dev sign-in: no token in response");
+
+ const expiresAt = new Date(Date.now() + TOKEN_TTL_MS).toISOString();
+ await saveToken({ token, expiresAt });
+ console.log(`[dev-auto-sign-in] signed in as ${DEV_EMAIL}`);
+ } catch (err) {
+ console.warn(
+ `[dev-auto-sign-in] failed (is the API up at ${mainEnv.NEXT_PUBLIC_API_URL}?):`,
+ err,
+ );
+ }
+}
diff --git a/apps/desktop/src/main/lib/dev-workspace-name.ts b/apps/desktop/src/main/lib/dev-workspace-name.ts
index ee730f59e34..472a74404e6 100644
--- a/apps/desktop/src/main/lib/dev-workspace-name.ts
+++ b/apps/desktop/src/main/lib/dev-workspace-name.ts
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import { workspaces, worktrees } from "@superset/local-db";
+import { isLocalProfile } from "@superset/shared/deployment-profile";
import BetterSqlite3 from "better-sqlite3";
import { and, desc, eq, isNull } from "drizzle-orm";
import { getWorkspaceName as getEnvWorkspaceName } from "shared/env.shared";
@@ -102,16 +103,22 @@ export function resolveDevWorkspaceName(
): string | undefined {
if (!IS_DEV) return undefined;
+ const workspaceNameFromEnv = getEnvWorkspaceName();
const segments = getWorktreeSegmentsFromCwd(cwd);
- if (!segments) return getEnvWorkspaceName();
+ if (!segments) return workspaceNameFromEnv;
const workspaceNameFromPath =
deriveWorkspaceNameFromWorktreeSegments(segments);
+
+ if (isLocalProfile()) {
+ return workspaceNameFromEnv ?? workspaceNameFromPath;
+ }
+
const worktreePath = getWorktreePathFromSegments(segments);
const workspaceNameFromDb = worktreePath
? (getWorkspaceNameForPathFromCurrentDb(worktreePath) ??
getWorkspaceNameForPathFromProdDb(worktreePath))
: undefined;
- return workspaceNameFromDb ?? workspaceNameFromPath ?? getEnvWorkspaceName();
+ return workspaceNameFromDb ?? workspaceNameFromPath ?? workspaceNameFromEnv;
}
diff --git a/apps/desktop/src/main/lib/relay-url/relay-url.ts b/apps/desktop/src/main/lib/relay-url/relay-url.ts
index eafc30e7f62..8574c6470cc 100644
--- a/apps/desktop/src/main/lib/relay-url/relay-url.ts
+++ b/apps/desktop/src/main/lib/relay-url/relay-url.ts
@@ -1,5 +1,5 @@
import { FEATURE_FLAGS } from "@superset/shared/constants";
-import { env } from "main/env.main";
+import { deploymentProfile, env } from "main/env.main";
import { getPosthogClient, getUserId } from "main/lib/analytics";
interface RelayUrlPayload {
@@ -16,7 +16,13 @@ interface RelayUrlPayload {
* opens land on the same URL.
*/
export async function getRelayUrl(): Promise {
- const fallback = env.RELAY_URL;
+ const usesLenientLocalEnv =
+ deploymentProfile === "local" || deploymentProfile === "ci";
+ // Vite always injects RELAY_URL, so track whether the value came from a
+ // real env var before enabling local tunnels in contributor profiles.
+ const hasExplicitRelayUrl = process.env.SUPERSET_EXPLICIT_RELAY_URL === "1";
+ const fallback =
+ usesLenientLocalEnv && !hasExplicitRelayUrl ? undefined : env.RELAY_URL;
const client = getPosthogClient();
const userId = getUserId();
if (!client || !userId) return fallback;
diff --git a/apps/desktop/src/renderer/env.renderer.ts b/apps/desktop/src/renderer/env.renderer.ts
index 445b0be9d46..495be6feee9 100644
--- a/apps/desktop/src/renderer/env.renderer.ts
+++ b/apps/desktop/src/renderer/env.renderer.ts
@@ -15,16 +15,43 @@ const envSchema = z.object({
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
- NEXT_PUBLIC_API_URL: z.url().default("https://api.superset.sh"),
- NEXT_PUBLIC_WEB_URL: z.url().default("https://app.superset.sh"),
+ // Hosted defaults for prod builds. In dev builds Vite replaces
+ // NODE_ENV with "development", so the dev-time defaults below kick in
+ // and a fresh-clone contributor never silently syncs against
+ // production Electric / API.
+ NEXT_PUBLIC_API_URL: z
+ .url()
+ .default(
+ process.env.NODE_ENV === "development"
+ ? "http://localhost:4641"
+ : "https://api.superset.sh",
+ ),
+ NEXT_PUBLIC_WEB_URL: z
+ .url()
+ .default(
+ process.env.NODE_ENV === "development"
+ ? "http://localhost:4640"
+ : "https://app.superset.sh",
+ ),
NEXT_PUBLIC_MARKETING_URL: z.url().default("https://superset.sh"),
NEXT_PUBLIC_ELECTRIC_URL: z
.url()
- .default("https://electric-proxy.avi-6ac.workers.dev"),
+ .default(
+ process.env.NODE_ENV === "development"
+ ? "https://localhost:4650"
+ : "https://electric-proxy.avi-6ac.workers.dev",
+ ),
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
NEXT_PUBLIC_POSTHOG_HOST: z.string().default("https://us.i.posthog.com"),
SENTRY_DSN_DESKTOP: z.string().optional(),
- RELAY_URL: z.url().default("https://relay.superset.sh"),
+ SUPERSET_PROFILE: z.enum(["cloud", "local", "ci", "internal"]).optional(),
+ RELAY_URL: z
+ .url()
+ .default(
+ process.env.NODE_ENV === "development"
+ ? "http://localhost:4653"
+ : "https://relay.superset.sh",
+ ),
});
/**
@@ -47,6 +74,7 @@ const rawEnv = {
| string
| undefined,
SENTRY_DSN_DESKTOP: import.meta.env.SENTRY_DSN_DESKTOP as string | undefined,
+ SUPERSET_PROFILE: process.env.SUPERSET_PROFILE,
RELAY_URL: process.env.RELAY_URL,
};
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/OpenInWorkspaceV2/OpenInWorkspaceV2.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/OpenInWorkspaceV2/OpenInWorkspaceV2.tsx
index fc0c44460f4..6fc59ad5d73 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/OpenInWorkspaceV2/OpenInWorkspaceV2.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/OpenInWorkspaceV2/OpenInWorkspaceV2.tsx
@@ -56,9 +56,9 @@ export function OpenInWorkspaceV2({ task }: OpenInWorkspaceV2Props) {
const { machineId, activeHostUrl } = hostService;
const { otherHosts } = useWorkspaceHostOptions();
const { data: session } = authClient.useSession();
- const activeOrganizationId = env.SKIP_ENV_VALIDATION
- ? MOCK_ORG_ID
- : (session?.session?.activeOrganizationId ?? null);
+ const activeOrganizationId =
+ session?.session?.activeOrganizationId ??
+ (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null);
const { submit } = useWorkspaceCreates();
const lastProjectId = useV2WorkspaceCreateDefaultsStore(
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunInWorkspacePopoverV2/RunInWorkspacePopoverV2.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunInWorkspacePopoverV2/RunInWorkspacePopoverV2.tsx
index d758e23487e..639578896d4 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunInWorkspacePopoverV2/RunInWorkspacePopoverV2.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunInWorkspacePopoverV2/RunInWorkspacePopoverV2.tsx
@@ -61,9 +61,9 @@ export function RunInWorkspacePopoverV2({
const hostService = useLocalHostService();
const { machineId, activeHostUrl } = hostService;
const { data: session } = authClient.useSession();
- const activeOrganizationId = env.SKIP_ENV_VALIDATION
- ? MOCK_ORG_ID
- : (session?.session?.activeOrganizationId ?? null);
+ const activeOrganizationId =
+ session?.session?.activeOrganizationId ??
+ (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null);
const { otherHosts } = useWorkspaceHostOptions();
const { submit } = useWorkspaceCreates();
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunIssuesInWorkspacePopover/RunIssuesInWorkspacePopover.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunIssuesInWorkspacePopover/RunIssuesInWorkspacePopover.tsx
index b2023e809f6..52c39d07df4 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunIssuesInWorkspacePopover/RunIssuesInWorkspacePopover.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunIssuesInWorkspacePopover/RunIssuesInWorkspacePopover.tsx
@@ -65,9 +65,9 @@ export function RunIssuesInWorkspacePopover({
const hostService = useLocalHostService();
const { machineId, activeHostUrl } = hostService;
const { data: session } = authClient.useSession();
- const activeOrganizationId = env.SKIP_ENV_VALIDATION
- ? MOCK_ORG_ID
- : (session?.session?.activeOrganizationId ?? null);
+ const activeOrganizationId =
+ session?.session?.activeOrganizationId ??
+ (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null);
const { otherHosts } = useWorkspaceHostOptions();
const { submit } = useWorkspaceCreates();
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/useAccessibleV2Workspaces.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/useAccessibleV2Workspaces.ts
index eb904571c63..6a95ea646b5 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/useAccessibleV2Workspaces.ts
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/useAccessibleV2Workspaces.ts
@@ -221,9 +221,9 @@ export function useAccessibleV2Workspaces(
const collections = useCollections();
const { machineId } = useLocalHostService();
- const activeOrganizationId = env.SKIP_ENV_VALIDATION
- ? MOCK_ORG_ID
- : (session?.session?.activeOrganizationId ?? null);
+ const activeOrganizationId =
+ session?.session?.activeOrganizationId ??
+ (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null);
const currentUserId = session?.user?.id ?? null;
const { data: rows = [] } = useLiveQuery(
diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions.ts
index b96c7534db5..6014b9ee911 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions.ts
+++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions.ts
@@ -26,9 +26,9 @@ export function useWorkspaceHostOptions(): UseWorkspaceHostOptionsResult {
const collections = useCollections();
const { machineId, activeHostUrl } = useLocalHostService();
- const activeOrganizationId = env.SKIP_ENV_VALIDATION
- ? MOCK_ORG_ID
- : (session?.session?.activeOrganizationId ?? null);
+ const activeOrganizationId =
+ session?.session?.activeOrganizationId ??
+ (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null);
const currentUserId = session?.user?.id ?? null;
const { data: accessibleHosts = [] } = useLiveQuery(
diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx
index da8399adba2..da6b96ba19d 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx
@@ -32,9 +32,9 @@ export function DashboardNewWorkspaceModalContent({
);
const collections = useCollections();
const { data: session } = authClient.useSession();
- const activeOrganizationId = env.SKIP_ENV_VALIDATION
- ? MOCK_ORG_ID
- : (session?.session?.activeOrganizationId ?? null);
+ const activeOrganizationId =
+ session?.session?.activeOrganizationId ??
+ (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null);
const { data: v2Projects } = useLiveQuery(
(q) =>
diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/V1ImportModal.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/V1ImportModal.tsx
index bd292fd0e90..8b127423c10 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/V1ImportModal.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/V1ImportModal.tsx
@@ -27,9 +27,9 @@ export function V1ImportModal() {
const close = useCloseV1ImportModal();
const { data: session } = authClient.useSession();
const { activeHostUrl } = useLocalHostService();
- const organizationId = env.SKIP_ENV_VALIDATION
- ? MOCK_ORG_ID
- : (session?.session?.activeOrganizationId ?? null);
+ const organizationId =
+ session?.session?.activeOrganizationId ??
+ (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null);
if (!organizationId) return null;
diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx
index dbc28401f70..4717948ec01 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx
@@ -62,9 +62,9 @@ function AuthenticatedLayout() {
const isV2CloudEnabled = useIsV2CloudEnabled();
const isSignedIn = env.SKIP_ENV_VALIDATION || !!session?.user;
- const activeOrganizationId = env.SKIP_ENV_VALIDATION
- ? MOCK_ORG_ID
- : session?.session?.activeOrganizationId;
+ const activeOrganizationId =
+ session?.session?.activeOrganizationId ??
+ (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null);
useAgentHookListener();
useUpdateListener();
diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx
index f5562295086..518fb6b4495 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx
@@ -33,9 +33,9 @@ export function preloadActiveOrganizationCollections(
export function CollectionsProvider({ children }: { children: ReactNode }) {
const { data: session, refetch: refetchSession } = authClient.useSession();
const [isSwitching, setIsSwitching] = useState(false);
- const activeOrganizationId = env.SKIP_ENV_VALIDATION
- ? MOCK_ORG_ID
- : session?.session?.activeOrganizationId;
+ const activeOrganizationId =
+ session?.session?.activeOrganizationId ??
+ (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null);
const switchOrganization = useCallback(
async (organizationId: string) => {
diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/LocalHostServiceProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/LocalHostServiceProvider.tsx
index 9f2b09c8f43..ea26b80efa9 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/LocalHostServiceProvider.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/LocalHostServiceProvider.tsx
@@ -35,9 +35,9 @@ export function LocalHostServiceProvider({
const { mutate: startHostService } =
electronTrpc.hostServiceCoordinator.start.useMutation();
- const activeOrganizationId = env.SKIP_ENV_VALIDATION
- ? MOCK_ORG_ID
- : (session?.session?.activeOrganizationId ?? null);
+ const activeOrganizationId =
+ session?.session?.activeOrganizationId ??
+ (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null);
const { data: organizations } = useLiveQuery(
(q) => q.from({ organizations: collections.organizations }),
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/HostsSettingsSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/HostsSettingsSidebar.tsx
index 0b4567a52e4..5a0c0454b7d 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/HostsSettingsSidebar.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/HostsSettingsSidebar.tsx
@@ -30,9 +30,9 @@ export function HostsSettingsSidebar({
const collections = useCollections();
const { data: session } = authClient.useSession();
- const activeOrganizationId = env.SKIP_ENV_VALIDATION
- ? MOCK_ORG_ID
- : (session?.session?.activeOrganizationId ?? null);
+ const activeOrganizationId =
+ session?.session?.activeOrganizationId ??
+ (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null);
const { data: hosts = [] } = useLiveQuery(
(q) =>
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx
index 643c8e8adbd..3c50905ddd5 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx
@@ -16,9 +16,9 @@ function HostsIndexPage() {
const { data: session } = authClient.useSession();
const navigate = useNavigate();
- const activeOrganizationId = env.SKIP_ENV_VALIDATION
- ? MOCK_ORG_ID
- : (session?.session?.activeOrganizationId ?? null);
+ const activeOrganizationId =
+ session?.session?.activeOrganizationId ??
+ (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null);
const { data: hosts = [] } = useLiveQuery(
(q) =>
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx
index e19226947d5..94f2d8da568 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx
@@ -29,9 +29,9 @@ function ProjectDetailPage() {
const { data: session } = authClient.useSession();
const searchQuery = useSettingsSearchQuery();
- const activeOrganizationId = env.SKIP_ENV_VALIDATION
- ? MOCK_ORG_ID
- : (session?.session?.activeOrganizationId ?? null);
+ const activeOrganizationId =
+ session?.session?.activeOrganizationId ??
+ (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null);
const { data: v2Match = [] } = useLiveQuery(
(q) =>
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx
index ce7d751ecc4..bedd6a8612b 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx
@@ -31,9 +31,9 @@ export function ProjectsSettingsSidebar({
const collections = useCollections();
const { data: session } = authClient.useSession();
- const activeOrganizationId = env.SKIP_ENV_VALIDATION
- ? MOCK_ORG_ID
- : (session?.session?.activeOrganizationId ?? null);
+ const activeOrganizationId =
+ session?.session?.activeOrganizationId ??
+ (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null);
const { data: groups = [] } =
electronTrpc.workspaces.getAllGrouped.useQuery();
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx
index 5db0608732d..60605248b49 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx
@@ -17,9 +17,9 @@ function ProjectsIndexPage() {
const { data: session } = authClient.useSession();
const navigate = useNavigate();
- const activeOrganizationId = env.SKIP_ENV_VALIDATION
- ? MOCK_ORG_ID
- : (session?.session?.activeOrganizationId ?? null);
+ const activeOrganizationId =
+ session?.session?.activeOrganizationId ??
+ (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null);
const { data: groups = [] } =
electronTrpc.workspaces.getAllGrouped.useQuery();
diff --git a/apps/desktop/src/renderer/routes/_authenticated/setup/project/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/setup/project/page.tsx
index fe5b99a03f3..10626d1ce1f 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/setup/project/page.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/setup/project/page.tsx
@@ -26,9 +26,9 @@ function OnboardingProjectPage() {
const markSkipped = useOnboardingStore((s) => s.markSkipped);
const { data: session } = authClient.useSession();
- const activeOrganizationId = env.SKIP_ENV_VALIDATION
- ? MOCK_ORG_ID
- : (session?.session?.activeOrganizationId ?? null);
+ const activeOrganizationId =
+ session?.session?.activeOrganizationId ??
+ (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null);
const { data: projects = [], isLoading } = useQuery({
queryKey: ["onboarding", "v2Projects", activeOrganizationId],
diff --git a/apps/desktop/src/renderer/routes/sign-in/components/LocalDevAuthForm/LocalDevAuthForm.tsx b/apps/desktop/src/renderer/routes/sign-in/components/LocalDevAuthForm/LocalDevAuthForm.tsx
new file mode 100644
index 00000000000..e0f1f6bc881
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/sign-in/components/LocalDevAuthForm/LocalDevAuthForm.tsx
@@ -0,0 +1,148 @@
+import { Button } from "@superset/ui/button";
+import { Input } from "@superset/ui/input";
+import { Label } from "@superset/ui/label";
+import { useNavigate } from "@tanstack/react-router";
+import { type FormEvent, useState } from "react";
+import { env } from "renderer/env.renderer";
+import { setAuthToken } from "renderer/lib/auth-client";
+import { electronTrpc } from "renderer/lib/electron-trpc";
+
+const DEV_EMAIL = "admin@local.test";
+const DEV_PASSWORD = "supersetdev";
+const DEV_NAME = "Local Admin";
+const TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30;
+
+interface AuthResponse {
+ token?: string;
+}
+
+interface AuthErrorBody {
+ code?: string;
+ message?: string;
+}
+
+async function postAuth(
+ path: string,
+ body: Record,
+): Promise<{
+ ok: boolean;
+ status: number;
+ data: AuthResponse | AuthErrorBody;
+}> {
+ const res = await fetch(`${env.NEXT_PUBLIC_API_URL}${path}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "omit",
+ body: JSON.stringify(body),
+ });
+ const data = (await res.json().catch(() => ({}))) as
+ | AuthResponse
+ | AuthErrorBody;
+ return { ok: res.ok, status: res.status, data };
+}
+
+export function LocalDevAuthForm() {
+ const navigate = useNavigate();
+ const persistToken = electronTrpc.auth.persistToken.useMutation();
+ const [email, setEmail] = useState(DEV_EMAIL);
+ const [password, setPassword] = useState(DEV_PASSWORD);
+ const [error, setError] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+
+ const onSubmit = async (event: FormEvent) => {
+ event.preventDefault();
+ setError(null);
+ setSubmitting(true);
+
+ try {
+ let signIn = await postAuth("/api/auth/sign-in/email", {
+ email,
+ password,
+ });
+
+ const errBody = signIn.data as AuthErrorBody;
+ if (
+ !signIn.ok &&
+ errBody.code === "INVALID_EMAIL_OR_PASSWORD" &&
+ email === DEV_EMAIL &&
+ password === DEV_PASSWORD
+ ) {
+ const signUp = await postAuth("/api/auth/sign-up/email", {
+ email,
+ password,
+ name: DEV_NAME,
+ });
+ if (!signUp.ok) {
+ const signUpError = signUp.data as AuthErrorBody;
+ throw new Error(
+ signUpError.message ?? `Sign-up failed (${signUp.status})`,
+ );
+ }
+
+ signIn = await postAuth("/api/auth/sign-in/email", {
+ email,
+ password,
+ });
+ }
+
+ if (!signIn.ok) {
+ const signInError = signIn.data as AuthErrorBody;
+ throw new Error(
+ signInError.message ?? `Sign-in failed (${signIn.status})`,
+ );
+ }
+
+ const token = (signIn.data as AuthResponse).token;
+ if (!token) throw new Error("Sign-in did not return a token");
+
+ const expiresAt = new Date(Date.now() + TOKEN_TTL_MS).toISOString();
+ await persistToken.mutateAsync({ token, expiresAt });
+ setAuthToken(token);
+ await navigate({ to: "/workspace", replace: true });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Sign-in failed");
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/desktop/src/renderer/routes/sign-in/components/LocalDevAuthForm/index.ts b/apps/desktop/src/renderer/routes/sign-in/components/LocalDevAuthForm/index.ts
new file mode 100644
index 00000000000..9514c0436a0
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/sign-in/components/LocalDevAuthForm/index.ts
@@ -0,0 +1 @@
+export { LocalDevAuthForm } from "./LocalDevAuthForm";
diff --git a/apps/desktop/src/renderer/routes/sign-in/page.tsx b/apps/desktop/src/renderer/routes/sign-in/page.tsx
index ddfc693a961..d7d7f5eb4e2 100644
--- a/apps/desktop/src/renderer/routes/sign-in/page.tsx
+++ b/apps/desktop/src/renderer/routes/sign-in/page.tsx
@@ -7,6 +7,7 @@ import { FcGoogle } from "react-icons/fc";
import { env } from "renderer/env.renderer";
import { track } from "renderer/lib/analytics";
import { electronTrpc } from "renderer/lib/electron-trpc";
+import { LocalDevAuthForm } from "./components/LocalDevAuthForm";
import { SupersetLogo } from "./components/SupersetLogo";
import { useSessionRecovery } from "./hooks/useSessionRecovery";
@@ -17,9 +18,10 @@ export const Route = createFileRoute("/sign-in/")({
function SignInPage() {
const signInMutation = electronTrpc.auth.signIn.useMutation();
const { hasLocalToken, isPending, session } = useSessionRecovery();
+ const isLocalProfile = env.SUPERSET_PROFILE === "local";
- // Dev bypass: skip sign-in entirely
- if (env.SKIP_ENV_VALIDATION) {
+ // Dev bypass: AuthProvider handles auto-sign-in; if session lands, redirect
+ if (env.SKIP_ENV_VALIDATION && session?.user) {
return ;
}
@@ -63,7 +65,11 @@ function SignInPage() {
-
+
+ {isLocalProfile &&
}
+
+ {isLocalProfile &&
}
+