From 59547ef4ec56259c6af24d5659e4634109bb4ca9 Mon Sep 17 00:00:00 2001 From: BilalG1 Date: Tue, 16 Jun 2026 10:57:59 -0700 Subject: [PATCH] Detect conflicting Hexclave and Stack env vars (#1604) Summary: Detects conflicting non-empty HEXCLAVE_* and STACK_* values across shared env helpers, dashboard public envs, generated SDK env access, Docker scripts, CLI/docs/examples, and related tests. Verification: pnpm test run packages/shared/src/utils/env.test.tsx apps/dashboard/src/lib/env.test.tsx packages/cli/src/lib/auth.test.ts; targeted lint/typecheck across touched workspaces; bash -n/node --check for changed scripts; node docker/local-emulator/generate-env-development.mjs --check. --- ## Summary by cubic Detects and blocks conflicting `HEXCLAVE_*` and `STACK_*` env vars across the monorepo. Prefers `HEXCLAVE_*`, falls back to `STACK_*` when empty, and fails fast when both are set to different values. - **New Features** - Added conflict-aware env resolvers used across apps, CLI, docs, examples, and Docker (build/runtime). - Validates critical vars (e.g., database connection, API/dashboard URLs, emulator flags, tokens) and ignores post-build sentinel values. - Prisma, Next.js, and Docker startup now error on mismatched values; CLI enforces project ID/key conflicts; tests added. - **Migration** - If both names are set with different values, builds/tests/scripts will error. Set only `HEXCLAVE_*` or make both equal. - Update `.env`, CI secrets, and Docker envs to use `HEXCLAVE_*`. Keep `STACK_*` only as a temporary fallback. Written for commit 4d63fa3bad73a72c490f55b2129baf4c24db2ac4. Summary will update on new commits. Review in cubic --- apps/backend/prisma.config.ts | 12 +++- apps/backend/prisma/seed.ts | 34 ++++----- .../auto-migrations/auto-migration.tests.ts | 22 ++++-- .../auto-migrations/migration-tests.test.ts | 18 ++++- apps/dashboard/next.config.mjs | 11 ++- apps/dashboard/src/lib/env.test.tsx | 54 +++++++++++++++ apps/dashboard/src/lib/env.tsx | 53 +++++++++----- .../credential-scanning/revoke.test.ts | 9 ++- .../v1/internal/config-local-emulator.test.ts | 3 +- .../v1/internal/failed-emails-digest.test.ts | 7 +- .../api/v1/internal/feedback.test.ts | 3 +- .../internal/local-emulator-project.test.ts | 3 +- .../api/v1/internal/transactions.test.ts | 3 +- apps/e2e/tests/general/cli.test.ts | 3 +- apps/e2e/tests/helpers.ts | 4 +- apps/hosted-components/src/routes/__root.tsx | 7 +- .../scripts/spacetime-publish.mjs | 16 ++++- .../internal-tool/scripts/spacetime-token.mjs | 11 ++- apps/internal-tool/src/app/app-client.tsx | 14 +++- .../generate-env-development.mjs | 11 ++- docker/local-emulator/rotate-secrets.sh | 4 ++ docker/server/entrypoint.sh | 4 ++ docs/src/components/api/enhanced-api-page.tsx | 12 ++-- docs/src/components/chat/ai-chat.tsx | 3 +- docs/src/lib/env.ts | 6 ++ docs/src/stack.ts | 9 +-- examples/convex/convex/auth.config.ts | 11 ++- examples/demo/cli-sim.mjs | 13 +++- packages/cli/src/lib/auth.test.ts | 17 +++++ packages/cli/src/lib/auth.ts | 21 ++++-- packages/shared/src/utils/env.test.tsx | 43 ++++++++++++ packages/shared/src/utils/env.tsx | 34 ++++++--- packages/shared/src/utils/errors.tsx | 7 +- packages/template/scripts/generate-env.ts | 69 ++++++++++++++++++- 34 files changed, 455 insertions(+), 96 deletions(-) create mode 100644 apps/dashboard/src/lib/env.test.tsx create mode 100644 docs/src/lib/env.ts create mode 100644 packages/shared/src/utils/env.test.tsx diff --git a/apps/backend/prisma.config.ts b/apps/backend/prisma.config.ts index 166cec3fc..ec6ca0620 100644 --- a/apps/backend/prisma.config.ts +++ b/apps/backend/prisma.config.ts @@ -1,6 +1,15 @@ import 'dotenv/config' import { defineConfig, env } from 'prisma/config' +function getDatabaseConnectionStringEnvVarName() { + const hexclaveValue = process.env.HEXCLAVE_DATABASE_CONNECTION_STRING; + const stackValue = process.env.STACK_DATABASE_CONNECTION_STRING; + if (hexclaveValue && stackValue && hexclaveValue !== stackValue) { + throw new Error("Environment variables HEXCLAVE_DATABASE_CONNECTION_STRING and STACK_DATABASE_CONNECTION_STRING are both set to different values. Remove one of them or set them to the same value."); + } + return hexclaveValue ? 'HEXCLAVE_DATABASE_CONNECTION_STRING' : 'STACK_DATABASE_CONNECTION_STRING'; +} + export default defineConfig({ schema: 'prisma/schema.prisma', migrations: { @@ -11,7 +20,7 @@ export default defineConfig({ // Hexclave rebrand: prefer the canonical name, fall back to the legacy one // (empty counts as unset — the checked-in .env templates define empty placeholders). // eslint-disable-next-line no-restricted-properties - url: env(process.env.HEXCLAVE_DATABASE_CONNECTION_STRING ? 'HEXCLAVE_DATABASE_CONNECTION_STRING' : 'STACK_DATABASE_CONNECTION_STRING'), + url: env(getDatabaseConnectionStringEnvVarName()), }, experimental: { externalTables: true, @@ -27,4 +36,3 @@ export default defineConfig({ ], }, }) - diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index a7923aac5..38b3cf125 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -19,6 +19,7 @@ import { DEFAULT_EMAIL_THEME_ID } from '@hexclave/shared/dist/helpers/emails'; import { AdminUserProjectsCrud } from '@hexclave/shared/dist/interface/crud/projects'; import { ITEM_IDS, PLAN_LIMITS } from '@hexclave/shared/dist/plans'; import { DayInterval } from '@hexclave/shared/dist/utils/dates'; +import { getEnvVariable } from '@hexclave/shared/dist/utils/env'; import { throwErr } from '@hexclave/shared/dist/utils/errors'; import { typedEntries, typedFromEntries } from '@hexclave/shared/dist/utils/objects'; @@ -60,17 +61,18 @@ export async function seed() { console.log('Seeding database...'); // Optional default admin user - const adminEmail = (process.env.HEXCLAVE_SEED_INTERNAL_PROJECT_USER_EMAIL || process.env.STACK_SEED_INTERNAL_PROJECT_USER_EMAIL); - const adminPassword = (process.env.HEXCLAVE_SEED_INTERNAL_PROJECT_USER_PASSWORD || process.env.STACK_SEED_INTERNAL_PROJECT_USER_PASSWORD); - const adminInternalAccess = (process.env.HEXCLAVE_SEED_INTERNAL_PROJECT_USER_INTERNAL_ACCESS || process.env.STACK_SEED_INTERNAL_PROJECT_USER_INTERNAL_ACCESS) === 'true'; - const adminGithubId = (process.env.HEXCLAVE_SEED_INTERNAL_PROJECT_USER_GITHUB_ID || process.env.STACK_SEED_INTERNAL_PROJECT_USER_GITHUB_ID); + const adminEmail = getEnvVariable("STACK_SEED_INTERNAL_PROJECT_USER_EMAIL", ""); + const adminPassword = getEnvVariable("STACK_SEED_INTERNAL_PROJECT_USER_PASSWORD", ""); + const adminInternalAccess = getEnvVariable("STACK_SEED_INTERNAL_PROJECT_USER_INTERNAL_ACCESS", "") === 'true'; + const adminGithubId = getEnvVariable("STACK_SEED_INTERNAL_PROJECT_USER_GITHUB_ID", ""); // dashboard settings - const dashboardDomain = (process.env.NEXT_PUBLIC_HEXCLAVE_DASHBOARD_URL || process.env.NEXT_PUBLIC_STACK_DASHBOARD_URL); - const oauthProviderIds = (process.env.HEXCLAVE_SEED_INTERNAL_PROJECT_OAUTH_PROVIDERS || process.env.STACK_SEED_INTERNAL_PROJECT_OAUTH_PROVIDERS)?.split(',') ?? []; - const otpEnabled = (process.env.HEXCLAVE_SEED_INTERNAL_PROJECT_OTP_ENABLED || process.env.STACK_SEED_INTERNAL_PROJECT_OTP_ENABLED) === 'true'; - const signUpEnabled = (process.env.HEXCLAVE_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED || process.env.STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED) === 'true'; - const allowLocalhost = (process.env.HEXCLAVE_SEED_INTERNAL_PROJECT_ALLOW_LOCALHOST || process.env.STACK_SEED_INTERNAL_PROJECT_ALLOW_LOCALHOST) === 'true'; + const dashboardDomain = getEnvVariable("NEXT_PUBLIC_STACK_DASHBOARD_URL", ""); + const rawOauthProviderIds = getEnvVariable("STACK_SEED_INTERNAL_PROJECT_OAUTH_PROVIDERS", ""); + const oauthProviderIds = rawOauthProviderIds ? rawOauthProviderIds.split(',') : []; + const otpEnabled = getEnvVariable("STACK_SEED_INTERNAL_PROJECT_OTP_ENABLED", "") === 'true'; + const signUpEnabled = getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED", "") === 'true'; + const allowLocalhost = getEnvVariable("STACK_SEED_INTERNAL_PROJECT_ALLOW_LOCALHOST", "") === 'true'; const localEmulatorEnabled = isLocalEmulatorEnabled(); @@ -366,8 +368,8 @@ export async function seed() { // seed, email/svix, clickhouse). The emulator CLI authenticates against the // internal project using the pck stored here, so it must land before the rest // of the seed even if something later fails. - const isLocalEmulator = (process.env.NEXT_PUBLIC_HEXCLAVE_IS_LOCAL_EMULATOR || process.env.NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR) === 'true'; - const rawPck = (process.env.HEXCLAVE_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY || process.env.STACK_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY); + const isLocalEmulator = getEnvVariable("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR", "") === 'true'; + const rawPck = getEnvVariable("STACK_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY", ""); if (isLocalEmulator && !rawPck) { // Emulator images build before a per-VM pck is available. Runtime boots set // HEXCLAVE_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY from the VM-generated @@ -377,11 +379,11 @@ export async function seed() { const keySet = { publishableClientKey: rawPck || throwErr('HEXCLAVE_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY is not set'), secretServerKey: isLocalEmulator - ? ((process.env.HEXCLAVE_INTERNAL_PROJECT_SECRET_SERVER_KEY || process.env.STACK_INTERNAL_PROJECT_SECRET_SERVER_KEY) ?? null) - : ((process.env.HEXCLAVE_INTERNAL_PROJECT_SECRET_SERVER_KEY || process.env.STACK_INTERNAL_PROJECT_SECRET_SERVER_KEY) || throwErr('HEXCLAVE_INTERNAL_PROJECT_SECRET_SERVER_KEY is not set')), + ? (getEnvVariable("STACK_INTERNAL_PROJECT_SECRET_SERVER_KEY", "") || null) + : (getEnvVariable("STACK_INTERNAL_PROJECT_SECRET_SERVER_KEY", "") || throwErr('HEXCLAVE_INTERNAL_PROJECT_SECRET_SERVER_KEY is not set')), superSecretAdminKey: isLocalEmulator - ? ((process.env.HEXCLAVE_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY || process.env.STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY) ?? null) - : ((process.env.HEXCLAVE_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY || process.env.STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY) || throwErr('HEXCLAVE_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY is not set')), + ? (getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY", "") || null) + : (getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY", "") || throwErr('HEXCLAVE_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY is not set')), }; await globalPrismaClient.apiKeySet.upsert({ @@ -401,7 +403,7 @@ export async function seed() { console.log('Updated internal API key set'); } - const shouldSeedDummyProject = (process.env.HEXCLAVE_SEED_ENABLE_DUMMY_PROJECT || process.env.STACK_SEED_ENABLE_DUMMY_PROJECT) === 'true'; + const shouldSeedDummyProject = getEnvVariable("STACK_SEED_ENABLE_DUMMY_PROJECT", "") === 'true'; if (shouldSeedDummyProject) { await seedDummyProject({ projectId: DUMMY_PROJECT_ID, diff --git a/apps/backend/src/auto-migrations/auto-migration.tests.ts b/apps/backend/src/auto-migrations/auto-migration.tests.ts index 56f62adec..3b128c11a 100644 --- a/apps/backend/src/auto-migrations/auto-migration.tests.ts +++ b/apps/backend/src/auto-migrations/auto-migration.tests.ts @@ -6,11 +6,25 @@ import { applyMigrations, runMigrationNeeded } from "./index"; const TEST_DB_PREFIX = 'stack_auth_test_db'; +const getDatabaseConnectionString = (): string => { + // @ts-ignore - ImportMeta.env is provided by Vite + const hexclaveValue: string | undefined = import.meta.env.HEXCLAVE_DATABASE_CONNECTION_STRING; + // @ts-ignore - ImportMeta.env is provided by Vite + const stackValue: string | undefined = import.meta.env.STACK_DATABASE_CONNECTION_STRING; + if (hexclaveValue && stackValue && hexclaveValue !== stackValue) { + throw new Error("Environment variables HEXCLAVE_DATABASE_CONNECTION_STRING and STACK_DATABASE_CONNECTION_STRING are both set to different values. Remove one of them or set them to the same value."); + } + const value = hexclaveValue || stackValue; + if (!value) { + throw new Error("Missing environment variable HEXCLAVE_DATABASE_CONNECTION_STRING or STACK_DATABASE_CONNECTION_STRING."); + } + return value; +}; + const getTestDbURL = (testDbName: string) => { - // @ts-ignore - ImportMeta.env is provided by Vite - const base = (import.meta.env.HEXCLAVE_DATABASE_CONNECTION_STRING || import.meta.env.STACK_DATABASE_CONNECTION_STRING).replace(/\/[^/]*$/, ''); - // @ts-ignore - ImportMeta.env is provided by Vite - const query = (import.meta.env.HEXCLAVE_DATABASE_CONNECTION_STRING || import.meta.env.STACK_DATABASE_CONNECTION_STRING).split('?')[1] ?? ''; + const connString = getDatabaseConnectionString(); + const base = connString.replace(/\/[^/]*$/, ''); + const query = connString.split('?')[1] ?? ''; return { full: `${base}/${testDbName}`, base, diff --git a/apps/backend/src/auto-migrations/migration-tests.test.ts b/apps/backend/src/auto-migrations/migration-tests.test.ts index 32d35485f..f69270d47 100644 --- a/apps/backend/src/auto-migrations/migration-tests.test.ts +++ b/apps/backend/src/auto-migrations/migration-tests.test.ts @@ -12,9 +12,23 @@ const MIGRATIONS_DIR = path.resolve(__dirname, '../../prisma/migrations'); const TEST_DB_PREFIX = 'stack_migration_test'; -const getTestDbURL = (testDbName: string) => { +const getDatabaseConnectionString = (): string => { // @ts-ignore - ImportMeta.env is provided by Vite - const connString: string = (import.meta.env.HEXCLAVE_DATABASE_CONNECTION_STRING || import.meta.env.STACK_DATABASE_CONNECTION_STRING); + const hexclaveValue: string | undefined = import.meta.env.HEXCLAVE_DATABASE_CONNECTION_STRING; + // @ts-ignore - ImportMeta.env is provided by Vite + const stackValue: string | undefined = import.meta.env.STACK_DATABASE_CONNECTION_STRING; + if (hexclaveValue && stackValue && hexclaveValue !== stackValue) { + throw new Error("Environment variables HEXCLAVE_DATABASE_CONNECTION_STRING and STACK_DATABASE_CONNECTION_STRING are both set to different values. Remove one of them or set them to the same value."); + } + const value = hexclaveValue || stackValue; + if (!value) { + throw new Error("Missing environment variable HEXCLAVE_DATABASE_CONNECTION_STRING or STACK_DATABASE_CONNECTION_STRING."); + } + return value; +}; + +const getTestDbURL = (testDbName: string) => { + const connString = getDatabaseConnectionString(); const base = connString.replace(/\/[^/]*(\?.*)?$/, ''); const query = connString.split('?')[1] ?? ''; return { full: `${base}/${testDbName}`, base, query }; diff --git a/apps/dashboard/next.config.mjs b/apps/dashboard/next.config.mjs index eb8526961..877eeb8d4 100644 --- a/apps/dashboard/next.config.mjs +++ b/apps/dashboard/next.config.mjs @@ -43,6 +43,15 @@ const withConfiguredSentryConfig = (nextConfig) => } ); +function resolveHexclaveStackEnvVar(hexclaveName, stackName) { + const hexclaveValue = process.env[hexclaveName]; + const stackValue = process.env[stackName]; + if (hexclaveValue && stackValue && hexclaveValue !== stackValue) { + throw new Error(`Environment variables ${hexclaveName} and ${stackName} are both set to different values. Remove one of them or set them to the same value.`); + } + return hexclaveValue || stackValue || undefined; +} + /** @type {import('next').NextConfig} */ const nextConfig = { // optionally set output to "standalone" for Docker builds @@ -100,7 +109,7 @@ const nextConfig = { }, async headers() { - const isLocalEmulator = (process.env.NEXT_PUBLIC_HEXCLAVE_IS_LOCAL_EMULATOR || process.env.NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR) === "true"; + const isLocalEmulator = resolveHexclaveStackEnvVar("NEXT_PUBLIC_HEXCLAVE_IS_LOCAL_EMULATOR", "NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true"; return [ { source: "/(.*)", diff --git a/apps/dashboard/src/lib/env.test.tsx b/apps/dashboard/src/lib/env.test.tsx new file mode 100644 index 000000000..229a00378 --- /dev/null +++ b/apps/dashboard/src/lib/env.test.tsx @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +async function loadEnvModule() { + vi.resetModules(); + return await import("./env"); +} + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe("dashboard public env var dual-read", () => { + it("falls back to the legacy Stack name when the Hexclave value is empty", async () => { + vi.stubEnv("NEXT_PUBLIC_HEXCLAVE_API_URL", ""); + vi.stubEnv("NEXT_PUBLIC_STACK_API_URL", "https://stack.example.test"); + + const { getPublicEnvVar } = await loadEnvModule(); + + expect(getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL")).toBe("https://stack.example.test"); + }); + + it("allows both names when they have the same non-empty value", async () => { + vi.stubEnv("NEXT_PUBLIC_HEXCLAVE_API_URL", "https://api.example.test"); + vi.stubEnv("NEXT_PUBLIC_STACK_API_URL", "https://api.example.test"); + + const { getPublicEnvVar } = await loadEnvModule(); + + expect(getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL")).toBe("https://api.example.test"); + }); + + it("throws when both names are non-empty and different", async () => { + vi.stubEnv("NEXT_PUBLIC_HEXCLAVE_API_URL", "https://hexclave.example.test"); + vi.stubEnv("NEXT_PUBLIC_STACK_API_URL", "https://stack.example.test"); + + await expect(loadEnvModule()).rejects.toThrow(/NEXT_PUBLIC_HEXCLAVE_API_URL.*NEXT_PUBLIC_STACK_API_URL.*different values/); + }); + + it("does not treat unreplaced post-build sentinels as a conflict", async () => { + vi.stubEnv("NEXT_PUBLIC_HEXCLAVE_API_URL", "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_HEXCLAVE_API_URL"); + vi.stubEnv("NEXT_PUBLIC_STACK_API_URL", "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_STACK_API_URL"); + + await expect(loadEnvModule()).resolves.toBeDefined(); + }); + + it("prefers a real value over a sentinel value", async () => { + vi.stubEnv("NEXT_PUBLIC_HEXCLAVE_API_URL", "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_HEXCLAVE_API_URL"); + vi.stubEnv("NEXT_PUBLIC_STACK_API_URL", "https://stack.example.test"); + + const { getPublicEnvVar } = await loadEnvModule(); + + expect(getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL")).toBe("https://stack.example.test"); + }); +}); diff --git a/apps/dashboard/src/lib/env.tsx b/apps/dashboard/src/lib/env.tsx index 91469a5b6..81640df22 100644 --- a/apps/dashboard/src/lib/env.tsx +++ b/apps/dashboard/src/lib/env.tsx @@ -8,40 +8,57 @@ export function expandHexclavePortPrefix(value?: string | null) { return prefix ? value.replace(/\$\{NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81\}/g, prefix as string) : value; } +function isPostBuildEnvVarSentinel(value: string): boolean { + return value.startsWith("STACK_ENV_VAR_SENTINEL"); +} + +function resolveInlineRenamedEnvVar(hexclaveName: string, stackName: string, hexclaveValue: string | undefined, stackValue: string | undefined): string | undefined { + const usableHexclaveValue = hexclaveValue && !isPostBuildEnvVarSentinel(hexclaveValue) ? hexclaveValue : undefined; + const usableStackValue = stackValue && !isPostBuildEnvVarSentinel(stackValue) ? stackValue : undefined; + if ( + usableHexclaveValue + && usableStackValue + && usableHexclaveValue !== usableStackValue + ) { + throw new Error(`Environment variables ${hexclaveName} and ${stackName} are both set to different values. Remove one of them or set them to the same value.`); + } + return usableHexclaveValue || usableStackValue || hexclaveValue || stackValue || undefined; +} + // Hexclave rebrand: each entry prefers the NEXT_PUBLIC_HEXCLAVE_* literal, falling back // to the legacy NEXT_PUBLIC_*STACK_* literal. Both operands must stay literal // `process.env.NEXT_PUBLIC_*` references so Next.js can inline them at build time. const _inlineEnvVars = { - NEXT_PUBLIC_STACK_API_URL: process.env.NEXT_PUBLIC_HEXCLAVE_API_URL || process.env.NEXT_PUBLIC_STACK_API_URL, - NEXT_PUBLIC_STACK_DASHBOARD_URL: process.env.NEXT_PUBLIC_HEXCLAVE_DASHBOARD_URL || process.env.NEXT_PUBLIC_STACK_DASHBOARD_URL, - NEXT_PUBLIC_STACK_SVIX_SERVER_URL: process.env.NEXT_PUBLIC_HEXCLAVE_SVIX_SERVER_URL || process.env.NEXT_PUBLIC_STACK_SVIX_SERVER_URL, - NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR: process.env.NEXT_PUBLIC_HEXCLAVE_IS_LOCAL_EMULATOR || process.env.NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR, - NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT: process.env.NEXT_PUBLIC_HEXCLAVE_IS_REMOTE_DEVELOPMENT_ENVIRONMENT || process.env.NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT, - NEXT_PUBLIC_STACK_IS_PREVIEW: process.env.NEXT_PUBLIC_HEXCLAVE_IS_PREVIEW || process.env.NEXT_PUBLIC_STACK_IS_PREVIEW, + NEXT_PUBLIC_STACK_API_URL: resolveInlineRenamedEnvVar("NEXT_PUBLIC_HEXCLAVE_API_URL", "NEXT_PUBLIC_STACK_API_URL", process.env.NEXT_PUBLIC_HEXCLAVE_API_URL, process.env.NEXT_PUBLIC_STACK_API_URL), + NEXT_PUBLIC_STACK_DASHBOARD_URL: resolveInlineRenamedEnvVar("NEXT_PUBLIC_HEXCLAVE_DASHBOARD_URL", "NEXT_PUBLIC_STACK_DASHBOARD_URL", process.env.NEXT_PUBLIC_HEXCLAVE_DASHBOARD_URL, process.env.NEXT_PUBLIC_STACK_DASHBOARD_URL), + NEXT_PUBLIC_STACK_SVIX_SERVER_URL: resolveInlineRenamedEnvVar("NEXT_PUBLIC_HEXCLAVE_SVIX_SERVER_URL", "NEXT_PUBLIC_STACK_SVIX_SERVER_URL", process.env.NEXT_PUBLIC_HEXCLAVE_SVIX_SERVER_URL, process.env.NEXT_PUBLIC_STACK_SVIX_SERVER_URL), + NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR: resolveInlineRenamedEnvVar("NEXT_PUBLIC_HEXCLAVE_IS_LOCAL_EMULATOR", "NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR", process.env.NEXT_PUBLIC_HEXCLAVE_IS_LOCAL_EMULATOR, process.env.NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR), + NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT: resolveInlineRenamedEnvVar("NEXT_PUBLIC_HEXCLAVE_IS_REMOTE_DEVELOPMENT_ENVIRONMENT", "NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT", process.env.NEXT_PUBLIC_HEXCLAVE_IS_REMOTE_DEVELOPMENT_ENVIRONMENT, process.env.NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT), + NEXT_PUBLIC_STACK_IS_PREVIEW: resolveInlineRenamedEnvVar("NEXT_PUBLIC_HEXCLAVE_IS_PREVIEW", "NEXT_PUBLIC_STACK_IS_PREVIEW", process.env.NEXT_PUBLIC_HEXCLAVE_IS_PREVIEW, process.env.NEXT_PUBLIC_STACK_IS_PREVIEW), NEXT_PUBLIC_HEXCLAVE_LOCAL_DASHBOARD_PORT: process.env.NEXT_PUBLIC_HEXCLAVE_LOCAL_DASHBOARD_PORT, - NEXT_PUBLIC_STACK_PROJECT_ID: process.env.NEXT_PUBLIC_HEXCLAVE_PROJECT_ID || process.env.NEXT_PUBLIC_STACK_PROJECT_ID, - NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY: process.env.NEXT_PUBLIC_HEXCLAVE_PUBLISHABLE_CLIENT_KEY || process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY, - NEXT_PUBLIC_STACK_URL: process.env.NEXT_PUBLIC_HEXCLAVE_URL || process.env.NEXT_PUBLIC_STACK_URL, - NEXT_PUBLIC_STACK_INBUCKET_WEB_URL: process.env.NEXT_PUBLIC_HEXCLAVE_INBUCKET_WEB_URL || process.env.NEXT_PUBLIC_STACK_INBUCKET_WEB_URL, - NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS: process.env.NEXT_PUBLIC_HEXCLAVE_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS || process.env.NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS, - NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_HEXCLAVE_STRIPE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY, + NEXT_PUBLIC_STACK_PROJECT_ID: resolveInlineRenamedEnvVar("NEXT_PUBLIC_HEXCLAVE_PROJECT_ID", "NEXT_PUBLIC_STACK_PROJECT_ID", process.env.NEXT_PUBLIC_HEXCLAVE_PROJECT_ID, process.env.NEXT_PUBLIC_STACK_PROJECT_ID), + NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY: resolveInlineRenamedEnvVar("NEXT_PUBLIC_HEXCLAVE_PUBLISHABLE_CLIENT_KEY", "NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY", process.env.NEXT_PUBLIC_HEXCLAVE_PUBLISHABLE_CLIENT_KEY, process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY), + NEXT_PUBLIC_STACK_URL: resolveInlineRenamedEnvVar("NEXT_PUBLIC_HEXCLAVE_URL", "NEXT_PUBLIC_STACK_URL", process.env.NEXT_PUBLIC_HEXCLAVE_URL, process.env.NEXT_PUBLIC_STACK_URL), + NEXT_PUBLIC_STACK_INBUCKET_WEB_URL: resolveInlineRenamedEnvVar("NEXT_PUBLIC_HEXCLAVE_INBUCKET_WEB_URL", "NEXT_PUBLIC_STACK_INBUCKET_WEB_URL", process.env.NEXT_PUBLIC_HEXCLAVE_INBUCKET_WEB_URL, process.env.NEXT_PUBLIC_STACK_INBUCKET_WEB_URL), + NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS: resolveInlineRenamedEnvVar("NEXT_PUBLIC_HEXCLAVE_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS", "NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS", process.env.NEXT_PUBLIC_HEXCLAVE_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS, process.env.NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS), + NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY: resolveInlineRenamedEnvVar("NEXT_PUBLIC_HEXCLAVE_STRIPE_PUBLISHABLE_KEY", "NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY", process.env.NEXT_PUBLIC_HEXCLAVE_STRIPE_PUBLISHABLE_KEY, process.env.NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY), // Hexclave rebrand: port-prefix var renamed outright (no dual-read). NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX: process.env.NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX, - NEXT_PUBLIC_STACK_DOCS_BASE_URL: process.env.NEXT_PUBLIC_HEXCLAVE_DOCS_BASE_URL || process.env.NEXT_PUBLIC_STACK_DOCS_BASE_URL, - NEXT_PUBLIC_STACK_ENABLE_REACT_SCAN_IN_DEVELOPMENT: process.env.NEXT_PUBLIC_HEXCLAVE_ENABLE_REACT_SCAN_IN_DEVELOPMENT || process.env.NEXT_PUBLIC_STACK_ENABLE_REACT_SCAN_IN_DEVELOPMENT, + NEXT_PUBLIC_STACK_DOCS_BASE_URL: resolveInlineRenamedEnvVar("NEXT_PUBLIC_HEXCLAVE_DOCS_BASE_URL", "NEXT_PUBLIC_STACK_DOCS_BASE_URL", process.env.NEXT_PUBLIC_HEXCLAVE_DOCS_BASE_URL, process.env.NEXT_PUBLIC_STACK_DOCS_BASE_URL), + NEXT_PUBLIC_STACK_ENABLE_REACT_SCAN_IN_DEVELOPMENT: resolveInlineRenamedEnvVar("NEXT_PUBLIC_HEXCLAVE_ENABLE_REACT_SCAN_IN_DEVELOPMENT", "NEXT_PUBLIC_STACK_ENABLE_REACT_SCAN_IN_DEVELOPMENT", process.env.NEXT_PUBLIC_HEXCLAVE_ENABLE_REACT_SCAN_IN_DEVELOPMENT, process.env.NEXT_PUBLIC_STACK_ENABLE_REACT_SCAN_IN_DEVELOPMENT), // TODO: NEXT_PUBLIC_BROWSER_STACK_API_URL should be renamed to NEXT_PUBLIC_STACK_BROWSER_API_URL - NEXT_PUBLIC_BROWSER_STACK_API_URL: process.env.NEXT_PUBLIC_BROWSER_HEXCLAVE_API_URL || process.env.NEXT_PUBLIC_BROWSER_STACK_API_URL, + NEXT_PUBLIC_BROWSER_STACK_API_URL: resolveInlineRenamedEnvVar("NEXT_PUBLIC_BROWSER_HEXCLAVE_API_URL", "NEXT_PUBLIC_BROWSER_STACK_API_URL", process.env.NEXT_PUBLIC_BROWSER_HEXCLAVE_API_URL, process.env.NEXT_PUBLIC_BROWSER_STACK_API_URL), // TODO: NEXT_PUBLIC_SERVER_STACK_API_URL should be renamed to NEXT_PUBLIC_STACK_SERVER_API_URL - NEXT_PUBLIC_SERVER_STACK_API_URL: process.env.NEXT_PUBLIC_SERVER_HEXCLAVE_API_URL || process.env.NEXT_PUBLIC_SERVER_STACK_API_URL, + NEXT_PUBLIC_SERVER_STACK_API_URL: resolveInlineRenamedEnvVar("NEXT_PUBLIC_SERVER_HEXCLAVE_API_URL", "NEXT_PUBLIC_SERVER_STACK_API_URL", process.env.NEXT_PUBLIC_SERVER_HEXCLAVE_API_URL, process.env.NEXT_PUBLIC_SERVER_STACK_API_URL), // TODO: NEXT_PUBLIC_SENTRY_DSN should be renamed to NEXT_PUBLIC_STACK_SENTRY_DSN NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN, // TODO: NEXT_PUBLIC_VERSION_ALERTER_SEVERE_ONLY should be renamed to NEXT_PUBLIC_STACK_VERSION_ALERTER_SEVERE_ONLY NEXT_PUBLIC_VERSION_ALERTER_SEVERE_ONLY: process.env.NEXT_PUBLIC_VERSION_ALERTER_SEVERE_ONLY, // TODO: NEXT_PUBLIC_BROWSER_STACK_DASHBOARD_URL should be renamed to NEXT_PUBLIC_STACK_BROWSER_DASHBOARD_URL - NEXT_PUBLIC_BROWSER_STACK_DASHBOARD_URL: process.env.NEXT_PUBLIC_BROWSER_HEXCLAVE_DASHBOARD_URL || process.env.NEXT_PUBLIC_BROWSER_STACK_DASHBOARD_URL, + NEXT_PUBLIC_BROWSER_STACK_DASHBOARD_URL: resolveInlineRenamedEnvVar("NEXT_PUBLIC_BROWSER_HEXCLAVE_DASHBOARD_URL", "NEXT_PUBLIC_BROWSER_STACK_DASHBOARD_URL", process.env.NEXT_PUBLIC_BROWSER_HEXCLAVE_DASHBOARD_URL, process.env.NEXT_PUBLIC_BROWSER_STACK_DASHBOARD_URL), // TODO: NEXT_PUBLIC_SERVER_STACK_DASHBOARD_URL should be renamed to NEXT_PUBLIC_STACK_SERVER_DASHBOARD_URL - NEXT_PUBLIC_SERVER_STACK_DASHBOARD_URL: process.env.NEXT_PUBLIC_SERVER_HEXCLAVE_DASHBOARD_URL || process.env.NEXT_PUBLIC_SERVER_STACK_DASHBOARD_URL, + NEXT_PUBLIC_SERVER_STACK_DASHBOARD_URL: resolveInlineRenamedEnvVar("NEXT_PUBLIC_SERVER_HEXCLAVE_DASHBOARD_URL", "NEXT_PUBLIC_SERVER_STACK_DASHBOARD_URL", process.env.NEXT_PUBLIC_SERVER_HEXCLAVE_DASHBOARD_URL, process.env.NEXT_PUBLIC_SERVER_STACK_DASHBOARD_URL), // TODO: NEXT_PUBLIC_POSTHOG_KEY should be renamed to NEXT_PUBLIC_STACK_POSTHOG_KEY NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY, } as const; diff --git a/apps/e2e/tests/backend/endpoints/api/v1/integrations/credential-scanning/revoke.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/integrations/credential-scanning/revoke.test.ts index d8c6aa9b0..91b2be928 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/integrations/credential-scanning/revoke.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/integrations/credential-scanning/revoke.test.ts @@ -1,10 +1,13 @@ import { generateSecureRandomString } from "@hexclave/shared/dist/utils/crypto"; +import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; import type { MailboxMessage } from "../../../../../../helpers"; import { it } from "../../../../../../helpers"; import { Auth, InternalApiKey, Project, ProjectApiKey, Team, backendContext, bumpEmailAddress, niceBackendFetch } from "../../../../../backend-helpers"; // TODO re-enable these tests when we re-enable credential scanning email notifications +const isSourceOfTruthTest = () => getEnvVariable("STACK_TEST_SOURCE_OF_TRUTH", "") === "true"; + it("should send email notification to user when revoking an API key through credential scanning", async ({ expect }: { expect: any }) => { await Project.createAndSwitch({ config: { magic_link_enabled: true, allow_team_api_keys: true, allow_user_api_keys: true } }); @@ -44,7 +47,7 @@ it("should send email notification to user when revoking an API key through cred }, }); - if ((process.env.HEXCLAVE_TEST_SOURCE_OF_TRUTH || process.env.STACK_TEST_SOURCE_OF_TRUTH) === "true") { + if (isSourceOfTruthTest()) { expect(revokeResponse).toMatchInlineSnapshot(` NiceResponse { "status": 404, @@ -180,7 +183,7 @@ it("should send email notification to team members when revoking a team API key }, }); - if ((process.env.HEXCLAVE_TEST_SOURCE_OF_TRUTH || process.env.STACK_TEST_SOURCE_OF_TRUTH) === "true") { + if (isSourceOfTruthTest()) { expect(revokeResponse).toMatchInlineSnapshot(` NiceResponse { "status": 404, @@ -353,7 +356,7 @@ it("should handle already revoked API keys gracefully", async ({ expect }: { exp }, }); - if ((process.env.HEXCLAVE_TEST_SOURCE_OF_TRUTH || process.env.STACK_TEST_SOURCE_OF_TRUTH) === "true") { + if (isSourceOfTruthTest()) { expect(revokeResponse).toMatchInlineSnapshot(` NiceResponse { "status": 404, diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/config-local-emulator.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/config-local-emulator.test.ts index 934e9395e..c1c9144ae 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/config-local-emulator.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/config-local-emulator.test.ts @@ -1,11 +1,12 @@ import { randomUUID } from "crypto"; +import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; import fs from "fs/promises"; import path from "path"; import { describe } from "vitest"; import { it } from "../../../../../helpers"; import { backendContext, niceBackendFetch } from "../../../../backend-helpers"; -const isLocalEmulator = (process.env.NEXT_PUBLIC_HEXCLAVE_IS_LOCAL_EMULATOR || process.env.NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR) === "true"; +const isLocalEmulator = getEnvVariable("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR", "") === "true"; const blockedMessage = "cannot be changed in a development environment"; const localEmulatorProjectEndpoint = "/api/v1/internal/local-emulator/project"; diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/failed-emails-digest.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/failed-emails-digest.test.ts index d63a59115..270b5ac86 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/failed-emails-digest.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/failed-emails-digest.test.ts @@ -1,3 +1,4 @@ +import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; import { wait } from "@hexclave/shared/dist/utils/promises"; import { stringCompare } from "@hexclave/shared/dist/utils/strings"; import { describe, expect } from "vitest"; @@ -13,6 +14,8 @@ type FailedEmailsBatch = { type DigestResponse = Awaited>; +const isSourceOfTruthTest = () => getEnvVariable("STACK_TEST_SOURCE_OF_TRUTH", "") === "true"; + // Always uses dry_run=true: the only callers are the polling helper below // (which must be side-effect-free since it fires repeatedly) and the snapshot // assertion (which uses the same SELECT result regardless of dry_run). @@ -168,7 +171,7 @@ describe("with valid credentials", () => { const { response, batches: mockProjectFailedEmails } = await waitForFailedEmailsDigest(2); expect(response.status).toBe(200); - if ((process.env.HEXCLAVE_TEST_SOURCE_OF_TRUTH || process.env.STACK_TEST_SOURCE_OF_TRUTH) === "true") { + if (isSourceOfTruthTest()) { expect(mockProjectFailedEmails).toMatchInlineSnapshot(`[]`); } else { expect(mockProjectFailedEmails).toMatchInlineSnapshot(` @@ -460,7 +463,7 @@ describe("with valid credentials", () => { (batch: any) => batch.project_id === projectId ); - if ((process.env.HEXCLAVE_TEST_SOURCE_OF_TRUTH || process.env.STACK_TEST_SOURCE_OF_TRUTH) === "true") { + if (isSourceOfTruthTest()) { expect(currentResponses).toMatchInlineSnapshot(`[]`); } else { expect(currentResponses.length).toBe(1); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/feedback.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/feedback.test.ts index 7674bea80..8bdf97a8d 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/feedback.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/feedback.test.ts @@ -1,9 +1,10 @@ import { randomUUID } from "crypto"; +import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; import { describe } from "vitest"; import { it } from "../../../../../helpers"; import { Auth, backendContext, createMailbox, niceBackendFetch, waitForOutboxEmailWithStatus } from "../../../../backend-helpers"; -const isLocalEmulator = (process.env.NEXT_PUBLIC_HEXCLAVE_IS_LOCAL_EMULATOR || process.env.NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR) === "true"; +const isLocalEmulator = getEnvVariable("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR", "") === "true"; const supportConversationsPath = "/api/v1/internal/dogfood/support/conversations"; describe("POST /api/v1/internal/feedback", () => { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/local-emulator-project.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/local-emulator-project.test.ts index 9688c121c..57de8bcaa 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/local-emulator-project.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/local-emulator-project.test.ts @@ -1,4 +1,5 @@ import { randomUUID } from "crypto"; +import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; import fs from "fs/promises"; import path from "path"; import { describe } from "vitest"; @@ -7,7 +8,7 @@ import { backendContext, niceBackendFetch } from "../../../../backend-helpers"; const LOCAL_EMULATOR_PROJECT_ENDPOINT = "/api/v1/internal/local-emulator/project"; const LOCAL_EMULATOR_OWNER_TEAM_ID = "5a0c858b-d9e9-49d4-9943-8ce385d86428"; -const isLocalEmulator = (process.env.NEXT_PUBLIC_HEXCLAVE_IS_LOCAL_EMULATOR || process.env.NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR) === "true"; +const isLocalEmulator = getEnvVariable("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR", "") === "true"; async function createTempConfigFile(): Promise { const filePath = `/tmp/${randomUUID()}/stack.config.ts`; diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts index 9779e38fc..ba4b93d57 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts @@ -1,4 +1,5 @@ import { createHmac } from "node:crypto"; +import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; import { expect } from "vitest"; import { it } from "../../../../../helpers"; import { Auth, Payments as PaymentsHelper, Project, Team, User, niceBackendFetch } from "../../../../backend-helpers"; @@ -72,7 +73,7 @@ async function createPurchaseCodeForCustomer(options: { customerType: "user" | " return code as string; } -const stripeWebhookSecret = (process.env.HEXCLAVE_STRIPE_WEBHOOK_SECRET || process.env.STACK_STRIPE_WEBHOOK_SECRET) ?? "mock_stripe_webhook_secret"; +const stripeWebhookSecret = getEnvVariable("STACK_STRIPE_WEBHOOK_SECRET", "mock_stripe_webhook_secret"); async function sendStripeWebhook(payload: unknown) { const timestamp = Math.floor(Date.now() / 1000); diff --git a/apps/e2e/tests/general/cli.test.ts b/apps/e2e/tests/general/cli.test.ts index 911547e27..573140fb1 100644 --- a/apps/e2e/tests/general/cli.test.ts +++ b/apps/e2e/tests/general/cli.test.ts @@ -3,11 +3,12 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { StackAdminApp } from "@hexclave/js"; +import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; import { Result } from "@hexclave/shared/dist/utils/results"; import { describe, beforeAll, afterAll } from "vitest"; import { it, niceFetch, STACK_BACKEND_BASE_URL, STACK_INTERNAL_PROJECT_CLIENT_KEY, STACK_INTERNAL_PROJECT_SERVER_KEY, STACK_INTERNAL_PROJECT_ADMIN_KEY } from "../helpers"; -const isLocalEmulator = (process.env.NEXT_PUBLIC_HEXCLAVE_IS_LOCAL_EMULATOR || process.env.NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR) === "true"; +const isLocalEmulator = getEnvVariable("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR", "") === "true"; const CLI_BIN = path.resolve("packages/cli/dist/index.js"); diff --git a/apps/e2e/tests/helpers.ts b/apps/e2e/tests/helpers.ts index 0a596e6a5..2a00e67be 100644 --- a/apps/e2e/tests/helpers.ts +++ b/apps/e2e/tests/helpers.ts @@ -241,7 +241,7 @@ export class Mailbox { }; this.waitForMessagesWithSubjectCount = async (subject: string, minCount: number, options?: { noBody?: boolean }) => { - const timeoutMs = Number((process.env.HEXCLAVE_MAILBOX_WAIT_TIMEOUT_MS || process.env.STACK_MAILBOX_WAIT_TIMEOUT_MS) ?? 60000); + const timeoutMs = Number(getEnvVariable("STACK_MAILBOX_WAIT_TIMEOUT_MS", "60000")); const intervalMs = 500; const deadline = Date.now() + timeoutMs; let messages: MailboxMessage[] = []; @@ -324,7 +324,7 @@ export const STACK_MCP_BASE_URL = getEnvVariable("STACK_MCP_BASE_URL"); * fallback port. Always thread this through to SDK constructors instead of * hardcoding `STACK_BACKEND_BASE_URL`. */ -export const SDK_BASE_URL: string | undefined = (process.env.HEXCLAVE_TEST_SDK_FALLBACK || process.env.STACK_TEST_SDK_FALLBACK) +export const SDK_BASE_URL: string | undefined = getEnvVariable("STACK_TEST_SDK_FALLBACK", "") ? undefined : STACK_BACKEND_BASE_URL; export const STACK_INTERNAL_PROJECT_ID = getEnvVariable("STACK_INTERNAL_PROJECT_ID"); diff --git a/apps/hosted-components/src/routes/__root.tsx b/apps/hosted-components/src/routes/__root.tsx index ebc437d2d..80629e013 100644 --- a/apps/hosted-components/src/routes/__root.tsx +++ b/apps/hosted-components/src/routes/__root.tsx @@ -50,7 +50,12 @@ function useProjectIdFromHostname(): string | null | undefined { } function getApiBaseUrlFromEnv(): string | undefined { - return import.meta.env.VITE_HEXCLAVE_API_URL ?? import.meta.env.VITE_STACK_API_URL ?? undefined; + const hexclaveValue = import.meta.env.VITE_HEXCLAVE_API_URL; + const stackValue = import.meta.env.VITE_STACK_API_URL; + if (hexclaveValue && stackValue && hexclaveValue !== stackValue) { + throw new Error("Environment variables VITE_HEXCLAVE_API_URL and VITE_STACK_API_URL are both set to different values. Remove one of them or set them to the same value."); + } + return hexclaveValue || stackValue || undefined; } function isTrustedNavigationTarget(to: string): boolean { diff --git a/apps/internal-tool/scripts/spacetime-publish.mjs b/apps/internal-tool/scripts/spacetime-publish.mjs index 78d109141..a209bd33c 100644 --- a/apps/internal-tool/scripts/spacetime-publish.mjs +++ b/apps/internal-tool/scripts/spacetime-publish.mjs @@ -6,10 +6,20 @@ import { spawnSync } from "node:child_process"; const target = process.argv[2]; // "local" or "prod" +function resolveHexclaveStackEnvVar(hexclaveName, stackName) { + const hexclaveValue = process.env[hexclaveName]; + const stackValue = process.env[stackName]; + if (hexclaveValue && stackValue && hexclaveValue !== stackValue) { + throw new Error(`Environment variables ${hexclaveName} and ${stackName} are both set to different values. Remove one of them or set them to the same value.`); + } + return hexclaveValue || stackValue || undefined; +} + /** HTTP API for 'spacetime publish' (matches docker/dependencies/docker.compose.yaml host port ...39). */ function localPublishServerUrl() { - if ((process.env.HEXCLAVE_SPACETIME_PUBLISH_URL || process.env.STACK_SPACETIME_PUBLISH_URL)) { - return (process.env.HEXCLAVE_SPACETIME_PUBLISH_URL || process.env.STACK_SPACETIME_PUBLISH_URL); + const publishUrl = resolveHexclaveStackEnvVar("HEXCLAVE_SPACETIME_PUBLISH_URL", "STACK_SPACETIME_PUBLISH_URL"); + if (publishUrl) { + return publishUrl; } const prefix = process.env.NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX ?? "81"; return `http://127.0.0.1:${prefix}39`; @@ -36,7 +46,7 @@ if (!args) { process.exit(1); } -if (target === "prod" && !(process.env.HEXCLAVE_MCP_LOG_TOKEN || process.env.STACK_MCP_LOG_TOKEN)) { +if (target === "prod" && !resolveHexclaveStackEnvVar("HEXCLAVE_MCP_LOG_TOKEN", "STACK_MCP_LOG_TOKEN")) { console.error("Error: HEXCLAVE_MCP_LOG_TOKEN (or legacy STACK_MCP_LOG_TOKEN) must be set for prod publish"); process.exit(1); } diff --git a/apps/internal-tool/scripts/spacetime-token.mjs b/apps/internal-tool/scripts/spacetime-token.mjs index 656588b26..6407b2d3b 100644 --- a/apps/internal-tool/scripts/spacetime-token.mjs +++ b/apps/internal-tool/scripts/spacetime-token.mjs @@ -11,8 +11,17 @@ const PLACEHOLDER = "__SPACETIMEDB_LOG_TOKEN__"; const action = process.argv[2]; +function resolveHexclaveStackEnvVar(hexclaveName, stackName) { + const hexclaveValue = process.env[hexclaveName]; + const stackValue = process.env[stackName]; + if (hexclaveValue && stackValue && hexclaveValue !== stackValue) { + throw new Error(`Environment variables ${hexclaveName} and ${stackName} are both set to different values. Remove one of them or set them to the same value.`); + } + return hexclaveValue || stackValue || undefined; +} + if (action === "inject") { - const token = (process.env.HEXCLAVE_MCP_LOG_TOKEN || process.env.STACK_MCP_LOG_TOKEN) || "change-me"; + const token = resolveHexclaveStackEnvVar("HEXCLAVE_MCP_LOG_TOKEN", "STACK_MCP_LOG_TOKEN") || "change-me"; if (existsSync(BACKUP)) { console.error("Refusing to inject: backup already exists. Run restore first."); process.exit(1); diff --git a/apps/internal-tool/src/app/app-client.tsx b/apps/internal-tool/src/app/app-client.tsx index 0cd7852e0..6e6ed5434 100644 --- a/apps/internal-tool/src/app/app-client.tsx +++ b/apps/internal-tool/src/app/app-client.tsx @@ -12,6 +12,13 @@ import type { McpCallLogRow } from "../types"; type Tab = "calls" | "knowledge" | "analytics"; +function resolveInlineRenamedEnvVar(hexclaveName: string, stackName: string, hexclaveValue: string | undefined, stackValue: string | undefined): string | undefined { + if (hexclaveValue && stackValue && hexclaveValue !== stackValue) { + throw new Error(`Environment variables ${hexclaveName} and ${stackName} are both set to different values. Remove one of them or set them to the same value.`); + } + return hexclaveValue || stackValue || undefined; +} + export default function App() { const user = useUser({ or: process.env.NODE_ENV === "development" ? "redirect" : "return-null" }); const [selectedRow, setSelectedRow] = useState(null); @@ -26,7 +33,12 @@ export default function App() {

MCP Review Tool

Sign in to the{" "} - + Hexclave Dashboard {" "}first, then reload this page. diff --git a/docker/local-emulator/generate-env-development.mjs b/docker/local-emulator/generate-env-development.mjs index 7bb5db64d..dcca011af 100644 --- a/docker/local-emulator/generate-env-development.mjs +++ b/docker/local-emulator/generate-env-development.mjs @@ -47,9 +47,16 @@ const dashboardEnv = parseEnvFile(dashboardEnvPath); const toCanonicalKey = (key) => key.includes("STACK_") ? key.replace("STACK_", "HEXCLAVE_") : key; const getRequiredEnvValue = (sourceName, envMap, key) => { - const value = envMap.get(toCanonicalKey(key)) ?? envMap.get(key); + const canonicalKey = toCanonicalKey(key); + const canonicalValue = envMap.get(canonicalKey); + const legacyValue = canonicalKey === key ? undefined : envMap.get(key); + if (canonicalValue && legacyValue && canonicalValue !== legacyValue) { + throw new Error(`${sourceName} defines both ${canonicalKey} and ${key} with different non-empty values. Remove one of them or set them to the same value.`); + } + const nonEmptyValue = canonicalValue || legacyValue; + const value = nonEmptyValue !== undefined ? nonEmptyValue : canonicalValue ?? legacyValue; if (value == null) { - throw new Error(`Missing ${toCanonicalKey(key)} in ${sourceName}; update the generator or source env file.`); + throw new Error(`Missing ${canonicalKey} in ${sourceName}; update the generator or source env file.`); } return value; }; diff --git a/docker/local-emulator/rotate-secrets.sh b/docker/local-emulator/rotate-secrets.sh index e9d20c85e..324cd8ff1 100644 --- a/docker/local-emulator/rotate-secrets.sh +++ b/docker/local-emulator/rotate-secrets.sh @@ -24,6 +24,10 @@ WORK_DIR="${STACK_RUNTIME_WORK_DIR:-/app}" # Hexclave rebrand: the container env may carry the canonical HEXCLAVE_ name # instead of the legacy STACK_ one this script reads. +if [ -n "${STACK_DATABASE_CONNECTION_STRING:-}" ] && [ -n "${HEXCLAVE_DATABASE_CONNECTION_STRING:-}" ] && [ "$STACK_DATABASE_CONNECTION_STRING" != "$HEXCLAVE_DATABASE_CONNECTION_STRING" ]; then + echo "ERROR: STACK_DATABASE_CONNECTION_STRING and HEXCLAVE_DATABASE_CONNECTION_STRING are both set to different non-empty values. Remove one of them or set them to the same value." >&2 + exit 1 +fi STACK_DATABASE_CONNECTION_STRING="${STACK_DATABASE_CONNECTION_STRING:-${HEXCLAVE_DATABASE_CONNECTION_STRING:-}}" PLACEHOLDER_PCK="00000000000000000000000000000000ffffffffffffffffffffffffffffffff" diff --git a/docker/server/entrypoint.sh b/docker/server/entrypoint.sh index e783a0227..04280032e 100644 --- a/docker/server/entrypoint.sh +++ b/docker/server/entrypoint.sh @@ -28,6 +28,10 @@ mirror_hexclave_stack_env() { *STACK_*) _twin=${_name/STACK_/HEXCLAVE_} ;; *) continue ;; esac + if [ -n "${!_name:-}" ] && [ -n "${!_twin:-}" ] && [ "${!_name}" != "${!_twin}" ]; then + echo "ERROR: $_name and $_twin are both set to different non-empty values. Remove one of them or set them to the same value." >&2 + exit 1 + fi if [ -z "${!_twin:-}" ] && [ -n "${!_name:-}" ]; then export "$_twin=${!_name}" fi diff --git a/docs/src/components/api/enhanced-api-page.tsx b/docs/src/components/api/enhanced-api-page.tsx index e5685018a..7190c151b 100644 --- a/docs/src/components/api/enhanced-api-page.tsx +++ b/docs/src/components/api/enhanced-api-page.tsx @@ -5,6 +5,7 @@ import { ArrowRight, Check, Code, Copy, Play, Send, Settings, Zap } from 'lucide import { useCallback, useEffect, useState } from 'react'; import type { OpenAPIOperation, OpenAPIParameter, OpenAPISchema, OpenAPISpec } from '../../lib/openapi-types'; import { resolveSchema } from '../../lib/openapi-utils'; +import { resolveInlineRenamedEnvVar } from '../../lib/env'; import { useAPIPageContext } from './api-page-wrapper'; import { Button } from '../mdx/button'; @@ -45,6 +46,9 @@ const HTTP_METHOD_COLORS = { DELETE: 'from-red-500 to-red-600 text-white shadow-red-500/25', } as const; +function getLocalApiUrlFromEnv(): string | undefined { + return resolveInlineRenamedEnvVar("NEXT_PUBLIC_HEXCLAVE_API_URL", "NEXT_PUBLIC_STACK_API_URL", process.env.NEXT_PUBLIC_HEXCLAVE_API_URL, process.env.NEXT_PUBLIC_STACK_API_URL); +} export function EnhancedAPIPage({ document, operations, description }: EnhancedAPIPageProps) { const apiContext = useAPIPageContext(); @@ -230,7 +234,7 @@ export function EnhancedAPIPage({ document, operations, description }: EnhancedA } // Use local API URL in development, production URL from OpenAPI spec otherwise const defaultBaseUrl = spec?.servers?.[0]?.url || ''; - const localApiUrl = process.env.NEXT_PUBLIC_HEXCLAVE_API_URL || process.env.NEXT_PUBLIC_STACK_API_URL; + const localApiUrl = getLocalApiUrlFromEnv(); const baseUrl = localApiUrl ? localApiUrl + '/api/v1' : defaultBaseUrl; let url = baseUrl + path; @@ -439,7 +443,7 @@ function ModernAPIPlayground({ const generateCurlCommand = useCallback(() => { // Use local API URL in development, production URL otherwise const defaultBaseUrl = spec.servers?.[0]?.url || ''; - const localApiUrl = process.env.NEXT_PUBLIC_HEXCLAVE_API_URL || process.env.NEXT_PUBLIC_STACK_API_URL; + const localApiUrl = getLocalApiUrlFromEnv(); const baseUrl = localApiUrl ? localApiUrl + '/api/v1' : defaultBaseUrl; @@ -492,7 +496,7 @@ function ModernAPIPlayground({ const generateJavaScriptCode = useCallback(() => { // Use local API URL in development, production URL otherwise const defaultBaseUrl = spec.servers?.[0]?.url || ''; - const localApiUrl = process.env.NEXT_PUBLIC_HEXCLAVE_API_URL || process.env.NEXT_PUBLIC_STACK_API_URL; + const localApiUrl = getLocalApiUrlFromEnv(); const baseUrl = localApiUrl ? localApiUrl + '/api/v1' : defaultBaseUrl; @@ -554,7 +558,7 @@ function ModernAPIPlayground({ const generatePythonCode = useCallback(() => { // Use local API URL in development, production URL otherwise const defaultBaseUrl = spec.servers?.[0]?.url || ''; - const localApiUrl = process.env.NEXT_PUBLIC_HEXCLAVE_API_URL || process.env.NEXT_PUBLIC_STACK_API_URL; + const localApiUrl = getLocalApiUrlFromEnv(); const baseUrl = localApiUrl ? localApiUrl + '/api/v1' : defaultBaseUrl; diff --git a/docs/src/components/chat/ai-chat.tsx b/docs/src/components/chat/ai-chat.tsx index ad7f4879a..88208c1de 100644 --- a/docs/src/components/chat/ai-chat.tsx +++ b/docs/src/components/chat/ai-chat.tsx @@ -6,6 +6,7 @@ import { runAsynchronously } from '@hexclave/shared/dist/utils/promises'; import { convertToModelMessages, DefaultChatTransport, type DynamicToolUIPart } from 'ai'; import { ChevronDown, ChevronUp, ExternalLink, FileText, Maximize2, Minimize2, Send, X } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; +import { resolveInlineRenamedEnvVar } from '../../lib/env'; import { useSidebar } from '../layouts/sidebar-context'; import { MessageFormatter } from './message-formatter'; @@ -351,7 +352,7 @@ export function AIChatDrawer() { const height = isHomePage && isScrolled ? 'h-[calc(100vh-1.5rem)]' : 'h-[calc(100vh-1.5rem)]'; const [input, setInput] = useState(''); - const apiBaseUrl = process.env.NEXT_PUBLIC_HEXCLAVE_API_URL || process.env.NEXT_PUBLIC_STACK_API_URL || throwErr("NEXT_PUBLIC_HEXCLAVE_API_URL or NEXT_PUBLIC_STACK_API_URL is not set"); + const apiBaseUrl = resolveInlineRenamedEnvVar("NEXT_PUBLIC_HEXCLAVE_API_URL", "NEXT_PUBLIC_STACK_API_URL", process.env.NEXT_PUBLIC_HEXCLAVE_API_URL, process.env.NEXT_PUBLIC_STACK_API_URL) || throwErr("NEXT_PUBLIC_HEXCLAVE_API_URL or NEXT_PUBLIC_STACK_API_URL is not set"); const { messages, diff --git a/docs/src/lib/env.ts b/docs/src/lib/env.ts new file mode 100644 index 000000000..94abc9d10 --- /dev/null +++ b/docs/src/lib/env.ts @@ -0,0 +1,6 @@ +export function resolveInlineRenamedEnvVar(hexclaveName: string, stackName: string, hexclaveValue: string | undefined, stackValue: string | undefined): string | undefined { + if (hexclaveValue && stackValue && hexclaveValue !== stackValue) { + throw new Error(`Environment variables ${hexclaveName} and ${stackName} are both set to different values. Remove one of them or set them to the same value.`); + } + return hexclaveValue || stackValue || undefined; +} diff --git a/docs/src/stack.ts b/docs/src/stack.ts index 9ab59f811..7b095f458 100644 --- a/docs/src/stack.ts +++ b/docs/src/stack.ts @@ -1,13 +1,14 @@ import { StackServerApp } from '@hexclave/next'; import "server-only"; +import { resolveInlineRenamedEnvVar } from './lib/env'; // Explicitly configure Stack Auth for docs app export const stackServerApp = new StackServerApp({ tokenStore: "nextjs-cookie", - projectId: process.env.NEXT_PUBLIC_HEXCLAVE_PROJECT_ID || process.env.NEXT_PUBLIC_STACK_PROJECT_ID, - publishableClientKey: process.env.NEXT_PUBLIC_HEXCLAVE_PUBLISHABLE_CLIENT_KEY || process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY, - secretServerKey: process.env.HEXCLAVE_SECRET_SERVER_KEY || process.env.STACK_SECRET_SERVER_KEY, - baseUrl: process.env.NEXT_PUBLIC_HEXCLAVE_API_URL || process.env.NEXT_PUBLIC_STACK_API_URL, + projectId: resolveInlineRenamedEnvVar("NEXT_PUBLIC_HEXCLAVE_PROJECT_ID", "NEXT_PUBLIC_STACK_PROJECT_ID", process.env.NEXT_PUBLIC_HEXCLAVE_PROJECT_ID, process.env.NEXT_PUBLIC_STACK_PROJECT_ID), + publishableClientKey: resolveInlineRenamedEnvVar("NEXT_PUBLIC_HEXCLAVE_PUBLISHABLE_CLIENT_KEY", "NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY", process.env.NEXT_PUBLIC_HEXCLAVE_PUBLISHABLE_CLIENT_KEY, process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY), + secretServerKey: resolveInlineRenamedEnvVar("HEXCLAVE_SECRET_SERVER_KEY", "STACK_SECRET_SERVER_KEY", process.env.HEXCLAVE_SECRET_SERVER_KEY, process.env.STACK_SECRET_SERVER_KEY), + baseUrl: resolveInlineRenamedEnvVar("NEXT_PUBLIC_HEXCLAVE_API_URL", "NEXT_PUBLIC_STACK_API_URL", process.env.NEXT_PUBLIC_HEXCLAVE_API_URL, process.env.NEXT_PUBLIC_STACK_API_URL), analytics: { replays: { enabled: true, diff --git a/examples/convex/convex/auth.config.ts b/examples/convex/convex/auth.config.ts index 6cc1c62fb..513788a2a 100644 --- a/examples/convex/convex/auth.config.ts +++ b/examples/convex/convex/auth.config.ts @@ -1,8 +1,15 @@ import { getConvexProvidersConfig } from "@hexclave/next/convex-auth.config"; +function resolveRenamedEnvVar(hexclaveName: string, stackName: string, hexclaveValue: string | undefined, stackValue: string | undefined): string | undefined { + if (hexclaveValue && stackValue && hexclaveValue !== stackValue) { + throw new Error(`Environment variables ${hexclaveName} and ${stackName} are both set to different values. Remove one of them or set them to the same value.`); + } + return hexclaveValue || stackValue || undefined; +} + export default { providers: getConvexProvidersConfig({ - projectId: (process.env.NEXT_PUBLIC_HEXCLAVE_PROJECT_ID || process.env.NEXT_PUBLIC_STACK_PROJECT_ID)!, - baseUrl: (process.env.NEXT_PUBLIC_HEXCLAVE_API_URL || process.env.NEXT_PUBLIC_STACK_API_URL), + projectId: resolveRenamedEnvVar("NEXT_PUBLIC_HEXCLAVE_PROJECT_ID", "NEXT_PUBLIC_STACK_PROJECT_ID", process.env.NEXT_PUBLIC_HEXCLAVE_PROJECT_ID, process.env.NEXT_PUBLIC_STACK_PROJECT_ID)!, + baseUrl: resolveRenamedEnvVar("NEXT_PUBLIC_HEXCLAVE_API_URL", "NEXT_PUBLIC_STACK_API_URL", process.env.NEXT_PUBLIC_HEXCLAVE_API_URL, process.env.NEXT_PUBLIC_STACK_API_URL), }), } diff --git a/examples/demo/cli-sim.mjs b/examples/demo/cli-sim.mjs index 2fb7b6d8e..266063f7a 100644 --- a/examples/demo/cli-sim.mjs +++ b/examples/demo/cli-sim.mjs @@ -2,8 +2,17 @@ /** Minimal `stack login` flow for local demos. Usage: `node cli-sim.mjs` */ -const API_URL = (process.env.HEXCLAVE_API_URL || process.env.STACK_API_URL) || "http://localhost:8102"; -const APP_URL = (process.env.HEXCLAVE_APP_URL || process.env.STACK_APP_URL) || "http://localhost:8103"; +function resolveHexclaveStackEnvVar(hexclaveName, stackName) { + const hexclaveValue = process.env[hexclaveName]; + const stackValue = process.env[stackName]; + if (hexclaveValue && stackValue && hexclaveValue !== stackValue) { + throw new Error(`Environment variables ${hexclaveName} and ${stackName} are both set to different values. Remove one of them or set them to the same value.`); + } + return hexclaveValue || stackValue || undefined; +} + +const API_URL = resolveHexclaveStackEnvVar("HEXCLAVE_API_URL", "STACK_API_URL") || "http://localhost:8102"; +const APP_URL = resolveHexclaveStackEnvVar("HEXCLAVE_APP_URL", "STACK_APP_URL") || "http://localhost:8103"; const PROJECT_ID = "internal"; const PUBLISHABLE_KEY = "this-publishable-client-key-is-for-local-development-only"; diff --git a/packages/cli/src/lib/auth.test.ts b/packages/cli/src/lib/auth.test.ts index ef6e557c7..9737c9def 100644 --- a/packages/cli/src/lib/auth.test.ts +++ b/packages/cli/src/lib/auth.test.ts @@ -93,11 +93,28 @@ describe("resolveProjectId", () => { expect(resolveProjectId(undefined)).toBe("proj_from_env"); }); + it("uses the HEXCLAVE_PROJECT_ID env var", () => { + process.env.HEXCLAVE_PROJECT_ID = "proj_from_hexclave_env"; + expect(resolveProjectId(undefined)).toBe("proj_from_hexclave_env"); + }); + + it("throws when the Hexclave and Stack env vars disagree", () => { + process.env.HEXCLAVE_PROJECT_ID = "proj_from_hexclave_env"; + process.env.STACK_PROJECT_ID = "proj_from_stack_env"; + expect(() => resolveProjectId(undefined)).toThrow(/HEXCLAVE_PROJECT_ID.*STACK_PROJECT_ID.*different values/); + }); + it("prefers the option over the env var", () => { process.env.STACK_PROJECT_ID = "proj_from_env"; expect(resolveProjectId("proj_from_flag")).toBe("proj_from_flag"); }); + it("does not inspect conflicting env vars when the option is present", () => { + process.env.HEXCLAVE_PROJECT_ID = "proj_from_hexclave_env"; + process.env.STACK_PROJECT_ID = "proj_from_stack_env"; + expect(resolveProjectId("proj_from_flag")).toBe("proj_from_flag"); + }); + it("treats an empty option string as absent and falls back to the env var", () => { process.env.STACK_PROJECT_ID = "proj_from_env"; expect(resolveProjectId("")).toBe("proj_from_env"); diff --git a/packages/cli/src/lib/auth.ts b/packages/cli/src/lib/auth.ts index 03715dd10..0ff465ca3 100644 --- a/packages/cli/src/lib/auth.ts +++ b/packages/cli/src/lib/auth.ts @@ -51,8 +51,17 @@ function resolveRefreshToken(): string { return token; } +function resolveHexclaveStackEnvVar(hexclaveName: string, stackName: string): string | undefined { + const hexclaveValue = process.env[hexclaveName]; + const stackValue = process.env[stackName]; + if (hexclaveValue && stackValue && hexclaveValue !== stackValue) { + throw new CliError(`Environment variables ${hexclaveName} and ${stackName} are both set to different values. Remove one of them or set them to the same value.`); + } + return hexclaveValue || stackValue || undefined; +} + function resolveSecretServerKey(): string | null { - return process.env.HEXCLAVE_SECRET_SERVER_KEY || process.env.STACK_SECRET_SERVER_KEY || null; + return resolveHexclaveStackEnvVar("HEXCLAVE_SECRET_SERVER_KEY", "STACK_SECRET_SERVER_KEY") || null; } export function resolveLoginConfig(): LoginConfig { @@ -91,10 +100,12 @@ export function resolveAuth(projectId: string): ProjectAuth { // STACK_PROJECT_ID name). Empty strings are treated as absent so callers can // pass through optional option values directly. export function resolveProjectId(projectIdOption?: string): string { - for (const candidate of [projectIdOption, process.env.HEXCLAVE_PROJECT_ID, process.env.STACK_PROJECT_ID]) { - if (candidate != null && candidate !== "") { - return candidate; - } + if (projectIdOption != null && projectIdOption !== "") { + return projectIdOption; + } + const projectIdFromEnv = resolveHexclaveStackEnvVar("HEXCLAVE_PROJECT_ID", "STACK_PROJECT_ID"); + if (projectIdFromEnv != null && projectIdFromEnv !== "") { + return projectIdFromEnv; } throw new CliError("No project ID provided. Pass --cloud-project-id or set the HEXCLAVE_PROJECT_ID environment variable."); } diff --git a/packages/shared/src/utils/env.test.tsx b/packages/shared/src/utils/env.test.tsx new file mode 100644 index 000000000..943e146ef --- /dev/null +++ b/packages/shared/src/utils/env.test.tsx @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getEnvVariable, getProcessEnv, resolveHexclaveStackEnvVarValue } from "./env"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("Hexclave/Stack env var dual-read", () => { + it("falls back to the legacy Stack name when the Hexclave value is empty", () => { + vi.stubEnv("NEXT_PUBLIC_HEXCLAVE_API_URL", ""); + vi.stubEnv("NEXT_PUBLIC_STACK_API_URL", "https://stack.example.test"); + + expect(getEnvVariable("NEXT_PUBLIC_STACK_API_URL")).toBe("https://stack.example.test"); + expect(getProcessEnv("NEXT_PUBLIC_STACK_API_URL")).toBe("https://stack.example.test"); + }); + + it("allows both names when they have the same non-empty value", () => { + vi.stubEnv("NEXT_PUBLIC_HEXCLAVE_API_URL", "https://api.example.test"); + vi.stubEnv("NEXT_PUBLIC_STACK_API_URL", "https://api.example.test"); + + expect(getEnvVariable("NEXT_PUBLIC_STACK_API_URL")).toBe("https://api.example.test"); + expect(getProcessEnv("NEXT_PUBLIC_STACK_API_URL")).toBe("https://api.example.test"); + }); + + it("throws when both names are non-empty and different", () => { + vi.stubEnv("NEXT_PUBLIC_HEXCLAVE_API_URL", "https://hexclave.example.test"); + vi.stubEnv("NEXT_PUBLIC_STACK_API_URL", "https://stack.example.test"); + + expect(() => getEnvVariable("NEXT_PUBLIC_STACK_API_URL")).toThrow(/NEXT_PUBLIC_HEXCLAVE_API_URL.*NEXT_PUBLIC_STACK_API_URL.*different values/); + expect(() => getProcessEnv("NEXT_PUBLIC_STACK_API_URL")).toThrow(/NEXT_PUBLIC_HEXCLAVE_API_URL.*NEXT_PUBLIC_STACK_API_URL.*different values/); + }); + + it("checks renamed legacy aliases when falling back", () => { + vi.stubEnv("NEXT_PUBLIC_HEXCLAVE_URL", "https://hexclave-url.example.test"); + vi.stubEnv("NEXT_PUBLIC_STACK_URL", "https://stack-url.example.test"); + + expect(() => getEnvVariable("NEXT_PUBLIC_STACK_API_URL")).toThrow(/NEXT_PUBLIC_HEXCLAVE_URL.*NEXT_PUBLIC_STACK_URL.*different values/); + }); + + it("returns undefined when both names are empty", () => { + expect(resolveHexclaveStackEnvVarValue("HEXCLAVE_FOO", "STACK_FOO", "", "")).toBeUndefined(); + }); +}); diff --git a/packages/shared/src/utils/env.tsx b/packages/shared/src/utils/env.tsx index 9f5b81a7d..757849413 100644 --- a/packages/shared/src/utils/env.tsx +++ b/packages/shared/src/utils/env.tsx @@ -6,7 +6,7 @@ export function isBrowserLike() { } // newName: oldName -const ENV_VAR_RENAME: Record = { +const ENV_VAR_RENAME: Record = { NEXT_PUBLIC_STACK_API_URL: ['STACK_BASE_URL', 'NEXT_PUBLIC_STACK_URL'], }; @@ -24,6 +24,21 @@ function getHexclaveEnvVarName(name: string): string | undefined { return name.replace("STACK_", "HEXCLAVE_"); } +export function resolveHexclaveStackEnvVarValue(hexclaveName: string, stackName: string, hexclaveValue: string | undefined, stackValue: string | undefined): string | undefined { + if (hexclaveValue && stackValue && hexclaveValue !== stackValue) { + throw new Error(`Environment variables ${hexclaveName} and ${stackName} are both set to different values. Remove one of them or set them to the same value.`); + } + return hexclaveValue || stackValue || undefined; +} + +function getEnvVarWithHexclaveFallback(name: string): string | undefined { + const hexclaveName = getHexclaveEnvVarName(name); + if (hexclaveName == null) { + return process.env[name]; + } + return resolveHexclaveStackEnvVarValue(hexclaveName, name, process.env[hexclaveName], process.env[name]); +} + /** * Returns the environment variable with the given name, returning the default (if given) or throwing an error (otherwise) if it's undefined or the empty string. */ @@ -45,7 +60,7 @@ export function getEnvVariable(name: string, defaultValue?: string | undefined): // throw error if the old name is used as the retrieve key for (const [newName, oldNames] of Object.entries(ENV_VAR_RENAME)) { - if (oldNames.includes(name)) { + if (oldNames?.includes(name)) { throwErr(`Environment variable ${name} has been renamed to ${newName}. Please update your configuration to use the new name.`); } } @@ -53,15 +68,13 @@ export function getEnvVariable(name: string, defaultValue?: string | undefined): // Hexclave rebrand: prefer the HEXCLAVE_*-prefixed equivalent, fall back to the STACK_* name. // Treat the empty string as unset — the checked-in .env templates define empty // HEXCLAVE_* placeholders, which must not shadow a real value under the legacy name. - const hexclaveName = getHexclaveEnvVarName(name); - let value = (hexclaveName ? process.env[hexclaveName] : undefined) || process.env[name]; + let value = getEnvVarWithHexclaveFallback(name); // check the key under the old name if the new name is not found - if (!value && ENV_VAR_RENAME[name] as any) { - for (const oldName of ENV_VAR_RENAME[name]) { - // Hexclave rebrand: also accept the HEXCLAVE_*-prefixed equivalent of each old alias. - const hexclaveOldName = getHexclaveEnvVarName(oldName); - value = (hexclaveOldName ? process.env[hexclaveOldName] : undefined) || process.env[oldName]; + const renamedNames = ENV_VAR_RENAME[name]; + if (!value && renamedNames != null) { + for (const oldName of renamedNames) { + value = getEnvVarWithHexclaveFallback(oldName); if (value) break; } } @@ -116,6 +129,5 @@ export function getProcessEnv(name: string): string | undefined { // Hexclave rebrand: prefer the HEXCLAVE_*-prefixed equivalent, fall back to the STACK_* name. // Empty counts as unset — the checked-in .env templates define empty HEXCLAVE_* placeholders, // which must not shadow a real value under the legacy name. - const hexclaveName = getHexclaveEnvVarName(name); - return (hexclaveName ? process.env[hexclaveName] : undefined) || process.env[name]; + return getEnvVarWithHexclaveFallback(name); } diff --git a/packages/shared/src/utils/errors.tsx b/packages/shared/src/utils/errors.tsx index 84d0550cd..69bd912e7 100644 --- a/packages/shared/src/utils/errors.tsx +++ b/packages/shared/src/utils/errors.tsx @@ -80,7 +80,12 @@ export class HexclaveAssertionError extends Error { // Use literal dot-form (guarded with `typeof process`) so Next.js / webpack // DefinePlugin can inline the value at build time. See getProcessEnv in ./env. - if ((typeof process !== "undefined" ? (process.env.NEXT_PUBLIC_HEXCLAVE_DEBUGGER_ON_ASSERTION_ERROR || process.env.NEXT_PUBLIC_STACK_DEBUGGER_ON_ASSERTION_ERROR) : undefined) === "true") { + const hexclaveDebuggerValue = typeof process !== "undefined" ? process.env.NEXT_PUBLIC_HEXCLAVE_DEBUGGER_ON_ASSERTION_ERROR : undefined; + const stackDebuggerValue = typeof process !== "undefined" ? process.env.NEXT_PUBLIC_STACK_DEBUGGER_ON_ASSERTION_ERROR : undefined; + if (hexclaveDebuggerValue && stackDebuggerValue && hexclaveDebuggerValue !== stackDebuggerValue) { + throw new Error("Environment variables NEXT_PUBLIC_HEXCLAVE_DEBUGGER_ON_ASSERTION_ERROR and NEXT_PUBLIC_STACK_DEBUGGER_ON_ASSERTION_ERROR are both set to different values. Remove one of them or set them to the same value."); + } + if ((hexclaveDebuggerValue || stackDebuggerValue) === "true") { debugger; } } diff --git a/packages/template/scripts/generate-env.ts b/packages/template/scripts/generate-env.ts index 022077df2..717452d79 100644 --- a/packages/template/scripts/generate-env.ts +++ b/packages/template/scripts/generate-env.ts @@ -63,20 +63,76 @@ const envVarsConfig: Record(); + const snippets: string[] = []; + + for (const variableName of allVariables) { + if (emittedVariables.has(variableName)) { + continue; + } + + const stackName = getStackEnvVarName(variableName); + if (stackName != null && allVariablesSet.has(stackName)) { + emittedVariables.add(variableName); + emittedVariables.add(stackName); + snippets.push(deindent` + resolveHexclaveStackEnvVar("${variableName}", "${stackName}", ${getEnvVarSnippet(variableName)}, ${getEnvVarSnippet(stackName)}) + `); + continue; + } + + const hexclaveName = getHexclaveEnvVarName(variableName); + if (hexclaveName != null && allVariablesSet.has(hexclaveName)) { + emittedVariables.add(hexclaveName); + emittedVariables.add(variableName); + snippets.push(deindent` + resolveHexclaveStackEnvVar("${hexclaveName}", "${variableName}", ${getEnvVarSnippet(hexclaveName)}, ${getEnvVarSnippet(variableName)}) + `); + continue; + } + + emittedVariables.add(variableName); + snippets.push(getEnvVarSnippet(variableName)); + } + + return snippets; +} + function generateEnvVarsConstSnippet() { const getters: string[] = []; for (const [key, config] of Object.entries(envVarsConfig)) { const allVariables = [key, ...(config.deprecatedLegacyNames ?? [])] .flatMap(k => k.startsWith("HEXCLAVE_") ? [k, k.replace("HEXCLAVE_", "STACK_")] : [k]) .flatMap(k => config.allowPublic ? [k, `NEXT_PUBLIC_${k}`, `VITE_${k}`] : [k]); + const candidateSnippets = getEnvVarCandidateSnippets(allVariables); // Use || (not ??) between candidates: empty-string env vars (e.g. the empty // HEXCLAVE_* placeholders in checked-in .env templates) must not shadow a // real value under a legacy STACK_* name further down the chain. getters.push(deindent` get ${key}() { - return ${allVariables.map(variableName => deindent` - ((typeof process !== "undefined" ? process.env.${variableName} : undefined) || import.meta.env?.${variableName}) - `).join("\n || ")} || undefined; + return ${candidateSnippets.join("\n || ")} || undefined; }, `); } @@ -85,6 +141,13 @@ function generateEnvVarsConstSnippet() { // DO NOT EDIT IT BY HAND. /* eslint-disable no-restricted-properties */ + function resolveHexclaveStackEnvVar(hexclaveName: string, stackName: string, hexclaveValue: string | undefined, stackValue: string | undefined): string | undefined { + if (hexclaveValue && stackValue && hexclaveValue !== stackValue) { + throw new Error(\`Environment variables \${hexclaveName} and \${stackName} are both set to different values. Remove one of them or set them to the same value.\`); + } + return hexclaveValue || stackValue || undefined; + } + export const envVars = { ${getters.join("\n")} };