diff --git a/.changeset/react-router-keyless-cli-init-error.md b/.changeset/react-router-keyless-cli-init-error.md new file mode 100644 index 00000000000..57bbe5ec392 --- /dev/null +++ b/.changeset/react-router-keyless-cli-init-error.md @@ -0,0 +1,5 @@ +--- +'@clerk/react-router': 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/react-router/keyless.test.ts b/integration/tests/react-router/keyless.test.ts index a2605806778..c68b1f46c9f 100644 --- a/integration/tests/react-router/keyless.test.ts +++ b/integration/tests/react-router/keyless.test.ts @@ -1,12 +1,11 @@ -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(); @@ -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/react-router 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/react-router/src/client/ReactRouterClerkProvider.tsx b/packages/react-router/src/client/ReactRouterClerkProvider.tsx index 3883b95ea55..380fc26087f 100644 --- a/packages/react-router/src/client/ReactRouterClerkProvider.tsx +++ b/packages/react-router/src/client/ReactRouterClerkProvider.tsx @@ -67,8 +67,6 @@ function ClerkProviderBase({ children, ...rest }: ClerkProv __telemetryDisabled, __telemetryDebug, __unsafeDisableDevelopmentModeConsoleWarning, - __keylessClaimUrl, - __keylessApiKeysUrl, } = clerkState?.__internal_clerk_state || {}; React.useEffect(() => { @@ -106,13 +104,6 @@ function ClerkProviderBase({ children, ...rest }: ClerkProv getPublicEnvVariables(undefined).unsafeDisableDevelopmentModeConsoleWarning, }; - const keylessProps = __keylessClaimUrl - ? { - __internal_keyless_claimKeylessApplicationUrl: __keylessClaimUrl, - __internal_keyless_copyInstanceKeysUrl: __keylessApiKeysUrl, - } - : {}; - return ( ({ children, ...rest }: ClerkProv initialState={__clerk_ssr_state} sdkMetadata={SDK_METADATA} {...mergedProps} - {...keylessProps} {...restProps} > {children} diff --git a/packages/react-router/src/client/types.ts b/packages/react-router/src/client/types.ts index c6d402f92a9..21dfbab6716 100644 --- a/packages/react-router/src/client/types.ts +++ b/packages/react-router/src/client/types.ts @@ -26,8 +26,6 @@ export type ClerkState = { __telemetryDisabled: boolean | undefined; __telemetryDebug: boolean | undefined; __unsafeDisableDevelopmentModeConsoleWarning: boolean | undefined; - __keylessClaimUrl?: string; - __keylessApiKeysUrl?: string; }; }; diff --git a/packages/react-router/src/server/clerkMiddleware.ts b/packages/react-router/src/server/clerkMiddleware.ts index 7b6e9a81413..c94d10e06ab 100644 --- a/packages/react-router/src/server/clerkMiddleware.ts +++ b/packages/react-router/src/server/clerkMiddleware.ts @@ -7,8 +7,9 @@ import type { PendingSessionOptions } from '@clerk/shared/types'; import type { MiddlewareFunction } from 'react-router'; import { createContext } from 'react-router'; +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 { AdditionalStateOptions, ClerkMiddlewareOptions } from './types'; @@ -36,18 +37,12 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun const clerkRequest = createClerkRequest(patchRequest(args.request)); const loadedOptions = loadOptions(args, options); - const { - publishableKey, - secretKey, - claimUrl: __keylessClaimUrl, - apiKeysUrl: __keylessApiKeysUrl, - } = await resolveKeysWithKeylessFallback(loadedOptions.publishableKey, loadedOptions.secretKey, args, options); - - if (publishableKey) { - loadedOptions.publishableKey = publishableKey; - } - if (secretKey) { - loadedOptions.secretKey = secretKey; + if (canUseKeyless) { + try { + await completeOnboardingIfClaimed(loadedOptions.publishableKey, args, options); + } catch { + // Silently fail - claimed-keys onboarding must not break requests + } } // Pick only the properties needed by authenticateRequest. @@ -102,8 +97,6 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun args.context.set(requestStateContext, { requestState, additionalState: { - __keylessClaimUrl, - __keylessApiKeysUrl, signInForceRedirectUrl: loadedOptions.signInForceRedirectUrl, signUpForceRedirectUrl: loadedOptions.signUpForceRedirectUrl, signInFallbackRedirectUrl: loadedOptions.signInFallbackRedirectUrl, diff --git a/packages/react-router/src/server/keyless/__tests__/utils.test.ts b/packages/react-router/src/server/keyless/__tests__/utils.test.ts new file mode 100644 index 00000000000..be3f4034d71 --- /dev/null +++ b/packages/react-router/src/server/keyless/__tests__/utils.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { DataFunctionArgs } from '../../loadOptions'; +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 args = {} as DataFunctionArgs; + +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', args); + + 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', args); + + 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', args); + + 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, args); + + 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/react-router/src/server/keyless/index.ts b/packages/react-router/src/server/keyless/index.ts index b347fa888e1..6021e0d1eca 100644 --- a/packages/react-router/src/server/keyless/index.ts +++ b/packages/react-router/src/server/keyless/index.ts @@ -13,18 +13,6 @@ export function keyless(args: DataFunctionArgs, options?: ClerkMiddlewareOptions keylessServiceInstance = createKeylessService({ storage: createFileStorage(), api: { - async createAccountlessApplication(requestHeaders?: Headers, source?: string) { - try { - return await clerkClient(args, options).__experimental_accountlessApplications.createAccountlessApplication( - { - requestHeaders, - source, - }, - ); - } catch { - return null; - } - }, async completeOnboarding(requestHeaders?: Headers, source?: string) { try { return await clerkClient( diff --git a/packages/react-router/src/server/keyless/utils.ts b/packages/react-router/src/server/keyless/utils.ts index 138a30d22cd..c2effb6f877 100644 --- a/packages/react-router/src/server/keyless/utils.ts +++ b/packages/react-router/src/server/keyless/utils.ts @@ -1,25 +1,36 @@ -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 type { DataFunctionArgs } from '../loadOptions'; import type { ClerkMiddlewareOptions } from '../types'; import { keyless } from './index'; /** - * Resolves Clerk keys, falling back to keyless mode in development if configured keys are missing. + * 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 resolveKeysWithKeylessFallback( +export async function completeOnboardingIfClaimed( configuredPublishableKey: string | undefined, - configuredSecretKey: string | undefined, args: DataFunctionArgs, options?: ClerkMiddlewareOptions, -) { - const keylessService = await keyless(args, options); - return sharedResolveKeysWithKeylessFallback( - configuredPublishableKey, - configuredSecretKey, - keylessService, - canUseKeyless, - ); +): Promise { + const keylessService = keyless(args, options); + 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/react-router/src/server/types.ts b/packages/react-router/src/server/types.ts index 9342f6b39af..b5008814e3d 100644 --- a/packages/react-router/src/server/types.ts +++ b/packages/react-router/src/server/types.ts @@ -63,16 +63,10 @@ export type RootAuthLoaderOptions = ClerkMiddlewareOptions & { loadOrganization?: boolean; }; -export interface KeylessUrls { - __keylessClaimUrl?: string; - __keylessApiKeysUrl?: string; -} - export type AdditionalStateOptions = SignInFallbackRedirectUrl & SignUpFallbackRedirectUrl & SignInForceRedirectUrl & - SignUpForceRedirectUrl & - KeylessUrls; + SignUpForceRedirectUrl; /** * @deprecated This type is no longer used internally. Use `AdditionalStateOptions` instead. @@ -81,8 +75,7 @@ export type RequestStateWithRedirectUrls = RequestState & SignInForceRedirectUrl & SignInFallbackRedirectUrl & SignUpForceRedirectUrl & - SignUpFallbackRedirectUrl & - KeylessUrls; + SignUpFallbackRedirectUrl; export type RootAuthLoaderCallback = ( args: LoaderFunctionArgsWithAuth, diff --git a/packages/react-router/src/server/utils.ts b/packages/react-router/src/server/utils.ts index 98904f11116..681b3abfa70 100644 --- a/packages/react-router/src/server/utils.ts +++ b/packages/react-router/src/server/utils.ts @@ -4,7 +4,6 @@ import { parse as parseCookie } from 'cookie'; import type { UNSAFE_DataWithResponseInit } from 'react-router'; import { getPublicEnvVariables } from '../utils/env'; -import { canUseKeyless } from '../utils/feature-flags'; import type { AdditionalStateOptions } from './types'; // AppLoadContext was removed from React Router v8. Keep a structural type for the context shape we use. @@ -94,7 +93,7 @@ export function getResponseClerkState( ) { const { reason, message, isSignedIn, ...rest } = requestState; const envVars = getPublicEnvVariables(context); - const { __keylessClaimUrl, __keylessApiKeysUrl, ...redirectUrlOptions } = additionalStateOptions; + const redirectUrlOptions = additionalStateOptions; const baseState: Record = { __clerk_ssr_state: rest.toAuth(), @@ -119,11 +118,6 @@ export function getResponseClerkState( __unsafeDisableDevelopmentModeConsoleWarning: envVars.unsafeDisableDevelopmentModeConsoleWarning, }; - if (canUseKeyless && __keylessClaimUrl) { - baseState.__keylessClaimUrl = __keylessClaimUrl; - baseState.__keylessApiKeysUrl = __keylessApiKeysUrl; - } - const clerkState = wrapWithClerkState(baseState); return {