From 66d416c3830297dd3b5e99eceb9b5542bc1b5959 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Thu, 17 Sep 2026 10:57:48 -0400 Subject: [PATCH 01/11] refactor(shared): extract claimed-onboarding helper and make keyless app creation optional Co-Authored-By: Claude Fable 5.1 --- .../src/keyless/__tests__/service.spec.ts | 23 ++++++++++ .../src/keyless/completeClaimedOnboarding.ts | 27 ++++++++++++ packages/shared/src/keyless/index.ts | 10 ++++- .../keyless/resolveKeysWithKeylessFallback.ts | 19 ++------- packages/shared/src/keyless/service.ts | 42 +++++++++---------- 5 files changed, 83 insertions(+), 38 deletions(-) create mode 100644 packages/shared/src/keyless/completeClaimedOnboarding.ts diff --git a/packages/shared/src/keyless/__tests__/service.spec.ts b/packages/shared/src/keyless/__tests__/service.spec.ts index 7a76b26cb3e..dca8716d5b2 100644 --- a/packages/shared/src/keyless/__tests__/service.spec.ts +++ b/packages/shared/src/keyless/__tests__/service.spec.ts @@ -49,6 +49,29 @@ describe('createKeylessService', () => { expect(source).toBe('nextjs'); }); + it('getOrCreateKeys resolves to null when the API cannot create applications and storage is empty', async () => { + const service = createKeylessService({ + storage: createStorage(), + api: { completeOnboarding: vi.fn(() => Promise.resolve(accountlessApplication)) }, + framework: 'astro', + }); + + await expect(service.getOrCreateKeys()).resolves.toBeNull(); + }); + + it('getOrCreateKeys returns stored keys even when the API cannot create applications', async () => { + const storage = createStorage(); + storage.write(JSON.stringify(accountlessApplication)); + + const service = createKeylessService({ + storage, + api: { completeOnboarding: vi.fn(() => Promise.resolve(accountlessApplication)) }, + framework: 'astro', + }); + + await expect(service.getOrCreateKeys()).resolves.toEqual(accountlessApplication); + }); + it('passes the framework as the source when completing accountless application onboarding', async () => { const completeOnboarding = vi.fn(() => Promise.resolve(accountlessApplication)); diff --git a/packages/shared/src/keyless/completeClaimedOnboarding.ts b/packages/shared/src/keyless/completeClaimedOnboarding.ts new file mode 100644 index 00000000000..949e2c41e28 --- /dev/null +++ b/packages/shared/src/keyless/completeClaimedOnboarding.ts @@ -0,0 +1,27 @@ +import { clerkDevelopmentCache, createConfirmationMessage } from './devCache'; +import type { AccountlessApplication } from './types'; + +interface CompletionService { + completeOnboarding: () => Promise; +} + +/** + * Notifies the backend that a claimed keyless application is running with its keys + * configured (cached to once per 24 hours) and logs the one-time claim confirmation. + * Resolves even when the completion request fails. + */ +export async function completeClaimedOnboarding(publishableKey: string, service: CompletionService): Promise { + try { + await clerkDevelopmentCache?.run(() => service.completeOnboarding(), { + cacheKey: `${publishableKey}_complete`, + onSuccessStale: 24 * 60 * 60 * 1000, // 24 hours + }); + } catch { + // noop + } + + clerkDevelopmentCache?.log({ + cacheKey: `${publishableKey}_claimed`, + msg: createConfirmationMessage(), + }); +} diff --git a/packages/shared/src/keyless/index.ts b/packages/shared/src/keyless/index.ts index 75e2cf16c91..92c5175ea45 100644 --- a/packages/shared/src/keyless/index.ts +++ b/packages/shared/src/keyless/index.ts @@ -9,8 +9,16 @@ export type { ClerkDevCache } from './devCache'; export { createNodeFileStorage } from './nodeFileStorage'; export type { FileSystemAdapter, NodeFileStorageOptions, PathAdapter } from './nodeFileStorage'; +export { completeClaimedOnboarding } from './completeClaimedOnboarding'; + export { createKeylessService } from './service'; -export type { KeylessAPI, KeylessService, KeylessServiceOptions, KeylessStorage } from './service'; +export type { + KeylessAPI, + KeylessCompletionAPI, + KeylessService, + KeylessServiceOptions, + KeylessStorage, +} from './service'; export { resolveKeysWithKeylessFallback } from './resolveKeysWithKeylessFallback'; export type { KeylessResult } from './resolveKeysWithKeylessFallback'; diff --git a/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts b/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts index fc923ae2a2f..8d54a315d43 100644 --- a/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts +++ b/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts @@ -1,4 +1,5 @@ -import { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } from './devCache'; +import { completeClaimedOnboarding } from './completeClaimedOnboarding'; +import { clerkDevelopmentCache, createKeylessModeMessage } from './devCache'; import type { KeylessService } from './service'; import type { AccountlessApplication } from './types'; @@ -45,21 +46,7 @@ export async function resolveKeysWithKeylessFallback( Boolean(configuredPublishableKey) && configuredPublishableKey === locallyStoredKeys?.publishableKey; if (runningWithClaimedKeys && locallyStoredKeys) { - // Complete onboarding when running with claimed keys - try { - await clerkDevelopmentCache?.run(() => keylessService.completeOnboarding(), { - cacheKey: `${locallyStoredKeys.publishableKey}_complete`, - onSuccessStale: 24 * 60 * 60 * 1000, // 24 hours - }); - } catch { - // noop - } - - clerkDevelopmentCache?.log({ - cacheKey: `${locallyStoredKeys.publishableKey}_claimed`, - msg: createConfirmationMessage(), - }); - + await completeClaimedOnboarding(locallyStoredKeys.publishableKey, keylessService); return { publishableKey, secretKey, claimUrl, apiKeysUrl }; } diff --git a/packages/shared/src/keyless/service.ts b/packages/shared/src/keyless/service.ts index 7e776a3e9ac..760f5cb136d 100644 --- a/packages/shared/src/keyless/service.ts +++ b/packages/shared/src/keyless/service.ts @@ -1,4 +1,5 @@ -import { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } from './devCache'; +import { completeClaimedOnboarding } from './completeClaimedOnboarding'; +import { clerkDevelopmentCache, createKeylessModeMessage } from './devCache'; import type { AccountlessApplication } from './types'; const KEYLESS_SOURCE_FALLBACK = 'javascript'; @@ -34,18 +35,20 @@ export interface KeylessStorage { } /** - * API adapter for keyless mode operations. + * API adapter for SDKs that only complete onboarding for already-claimed keyless + * applications and no longer mint new ones. * This abstraction allows the service to work without depending on @clerk/backend. */ -export interface KeylessAPI { +export interface KeylessCompletionAPI { /** * Creates a new accountless application. + * Omitted by SDKs that no longer mint keyless applications. * * @param requestHeaders - Optional headers to include with the request. * @param source - Optional source value to include with the request. * @returns The created AccountlessApplication or null if failed. */ - createAccountlessApplication(requestHeaders?: Headers, source?: string): Promise; + createAccountlessApplication?(requestHeaders?: Headers, source?: string): Promise; /** * Notifies the backend that onboarding is complete (instance has been claimed). @@ -57,6 +60,13 @@ export interface KeylessAPI { completeOnboarding(requestHeaders?: Headers, source?: string): Promise; } +/** + * API adapter for keyless mode operations, for SDKs that can still mint keyless applications. + */ +export interface KeylessAPI extends KeylessCompletionAPI { + createAccountlessApplication(requestHeaders?: Headers, source?: string): Promise; +} + /** * Options for creating a keyless service. */ @@ -67,9 +77,9 @@ export interface KeylessServiceOptions { storage: KeylessStorage; /** - * API adapter for keyless operations (create application, complete onboarding). + * API adapter for keyless operations (complete onboarding, optionally create application). */ - api: KeylessAPI; + api: KeylessCompletionAPI; /** * Optional: Framework name for metadata (e.g., 'Next.js', 'TanStack Start'). @@ -210,6 +220,10 @@ export function createKeylessService(options: KeylessServiceOptions): KeylessSer return existingConfig; } + if (!api.createAccountlessApplication) { + return null; + } + // Create metadata headers const headers = createMetadataHeaders(framework, frameworkVersion); @@ -260,21 +274,7 @@ export function createKeylessService(options: KeylessServiceOptions): KeylessSer Boolean(configuredPublishableKey) && configuredPublishableKey === locallyStoredKeys?.publishableKey; if (runningWithClaimedKeys && locallyStoredKeys) { - // Complete onboarding when running with claimed keys - try { - await clerkDevelopmentCache?.run(() => this.completeOnboarding(), { - cacheKey: `${locallyStoredKeys.publishableKey}_complete`, - onSuccessStale: 24 * 60 * 60 * 1000, // 24 hours - }); - } catch { - // noop - } - - clerkDevelopmentCache?.log({ - cacheKey: `${locallyStoredKeys.publishableKey}_claimed`, - msg: createConfirmationMessage(), - }); - + await completeClaimedOnboarding(locallyStoredKeys.publishableKey, this); return { publishableKey, secretKey, claimUrl, apiKeysUrl }; } From b31e9b7d72e55b4bdd9496335b9b1d06cb29962b Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Wed, 26 Aug 2026 12:51:49 -0400 Subject: [PATCH 02/11] 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'); From f87e02b15752e65239fbe709b37f747495c255d2 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Tue, 15 Sep 2026 16:44:56 -0400 Subject: [PATCH 03/11] test(tanstack-react-start): assert missing-key error from dev server stderr TanStack Start answers an unhandled middleware error with an opaque JSON body, so the message never reaches the page. Co-Authored-By: Claude Fable 5.1 --- integration/tests/tanstack-start/keyless.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/integration/tests/tanstack-start/keyless.test.ts b/integration/tests/tanstack-start/keyless.test.ts index 17ff4eecaf6..9a1090b3070 100644 --- a/integration/tests/tanstack-start/keyless.test.ts +++ b/integration/tests/tanstack-start/keyless.test.ts @@ -25,8 +25,8 @@ test.describe('Keyless mode @tanstack-react-start', () => { app = await commonSetup.commit(); await app.setup(); await app.withEnv(appConfigs.envs.withKeyless); - // Without keys the app 500s on every request, so readiness can't wait for a 2xx - await app.dev({ acceptAnyResponse: true }); + // Without keys the app 500s on every request (opaque JSON body), so readiness can't wait for a 2xx and the error is asserted from the detached server's stderr file + await app.dev({ acceptAnyResponse: true, detached: true }); }); test.afterAll(async () => { @@ -38,8 +38,11 @@ test.describe('Keyless mode @tanstack-react-start', () => { }) => { 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(); + + const stderrLogs = (await fs.readdir(app.appDir)).filter(f => f.endsWith('.err.log')); + const stderr = (await Promise.all(stderrLogs.map(f => fs.readFile(path.join(app.appDir, f), 'utf-8')))).join('\n'); + expect(stderr).toContain('Publishable key is missing'); + expect(stderr).toContain('npx clerk@latest init'); }); test('Claimed application with keys inside .env boots and serves the app.', async ({ page, context }) => { From 610dffd299bec3e8025f503ce1426e75a4ddfb07 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Thu, 17 Sep 2026 10:58:00 -0400 Subject: [PATCH 04/11] chore(shared): include shared in the tanstack keyless changeset Co-Authored-By: Claude Fable 5.1 --- .changeset/tanstack-keyless-cli-init-error.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/tanstack-keyless-cli-init-error.md b/.changeset/tanstack-keyless-cli-init-error.md index c4b841e844c..6ffcc8a084b 100644 --- a/.changeset/tanstack-keyless-cli-init-error.md +++ b/.changeset/tanstack-keyless-cli-init-error.md @@ -1,5 +1,6 @@ --- '@clerk/tanstack-react-start': minor +'@clerk/shared': patch --- 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. From 511b706eff219cab669ee7cb1fb822073606d506 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Thu, 17 Sep 2026 11:49:53 -0400 Subject: [PATCH 05/11] test(tanstack-react-start): use the active-instance dashboard link in keyless fixtures Co-Authored-By: Claude Fable 5.1 --- integration/tests/tanstack-start/keyless.test.ts | 2 +- .../src/server/keyless/__tests__/utils.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/integration/tests/tanstack-start/keyless.test.ts b/integration/tests/tanstack-start/keyless.test.ts index 9a1090b3070..b0d6e4e5201 100644 --- a/integration/tests/tanstack-start/keyless.test.ts +++ b/integration/tests/tanstack-start/keyless.test.ts @@ -60,7 +60,7 @@ test.describe('Keyless mode @tanstack-react-start', () => { publishableKey, secretKey, claimUrl: 'https://dashboard.clerk.com/apps/claim', - apiKeysUrl: 'https://dashboard.clerk.com/last-active?path=api-keys', + apiKeysUrl: 'https://dashboard.clerk.com/~/api-keys', }); await app.withEnv(appConfigs.envs.withEmailCodes); // Restart the dev server to pick up new env vars 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 index bdc7d39165d..be06834246c 100644 --- a/packages/tanstack-react-start/src/server/keyless/__tests__/utils.test.ts +++ b/packages/tanstack-react-start/src/server/keyless/__tests__/utils.test.ts @@ -22,7 +22,7 @@ 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', + apiKeysUrl: 'https://dashboard.clerk.com/~/api-keys', }; describe('completeOnboardingIfClaimed', () => { From 8d4cfe4b5d0ecc34569a58d86868605e6c243eb0 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Thu, 17 Sep 2026 13:31:15 -0400 Subject: [PATCH 06/11] test(e2e): assert dev server output and exercise the tanstack claimed-keys path Co-Authored-By: Claude Fable 5.1 --- integration/models/application.ts | 11 +++++++- .../tests/tanstack-start/keyless.test.ts | 26 ++++++++----------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/integration/models/application.ts b/integration/models/application.ts index c74dab234f9..b8ca49851e0 100644 --- a/integration/models/application.ts +++ b/integration/models/application.ts @@ -66,6 +66,7 @@ export const application = ( const stdoutFilePath = path.resolve(appDirPath, `e2e.${now}.log`); const stderrFilePath = path.resolve(appDirPath, `e2e.${now}.err.log`); let buildOutput = ''; + let devOutput = ''; let serveOutput = ''; const self = { @@ -133,7 +134,12 @@ export const application = ( detached: opts.detached, stdout: opts.detached ? fs.openSync(stdoutFilePath, 'a') : undefined, stderr: opts.detached ? fs.openSync(stderrFilePath, 'a') : undefined, - log: opts.detached ? undefined : log, + log: opts.detached + ? undefined + : (msg: string) => { + devOutput += `\n${msg}`; + log(msg); + }, }); const shouldExit = () => !!proc.exitCode && proc.exitCode !== 0; @@ -199,6 +205,9 @@ export const application = ( get buildOutput() { return buildOutput; }, + get devOutput() { + return devOutput; + }, get serveOutput() { return serveOutput; }, diff --git a/integration/tests/tanstack-start/keyless.test.ts b/integration/tests/tanstack-start/keyless.test.ts index b0d6e4e5201..a4b481c69bd 100644 --- a/integration/tests/tanstack-start/keyless.test.ts +++ b/integration/tests/tanstack-start/keyless.test.ts @@ -1,5 +1,6 @@ import * as path from 'node:path'; +import { automatedEnvironmentVariables } from '@clerk/shared/utils'; import { expect, test } from '@playwright/test'; import type { Application } from '../../models/application'; @@ -25,8 +26,8 @@ test.describe('Keyless mode @tanstack-react-start', () => { app = await commonSetup.commit(); await app.setup(); await app.withEnv(appConfigs.envs.withKeyless); - // Without keys the app 500s on every request (opaque JSON body), so readiness can't wait for a 2xx and the error is asserted from the detached server's stderr file - await app.dev({ acceptAnyResponse: true, detached: true }); + // Without keys the app 500s on every request, so readiness can't wait for a 2xx + await app.dev({ acceptAnyResponse: true }); }); test.afterAll(async () => { @@ -38,21 +39,12 @@ test.describe('Keyless mode @tanstack-react-start', () => { }) => { const response = await page.goto(`${app.serverUrl}/`); expect(response?.status()).toBe(500); - - const stderrLogs = (await fs.readdir(app.appDir)).filter(f => f.endsWith('.err.log')); - const stderr = (await Promise.all(stderrLogs.map(f => fs.readFile(path.join(app.appDir, f), 'utf-8')))).join('\n'); - expect(stderr).toContain('Publishable key is missing'); - expect(stderr).toContain('npx clerk@latest init'); + expect(app.devOutput).toContain('Publishable key is missing'); + expect(app.devOutput).toContain('npx clerk@latest init'); }); 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. - */ + // The SDK no longer mints keys, so seed the claimed keyless state directly 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')); @@ -62,7 +54,10 @@ test.describe('Keyless mode @tanstack-react-start', () => { claimUrl: 'https://dashboard.clerk.com/apps/claim', apiKeysUrl: 'https://dashboard.clerk.com/~/api-keys', }); - await app.withEnv(appConfigs.envs.withEmailCodes); + // `base` disables keyless and CI counts as automated, so undo both (as `withKeyless` does) or the claimed-onboarding path never runs + const claimedEnv = appConfigs.envs.withEmailCodes.clone().setEnvVariable('public', 'CLERK_KEYLESS_DISABLED', false); + automatedEnvironmentVariables.forEach(name => claimedEnv.setEnvVariable('private', name, 'false')); + await app.withEnv(claimedEnv); // Restart the dev server to pick up new env vars await app.restart(); @@ -70,5 +65,6 @@ test.describe('Keyless mode @tanstack-react-start', () => { await u.page.goToAppHome(); await u.page.waitForClerkJsLoaded(); await u.po.expect.toBeSignedOut(); + expect(app.devOutput).toContain('Your application is running with your claimed keys'); }); }); From 1320ade9a19ffc4aa961864dd5500f4f60a1cd43 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Thu, 17 Sep 2026 13:31:16 -0400 Subject: [PATCH 07/11] fix(tanstack-react-start): CLI-pointing secret-key error and claim-first stored-keys hint Co-Authored-By: Claude Fable 5.1 --- .../src/server/__tests__/loadOptions.test.ts | 35 +++++++++++++++++++ .../src/server/keyless/utils.ts | 2 +- .../src/server/loadOptions.ts | 6 +++- .../src/utils/feature-flags.ts | 10 +++--- 4 files changed, 46 insertions(+), 7 deletions(-) create mode 100644 packages/tanstack-react-start/src/server/__tests__/loadOptions.test.ts diff --git a/packages/tanstack-react-start/src/server/__tests__/loadOptions.test.ts b/packages/tanstack-react-start/src/server/__tests__/loadOptions.test.ts new file mode 100644 index 00000000000..fd5ae6cdd86 --- /dev/null +++ b/packages/tanstack-react-start/src/server/__tests__/loadOptions.test.ts @@ -0,0 +1,35 @@ +import type { ClerkRequest } from '@clerk/backend/internal'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { loadOptions } from '../loadOptions'; + +const featureFlags = vi.hoisted(() => ({ canUseKeyless: false })); + +vi.mock('../../utils/feature-flags', () => featureFlags); +vi.mock('../constants', () => ({ commonEnvs: () => ({}) })); + +const request = {} as ClerkRequest; + +describe('loadOptions', () => { + beforeEach(() => { + featureFlags.canUseKeyless = false; + }); + + it('throws the CLI-pointing error when only the secret key is missing', () => { + featureFlags.canUseKeyless = true; + + expect(() => loadOptions(request, { publishableKey: 'pk_test_Zm9vLWJhci0xMi5jbGVyay5hY2NvdW50cy5kZXYk' })).toThrow( + /Missing secretKey[\s\S]*npx clerk@latest init/, + ); + }); + + it('defers to authenticateRequest in development when both keys are missing', () => { + featureFlags.canUseKeyless = true; + + expect(() => loadOptions(request)).not.toThrow(); + }); + + it('throws when both keys are missing outside development', () => { + expect(() => loadOptions(request)).toThrow('Clerk: no secret key provided'); + }); +}); diff --git a/packages/tanstack-react-start/src/server/keyless/utils.ts b/packages/tanstack-react-start/src/server/keyless/utils.ts index 9bce40385e0..08a372c0977 100644 --- a/packages/tanstack-react-start/src/server/keyless/utils.ts +++ b/packages/tanstack-react-start/src/server/keyless/utils.ts @@ -17,7 +17,7 @@ export async function completeOnboardingIfClaimed(configuredPublishableKey: stri 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}`, + msg: `[Clerk]: Found existing keyless-mode keys in .clerk/.tmp/keyless.json. To keep using that application, claim it at ${locallyStoredKeys.claimUrl}, then copy the publishableKey and secretKey into .env (CLERK_PUBLISHABLE_KEY / CLERK_SECRET_KEY).`, }); return; } diff --git a/packages/tanstack-react-start/src/server/loadOptions.ts b/packages/tanstack-react-start/src/server/loadOptions.ts index 3af9632686b..466dd05cc55 100644 --- a/packages/tanstack-react-start/src/server/loadOptions.ts +++ b/packages/tanstack-react-start/src/server/loadOptions.ts @@ -30,7 +30,11 @@ export const loadOptions = (request: ClerkRequest, overrides: LoaderOptions = {} proxyUrl = relativeOrAbsoluteProxyUrl; } - // In development, defer missing-key errors to authenticateRequest so the CLI-pointing message surfaces + if (!secretKey && publishableKey) { + errorThrower.throwMissingSecretKeyError(); + } + + // In development with no keys at all, defer to authenticateRequest so its CLI-pointing missing-publishable-key error surfaces if (!secretKey && !canUseKeyless) { // eslint-disable-next-line @typescript-eslint/only-throw-error throw errorThrower.throw('Clerk: no secret key provided'); diff --git a/packages/tanstack-react-start/src/utils/feature-flags.ts b/packages/tanstack-react-start/src/utils/feature-flags.ts index 4d129d8887c..7a069ab01c9 100644 --- a/packages/tanstack-react-start/src/utils/feature-flags.ts +++ b/packages/tanstack-react-start/src/utils/feature-flags.ts @@ -2,18 +2,18 @@ import { getEnvVariable } from '@clerk/shared/getEnvVariable'; import { isTruthy } from '@clerk/shared/underscore'; import { isAutomatedEnvironment, isDevelopmentEnvironment } from '@clerk/shared/utils'; -// Support both Vite-style and generic env var names for disabling keyless mode +// Support both Vite-style and generic env var names const KEYLESS_DISABLED = isTruthy(getEnvVariable('VITE_CLERK_KEYLESS_DISABLED')) || isTruthy(getEnvVariable('CLERK_KEYLESS_DISABLED')) || false; /** - * Whether keyless mode can be used in the current environment. - * Keyless mode is only available in development, when not explicitly disabled, - * and when not running in an automated/CI environment. + * Whether the development-only keyless leftovers run: claimed-onboarding completion, + * the stored-keys hint, and deferring the no-keys error to `authenticateRequest`. + * The SDK no longer activates keyless mode. * - * To disable keyless mode, set either: + * True only in development, outside automated/CI environments, and unless either is set: * - `VITE_CLERK_KEYLESS_DISABLED=1` (for Vite-based projects) * - `CLERK_KEYLESS_DISABLED=1` (generic) */ From 048185edce78687f0a140772004c9805896234d3 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Thu, 17 Sep 2026 13:31:17 -0400 Subject: [PATCH 08/11] chore(shared): test completeClaimedOnboarding and give shared its own changeset Co-Authored-By: Claude Fable 5.1 --- .../shared-keyless-claimed-onboarding.md | 5 ++ .changeset/tanstack-keyless-cli-init-error.md | 1 - .../completeClaimedOnboarding.spec.ts | 49 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 .changeset/shared-keyless-claimed-onboarding.md create mode 100644 packages/shared/src/keyless/__tests__/completeClaimedOnboarding.spec.ts diff --git a/.changeset/shared-keyless-claimed-onboarding.md b/.changeset/shared-keyless-claimed-onboarding.md new file mode 100644 index 00000000000..3a1f1a50213 --- /dev/null +++ b/.changeset/shared-keyless-claimed-onboarding.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': minor +--- + +Add `completeClaimedOnboarding` and the `KeylessCompletionAPI` type to `@clerk/shared/keyless`. `createKeylessService` now accepts an API adapter without `createAccountlessApplication`, in which case `getOrCreateKeys()` only returns stored keys. diff --git a/.changeset/tanstack-keyless-cli-init-error.md b/.changeset/tanstack-keyless-cli-init-error.md index 6ffcc8a084b..c4b841e844c 100644 --- a/.changeset/tanstack-keyless-cli-init-error.md +++ b/.changeset/tanstack-keyless-cli-init-error.md @@ -1,6 +1,5 @@ --- '@clerk/tanstack-react-start': minor -'@clerk/shared': patch --- 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/packages/shared/src/keyless/__tests__/completeClaimedOnboarding.spec.ts b/packages/shared/src/keyless/__tests__/completeClaimedOnboarding.spec.ts new file mode 100644 index 00000000000..8619879ddf0 --- /dev/null +++ b/packages/shared/src/keyless/__tests__/completeClaimedOnboarding.spec.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const loadHelper = async () => (await import('../completeClaimedOnboarding')).completeClaimedOnboarding; + +describe('completeClaimedOnboarding', () => { + beforeEach(() => { + vi.stubEnv('NODE_ENV', 'development'); + globalThis.__clerk_internal_keyless_logger = undefined; + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + globalThis.__clerk_internal_keyless_logger = undefined; + }); + + it('completes onboarding once per publishable key', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + const completeClaimedOnboarding = await loadHelper(); + const completeOnboarding = vi.fn(() => Promise.resolve(null)); + + await completeClaimedOnboarding('pk_test_claimed', { completeOnboarding }); + await completeClaimedOnboarding('pk_test_claimed', { completeOnboarding }); + + expect(completeOnboarding).toHaveBeenCalledTimes(1); + }); + + it('resolves when the completion request rejects', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + const completeClaimedOnboarding = await loadHelper(); + const completeOnboarding = vi.fn(() => Promise.reject(new Error('network'))); + + await expect(completeClaimedOnboarding('pk_test_claimed', { completeOnboarding })).resolves.toBeUndefined(); + expect(completeOnboarding).toHaveBeenCalledTimes(1); + }); + + it('logs the claimed confirmation once', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const completeClaimedOnboarding = await loadHelper(); + const completeOnboarding = vi.fn(() => Promise.resolve(null)); + + await completeClaimedOnboarding('pk_test_claimed', { completeOnboarding }); + await completeClaimedOnboarding('pk_test_claimed', { completeOnboarding }); + + expect(log).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith(expect.stringContaining('running with your claimed keys')); + }); +}); From df03978c7eb64f64572bec7d4dcf32dbd81d80b6 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Mon, 21 Sep 2026 10:26:19 -0400 Subject: [PATCH 09/11] refactor(tanstack-react-start): remove the claimed-app keyless flow Drops src/server/keyless/ and the onboarding-completion call from the middleware. The package no longer needs the @clerk/shared keyless changes, so those are reverted. Co-Authored-By: Claude Fable 5.1 --- .../shared-keyless-claimed-onboarding.md | 5 -- .../tests/tanstack-start/keyless.test.ts | 30 -------- .../completeClaimedOnboarding.spec.ts | 49 ------------ .../src/keyless/__tests__/service.spec.ts | 23 ------ .../src/keyless/completeClaimedOnboarding.ts | 27 ------- packages/shared/src/keyless/index.ts | 10 +-- .../keyless/resolveKeysWithKeylessFallback.ts | 19 ++++- packages/shared/src/keyless/service.ts | 42 +++++------ .../src/server/clerkMiddleware.ts | 10 --- .../server/keyless/__tests__/utils.test.ts | 75 ------------------- .../src/server/keyless/fileStorage.ts | 19 ----- .../src/server/keyless/index.ts | 29 ------- .../src/server/keyless/utils.ts | 30 -------- .../src/utils/feature-flags.ts | 5 +- 14 files changed, 40 insertions(+), 333 deletions(-) delete mode 100644 .changeset/shared-keyless-claimed-onboarding.md delete mode 100644 packages/shared/src/keyless/__tests__/completeClaimedOnboarding.spec.ts delete mode 100644 packages/shared/src/keyless/completeClaimedOnboarding.ts delete mode 100644 packages/tanstack-react-start/src/server/keyless/__tests__/utils.test.ts delete mode 100644 packages/tanstack-react-start/src/server/keyless/fileStorage.ts delete mode 100644 packages/tanstack-react-start/src/server/keyless/index.ts delete mode 100644 packages/tanstack-react-start/src/server/keyless/utils.ts diff --git a/.changeset/shared-keyless-claimed-onboarding.md b/.changeset/shared-keyless-claimed-onboarding.md deleted file mode 100644 index 3a1f1a50213..00000000000 --- a/.changeset/shared-keyless-claimed-onboarding.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@clerk/shared': minor ---- - -Add `completeClaimedOnboarding` and the `KeylessCompletionAPI` type to `@clerk/shared/keyless`. `createKeylessService` now accepts an API adapter without `createAccountlessApplication`, in which case `getOrCreateKeys()` only returns stored keys. diff --git a/integration/tests/tanstack-start/keyless.test.ts b/integration/tests/tanstack-start/keyless.test.ts index a4b481c69bd..acae8d69203 100644 --- a/integration/tests/tanstack-start/keyless.test.ts +++ b/integration/tests/tanstack-start/keyless.test.ts @@ -1,12 +1,7 @@ -import * as path from 'node:path'; - -import { automatedEnvironmentVariables } from '@clerk/shared/utils'; import { expect, test } from '@playwright/test'; import type { Application } from '../../models/application'; import { appConfigs } from '../../presets'; -import { fs } from '../../scripts'; -import { createTestUtils } from '../../testUtils'; const commonSetup = appConfigs.tanstack.reactStart.clone(); @@ -42,29 +37,4 @@ test.describe('Keyless mode @tanstack-react-start', () => { expect(app.devOutput).toContain('Publishable key is missing'); expect(app.devOutput).toContain('npx clerk@latest init'); }); - - test('Claimed application with keys inside .env boots and serves the app.', async ({ page, context }) => { - // The SDK no longer mints keys, so seed the claimed keyless state directly - 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/~/api-keys', - }); - // `base` disables keyless and CI counts as automated, so undo both (as `withKeyless` does) or the claimed-onboarding path never runs - const claimedEnv = appConfigs.envs.withEmailCodes.clone().setEnvVariable('public', 'CLERK_KEYLESS_DISABLED', false); - automatedEnvironmentVariables.forEach(name => claimedEnv.setEnvVariable('private', name, 'false')); - await app.withEnv(claimedEnv); - // 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(); - expect(app.devOutput).toContain('Your application is running with your claimed keys'); - }); }); diff --git a/packages/shared/src/keyless/__tests__/completeClaimedOnboarding.spec.ts b/packages/shared/src/keyless/__tests__/completeClaimedOnboarding.spec.ts deleted file mode 100644 index 8619879ddf0..00000000000 --- a/packages/shared/src/keyless/__tests__/completeClaimedOnboarding.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -const loadHelper = async () => (await import('../completeClaimedOnboarding')).completeClaimedOnboarding; - -describe('completeClaimedOnboarding', () => { - beforeEach(() => { - vi.stubEnv('NODE_ENV', 'development'); - globalThis.__clerk_internal_keyless_logger = undefined; - vi.resetModules(); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - vi.restoreAllMocks(); - globalThis.__clerk_internal_keyless_logger = undefined; - }); - - it('completes onboarding once per publishable key', async () => { - vi.spyOn(console, 'log').mockImplementation(() => {}); - const completeClaimedOnboarding = await loadHelper(); - const completeOnboarding = vi.fn(() => Promise.resolve(null)); - - await completeClaimedOnboarding('pk_test_claimed', { completeOnboarding }); - await completeClaimedOnboarding('pk_test_claimed', { completeOnboarding }); - - expect(completeOnboarding).toHaveBeenCalledTimes(1); - }); - - it('resolves when the completion request rejects', async () => { - vi.spyOn(console, 'log').mockImplementation(() => {}); - const completeClaimedOnboarding = await loadHelper(); - const completeOnboarding = vi.fn(() => Promise.reject(new Error('network'))); - - await expect(completeClaimedOnboarding('pk_test_claimed', { completeOnboarding })).resolves.toBeUndefined(); - expect(completeOnboarding).toHaveBeenCalledTimes(1); - }); - - it('logs the claimed confirmation once', async () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => {}); - const completeClaimedOnboarding = await loadHelper(); - const completeOnboarding = vi.fn(() => Promise.resolve(null)); - - await completeClaimedOnboarding('pk_test_claimed', { completeOnboarding }); - await completeClaimedOnboarding('pk_test_claimed', { completeOnboarding }); - - expect(log).toHaveBeenCalledTimes(1); - expect(log).toHaveBeenCalledWith(expect.stringContaining('running with your claimed keys')); - }); -}); diff --git a/packages/shared/src/keyless/__tests__/service.spec.ts b/packages/shared/src/keyless/__tests__/service.spec.ts index dca8716d5b2..7a76b26cb3e 100644 --- a/packages/shared/src/keyless/__tests__/service.spec.ts +++ b/packages/shared/src/keyless/__tests__/service.spec.ts @@ -49,29 +49,6 @@ describe('createKeylessService', () => { expect(source).toBe('nextjs'); }); - it('getOrCreateKeys resolves to null when the API cannot create applications and storage is empty', async () => { - const service = createKeylessService({ - storage: createStorage(), - api: { completeOnboarding: vi.fn(() => Promise.resolve(accountlessApplication)) }, - framework: 'astro', - }); - - await expect(service.getOrCreateKeys()).resolves.toBeNull(); - }); - - it('getOrCreateKeys returns stored keys even when the API cannot create applications', async () => { - const storage = createStorage(); - storage.write(JSON.stringify(accountlessApplication)); - - const service = createKeylessService({ - storage, - api: { completeOnboarding: vi.fn(() => Promise.resolve(accountlessApplication)) }, - framework: 'astro', - }); - - await expect(service.getOrCreateKeys()).resolves.toEqual(accountlessApplication); - }); - it('passes the framework as the source when completing accountless application onboarding', async () => { const completeOnboarding = vi.fn(() => Promise.resolve(accountlessApplication)); diff --git a/packages/shared/src/keyless/completeClaimedOnboarding.ts b/packages/shared/src/keyless/completeClaimedOnboarding.ts deleted file mode 100644 index 949e2c41e28..00000000000 --- a/packages/shared/src/keyless/completeClaimedOnboarding.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { clerkDevelopmentCache, createConfirmationMessage } from './devCache'; -import type { AccountlessApplication } from './types'; - -interface CompletionService { - completeOnboarding: () => Promise; -} - -/** - * Notifies the backend that a claimed keyless application is running with its keys - * configured (cached to once per 24 hours) and logs the one-time claim confirmation. - * Resolves even when the completion request fails. - */ -export async function completeClaimedOnboarding(publishableKey: string, service: CompletionService): Promise { - try { - await clerkDevelopmentCache?.run(() => service.completeOnboarding(), { - cacheKey: `${publishableKey}_complete`, - onSuccessStale: 24 * 60 * 60 * 1000, // 24 hours - }); - } catch { - // noop - } - - clerkDevelopmentCache?.log({ - cacheKey: `${publishableKey}_claimed`, - msg: createConfirmationMessage(), - }); -} diff --git a/packages/shared/src/keyless/index.ts b/packages/shared/src/keyless/index.ts index 92c5175ea45..75e2cf16c91 100644 --- a/packages/shared/src/keyless/index.ts +++ b/packages/shared/src/keyless/index.ts @@ -9,16 +9,8 @@ export type { ClerkDevCache } from './devCache'; export { createNodeFileStorage } from './nodeFileStorage'; export type { FileSystemAdapter, NodeFileStorageOptions, PathAdapter } from './nodeFileStorage'; -export { completeClaimedOnboarding } from './completeClaimedOnboarding'; - export { createKeylessService } from './service'; -export type { - KeylessAPI, - KeylessCompletionAPI, - KeylessService, - KeylessServiceOptions, - KeylessStorage, -} from './service'; +export type { KeylessAPI, KeylessService, KeylessServiceOptions, KeylessStorage } from './service'; export { resolveKeysWithKeylessFallback } from './resolveKeysWithKeylessFallback'; export type { KeylessResult } from './resolveKeysWithKeylessFallback'; diff --git a/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts b/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts index 8d54a315d43..fc923ae2a2f 100644 --- a/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts +++ b/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts @@ -1,5 +1,4 @@ -import { completeClaimedOnboarding } from './completeClaimedOnboarding'; -import { clerkDevelopmentCache, createKeylessModeMessage } from './devCache'; +import { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } from './devCache'; import type { KeylessService } from './service'; import type { AccountlessApplication } from './types'; @@ -46,7 +45,21 @@ export async function resolveKeysWithKeylessFallback( Boolean(configuredPublishableKey) && configuredPublishableKey === locallyStoredKeys?.publishableKey; if (runningWithClaimedKeys && locallyStoredKeys) { - await completeClaimedOnboarding(locallyStoredKeys.publishableKey, keylessService); + // Complete onboarding when running with claimed keys + try { + await clerkDevelopmentCache?.run(() => keylessService.completeOnboarding(), { + cacheKey: `${locallyStoredKeys.publishableKey}_complete`, + onSuccessStale: 24 * 60 * 60 * 1000, // 24 hours + }); + } catch { + // noop + } + + clerkDevelopmentCache?.log({ + cacheKey: `${locallyStoredKeys.publishableKey}_claimed`, + msg: createConfirmationMessage(), + }); + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; } diff --git a/packages/shared/src/keyless/service.ts b/packages/shared/src/keyless/service.ts index 760f5cb136d..7e776a3e9ac 100644 --- a/packages/shared/src/keyless/service.ts +++ b/packages/shared/src/keyless/service.ts @@ -1,5 +1,4 @@ -import { completeClaimedOnboarding } from './completeClaimedOnboarding'; -import { clerkDevelopmentCache, createKeylessModeMessage } from './devCache'; +import { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } from './devCache'; import type { AccountlessApplication } from './types'; const KEYLESS_SOURCE_FALLBACK = 'javascript'; @@ -35,20 +34,18 @@ export interface KeylessStorage { } /** - * API adapter for SDKs that only complete onboarding for already-claimed keyless - * applications and no longer mint new ones. + * API adapter for keyless mode operations. * This abstraction allows the service to work without depending on @clerk/backend. */ -export interface KeylessCompletionAPI { +export interface KeylessAPI { /** * Creates a new accountless application. - * Omitted by SDKs that no longer mint keyless applications. * * @param requestHeaders - Optional headers to include with the request. * @param source - Optional source value to include with the request. * @returns The created AccountlessApplication or null if failed. */ - createAccountlessApplication?(requestHeaders?: Headers, source?: string): Promise; + createAccountlessApplication(requestHeaders?: Headers, source?: string): Promise; /** * Notifies the backend that onboarding is complete (instance has been claimed). @@ -60,13 +57,6 @@ export interface KeylessCompletionAPI { completeOnboarding(requestHeaders?: Headers, source?: string): Promise; } -/** - * API adapter for keyless mode operations, for SDKs that can still mint keyless applications. - */ -export interface KeylessAPI extends KeylessCompletionAPI { - createAccountlessApplication(requestHeaders?: Headers, source?: string): Promise; -} - /** * Options for creating a keyless service. */ @@ -77,9 +67,9 @@ export interface KeylessServiceOptions { storage: KeylessStorage; /** - * API adapter for keyless operations (complete onboarding, optionally create application). + * API adapter for keyless operations (create application, complete onboarding). */ - api: KeylessCompletionAPI; + api: KeylessAPI; /** * Optional: Framework name for metadata (e.g., 'Next.js', 'TanStack Start'). @@ -220,10 +210,6 @@ export function createKeylessService(options: KeylessServiceOptions): KeylessSer return existingConfig; } - if (!api.createAccountlessApplication) { - return null; - } - // Create metadata headers const headers = createMetadataHeaders(framework, frameworkVersion); @@ -274,7 +260,21 @@ export function createKeylessService(options: KeylessServiceOptions): KeylessSer Boolean(configuredPublishableKey) && configuredPublishableKey === locallyStoredKeys?.publishableKey; if (runningWithClaimedKeys && locallyStoredKeys) { - await completeClaimedOnboarding(locallyStoredKeys.publishableKey, this); + // Complete onboarding when running with claimed keys + try { + await clerkDevelopmentCache?.run(() => this.completeOnboarding(), { + cacheKey: `${locallyStoredKeys.publishableKey}_complete`, + onSuccessStale: 24 * 60 * 60 * 1000, // 24 hours + }); + } catch { + // noop + } + + clerkDevelopmentCache?.log({ + cacheKey: `${locallyStoredKeys.publishableKey}_claimed`, + msg: createConfirmationMessage(), + }); + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; } diff --git a/packages/tanstack-react-start/src/server/clerkMiddleware.ts b/packages/tanstack-react-start/src/server/clerkMiddleware.ts index a3158abf6bc..71b32db568c 100644 --- a/packages/tanstack-react-start/src/server/clerkMiddleware.ts +++ b/packages/tanstack-react-start/src/server/clerkMiddleware.ts @@ -6,9 +6,7 @@ import type { PendingSessionOptions } from '@clerk/shared/types'; import type { AnyRequestMiddleware } from '@tanstack/react-start'; import { createMiddleware } from '@tanstack/react-start'; -import { canUseKeyless } from '../utils/feature-flags'; import { clerkClient } from './clerkClient'; -import { completeOnboardingIfClaimed } from './keyless/utils'; import { loadOptions } from './loadOptions'; import type { ClerkMiddlewareOptions, ClerkMiddlewareOptionsCallback } from './types'; import { getResponseClerkState } from './utils'; @@ -29,14 +27,6 @@ export const clerkMiddleware = ( secretKey: resolvedOptions?.secretKey, }); - if (canUseKeyless) { - try { - await completeOnboardingIfClaimed(loadedOptions.publishableKey); - } catch { - // Silently fail - claimed-keys onboarding must not break requests - } - } - const requestState = await clerkClient().authenticateRequest(clerkRequest, { ...loadedOptions, acceptsToken: 'any', 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 deleted file mode 100644 index be06834246c..00000000000 --- a/packages/tanstack-react-start/src/server/keyless/__tests__/utils.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -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/~/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/fileStorage.ts b/packages/tanstack-react-start/src/server/keyless/fileStorage.ts deleted file mode 100644 index 24929cc7ebd..00000000000 --- a/packages/tanstack-react-start/src/server/keyless/fileStorage.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; - -import { createNodeFileStorage, type KeylessStorage } from '@clerk/shared/keyless'; - -export type { KeylessStorage }; - -export interface FileStorageOptions { - cwd?: () => string; -} - -export function createFileStorage(options: FileStorageOptions = {}): KeylessStorage { - const { cwd = () => process.cwd() } = options; - - return createNodeFileStorage(fs, path, { - cwd, - frameworkPackageName: '@clerk/tanstack-react-start', - }); -} diff --git a/packages/tanstack-react-start/src/server/keyless/index.ts b/packages/tanstack-react-start/src/server/keyless/index.ts deleted file mode 100644 index e2a5d74fdb9..00000000000 --- a/packages/tanstack-react-start/src/server/keyless/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { createKeylessService } from '@clerk/shared/keyless'; - -import { clerkClient } from '../clerkClient'; -import { createFileStorage } from './fileStorage'; - -// Lazily initialized keyless service singleton -let keylessServiceInstance: ReturnType | null = null; - -export function keyless() { - if (!keylessServiceInstance) { - keylessServiceInstance = createKeylessService({ - storage: createFileStorage(), - api: { - async completeOnboarding(requestHeaders?: Headers, source?: string) { - try { - return await clerkClient().__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({ - requestHeaders, - source, - }); - } catch { - return null; - } - }, - }, - framework: 'tanstack-react-start', - }); - } - return keylessServiceInstance; -} diff --git a/packages/tanstack-react-start/src/server/keyless/utils.ts b/packages/tanstack-react-start/src/server/keyless/utils.ts deleted file mode 100644 index 08a372c0977..00000000000 --- a/packages/tanstack-react-start/src/server/keyless/utils.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { clerkDevelopmentCache, completeClaimedOnboarding } from '@clerk/shared/keyless'; - -import { keyless } from './index'; - -/** - * 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 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. To keep using that application, claim it at ${locallyStoredKeys.claimUrl}, then copy the publishableKey and secretKey into .env (CLERK_PUBLISHABLE_KEY / CLERK_SECRET_KEY).`, - }); - return; - } - - if (locallyStoredKeys.publishableKey !== configuredPublishableKey) { - return; - } - - await completeClaimedOnboarding(locallyStoredKeys.publishableKey, keylessService); -} diff --git a/packages/tanstack-react-start/src/utils/feature-flags.ts b/packages/tanstack-react-start/src/utils/feature-flags.ts index 7a069ab01c9..ce8ec65e595 100644 --- a/packages/tanstack-react-start/src/utils/feature-flags.ts +++ b/packages/tanstack-react-start/src/utils/feature-flags.ts @@ -9,9 +9,8 @@ const KEYLESS_DISABLED = false; /** - * Whether the development-only keyless leftovers run: claimed-onboarding completion, - * the stored-keys hint, and deferring the no-keys error to `authenticateRequest`. - * The SDK no longer activates keyless mode. + * Whether a missing key pair defers to `authenticateRequest`, so its CLI-pointing + * missing-publishable-key error surfaces. The SDK no longer activates keyless mode. * * True only in development, outside automated/CI environments, and unless either is set: * - `VITE_CLERK_KEYLESS_DISABLED=1` (for Vite-based projects) From 719f278224f62e2b1bd898ce918ae7742888904a Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Mon, 21 Sep 2026 11:56:13 -0400 Subject: [PATCH 10/11] refactor(tanstack-react-start): remove canUseKeyless With keyless gone the flag only chose which missing-key error to throw. A missing secret key now throws the shared CLI-pointing error in every environment, and the *_KEYLESS_DISABLED env vars no longer do anything. Co-Authored-By: Claude Fable 5.1 --- .changeset/tanstack-keyless-cli-init-error.md | 2 +- .../tests/tanstack-start/keyless.test.ts | 2 +- .../src/server/__tests__/loadOptions.test.ts | 30 +++--------- .../src/server/loadOptions.ts | 9 +--- .../src/utils/__tests__/feature-flags.test.ts | 48 ------------------- .../src/utils/feature-flags.ts | 19 -------- 6 files changed, 9 insertions(+), 101 deletions(-) delete mode 100644 packages/tanstack-react-start/src/utils/__tests__/feature-flags.test.ts delete mode 100644 packages/tanstack-react-start/src/utils/feature-flags.ts diff --git a/.changeset/tanstack-keyless-cli-init-error.md b/.changeset/tanstack-keyless-cli-init-error.md index c4b841e844c..d1a6ef4aa89 100644 --- a/.changeset/tanstack-keyless-cli-init-error.md +++ b/.changeset/tanstack-keyless-cli-init-error.md @@ -2,4 +2,4 @@ '@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. +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`. `VITE_CLERK_KEYLESS_DISABLED` and `CLERK_KEYLESS_DISABLED` no longer have any effect. 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 acae8d69203..90309c9c72a 100644 --- a/integration/tests/tanstack-start/keyless.test.ts +++ b/integration/tests/tanstack-start/keyless.test.ts @@ -34,7 +34,7 @@ test.describe('Keyless mode @tanstack-react-start', () => { }) => { const response = await page.goto(`${app.serverUrl}/`); expect(response?.status()).toBe(500); - expect(app.devOutput).toContain('Publishable key is missing'); + expect(app.devOutput).toContain('Missing secretKey'); expect(app.devOutput).toContain('npx clerk@latest init'); }); }); diff --git a/packages/tanstack-react-start/src/server/__tests__/loadOptions.test.ts b/packages/tanstack-react-start/src/server/__tests__/loadOptions.test.ts index fd5ae6cdd86..09ff5aa0294 100644 --- a/packages/tanstack-react-start/src/server/__tests__/loadOptions.test.ts +++ b/packages/tanstack-react-start/src/server/__tests__/loadOptions.test.ts @@ -1,35 +1,17 @@ import type { ClerkRequest } from '@clerk/backend/internal'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { loadOptions } from '../loadOptions'; -const featureFlags = vi.hoisted(() => ({ canUseKeyless: false })); - -vi.mock('../../utils/feature-flags', () => featureFlags); vi.mock('../constants', () => ({ commonEnvs: () => ({}) })); const request = {} as ClerkRequest; describe('loadOptions', () => { - beforeEach(() => { - featureFlags.canUseKeyless = false; - }); - - it('throws the CLI-pointing error when only the secret key is missing', () => { - featureFlags.canUseKeyless = true; - - expect(() => loadOptions(request, { publishableKey: 'pk_test_Zm9vLWJhci0xMi5jbGVyay5hY2NvdW50cy5kZXYk' })).toThrow( - /Missing secretKey[\s\S]*npx clerk@latest init/, - ); - }); - - it('defers to authenticateRequest in development when both keys are missing', () => { - featureFlags.canUseKeyless = true; - - expect(() => loadOptions(request)).not.toThrow(); - }); - - it('throws when both keys are missing outside development', () => { - expect(() => loadOptions(request)).toThrow('Clerk: no secret key provided'); + it.each([ + ['only the secret key is missing', { publishableKey: 'pk_test_Zm9vLWJhci0xMi5jbGVyay5hY2NvdW50cy5kZXYk' }], + ['both keys are missing', {}], + ])('throws the CLI-pointing error when %s', (_, overrides) => { + expect(() => loadOptions(request, overrides)).toThrow(/Missing secretKey[\s\S]*npx clerk@latest init/); }); }); diff --git a/packages/tanstack-react-start/src/server/loadOptions.ts b/packages/tanstack-react-start/src/server/loadOptions.ts index 466dd05cc55..cbb72cdb125 100644 --- a/packages/tanstack-react-start/src/server/loadOptions.ts +++ b/packages/tanstack-react-start/src/server/loadOptions.ts @@ -5,7 +5,6 @@ import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; import { isHttpOrHttps, isProxyUrlRelative } from '@clerk/shared/proxy'; import { errorThrower } from '../utils'; -import { canUseKeyless } from '../utils/feature-flags'; import { commonEnvs } from './constants'; import type { LoaderOptions } from './types'; @@ -30,16 +29,10 @@ export const loadOptions = (request: ClerkRequest, overrides: LoaderOptions = {} proxyUrl = relativeOrAbsoluteProxyUrl; } - if (!secretKey && publishableKey) { + if (!secretKey) { errorThrower.throwMissingSecretKeyError(); } - // In development with no keys at all, defer to authenticateRequest so its CLI-pointing missing-publishable-key error surfaces - if (!secretKey && !canUseKeyless) { - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw errorThrower.throw('Clerk: no secret key provided'); - } - if (isSatellite && !proxyUrl && !domain) { // eslint-disable-next-line @typescript-eslint/only-throw-error throw errorThrower.throw('Clerk: satellite mode requires a proxy URL or domain'); diff --git a/packages/tanstack-react-start/src/utils/__tests__/feature-flags.test.ts b/packages/tanstack-react-start/src/utils/__tests__/feature-flags.test.ts deleted file mode 100644 index 693278f61f9..00000000000 --- a/packages/tanstack-react-start/src/utils/__tests__/feature-flags.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { automatedEnvironmentVariables } from '@clerk/shared/utils'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -async function loadCanUseKeyless() { - vi.resetModules(); - const { canUseKeyless } = await import('../feature-flags.js'); - return canUseKeyless; -} - -describe('canUseKeyless', () => { - beforeEach(() => { - vi.stubEnv('NODE_ENV', 'development'); - vi.stubEnv('VITE_CLERK_KEYLESS_DISABLED', undefined); - vi.stubEnv('CLERK_KEYLESS_DISABLED', undefined); - automatedEnvironmentVariables.forEach(name => { - vi.stubEnv(name, undefined); - vi.stubGlobal(name, undefined); - }); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - vi.unstubAllGlobals(); - vi.resetModules(); - }); - - it('enables keyless in development when automation signals are absent', async () => { - await expect(loadCanUseKeyless()).resolves.toBe(true); - }); - - it('disables keyless in CI even when the app runs in development mode', async () => { - vi.stubEnv('CI', 'true'); - - await expect(loadCanUseKeyless()).resolves.toBe(false); - }); - - it('disables keyless outside development mode', async () => { - vi.stubEnv('NODE_ENV', 'production'); - - await expect(loadCanUseKeyless()).resolves.toBe(false); - }); - - it('disables keyless when explicitly disabled', async () => { - vi.stubEnv('VITE_CLERK_KEYLESS_DISABLED', 'true'); - - await expect(loadCanUseKeyless()).resolves.toBe(false); - }); -}); diff --git a/packages/tanstack-react-start/src/utils/feature-flags.ts b/packages/tanstack-react-start/src/utils/feature-flags.ts deleted file mode 100644 index ce8ec65e595..00000000000 --- a/packages/tanstack-react-start/src/utils/feature-flags.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { getEnvVariable } from '@clerk/shared/getEnvVariable'; -import { isTruthy } from '@clerk/shared/underscore'; -import { isAutomatedEnvironment, isDevelopmentEnvironment } from '@clerk/shared/utils'; - -// Support both Vite-style and generic env var names -const KEYLESS_DISABLED = - isTruthy(getEnvVariable('VITE_CLERK_KEYLESS_DISABLED')) || - isTruthy(getEnvVariable('CLERK_KEYLESS_DISABLED')) || - false; - -/** - * Whether a missing key pair defers to `authenticateRequest`, so its CLI-pointing - * missing-publishable-key error surfaces. The SDK no longer activates keyless mode. - * - * True only in development, outside automated/CI environments, and unless either is set: - * - `VITE_CLERK_KEYLESS_DISABLED=1` (for Vite-based projects) - * - `CLERK_KEYLESS_DISABLED=1` (generic) - */ -export const canUseKeyless = isDevelopmentEnvironment() && !isAutomatedEnvironment() && !KEYLESS_DISABLED; From 6daff8500fd9d34ed35f6501fa6111e98cadbe6f Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Wed, 23 Sep 2026 12:09:50 -0400 Subject: [PATCH 11/11] fix(tanstack-react-start): correct keyless removal changeset and drop leftover overrides - Changeset: `clerk init` writes `VITE_CLERK_PUBLISHABLE_KEY` to `.env.local`; keys stored in `.clerk/` are no longer read - clerkMiddleware: pass resolved options straight to `loadOptions` Co-Authored-By: Claude Opus 5.5 --- .changeset/tanstack-keyless-cli-init-error.md | 2 +- .../tanstack-react-start/src/server/clerkMiddleware.ts | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/.changeset/tanstack-keyless-cli-init-error.md b/.changeset/tanstack-keyless-cli-init-error.md index d1a6ef4aa89..d321f6d87cb 100644 --- a/.changeset/tanstack-keyless-cli-init-error.md +++ b/.changeset/tanstack-keyless-cli-init-error.md @@ -2,4 +2,4 @@ '@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`. `VITE_CLERK_KEYLESS_DISABLED` and `CLERK_KEYLESS_DISABLED` no longer have any effect. Existing apps with configured or claimed keys are unaffected. +In development, missing Clerk keys no longer activate keyless mode. When `VITE_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.local`. Keys that keyless mode stored in the `.clerk/` directory are no longer read, and `VITE_CLERK_KEYLESS_DISABLED` and `CLERK_KEYLESS_DISABLED` no longer have any effect. Existing apps with configured keys are unaffected. diff --git a/packages/tanstack-react-start/src/server/clerkMiddleware.ts b/packages/tanstack-react-start/src/server/clerkMiddleware.ts index 71b32db568c..dfac9f37e7f 100644 --- a/packages/tanstack-react-start/src/server/clerkMiddleware.ts +++ b/packages/tanstack-react-start/src/server/clerkMiddleware.ts @@ -20,12 +20,7 @@ export const clerkMiddleware = ( // Resolve options: if function, call it with context object; otherwise use as-is const resolvedOptions = typeof options === 'function' ? await options({ url: clerkRequest.clerkUrl }) : options; - // Load options with resolved keys - const loadedOptions = loadOptions(clerkRequest, { - ...resolvedOptions, - publishableKey: resolvedOptions?.publishableKey, - secretKey: resolvedOptions?.secretKey, - }); + const loadedOptions = loadOptions(clerkRequest, resolvedOptions); const requestState = await clerkClient().authenticateRequest(clerkRequest, { ...loadedOptions,