From 5e9a05857b9d0f5d07bb701e14113a69454935ab Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Wed, 26 Aug 2026 12:51:49 -0400 Subject: [PATCH] feat(tanstack-react-start): throw missing-env error instead of keyless bootstrap Co-Authored-By: Claude Fable 5 --- .changeset/tanstack-keyless-cli-init-error.md | 5 ++ .../tests/tanstack-start/keyless.test.ts | 62 +++++++++------ .../src/client/ClerkProvider.tsx | 13 +--- .../tanstack-react-start/src/client/utils.ts | 6 -- .../src/server/clerkMiddleware.ts | 30 ++------ .../server/keyless/__tests__/utils.test.ts | 75 +++++++++++++++++++ .../src/server/keyless/index.ts | 10 --- .../src/server/keyless/utils.ts | 37 +++++---- .../src/server/loadOptions.ts | 2 +- 9 files changed, 152 insertions(+), 88 deletions(-) create mode 100644 .changeset/tanstack-keyless-cli-init-error.md create mode 100644 packages/tanstack-react-start/src/server/keyless/__tests__/utils.test.ts diff --git a/.changeset/tanstack-keyless-cli-init-error.md b/.changeset/tanstack-keyless-cli-init-error.md new file mode 100644 index 00000000000..c4b841e844c --- /dev/null +++ b/.changeset/tanstack-keyless-cli-init-error.md @@ -0,0 +1,5 @@ +--- +'@clerk/tanstack-react-start': minor +--- + +In development, missing Clerk keys no longer activate keyless mode. When `CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY` are not set, the SDK now fails with an error directing you to run `npx clerk@latest init`, which provisions a Clerk application and writes the keys to `.env`. Existing apps with configured or claimed keys are unaffected. diff --git a/integration/tests/tanstack-start/keyless.test.ts b/integration/tests/tanstack-start/keyless.test.ts index a2605806778..17ff4eecaf6 100644 --- a/integration/tests/tanstack-start/keyless.test.ts +++ b/integration/tests/tanstack-start/keyless.test.ts @@ -1,16 +1,15 @@ -import { test } from '@playwright/test'; +import * as path from 'node:path'; + +import { expect, test } from '@playwright/test'; import type { Application } from '../../models/application'; import { appConfigs } from '../../presets'; -import { - testClaimedAppWithMissingKeys, - testKeylessRemovedAfterEnvAndRestart, - testToggleCollapsePopoverAndClaim, -} from '../../testUtils/keylessHelpers'; +import { fs } from '../../scripts'; +import { createTestUtils } from '../../testUtils'; -const commonSetup = appConfigs.reactRouter.reactRouterNode.clone(); +const commonSetup = appConfigs.tanstack.reactStart.clone(); -test.describe('Keyless mode @react-router', () => { +test.describe('Keyless mode @tanstack-react-start', () => { test.describe.configure({ mode: 'serial' }); test.setTimeout(90_000); @@ -21,35 +20,52 @@ test.describe('Keyless mode @react-router', () => { }); let app: Application; - let dashboardUrl = 'https://dashboard.clerk.com/'; test.beforeAll(async () => { app = await commonSetup.commit(); await app.setup(); await app.withEnv(appConfigs.envs.withKeyless); - if (appConfigs.envs.withKeyless.privateVariables.get('CLERK_API_URL')?.includes('clerkstage')) { - dashboardUrl = 'https://dashboard.clerkstage.dev/'; - } - await app.dev(); + // Without keys the app 500s on every request, so readiness can't wait for a 2xx + await app.dev({ acceptAnyResponse: true }); }); test.afterAll(async () => { - // Keep files for debugging await app?.teardown(); }); - test('Toggle collapse popover and claim.', async ({ page, context }) => { - await testToggleCollapsePopoverAndClaim({ page, context, app, dashboardUrl, framework: 'react-router' }); - }); - - test('Lands on claimed application with missing explicit keys, expanded by default, click to get keys from dashboard.', async ({ + test('Without keys, requests fail with the missing env vars error instead of keyless bootstrap.', async ({ page, - context, }) => { - await testClaimedAppWithMissingKeys({ page, context, app, dashboardUrl }); + const response = await page.goto(`${app.serverUrl}/`); + expect(response?.status()).toBe(500); + await expect(page.getByText('Publishable key is missing').first()).toBeVisible(); + await expect(page.getByText('npx clerk@latest init').first()).toBeVisible(); }); - test('Keyless popover is removed after adding keys to .env and restarting.', async ({ page, context }) => { - await testKeylessRemovedAfterEnvAndRestart({ page, context, app }); + test('Claimed application with keys inside .env boots and serves the app.', async ({ page, context }) => { + /** + * Seed claimed keyless state directly: the SDK no longer mints keys, so write the + * keys fixture to `.clerk/.tmp/keyless.json` and configure the matching environment + * (keys AND api url, so the server-side onboarding-completion call targets the right + * instance). The completion request itself is BAPI-bound from the server, invisible + * to Playwright — its logic is covered by packages/tanstack-react-start keyless unit tests. + */ + const publishableKey = appConfigs.envs.withEmailCodes.publicVariables.get('CLERK_PUBLISHABLE_KEY'); + const secretKey = appConfigs.envs.withEmailCodes.privateVariables.get('CLERK_SECRET_KEY'); + await fs.ensureDir(path.join(app.appDir, '.clerk', '.tmp')); + await fs.writeJSON(path.join(app.appDir, '.clerk', '.tmp', 'keyless.json'), { + publishableKey, + secretKey, + claimUrl: 'https://dashboard.clerk.com/apps/claim', + apiKeysUrl: 'https://dashboard.clerk.com/last-active?path=api-keys', + }); + await app.withEnv(appConfigs.envs.withEmailCodes); + // Restart the dev server to pick up new env vars + await app.restart(); + + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + await u.po.expect.toBeSignedOut(); }); }); diff --git a/packages/tanstack-react-start/src/client/ClerkProvider.tsx b/packages/tanstack-react-start/src/client/ClerkProvider.tsx index 9231da2a554..f73a3310e1b 100644 --- a/packages/tanstack-react-start/src/client/ClerkProvider.tsx +++ b/packages/tanstack-react-start/src/client/ClerkProvider.tsx @@ -31,23 +31,13 @@ export function ClerkProvider({ const clerkInitState = isClient() ? (window as any).__clerk_init_state : clerkInitialState; - const { clerkSsrState, __keylessClaimUrl, __keylessApiKeysUrl, ...restInitState } = pickFromClerkInitState( - clerkInitState?.__internal_clerk_state, - ); + const { clerkSsrState, ...restInitState } = pickFromClerkInitState(clerkInitState?.__internal_clerk_state); const mergedProps = { ...mergeWithPublicEnvs(restInitState), ...providerProps, }; - // Add keyless mode props if present - const keylessProps = __keylessClaimUrl - ? { - __internal_keyless_claimKeylessApplicationUrl: __keylessClaimUrl, - __internal_keyless_copyInstanceKeysUrl: __keylessApiKeysUrl, - } - : {}; - return ( <> {`window.__clerk_init_state = ${htmlSafeJson(clerkInitialState)};`} @@ -74,7 +64,6 @@ export function ClerkProvider({ }); }} {...mergedProps} - {...keylessProps} > {children} diff --git a/packages/tanstack-react-start/src/client/utils.ts b/packages/tanstack-react-start/src/client/utils.ts index fd9bcea0afd..4198618b81e 100644 --- a/packages/tanstack-react-start/src/client/utils.ts +++ b/packages/tanstack-react-start/src/client/utils.ts @@ -10,8 +10,6 @@ export const pickFromClerkInitState = ( ): TanStackProviderAndInitialProps & InternalClerkScriptProps & { clerkSsrState: any; - __keylessClaimUrl?: string; - __keylessApiKeysUrl?: string; } => { const { __clerk_ssr_state, @@ -32,8 +30,6 @@ export const pickFromClerkInitState = ( __signUpForceRedirectUrl, __signInFallbackRedirectUrl, __signUpFallbackRedirectUrl, - __keylessClaimUrl, - __keylessApiKeysUrl, __prefetchUI, } = clerkInitState || {}; @@ -59,8 +55,6 @@ export const pickFromClerkInitState = ( signUpForceRedirectUrl: __signUpForceRedirectUrl, signInFallbackRedirectUrl: __signInFallbackRedirectUrl, signUpFallbackRedirectUrl: __signUpFallbackRedirectUrl, - __keylessClaimUrl, - __keylessApiKeysUrl, }; }; diff --git a/packages/tanstack-react-start/src/server/clerkMiddleware.ts b/packages/tanstack-react-start/src/server/clerkMiddleware.ts index 5ba42112a7c..a3158abf6bc 100644 --- a/packages/tanstack-react-start/src/server/clerkMiddleware.ts +++ b/packages/tanstack-react-start/src/server/clerkMiddleware.ts @@ -8,7 +8,7 @@ import { createMiddleware } from '@tanstack/react-start'; import { canUseKeyless } from '../utils/feature-flags'; import { clerkClient } from './clerkClient'; -import { resolveKeysWithKeylessFallback } from './keyless/utils'; +import { completeOnboardingIfClaimed } from './keyless/utils'; import { loadOptions } from './loadOptions'; import type { ClerkMiddlewareOptions, ClerkMiddlewareOptionsCallback } from './types'; import { getResponseClerkState } from './utils'; @@ -29,19 +29,12 @@ export const clerkMiddleware = ( secretKey: resolvedOptions?.secretKey, }); - // Get keys - either from options, env, or keyless mode - const { - publishableKey, - secretKey, - claimUrl: keylessClaimUrl, - apiKeysUrl: keylessApiKeysUrl, - } = await resolveKeysWithKeylessFallback(loadedOptions.publishableKey, loadedOptions.secretKey); - - if (publishableKey) { - loadedOptions.publishableKey = publishableKey; - } - if (secretKey) { - loadedOptions.secretKey = secretKey; + if (canUseKeyless) { + try { + await completeOnboardingIfClaimed(loadedOptions.publishableKey); + } catch { + // Silently fail - claimed-keys onboarding must not break requests + } } const requestState = await clerkClient().authenticateRequest(clerkRequest, { @@ -67,15 +60,6 @@ export const clerkMiddleware = ( const clerkInitialState = getResponseClerkState(requestState as RequestState, loadedOptions); - // Include keyless mode URLs if applicable - if (canUseKeyless && keylessClaimUrl) { - (clerkInitialState as Record).__internal_clerk_state = { - ...((clerkInitialState as Record).__internal_clerk_state as Record), - __keylessClaimUrl: keylessClaimUrl, - __keylessApiKeysUrl: keylessApiKeysUrl, - }; - } - const result = await next({ context: { clerkInitialState, diff --git a/packages/tanstack-react-start/src/server/keyless/__tests__/utils.test.ts b/packages/tanstack-react-start/src/server/keyless/__tests__/utils.test.ts new file mode 100644 index 00000000000..bdc7d39165d --- /dev/null +++ b/packages/tanstack-react-start/src/server/keyless/__tests__/utils.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { completeOnboardingIfClaimed } from '../utils'; + +const { completeClaimedOnboarding, log, readKeys, completeOnboarding } = vi.hoisted(() => ({ + completeClaimedOnboarding: vi.fn(() => Promise.resolve()), + log: vi.fn(), + readKeys: vi.fn(), + completeOnboarding: vi.fn(), +})); + +vi.mock('@clerk/shared/keyless', () => ({ + completeClaimedOnboarding, + clerkDevelopmentCache: { log }, +})); + +vi.mock('../index', () => ({ + keyless: () => ({ readKeys, completeOnboarding }), +})); + +const storedKeys = { + publishableKey: 'pk_test_stored', + secretKey: 'sk_test_stored', + claimUrl: 'https://dashboard.clerk.com/apps/claim', + apiKeysUrl: 'https://dashboard.clerk.com/last-active?path=api-keys', +}; + +describe('completeOnboardingIfClaimed', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('completes onboarding when the configured key matches the stored keyless keys', async () => { + readKeys.mockReturnValue(storedKeys); + + await completeOnboardingIfClaimed('pk_test_stored'); + + expect(completeClaimedOnboarding).toHaveBeenCalledTimes(1); + expect(completeClaimedOnboarding).toHaveBeenCalledWith('pk_test_stored', expect.objectContaining({ readKeys })); + }); + + it('does nothing when the configured key does not match the stored keys', async () => { + readKeys.mockReturnValue(storedKeys); + + await completeOnboardingIfClaimed('pk_test_other'); + + expect(completeClaimedOnboarding).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + }); + + it('does nothing when no keyless keys are stored', async () => { + readKeys.mockReturnValue(undefined); + + await completeOnboardingIfClaimed('pk_test_stored'); + + expect(completeClaimedOnboarding).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + }); + + it('logs a pointer to the stored keys when no key is configured, without completing onboarding', async () => { + readKeys.mockReturnValue(storedKeys); + + await completeOnboardingIfClaimed(undefined); + + expect(completeClaimedOnboarding).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ + cacheKey: 'pk_test_stored_stored', + msg: expect.stringContaining('.clerk/.tmp/keyless.json'), + }), + ); + expect(log).toHaveBeenCalledWith(expect.objectContaining({ msg: expect.stringContaining(storedKeys.claimUrl) })); + }); +}); diff --git a/packages/tanstack-react-start/src/server/keyless/index.ts b/packages/tanstack-react-start/src/server/keyless/index.ts index bb91fbedfa9..e2a5d74fdb9 100644 --- a/packages/tanstack-react-start/src/server/keyless/index.ts +++ b/packages/tanstack-react-start/src/server/keyless/index.ts @@ -11,16 +11,6 @@ export function keyless() { keylessServiceInstance = createKeylessService({ storage: createFileStorage(), api: { - async createAccountlessApplication(requestHeaders?: Headers, source?: string) { - try { - return await clerkClient().__experimental_accountlessApplications.createAccountlessApplication({ - requestHeaders, - source, - }); - } catch { - return null; - } - }, async completeOnboarding(requestHeaders?: Headers, source?: string) { try { return await clerkClient().__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({ diff --git a/packages/tanstack-react-start/src/server/keyless/utils.ts b/packages/tanstack-react-start/src/server/keyless/utils.ts index ab4896cadb5..9bce40385e0 100644 --- a/packages/tanstack-react-start/src/server/keyless/utils.ts +++ b/packages/tanstack-react-start/src/server/keyless/utils.ts @@ -1,19 +1,30 @@ -import { resolveKeysWithKeylessFallback as sharedResolveKeysWithKeylessFallback } from '@clerk/shared/keyless'; -export type { KeylessResult } from '@clerk/shared/keyless'; +import { clerkDevelopmentCache, completeClaimedOnboarding } from '@clerk/shared/keyless'; -import { canUseKeyless } from '../../utils/feature-flags'; import { keyless } from './index'; /** - * Resolves Clerk keys, falling back to keyless mode in development if configured keys are missing. - * - * @param configuredPublishableKey - The publishable key from options or environment - * @param configuredSecretKey - The secret key from options or environment - * @returns The resolved keys (either configured or from keyless mode) + * Notifies the dashboard that a claimed keyless application is now running with its + * keys configured. When no key is configured but stored keyless keys exist, logs a + * one-time pointer to them instead (the missing-key error throws downstream). */ -export function resolveKeysWithKeylessFallback( - configuredPublishableKey: string | undefined, - configuredSecretKey: string | undefined, -) { - return sharedResolveKeysWithKeylessFallback(configuredPublishableKey, configuredSecretKey, keyless(), canUseKeyless); +export async function completeOnboardingIfClaimed(configuredPublishableKey: string | undefined): Promise { + const keylessService = keyless(); + const locallyStoredKeys = keylessService.readKeys(); + if (!locallyStoredKeys) { + return; + } + + if (!configuredPublishableKey) { + clerkDevelopmentCache?.log({ + cacheKey: `${locallyStoredKeys.publishableKey}_stored`, + msg: `[Clerk]: Found existing keyless-mode keys in .clerk/.tmp/keyless.json. Copy the publishableKey and secretKey into .env (CLERK_PUBLISHABLE_KEY / CLERK_SECRET_KEY) to keep using that application, or claim it at ${locallyStoredKeys.claimUrl}`, + }); + return; + } + + if (locallyStoredKeys.publishableKey !== configuredPublishableKey) { + return; + } + + await completeClaimedOnboarding(locallyStoredKeys.publishableKey, keylessService); } diff --git a/packages/tanstack-react-start/src/server/loadOptions.ts b/packages/tanstack-react-start/src/server/loadOptions.ts index 694494a8c6d..3af9632686b 100644 --- a/packages/tanstack-react-start/src/server/loadOptions.ts +++ b/packages/tanstack-react-start/src/server/loadOptions.ts @@ -30,7 +30,7 @@ export const loadOptions = (request: ClerkRequest, overrides: LoaderOptions = {} proxyUrl = relativeOrAbsoluteProxyUrl; } - // In keyless mode, don't throw if secretKey is missing - ClerkProvider will handle it + // In development, defer missing-key errors to authenticateRequest so the CLI-pointing message surfaces if (!secretKey && !canUseKeyless) { // eslint-disable-next-line @typescript-eslint/only-throw-error throw errorThrower.throw('Clerk: no secret key provided');