From a659eaf88f8e34e8547356d4897a4e5c46ebeee2 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 6 Feb 2026 12:43:43 -0800 Subject: [PATCH 01/21] feat(react-router): Keyless support --- .../src/client/ReactRouterClerkProvider.tsx | 10 ++ packages/react-router/src/client/types.ts | 2 + .../src/server/clerkMiddleware.ts | 49 ++++++--- .../src/server/keyless/fileStorage.ts | 32 ++++++ .../react-router/src/server/keyless/index.ts | 102 ++++++++++++++++++ .../react-router/src/server/keyless/utils.ts | 98 +++++++++++++++++ packages/react-router/src/server/types.ts | 8 +- packages/react-router/src/server/utils.ts | 15 ++- .../react-router/src/utils/feature-flags.ts | 15 +++ 9 files changed, 313 insertions(+), 18 deletions(-) create mode 100644 packages/react-router/src/server/keyless/fileStorage.ts create mode 100644 packages/react-router/src/server/keyless/index.ts create mode 100644 packages/react-router/src/server/keyless/utils.ts create mode 100644 packages/react-router/src/utils/feature-flags.ts diff --git a/packages/react-router/src/client/ReactRouterClerkProvider.tsx b/packages/react-router/src/client/ReactRouterClerkProvider.tsx index 5da08272f20..1ee21eb578b 100644 --- a/packages/react-router/src/client/ReactRouterClerkProvider.tsx +++ b/packages/react-router/src/client/ReactRouterClerkProvider.tsx @@ -67,6 +67,8 @@ function ClerkProviderBase({ children, ...rest }: ClerkProv __prefetchUI, __telemetryDisabled, __telemetryDebug, + __keylessClaimUrl, + __keylessApiKeysUrl, } = clerkState?.__internal_clerk_state || {}; React.useEffect(() => { @@ -100,6 +102,13 @@ function ClerkProviderBase({ children, ...rest }: ClerkProv }, }; + 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 1c7c15fcbb3..df3d942d5c8 100644 --- a/packages/react-router/src/client/types.ts +++ b/packages/react-router/src/client/types.ts @@ -24,6 +24,8 @@ export type ClerkState = { __prefetchUI: boolean | undefined; __telemetryDisabled: boolean | undefined; __telemetryDebug: 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 19af698c118..b647679fec8 100644 --- a/packages/react-router/src/server/clerkMiddleware.ts +++ b/packages/react-router/src/server/clerkMiddleware.ts @@ -7,8 +7,9 @@ import type { MiddlewareFunction } from 'react-router'; import { createContext } from 'react-router'; import { clerkClient } from './clerkClient'; +import { resolveKeysWithKeylessFallback } from './keyless/utils'; import { loadOptions } from './loadOptions'; -import type { ClerkMiddlewareOptions } from './types'; +import type { ClerkMiddlewareOptions, RequestStateWithRedirectUrls } from './types'; import { patchRequest } from './utils'; export const authFnContext = createContext<((options?: PendingSessionOptions) => AuthObject) | null>(null); @@ -35,16 +36,30 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun const clerkRequest = createClerkRequest(patchRequest(args.request)); const loadedOptions = loadOptions(args, options); + // Resolve keys with keyless fallback + const { + publishableKey, + secretKey, + claimUrl: __keylessClaimUrl, + apiKeysUrl: __keylessApiKeysUrl, + } = await resolveKeysWithKeylessFallback(loadedOptions.publishableKey, loadedOptions.secretKey, args, options); + + // Update loaded options with resolved keys + if (publishableKey) { + loadedOptions.publishableKey = publishableKey; + } + if (secretKey) { + loadedOptions.secretKey = secretKey; + } + // Pick only the properties needed by authenticateRequest. // Used when manually providing options to the middleware. const { apiUrl, - secretKey, jwtKey, proxyUrl, isSatellite, domain, - publishableKey, machineSecretKey, audience, authorizedParties, @@ -55,12 +70,12 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun const requestState = await clerkClient(args, options).authenticateRequest(clerkRequest, { apiUrl, - secretKey, + secretKey: loadedOptions.secretKey, jwtKey, proxyUrl, isSatellite, domain, - publishableKey, + publishableKey: loadedOptions.publishableKey, machineSecretKey, audience, authorizedParties, @@ -70,28 +85,34 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun acceptsToken: 'any', }); - const locationHeader = requestState.headers.get(constants.Headers.Location); + // Attach keyless URLs to requestState + const requestStateWithKeyless = Object.assign(requestState, { + __keylessClaimUrl, + __keylessApiKeysUrl, + }) as RequestStateWithRedirectUrls; + + const locationHeader = requestStateWithKeyless.headers.get(constants.Headers.Location); if (locationHeader) { handleNetlifyCacheInDevInstance({ locationHeader, - requestStateHeaders: requestState.headers, - publishableKey: requestState.publishableKey, + requestStateHeaders: requestStateWithKeyless.headers, + publishableKey: requestStateWithKeyless.publishableKey, }); // Trigger a handshake redirect - return new Response(null, { status: 307, headers: requestState.headers }); + return new Response(null, { status: 307, headers: requestStateWithKeyless.headers }); } - if (requestState.status === AuthStatus.Handshake) { + if (requestStateWithKeyless.status === AuthStatus.Handshake) { throw new Error('Clerk: handshake status without redirect'); } - args.context.set(authFnContext, (options?: PendingSessionOptions) => requestState.toAuth(options)); - args.context.set(requestStateContext, requestState); + args.context.set(authFnContext, (opts?: PendingSessionOptions) => requestStateWithKeyless.toAuth(opts)); + args.context.set(requestStateContext, requestStateWithKeyless); const response = await next(); - if (requestState.headers) { - requestState.headers.forEach((value, key) => { + if (requestStateWithKeyless.headers) { + requestStateWithKeyless.headers.forEach((value, key) => { response.headers.append(key, value); }); } diff --git a/packages/react-router/src/server/keyless/fileStorage.ts b/packages/react-router/src/server/keyless/fileStorage.ts new file mode 100644 index 00000000000..9fdb3acf644 --- /dev/null +++ b/packages/react-router/src/server/keyless/fileStorage.ts @@ -0,0 +1,32 @@ +import { createNodeFileStorage, type KeylessStorage } from '@clerk/shared/keyless'; + +export type { KeylessStorage }; + +export interface FileStorageOptions { + cwd?: () => string; +} + +/** + * Creates a file-based storage adapter for keyless mode. + * Uses dynamic imports to avoid breaking Cloudflare Workers. + * + * @throws {Error} If called in a non-Node.js environment + */ +export async function createFileStorage(options: FileStorageOptions = {}): Promise { + const { cwd = () => process.cwd() } = options; + + try { + // Dynamic import to avoid bundler issues with edge runtimes + const [fs, path] = await Promise.all([import('node:fs'), import('node:path')]); + + return createNodeFileStorage(fs, path, { + cwd, + frameworkPackageName: '@clerk/react-router', + }); + } catch (error) { + throw new Error( + 'Keyless mode requires a Node.js runtime with file system access. ' + + 'Set VITE_CLERK_KEYLESS_DISABLED=1 to disable keyless mode.', + ); + } +} diff --git a/packages/react-router/src/server/keyless/index.ts b/packages/react-router/src/server/keyless/index.ts new file mode 100644 index 00000000000..f8741cf9579 --- /dev/null +++ b/packages/react-router/src/server/keyless/index.ts @@ -0,0 +1,102 @@ +import { createKeylessService } from '@clerk/shared/keyless'; + +import { clerkClient } from '../clerkClient'; +import type { DataFunctionArgs } from '../loadOptions'; +import type { ClerkMiddlewareOptions } from '../types'; +import { createFileStorage } from './fileStorage'; + +// Singleton with lazy initialization +let keylessServiceInstance: ReturnType | null = null; +let keylessInitPromise: Promise | null> | null = null; + +/** + * Detects if the current runtime supports file system operations. + */ +function canUseFileSystem(): boolean { + try { + return typeof process !== 'undefined' && typeof process.cwd === 'function'; + } catch { + return false; + } +} + +/** + * Gets or creates the keyless service instance. + * + * Returns null for non-Node.js runtimes (Cloudflare Workers). + * This function is async because storage creation may involve dynamic imports. + */ +export async function keyless( + args?: DataFunctionArgs, + options?: ClerkMiddlewareOptions, +): Promise | null> { + // Guard: Return null for non-Node.js runtimes + if (!canUseFileSystem()) { + return null; + } + + // Return existing instance + if (keylessServiceInstance) { + return keylessServiceInstance; + } + + // Return in-flight initialization + if (keylessInitPromise) { + return keylessInitPromise; + } + + // Initialize service + keylessInitPromise = (async () => { + try { + const storage = await createFileStorage(); + + const service = createKeylessService({ + storage, + api: { + async createAccountlessApplication(requestHeaders?: Headers) { + try { + // Create a default args object if not provided + const client = args ? clerkClient(args, options) : clerkClient({} as any, options); + return await client.__experimental_accountlessApplications.createAccountlessApplication({ + requestHeaders, + }); + } catch { + return null; + } + }, + async completeOnboarding(requestHeaders?: Headers) { + try { + const client = args ? clerkClient(args, options) : clerkClient({} as any, options); + return await client.__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({ + requestHeaders, + }); + } catch { + return null; + } + }, + }, + framework: 'react-router', + frameworkVersion: PACKAGE_VERSION, + }); + + keylessServiceInstance = service; + return service; + } catch (error) { + console.warn('[Clerk] Failed to initialize keyless service:', error); + return null; + } finally { + keylessInitPromise = null; + } + })(); + + return keylessInitPromise; +} + +/** + * Resets the keyless service instance (for testing). + * @internal + */ +export function resetKeylessService(): void { + keylessServiceInstance = null; + keylessInitPromise = null; +} diff --git a/packages/react-router/src/server/keyless/utils.ts b/packages/react-router/src/server/keyless/utils.ts new file mode 100644 index 00000000000..a2f0b6f2e8a --- /dev/null +++ b/packages/react-router/src/server/keyless/utils.ts @@ -0,0 +1,98 @@ +import type { AccountlessApplication } from '@clerk/shared/keyless'; +import { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } from '@clerk/shared/keyless'; + +import { canUseKeyless } from '../../utils/feature-flags'; +import type { DataFunctionArgs } from '../loadOptions'; +import type { ClerkMiddlewareOptions } from '../types'; +import { keyless } from './index'; + +export interface KeylessResult { + publishableKey: string | undefined; + secretKey: string | undefined; + claimUrl: string | undefined; + apiKeysUrl: string | undefined; +} + +/** + * Resolves Clerk keys, falling back to keyless mode in development if configured keys are missing. + * + * Implements the TanStack keyless pattern: + * 1. Check if keyless mode is enabled (dev + not disabled) + * 2. If running with claimed keys (configured === stored), complete onboarding + * 3. If no keys configured, create/read keyless keys from storage + * 4. Return resolved keys + keyless URLs + * + * @returns The resolved keys + keyless URLs to inject into state + */ +export async function resolveKeysWithKeylessFallback( + configuredPublishableKey: string | undefined, + configuredSecretKey: string | undefined, + args?: DataFunctionArgs, + options?: ClerkMiddlewareOptions, +): Promise { + let publishableKey = configuredPublishableKey; + let secretKey = configuredSecretKey; + let claimUrl: string | undefined; + let apiKeysUrl: string | undefined; + + // Early return if keyless is disabled + if (!canUseKeyless) { + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; + } + + try { + const keylessService = await keyless(args, options); + + // Early return if keyless service unavailable (e.g., Cloudflare) + if (!keylessService) { + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; + } + + const locallyStoredKeys = keylessService.readKeys(); + + // Scenario 1: Running with claimed keys + const runningWithClaimedKeys = + Boolean(configuredPublishableKey) && configuredPublishableKey === locallyStoredKeys?.publishableKey; + + if (runningWithClaimedKeys && locallyStoredKeys) { + // Complete onboarding (throttled by dev cache) + try { + await clerkDevelopmentCache?.run(() => keylessService.completeOnboarding(), { + cacheKey: `${locallyStoredKeys.publishableKey}_complete`, + onSuccessStale: 24 * 60 * 60 * 1000, // 24 hours + }); + } catch { + // noop - non-critical + } + + clerkDevelopmentCache?.log({ + cacheKey: `${locallyStoredKeys.publishableKey}_claimed`, + msg: createConfirmationMessage(), + }); + + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; + } + + // Scenario 2: Keyless mode (no keys configured) + if (!publishableKey || !secretKey) { + const keylessApp: AccountlessApplication | null = await keylessService.getOrCreateKeys(); + + if (keylessApp) { + publishableKey = publishableKey || keylessApp.publishableKey; + secretKey = secretKey || keylessApp.secretKey; + claimUrl = keylessApp.claimUrl; + apiKeysUrl = keylessApp.apiKeysUrl; + + clerkDevelopmentCache?.log({ + cacheKey: keylessApp.publishableKey, + msg: createKeylessModeMessage(keylessApp), + }); + } + } + } catch (error) { + // Graceful fallback - never break the app + console.warn('[Clerk] Keyless resolution failed:', error); + } + + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; +} diff --git a/packages/react-router/src/server/types.ts b/packages/react-router/src/server/types.ts index 467afce52a9..662d100fd58 100644 --- a/packages/react-router/src/server/types.ts +++ b/packages/react-router/src/server/types.ts @@ -63,11 +63,17 @@ export type RootAuthLoaderOptions = ClerkMiddlewareOptions & { loadOrganization?: boolean; }; +export interface KeylessUrls { + __keylessClaimUrl?: string; + __keylessApiKeysUrl?: string; +} + export type RequestStateWithRedirectUrls = RequestState & SignInForceRedirectUrl & SignInFallbackRedirectUrl & SignUpForceRedirectUrl & - SignUpFallbackRedirectUrl; + SignUpFallbackRedirectUrl & + KeylessUrls; export type RootAuthLoaderCallback = ( args: LoaderFunctionArgsWithAuth, diff --git a/packages/react-router/src/server/utils.ts b/packages/react-router/src/server/utils.ts index 2070bdeef5e..5b3d8c2a464 100644 --- a/packages/react-router/src/server/utils.ts +++ b/packages/react-router/src/server/utils.ts @@ -3,6 +3,7 @@ import cookie from 'cookie'; import type { AppLoadContext, UNSAFE_DataWithResponseInit } from 'react-router'; import { getPublicEnvVariables } from '../utils/env'; +import { canUseKeyless } from '../utils/feature-flags'; import type { RequestStateWithRedirectUrls } from './types'; export function isResponse(value: any): value is Response { @@ -78,9 +79,10 @@ export const injectRequestStateIntoResponse = async ( * @internal */ export function getResponseClerkState(requestState: RequestStateWithRedirectUrls, context: AppLoadContext) { - const { reason, message, isSignedIn, ...rest } = requestState; + const { reason, message, isSignedIn, __keylessClaimUrl, __keylessApiKeysUrl, ...rest } = requestState; const envVars = getPublicEnvVariables(context); - const clerkState = wrapWithClerkState({ + + const baseState = { __clerk_ssr_state: rest.toAuth(), __publishableKey: requestState.publishableKey, __proxyUrl: requestState.proxyUrl, @@ -99,7 +101,14 @@ export function getResponseClerkState(requestState: RequestStateWithRedirectUrls __prefetchUI: envVars.prefetchUI, __telemetryDisabled: envVars.telemetryDisabled, __telemetryDebug: envVars.telemetryDebug, - }); + }; + + if (canUseKeyless && __keylessClaimUrl) { + (baseState as any).__keylessClaimUrl = __keylessClaimUrl; + (baseState as any).__keylessApiKeysUrl = __keylessApiKeysUrl; + } + + const clerkState = wrapWithClerkState(baseState); return { clerkState, diff --git a/packages/react-router/src/utils/feature-flags.ts b/packages/react-router/src/utils/feature-flags.ts new file mode 100644 index 00000000000..61514ac623e --- /dev/null +++ b/packages/react-router/src/utils/feature-flags.ts @@ -0,0 +1,15 @@ +import { getEnvVariable } from '@clerk/shared/getEnvVariable'; +import { isTruthy } from '@clerk/shared/underscore'; +import { 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 keyless mode can be used in the current environment. + * Keyless mode is only available in development and when not explicitly disabled. + */ +export const canUseKeyless = isDevelopmentEnvironment() && !KEYLESS_DISABLED; From e08192c2ff5e648dd2f1859caeeb11309d3c71fe Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 6 Feb 2026 14:10:34 -0800 Subject: [PATCH 02/21] chore: clean up --- .../react-router/src/server/clerkMiddleware.ts | 3 --- .../src/server/keyless/fileStorage.ts | 7 ++----- .../react-router/src/server/keyless/index.ts | 16 ++-------------- .../react-router/src/server/keyless/utils.ts | 18 ++---------------- .../react-router/src/utils/feature-flags.ts | 5 ----- 5 files changed, 6 insertions(+), 43 deletions(-) diff --git a/packages/react-router/src/server/clerkMiddleware.ts b/packages/react-router/src/server/clerkMiddleware.ts index b647679fec8..81e9c82b6e0 100644 --- a/packages/react-router/src/server/clerkMiddleware.ts +++ b/packages/react-router/src/server/clerkMiddleware.ts @@ -36,7 +36,6 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun const clerkRequest = createClerkRequest(patchRequest(args.request)); const loadedOptions = loadOptions(args, options); - // Resolve keys with keyless fallback const { publishableKey, secretKey, @@ -44,7 +43,6 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun apiKeysUrl: __keylessApiKeysUrl, } = await resolveKeysWithKeylessFallback(loadedOptions.publishableKey, loadedOptions.secretKey, args, options); - // Update loaded options with resolved keys if (publishableKey) { loadedOptions.publishableKey = publishableKey; } @@ -85,7 +83,6 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun acceptsToken: 'any', }); - // Attach keyless URLs to requestState const requestStateWithKeyless = Object.assign(requestState, { __keylessClaimUrl, __keylessApiKeysUrl, diff --git a/packages/react-router/src/server/keyless/fileStorage.ts b/packages/react-router/src/server/keyless/fileStorage.ts index 9fdb3acf644..35f2bfc2b69 100644 --- a/packages/react-router/src/server/keyless/fileStorage.ts +++ b/packages/react-router/src/server/keyless/fileStorage.ts @@ -8,22 +8,19 @@ export interface FileStorageOptions { /** * Creates a file-based storage adapter for keyless mode. - * Uses dynamic imports to avoid breaking Cloudflare Workers. - * - * @throws {Error} If called in a non-Node.js environment + * Uses dynamic imports to avoid bundler issues with edge runtimes. */ export async function createFileStorage(options: FileStorageOptions = {}): Promise { const { cwd = () => process.cwd() } = options; try { - // Dynamic import to avoid bundler issues with edge runtimes const [fs, path] = await Promise.all([import('node:fs'), import('node:path')]); return createNodeFileStorage(fs, path, { cwd, frameworkPackageName: '@clerk/react-router', }); - } catch (error) { + } catch { throw new Error( 'Keyless mode requires a Node.js runtime with file system access. ' + 'Set VITE_CLERK_KEYLESS_DISABLED=1 to disable keyless mode.', diff --git a/packages/react-router/src/server/keyless/index.ts b/packages/react-router/src/server/keyless/index.ts index f8741cf9579..257e7384330 100644 --- a/packages/react-router/src/server/keyless/index.ts +++ b/packages/react-router/src/server/keyless/index.ts @@ -5,13 +5,9 @@ import type { DataFunctionArgs } from '../loadOptions'; import type { ClerkMiddlewareOptions } from '../types'; import { createFileStorage } from './fileStorage'; -// Singleton with lazy initialization let keylessServiceInstance: ReturnType | null = null; let keylessInitPromise: Promise | null> | null = null; -/** - * Detects if the current runtime supports file system operations. - */ function canUseFileSystem(): boolean { try { return typeof process !== 'undefined' && typeof process.cwd === 'function'; @@ -21,31 +17,25 @@ function canUseFileSystem(): boolean { } /** - * Gets or creates the keyless service instance. - * - * Returns null for non-Node.js runtimes (Cloudflare Workers). - * This function is async because storage creation may involve dynamic imports. + * Gets or creates the keyless service singleton. + * Returns null for non-Node.js runtimes (e.g., Cloudflare Workers). */ export async function keyless( args?: DataFunctionArgs, options?: ClerkMiddlewareOptions, ): Promise | null> { - // Guard: Return null for non-Node.js runtimes if (!canUseFileSystem()) { return null; } - // Return existing instance if (keylessServiceInstance) { return keylessServiceInstance; } - // Return in-flight initialization if (keylessInitPromise) { return keylessInitPromise; } - // Initialize service keylessInitPromise = (async () => { try { const storage = await createFileStorage(); @@ -55,7 +45,6 @@ export async function keyless( api: { async createAccountlessApplication(requestHeaders?: Headers) { try { - // Create a default args object if not provided const client = args ? clerkClient(args, options) : clerkClient({} as any, options); return await client.__experimental_accountlessApplications.createAccountlessApplication({ requestHeaders, @@ -93,7 +82,6 @@ export async function keyless( } /** - * Resets the keyless service instance (for testing). * @internal */ export function resetKeylessService(): void { diff --git a/packages/react-router/src/server/keyless/utils.ts b/packages/react-router/src/server/keyless/utils.ts index a2f0b6f2e8a..85cba72b0fa 100644 --- a/packages/react-router/src/server/keyless/utils.ts +++ b/packages/react-router/src/server/keyless/utils.ts @@ -15,14 +15,6 @@ export interface KeylessResult { /** * Resolves Clerk keys, falling back to keyless mode in development if configured keys are missing. - * - * Implements the TanStack keyless pattern: - * 1. Check if keyless mode is enabled (dev + not disabled) - * 2. If running with claimed keys (configured === stored), complete onboarding - * 3. If no keys configured, create/read keyless keys from storage - * 4. Return resolved keys + keyless URLs - * - * @returns The resolved keys + keyless URLs to inject into state */ export async function resolveKeysWithKeylessFallback( configuredPublishableKey: string | undefined, @@ -35,7 +27,6 @@ export async function resolveKeysWithKeylessFallback( let claimUrl: string | undefined; let apiKeysUrl: string | undefined; - // Early return if keyless is disabled if (!canUseKeyless) { return { publishableKey, secretKey, claimUrl, apiKeysUrl }; } @@ -43,26 +34,23 @@ export async function resolveKeysWithKeylessFallback( try { const keylessService = await keyless(args, options); - // Early return if keyless service unavailable (e.g., Cloudflare) if (!keylessService) { return { publishableKey, secretKey, claimUrl, apiKeysUrl }; } const locallyStoredKeys = keylessService.readKeys(); - // Scenario 1: Running with claimed keys const runningWithClaimedKeys = Boolean(configuredPublishableKey) && configuredPublishableKey === locallyStoredKeys?.publishableKey; if (runningWithClaimedKeys && locallyStoredKeys) { - // Complete onboarding (throttled by dev cache) try { await clerkDevelopmentCache?.run(() => keylessService.completeOnboarding(), { cacheKey: `${locallyStoredKeys.publishableKey}_complete`, - onSuccessStale: 24 * 60 * 60 * 1000, // 24 hours + onSuccessStale: 24 * 60 * 60 * 1000, }); } catch { - // noop - non-critical + // noop } clerkDevelopmentCache?.log({ @@ -73,7 +61,6 @@ export async function resolveKeysWithKeylessFallback( return { publishableKey, secretKey, claimUrl, apiKeysUrl }; } - // Scenario 2: Keyless mode (no keys configured) if (!publishableKey || !secretKey) { const keylessApp: AccountlessApplication | null = await keylessService.getOrCreateKeys(); @@ -90,7 +77,6 @@ export async function resolveKeysWithKeylessFallback( } } } catch (error) { - // Graceful fallback - never break the app console.warn('[Clerk] Keyless resolution failed:', error); } diff --git a/packages/react-router/src/utils/feature-flags.ts b/packages/react-router/src/utils/feature-flags.ts index 61514ac623e..bd40eaca25e 100644 --- a/packages/react-router/src/utils/feature-flags.ts +++ b/packages/react-router/src/utils/feature-flags.ts @@ -2,14 +2,9 @@ import { getEnvVariable } from '@clerk/shared/getEnvVariable'; import { isTruthy } from '@clerk/shared/underscore'; import { 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 keyless mode can be used in the current environment. - * Keyless mode is only available in development and when not explicitly disabled. - */ export const canUseKeyless = isDevelopmentEnvironment() && !KEYLESS_DISABLED; From 140ba30cd28ec410e3a7378a6a7be53b24c82bff Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 6 Feb 2026 14:12:59 -0800 Subject: [PATCH 03/21] chore: clean up var name --- .../src/server/clerkMiddleware.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/react-router/src/server/clerkMiddleware.ts b/packages/react-router/src/server/clerkMiddleware.ts index 81e9c82b6e0..b78f38e05c9 100644 --- a/packages/react-router/src/server/clerkMiddleware.ts +++ b/packages/react-router/src/server/clerkMiddleware.ts @@ -9,7 +9,7 @@ import { createContext } from 'react-router'; import { clerkClient } from './clerkClient'; import { resolveKeysWithKeylessFallback } from './keyless/utils'; import { loadOptions } from './loadOptions'; -import type { ClerkMiddlewareOptions, RequestStateWithRedirectUrls } from './types'; +import type { ClerkMiddlewareOptions } from './types'; import { patchRequest } from './utils'; export const authFnContext = createContext<((options?: PendingSessionOptions) => AuthObject) | null>(null); @@ -83,33 +83,33 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun acceptsToken: 'any', }); - const requestStateWithKeyless = Object.assign(requestState, { + Object.assign(requestState, { __keylessClaimUrl, __keylessApiKeysUrl, - }) as RequestStateWithRedirectUrls; + }); - const locationHeader = requestStateWithKeyless.headers.get(constants.Headers.Location); + const locationHeader = requestState.headers.get(constants.Headers.Location); if (locationHeader) { handleNetlifyCacheInDevInstance({ locationHeader, - requestStateHeaders: requestStateWithKeyless.headers, - publishableKey: requestStateWithKeyless.publishableKey, + requestStateHeaders: requestState.headers, + publishableKey: requestState.publishableKey, }); // Trigger a handshake redirect - return new Response(null, { status: 307, headers: requestStateWithKeyless.headers }); + return new Response(null, { status: 307, headers: requestState.headers }); } - if (requestStateWithKeyless.status === AuthStatus.Handshake) { + if (requestState.status === AuthStatus.Handshake) { throw new Error('Clerk: handshake status without redirect'); } - args.context.set(authFnContext, (opts?: PendingSessionOptions) => requestStateWithKeyless.toAuth(opts)); - args.context.set(requestStateContext, requestStateWithKeyless); + args.context.set(authFnContext, (opts?: PendingSessionOptions) => requestState.toAuth(opts)); + args.context.set(requestStateContext, requestState); const response = await next(); - if (requestStateWithKeyless.headers) { - requestStateWithKeyless.headers.forEach((value, key) => { + if (requestState.headers) { + requestState.headers.forEach((value, key) => { response.headers.append(key, value); }); } From 6bc3243cbce49d4bd759cbcf2aaa065e96b5914e Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 6 Feb 2026 14:17:17 -0800 Subject: [PATCH 04/21] chore: remove any assertion --- packages/react-router/src/server/utils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-router/src/server/utils.ts b/packages/react-router/src/server/utils.ts index 5b3d8c2a464..e5117903375 100644 --- a/packages/react-router/src/server/utils.ts +++ b/packages/react-router/src/server/utils.ts @@ -82,7 +82,7 @@ export function getResponseClerkState(requestState: RequestStateWithRedirectUrls const { reason, message, isSignedIn, __keylessClaimUrl, __keylessApiKeysUrl, ...rest } = requestState; const envVars = getPublicEnvVariables(context); - const baseState = { + const baseState: Record = { __clerk_ssr_state: rest.toAuth(), __publishableKey: requestState.publishableKey, __proxyUrl: requestState.proxyUrl, @@ -104,8 +104,8 @@ export function getResponseClerkState(requestState: RequestStateWithRedirectUrls }; if (canUseKeyless && __keylessClaimUrl) { - (baseState as any).__keylessClaimUrl = __keylessClaimUrl; - (baseState as any).__keylessApiKeysUrl = __keylessApiKeysUrl; + baseState.__keylessClaimUrl = __keylessClaimUrl; + baseState.__keylessApiKeysUrl = __keylessApiKeysUrl; } const clerkState = wrapWithClerkState(baseState); From 6638d891f3cd7fe8719151b4dda3bafa6ef5c893 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 6 Feb 2026 14:26:39 -0800 Subject: [PATCH 05/21] chore: throw only if not keyless mode --- packages/react-router/src/server/loadOptions.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react-router/src/server/loadOptions.ts b/packages/react-router/src/server/loadOptions.ts index 2ae4dab747f..9832552883b 100644 --- a/packages/react-router/src/server/loadOptions.ts +++ b/packages/react-router/src/server/loadOptions.ts @@ -8,6 +8,7 @@ import type { MiddlewareFunction } from 'react-router'; import { getPublicEnvVariables } from '../utils/env'; import { noSecretKeyError, satelliteAndMissingProxyUrlAndDomain, satelliteAndMissingSignInUrl } from '../utils/errors'; +import { canUseKeyless } from '../utils/feature-flags'; import type { ClerkMiddlewareOptions } from './types'; import { patchRequest } from './utils'; @@ -55,13 +56,13 @@ export const loadOptions = (args: DataFunctionArgs, overrides: ClerkMiddlewareOp proxyUrl = relativeOrAbsoluteProxyUrl; } - if (!secretKey) { + if (!secretKey && !canUseKeyless) { throw new Error(noSecretKeyError); } if (isSatellite && !proxyUrl && !domain) { throw new Error(satelliteAndMissingProxyUrlAndDomain); } - if (isSatellite && !isHttpOrHttps(signInUrl) && isDevelopmentFromSecretKey(secretKey)) { + if (isSatellite && secretKey && !isHttpOrHttps(signInUrl) && isDevelopmentFromSecretKey(secretKey)) { throw new Error(satelliteAndMissingSignInUrl); } From fe7577efcf221774ba30a933ac7369b49ba4a668 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 6 Feb 2026 15:40:58 -0800 Subject: [PATCH 06/21] add integraiton test --- integration/testUtils/index.ts | 1 + integration/testUtils/keylessHelpers.ts | 20 +++ .../tests/next-quickstart-keyless.test.ts | 18 +-- .../tests/react-router/keyless.test.ts | 115 ++++++++++++++++++ .../tests/tanstack-start/keyless.test.ts | 18 +-- 5 files changed, 138 insertions(+), 34 deletions(-) create mode 100644 integration/testUtils/keylessHelpers.ts create mode 100644 integration/tests/react-router/keyless.test.ts diff --git a/integration/testUtils/index.ts b/integration/testUtils/index.ts index 8aef94cccd0..fa2b83babca 100644 --- a/integration/testUtils/index.ts +++ b/integration/testUtils/index.ts @@ -88,3 +88,4 @@ export const createTestUtils = < }; export { testAgainstRunningApps } from './testAgainstRunningApps'; +export { mockClaimedInstanceEnvironmentCall } from './keylessHelpers'; diff --git a/integration/testUtils/keylessHelpers.ts b/integration/testUtils/keylessHelpers.ts new file mode 100644 index 00000000000..f1a7270978d --- /dev/null +++ b/integration/testUtils/keylessHelpers.ts @@ -0,0 +1,20 @@ +import type { Page } from '@playwright/test'; + +/** + * Mocks the environment API call to return a claimed instance. + * Used in keyless mode tests to simulate an instance that has been claimed. + */ +export const mockClaimedInstanceEnvironmentCall = async (page: Page) => { + await page.route('*/**/v1/environment*', async route => { + const response = await route.fetch(); + const json = await response.json(); + const newJson = { + ...json, + auth_config: { + ...json.auth_config, + claimed_at: Date.now(), + }, + }; + await route.fulfill({ response, json: newJson }); + }); +}; diff --git a/integration/tests/next-quickstart-keyless.test.ts b/integration/tests/next-quickstart-keyless.test.ts index d143b60385e..4704fe83447 100644 --- a/integration/tests/next-quickstart-keyless.test.ts +++ b/integration/tests/next-quickstart-keyless.test.ts @@ -1,27 +1,11 @@ -import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; import type { Application } from '../models/application'; import { appConfigs } from '../presets'; -import { createTestUtils } from '../testUtils'; +import { createTestUtils, mockClaimedInstanceEnvironmentCall } from '../testUtils'; const commonSetup = appConfigs.next.appRouterQuickstart.clone(); -const mockClaimedInstanceEnvironmentCall = async (page: Page) => { - await page.route('*/**/v1/environment*', async route => { - const response = await route.fetch(); - const json = await response.json(); - const newJson = { - ...json, - auth_config: { - ...json.auth_config, - claimed_at: Date.now(), - }, - }; - await route.fulfill({ response, json: newJson }); - }); -}; - test.describe('Keyless mode @quickstart', () => { test.describe.configure({ mode: 'serial' }); diff --git a/integration/tests/react-router/keyless.test.ts b/integration/tests/react-router/keyless.test.ts new file mode 100644 index 00000000000..bab7b8481fc --- /dev/null +++ b/integration/tests/react-router/keyless.test.ts @@ -0,0 +1,115 @@ +import { expect, test } from '@playwright/test'; + +import type { Application } from '../../models/application'; +import { appConfigs } from '../../presets'; +import { createTestUtils, mockClaimedInstanceEnvironmentCall } from '../../testUtils'; + +const commonSetup = appConfigs.reactRouter.reactRouterNode.clone(); + +test.describe('Keyless mode @react-router', () => { + test.describe.configure({ mode: 'serial' }); + test.setTimeout(90_000); + + test.use({ + extraHTTPHeaders: { + 'x-vercel-protection-bypass': process.env.VERCEL_AUTOMATION_BYPASS_SECRET || '', + }, + }); + + 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(); + }); + + test.afterAll(async () => { + // Keep files for debugging + await app?.teardown(); + }); + + test('Toggle collapse popover and claim.', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + await u.po.expect.toBeSignedOut(); + + await u.po.keylessPopover.waitForMounted(); + + expect(await u.po.keylessPopover.isExpanded()).toBe(false); + await u.po.keylessPopover.toggle(); + expect(await u.po.keylessPopover.isExpanded()).toBe(true); + + const claim = await u.po.keylessPopover.promptsToClaim(); + + const [newPage] = await Promise.all([context.waitForEvent('page'), claim.click()]); + + await newPage.waitForLoadState(); + + await newPage.waitForURL(url => { + const urlToReturnTo = `${dashboardUrl}apps/claim?token=`; + + const signUpForceRedirectUrl = url.searchParams.get('sign_up_force_redirect_url'); + + const signUpForceRedirectUrlCheck = + signUpForceRedirectUrl?.startsWith(urlToReturnTo) || + (signUpForceRedirectUrl?.startsWith(`${dashboardUrl}prepare-account`) && + signUpForceRedirectUrl?.includes(encodeURIComponent('apps/claim?token='))); + + return ( + url.pathname === '/apps/claim/sign-in' && + url.searchParams.get('sign_in_force_redirect_url')?.startsWith(urlToReturnTo) && + signUpForceRedirectUrlCheck + ); + }); + }); + + test('Lands on claimed application with missing explicit keys, expanded by default, click to get keys from dashboard.', async ({ + page, + context, + }) => { + await mockClaimedInstanceEnvironmentCall(page); + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + + await u.po.keylessPopover.waitForMounted(); + expect(await u.po.keylessPopover.isExpanded()).toBe(true); + await expect(u.po.keylessPopover.promptToUseClaimedKeys()).toBeVisible(); + + const [newPage] = await Promise.all([ + context.waitForEvent('page'), + u.po.keylessPopover.promptToUseClaimedKeys().click(), + ]); + + await newPage.waitForLoadState(); + await newPage.waitForURL(url => { + return url.href.startsWith(`${dashboardUrl}sign-in?redirect_url=${encodeURIComponent(dashboardUrl)}apps%2Fapp_`); + }); + }); + + test('Keyless popover is removed after adding keys to .env and restarting.', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + + await u.po.keylessPopover.waitForMounted(); + expect(await u.po.keylessPopover.isExpanded()).toBe(false); + + // Copy keys from keyless.json to .env + await app.keylessToEnv(); + + // Restart the dev server to pick up new env vars (Vite doesn't hot-reload .env) + await app.restart(); + + await u.page.goToAppHome(); + + // Keyless popover should no longer be present since we now have explicit keys + await u.po.keylessPopover.waitForUnmounted(); + }); +}); diff --git a/integration/tests/tanstack-start/keyless.test.ts b/integration/tests/tanstack-start/keyless.test.ts index 759409b4929..cd5f1924e78 100644 --- a/integration/tests/tanstack-start/keyless.test.ts +++ b/integration/tests/tanstack-start/keyless.test.ts @@ -1,27 +1,11 @@ -import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; import type { Application } from '../../models/application'; import { appConfigs } from '../../presets'; -import { createTestUtils } from '../../testUtils'; +import { createTestUtils, mockClaimedInstanceEnvironmentCall } from '../../testUtils'; const commonSetup = appConfigs.tanstack.reactStart.clone(); -const mockClaimedInstanceEnvironmentCall = async (page: Page) => { - await page.route('*/**/v1/environment*', async route => { - const response = await route.fetch(); - const json = await response.json(); - const newJson = { - ...json, - auth_config: { - ...json.auth_config, - claimed_at: Date.now(), - }, - }; - await route.fulfill({ response, json: newJson }); - }); -}; - test.describe('Keyless mode @tanstack-react-start', () => { test.describe.configure({ mode: 'serial' }); test.setTimeout(90_000); From 4a6b95936c312851e892dc14e739eaf2f9bc2044 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 6 Feb 2026 16:04:24 -0800 Subject: [PATCH 07/21] chore: extract shared test utils --- integration/testUtils/index.ts | 1 - integration/testUtils/keylessHelpers.ts | 2 +- integration/tests/next-quickstart-keyless.test.ts | 3 ++- integration/tests/react-router/keyless.test.ts | 3 ++- integration/tests/tanstack-start/keyless.test.ts | 3 ++- packages/react-router/src/server/keyless/utils.ts | 6 +++--- 6 files changed, 10 insertions(+), 8 deletions(-) diff --git a/integration/testUtils/index.ts b/integration/testUtils/index.ts index fa2b83babca..8aef94cccd0 100644 --- a/integration/testUtils/index.ts +++ b/integration/testUtils/index.ts @@ -88,4 +88,3 @@ export const createTestUtils = < }; export { testAgainstRunningApps } from './testAgainstRunningApps'; -export { mockClaimedInstanceEnvironmentCall } from './keylessHelpers'; diff --git a/integration/testUtils/keylessHelpers.ts b/integration/testUtils/keylessHelpers.ts index f1a7270978d..4c941e64f62 100644 --- a/integration/testUtils/keylessHelpers.ts +++ b/integration/testUtils/keylessHelpers.ts @@ -4,7 +4,7 @@ import type { Page } from '@playwright/test'; * Mocks the environment API call to return a claimed instance. * Used in keyless mode tests to simulate an instance that has been claimed. */ -export const mockClaimedInstanceEnvironmentCall = async (page: Page) => { +export const mockClaimedInstanceEnvironmentCall = async (page: Page): Promise => { await page.route('*/**/v1/environment*', async route => { const response = await route.fetch(); const json = await response.json(); diff --git a/integration/tests/next-quickstart-keyless.test.ts b/integration/tests/next-quickstart-keyless.test.ts index 4704fe83447..52c0e059f63 100644 --- a/integration/tests/next-quickstart-keyless.test.ts +++ b/integration/tests/next-quickstart-keyless.test.ts @@ -2,7 +2,8 @@ import { expect, test } from '@playwright/test'; import type { Application } from '../models/application'; import { appConfigs } from '../presets'; -import { createTestUtils, mockClaimedInstanceEnvironmentCall } from '../testUtils'; +import { createTestUtils } from '../testUtils'; +import { mockClaimedInstanceEnvironmentCall } from '../testUtils/keylessHelpers'; const commonSetup = appConfigs.next.appRouterQuickstart.clone(); diff --git a/integration/tests/react-router/keyless.test.ts b/integration/tests/react-router/keyless.test.ts index bab7b8481fc..410cf024c22 100644 --- a/integration/tests/react-router/keyless.test.ts +++ b/integration/tests/react-router/keyless.test.ts @@ -2,7 +2,8 @@ import { expect, test } from '@playwright/test'; import type { Application } from '../../models/application'; import { appConfigs } from '../../presets'; -import { createTestUtils, mockClaimedInstanceEnvironmentCall } from '../../testUtils'; +import { createTestUtils } from '../../testUtils'; +import { mockClaimedInstanceEnvironmentCall } from '../../testUtils/keylessHelpers'; const commonSetup = appConfigs.reactRouter.reactRouterNode.clone(); diff --git a/integration/tests/tanstack-start/keyless.test.ts b/integration/tests/tanstack-start/keyless.test.ts index cd5f1924e78..e7eaeea9530 100644 --- a/integration/tests/tanstack-start/keyless.test.ts +++ b/integration/tests/tanstack-start/keyless.test.ts @@ -2,7 +2,8 @@ import { expect, test } from '@playwright/test'; import type { Application } from '../../models/application'; import { appConfigs } from '../../presets'; -import { createTestUtils, mockClaimedInstanceEnvironmentCall } from '../../testUtils'; +import { createTestUtils } from '../../testUtils'; +import { mockClaimedInstanceEnvironmentCall } from '../../testUtils/keylessHelpers'; const commonSetup = appConfigs.tanstack.reactStart.clone(); diff --git a/packages/react-router/src/server/keyless/utils.ts b/packages/react-router/src/server/keyless/utils.ts index 85cba72b0fa..48eb8712733 100644 --- a/packages/react-router/src/server/keyless/utils.ts +++ b/packages/react-router/src/server/keyless/utils.ts @@ -61,12 +61,12 @@ export async function resolveKeysWithKeylessFallback( return { publishableKey, secretKey, claimUrl, apiKeysUrl }; } - if (!publishableKey || !secretKey) { + if (!publishableKey && !secretKey) { const keylessApp: AccountlessApplication | null = await keylessService.getOrCreateKeys(); if (keylessApp) { - publishableKey = publishableKey || keylessApp.publishableKey; - secretKey = secretKey || keylessApp.secretKey; + publishableKey = keylessApp.publishableKey; + secretKey = keylessApp.secretKey; claimUrl = keylessApp.claimUrl; apiKeysUrl = keylessApp.apiKeysUrl; From f79def913bfe4c1da6cde18d2d3fb218cb5b192e Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Fri, 6 Feb 2026 16:07:48 -0800 Subject: [PATCH 08/21] chore: add changeset --- .changeset/curvy-jobs-pump.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/curvy-jobs-pump.md diff --git a/.changeset/curvy-jobs-pump.md b/.changeset/curvy-jobs-pump.md new file mode 100644 index 00000000000..3161921deae --- /dev/null +++ b/.changeset/curvy-jobs-pump.md @@ -0,0 +1,5 @@ +--- +"@clerk/react-router": minor +--- + +Introduce Keyless quickstart for React Router. This allows the Clerk SDK to be used without having to sign up and paste your keys manually. From 9354709479d331ce78ad90d8abddf8a5deecc678 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Sun, 8 Feb 2026 14:10:19 -0800 Subject: [PATCH 09/21] chore: share main keyless fallback function --- docs/KEYLESS_MODE.md | 467 ++++++++++++++++++ integration/testUtils/keylessHelpers.ts | 120 ++++- .../tests/react-router/keyless.test.ts | 79 +-- .../tests/tanstack-start/keyless.test.ts | 79 +-- .../react-router/src/server/keyless/index.ts | 14 +- .../react-router/src/server/keyless/utils.ts | 81 +-- packages/shared/src/keyless/index.ts | 3 + .../keyless/resolveKeysWithKeylessFallback.ts | 87 ++++ .../src/server/keyless/utils.ts | 69 +-- 9 files changed, 719 insertions(+), 280 deletions(-) create mode 100644 docs/KEYLESS_MODE.md create mode 100644 packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts diff --git a/docs/KEYLESS_MODE.md b/docs/KEYLESS_MODE.md new file mode 100644 index 00000000000..15f90a3d89c --- /dev/null +++ b/docs/KEYLESS_MODE.md @@ -0,0 +1,467 @@ +# Clerk Keyless Mode: Cross-Framework Implementation + +## What is Keyless Mode? + +**Keyless mode** is a zero-configuration development experience that allows developers to start using Clerk without manually creating API keys. When a developer runs their app in development without configured keys, Clerk automatically: + +1. Generates temporary API keys (publishable + secret) +2. Creates a temporary "accountless application" +3. Displays a banner with a "claim URL" to associate keys with their Clerk account +4. Stores keys locally so they persist across restarts + +**Key benefits:** + +- Instant start for new developers (no dashboard visit required) +- Reduces onboarding friction +- Keys can be claimed later and associated with production account + +## Architecture Overview + +### Core Shared Code (`@clerk/shared/keyless`) + +**Location:** `packages/shared/src/keyless/` + +**Key files:** + +- `service.ts` - Core keyless service that communicates with backend +- `storage.ts` - Abstract storage interface for persisting keys +- `types.ts` - TypeScript types (AccountlessApplication, etc.) +- `messages.ts` - Console banner messages shown to developers + +**Key function:** + +```typescript +export function createKeylessService(options: KeylessServiceOptions): KeylessService { + const { storage, api, framework, frameworkVersion } = options; + + return { + async getOrCreateKeys(): Promise { + // Check local storage first + const existingKeys = storage.readKeys(); + if (existingKeys) return existingKeys; + + // Create headers with framework info + const headers = new Headers(); + if (framework) { + headers.set('Clerk-Framework', framework); // ← Sent to backend + } + + // Call backend to create new accountless application + const app = await api.createAccountlessApplication(headers); + storage.writeKeys(app); + return app; + }, + // ... other methods + }; +} +``` + +### Backend (Go) + +**Location:** `clerk_go/api/bapi/v1/accountless_applications/` + +**Endpoints:** + +- `POST /v1/accountless_applications` - Create new accountless app +- `POST /v1/accountless_applications/complete` - Mark onboarding complete + +**Key feature: Framework-aware claim URLs** + +```go +// Read framework from header +if framework := r.Header.Get("Clerk-Framework"); framework != "" { + params.Framework = &framework +} + +// Build claim URL with framework parameter +func buildClaimURL(token string, framework *string) (string, error) { + query.Add("token", token) + if framework != nil && *framework != "" { + query.Add("framework", *framework) // ← Dashboard uses this + } + return link.String(), nil +} +``` + +**Why this matters:** Dashboard reads the `framework` query param and shows correct environment variable names for each SDK (e.g., `PUBLIC_CLERK_PUBLISHABLE_KEY` for Astro vs `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` for Next.js). + +### Dashboard + +**Location:** `apps/dashboard/app/(routes)/apps/claim/page.tsx` + +**Already supports framework parameter:** + +```typescript +const { + token: claimToken, + framework: frameworkId = 'nextjs', // ← Defaults to Next.js if not provided +} = await searchParams; + +// Uses getKeyValueTemplate() to show framework-specific env vars + +``` + +## Framework-Specific Implementations + +Each framework has its own keyless wrapper due to different runtime environments, bundling requirements, and lifecycle hooks. + +### 1. Next.js (Original Implementation) + +**Location:** `packages/nextjs/src/server/keyless/` + +**Characteristics:** + +- Uses **conditional exports** (`#safe-node-apis`) for Node vs Edge runtime compatibility +- Storage: `createNodeFileStorage()` with `nodeFsOrThrow()`, `nodePathOrThrow()` +- Why: Server Actions bundle aggressively, need explicit runtime detection + +**Key files:** + +``` +keyless/ + index.ts # Keyless service singleton + fileStorage.ts # Node.js file storage with fs/path checks + utils.ts # resolveKeysWithKeylessFallback() +``` + +**Framework ID sent:** `'nextjs'` + +### 2. TanStack Start (Introduced Shared Keyless) + +**PR:** https://github.com/clerk/javascript/pull/7518 + +**Location:** `packages/tanstack-start/src/server/keyless/` + +**Characteristics:** + +- Uses **static imports** (works due to Vite tree-shaking) +- Storage: Direct import from `@clerk/shared/keyless/node` +- Simpler than Next.js since Vite handles dead code elimination better + +**Framework ID sent:** `'tanstack-react-start'` + +### 3. React Router + +**PR:** https://github.com/clerk/javascript/pull/7794 (branch: `rob/react-router-keyless`) + +**Location:** `packages/react-router/src/server/keyless/` + +**Characteristics:** + +- Uses **runtime checks** (`canUseFileSystem()`) for Cloudflare Workers support +- Storage: Conditional based on runtime environment +- Why: Must support both Node.js and Cloudflare Workers (no filesystem) + +**Framework ID sent:** `'react-router'` + +### 4. Astro (Current Implementation) + +**Branch:** `rob/astro-keyless-mode` + +**Location:** `packages/astro/src/server/keyless/` + +**Characteristics:** + +- Uses **dynamic imports** + `hasFileSystemSupport()` check +- Moved from compile-time (integration API) to runtime (middleware) +- Storage: `createNodeFileStorage()` imported dynamically + +**Framework ID sent:** `'astro'` (changed from `'@clerk/astro'`) + +**Major refactor completed:** + +- **Before:** Keyless resolved once at server startup, injected via `vite.define` +- **After:** Keyless resolved per-request in middleware, injected via script tag +- **Benefit:** No browser caching issues, instant updates when switching modes + +## Why Code Duplication Exists + +### What's Shared + +- Core keyless service logic (`createKeylessService`) +- Storage interfaces and Node.js implementation +- Type definitions +- Console messages + +### What's Per-Framework + +- **Keyless service wrapper** (`keyless()` function) + - Different singleton patterns + - Different runtime environment checks + +- **File storage creation** (`createFileStorage()`) + - Next.js: Conditional exports for Server Actions + - React Router: Runtime checks for Cloudflare Workers + - TanStack/Astro: Direct imports work fine + +- **Key resolution** (`resolveKeysWithKeylessFallback()`) + - Different integration points (middleware, API routes, etc.) + - Different context objects + - Framework-specific caching strategies + +**Conclusion:** The duplication is intentional and correct. Each framework has unique runtime constraints that require custom handling. + +## Common Patterns + +### Pattern 1: Feature Flag Check + +All frameworks check if keyless can be used: + +```typescript +export const canUseKeyless = + isDevelopmentEnvironment() && // Only in dev mode + !KEYLESS_DISABLED && // Not explicitly disabled + hasFileSystemSupport(); // Runtime has filesystem +``` + +### Pattern 2: Keyless Service Singleton + +```typescript +let keylessInstance: KeylessService | null = null; + +export async function keyless(): Promise { + if (!keylessInstance) { + const storage = await createFileStorage(); + keylessInstance = createKeylessService({ + storage, + api: clerkClient(), + framework: 'framework-name', // ← Framework-specific + frameworkVersion: PACKAGE_VERSION, + }); + } + return keylessInstance; +} +``` + +### Pattern 3: Key Resolution with Fallback + +```typescript +export async function resolveKeysWithKeylessFallback( + configuredPublishableKey: string | undefined, + configuredSecretKey: string | undefined, +): Promise { + // Early return if keyless not available + if (!canUseKeyless) { + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; + } + + // Skip keyless if both keys configured + if (publishableKey && secretKey) { + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; + } + + // Try keyless mode + const keylessService = await keyless(); + const keylessApp = await keylessService.getOrCreateKeys(); + + return { + publishableKey: keylessApp.publishableKey, + secretKey: keylessApp.secretKey, + claimUrl: keylessApp.claimUrl, + apiKeysUrl: keylessApp.apiKeysUrl, + }; +} +``` + +## Recent Improvements + +### 1. Framework-Aware Claim URLs (Completed) + +**Problem:** Dashboard always showed Next.js environment variable names, confusing for other frameworks. + +**Solution:** + +- SDKs send framework ID via `Clerk-Framework` header +- Backend appends to claim URL: `?token=xxx&framework=astro` +- Dashboard shows correct env vars for each framework + +**Implementation:** + +- Backend: Read header, append to claim URL +- All SDKs: Send correct framework ID (not package name) +- Tests: Added backend tests for framework parameter + +### 2. Astro Runtime Resolution (Completed) + +**Problem:** Compile-time injection via `vite.define` caused browser caching issues. + +**Solution:** + +- Moved keyless resolution from integration API to middleware +- Inject URLs via runtime script tag instead of `import.meta.env` +- Client reads from server-injected data + +**Benefits:** + +- No hard reload needed when switching keyless ↔ configured modes +- Matches patterns from TanStack Start and React Router +- Cleaner separation of concerns + +## File Organization + +``` +packages/ +├── shared/ +│ └── src/ +│ └── keyless/ +│ ├── service.ts # Core shared logic +│ ├── storage.ts # Abstract storage interface +│ ├── node.ts # Node.js file storage +│ ├── types.ts # Shared types +│ └── messages.ts # Console messages +│ +├── nextjs/ +│ └── src/ +│ └── server/ +│ └── keyless/ +│ ├── index.ts # Next.js wrapper +│ ├── fileStorage.ts # Conditional exports +│ └── utils.ts # Next.js key resolution +│ +├── tanstack-start/ +│ └── src/ +│ └── server/ +│ └── keyless/ +│ ├── index.ts # TanStack wrapper +│ └── utils.ts # TanStack key resolution +│ +├── react-router/ +│ └── src/ +│ └── server/ +│ └── keyless/ +│ ├── index.ts # React Router wrapper +│ ├── fileStorage.ts # Runtime checks +│ └── utils.ts # React Router key resolution +│ +└── astro/ + └── src/ + └── server/ + └── keyless/ + ├── index.ts # Astro wrapper + ├── fileStorage.ts # Dynamic imports + └── utils.ts # Astro key resolution +``` + +## Testing Strategy + +### Backend Tests + +**Location:** `clerk_go/tests/bapi/accountless_application_test.go` + +```go +func TestCreateAccountlessApplicationWithFrameworkHeader(t *testing.T) { + // Test that Clerk-Framework header is appended to claim URL +} + +func TestCreateAccountlessApplicationWithoutFrameworkHeader(t *testing.T) { + // Test backward compatibility without header +} +``` + +### SDK Tests (Per Framework) + +- Unit tests: Key resolution logic +- Integration tests: Keyless flow with/without configured keys +- E2E tests: Full dev flow with claim URL + +## Development Workflow + +### Testing Keyless Locally + +1. **Fresh start (no keys):** + +```bash +# Remove local storage +rm -rf .clerk/ + +# Remove env vars +rm .env.local # or comment out CLERK_* vars + +# Start dev server +npm run dev +``` + +Expected: Keyless banner appears with claim URL + +2. **Switching to configured keys:** + +```bash +# Add keys to .env.local +echo "PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxx" >> .env.local +echo "CLERK_SECRET_KEY=sk_test_xxx" >> .env.local + +# Refresh page (normal refresh, not hard reload) +``` + +Expected: Banner disappears immediately (no caching issues) + +3. **Claiming keys:** + +- Click claim URL in banner +- Dashboard shows framework-specific env vars +- Add claimed keys to `.env.local` +- Restart dev server +- See confirmation message + +## Key Differences Between Frameworks + +| Framework | Resolution Timing | Storage Method | Runtime Checks | Bundling Consideration | +| ---------------- | ------------------------ | ------------------- | ------------------------ | ---------------------- | +| **Next.js** | Server startup | Conditional exports | `nodeFsOrThrow()` | Server Actions | +| **TanStack** | Server startup | Static import | None needed | Vite tree-shaking | +| **React Router** | Server startup | Runtime conditional | `canUseFileSystem()` | Cloudflare Workers | +| **Astro** | Per-request (middleware) | Dynamic import | `hasFileSystemSupport()` | Vite + SSR adapters | + +## Environment Detection + +All frameworks check for development mode via: + +```typescript +// packages/shared/src/utils/runtimeEnvironment.ts +export const isDevelopmentEnvironment = (): boolean => { + try { + return process.env.NODE_ENV === 'development'; + } catch {} + + // TODO: add support for import.meta.env.DEV (Vite) + return false; +}; +``` + +**Note:** There's a TODO to support `import.meta.env.DEV` for Vite-based frameworks, but currently all frameworks use `process.env.NODE_ENV`. + +## Current Status + +### Completed + +- Next.js keyless (original implementation) +- TanStack Start keyless (PR #7518) +- React Router keyless (PR #7794, branch: `rob/react-router-keyless`) +- Astro keyless (branch: `rob/astro-keyless-mode`) +- Framework-aware claim URLs (backend + all SDKs) + +### In Progress + +- Astro keyless PR needs to be created and merged + +### Future Considerations + +- Support `import.meta.env.DEV` for Vite frameworks +- Potentially support keyless in preview/staging environments +- Consider keyless for other frameworks (Vue, Svelte, etc.) + +## Quick Reference: Framework IDs + +| SDK Package | Framework ID Sent | Env Var Prefix | +| ----------------------- | ---------------------- | -------------- | +| `@clerk/nextjs` | `nextjs` | `NEXT_PUBLIC_` | +| `@clerk/tanstack-start` | `tanstack-react-start` | `VITE_` | +| `@clerk/react-router` | `react-router` | `VITE_` | +| `@clerk/astro` | `astro` | `PUBLIC_` | + +**Important:** SDKs should send framework IDs (not package names). Backend passes these directly to dashboard without mapping. + +## Related Documentation + +- Astro env vars: https://docs.astro.build/en/guides/environment-variables/ +- Backend API spec: `clerk_go/api/bapi/v1/accountless_applications/` diff --git a/integration/testUtils/keylessHelpers.ts b/integration/testUtils/keylessHelpers.ts index 4c941e64f62..941885ce346 100644 --- a/integration/testUtils/keylessHelpers.ts +++ b/integration/testUtils/keylessHelpers.ts @@ -1,4 +1,8 @@ -import type { Page } from '@playwright/test'; +import type { BrowserContext, Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +import type { Application } from '../models/application'; +import { createTestUtils } from './index'; /** * Mocks the environment API call to return a claimed instance. @@ -18,3 +22,117 @@ export const mockClaimedInstanceEnvironmentCall = async (page: Page): Promise { + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + await u.po.expect.toBeSignedOut(); + + await u.po.keylessPopover.waitForMounted(); + + expect(await u.po.keylessPopover.isExpanded()).toBe(false); + await u.po.keylessPopover.toggle(); + expect(await u.po.keylessPopover.isExpanded()).toBe(true); + + const claim = u.po.keylessPopover.promptsToClaim(); + + const [newPage] = await Promise.all([context.waitForEvent('page'), claim.click()]); + + await newPage.waitForLoadState(); + + await newPage.waitForURL(url => { + const urlToReturnTo = `${dashboardUrl}apps/claim?token=`; + + const signUpForceRedirectUrl = url.searchParams.get('sign_up_force_redirect_url'); + + const signUpForceRedirectUrlCheck = + signUpForceRedirectUrl?.startsWith(urlToReturnTo) || + (signUpForceRedirectUrl?.startsWith(`${dashboardUrl}prepare-account`) && + signUpForceRedirectUrl?.includes(encodeURIComponent('apps/claim?token='))); + + return ( + url.pathname === '/apps/claim/sign-in' && + url.searchParams.get('sign_in_force_redirect_url')?.startsWith(urlToReturnTo) && + signUpForceRedirectUrlCheck + ); + }); +} + +/** + * Tests that a claimed application with missing explicit keys shows the popover expanded + * with a prompt to get keys from the dashboard. + */ +export async function testClaimedAppWithMissingKeys({ + page, + context, + app, + dashboardUrl, +}: { + page: Page; + context: BrowserContext; + app: Application; + dashboardUrl: string; +}): Promise { + await mockClaimedInstanceEnvironmentCall(page); + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + + await u.po.keylessPopover.waitForMounted(); + expect(await u.po.keylessPopover.isExpanded()).toBe(true); + await expect(u.po.keylessPopover.promptToUseClaimedKeys()).toBeVisible(); + + const [newPage] = await Promise.all([ + context.waitForEvent('page'), + u.po.keylessPopover.promptToUseClaimedKeys().click(), + ]); + + await newPage.waitForLoadState(); + await newPage.waitForURL(url => { + return url.href.startsWith(`${dashboardUrl}sign-in?redirect_url=${encodeURIComponent(dashboardUrl)}apps%2Fapp_`); + }); +} + +/** + * Tests that the keyless popover is removed after adding keys to .env and restarting the dev server. + */ +export async function testKeylessRemovedAfterEnvAndRestart({ + page, + context, + app, +}: { + page: Page; + context: BrowserContext; + app: Application; +}): Promise { + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + + await u.po.keylessPopover.waitForMounted(); + expect(await u.po.keylessPopover.isExpanded()).toBe(false); + + // Copy keys from keyless.json to .env + await app.keylessToEnv(); + + // Restart the dev server to pick up new env vars (Vite doesn't hot-reload .env) + await app.restart(); + + await u.page.goToAppHome(); + + // Keyless popover should no longer be present since we now have explicit keys + await u.po.keylessPopover.waitForUnmounted(); +} diff --git a/integration/tests/react-router/keyless.test.ts b/integration/tests/react-router/keyless.test.ts index 410cf024c22..aafb23cdcda 100644 --- a/integration/tests/react-router/keyless.test.ts +++ b/integration/tests/react-router/keyless.test.ts @@ -1,9 +1,12 @@ -import { expect, test } from '@playwright/test'; +import { test } from '@playwright/test'; import type { Application } from '../../models/application'; import { appConfigs } from '../../presets'; -import { createTestUtils } from '../../testUtils'; -import { mockClaimedInstanceEnvironmentCall } from '../../testUtils/keylessHelpers'; +import { + testClaimedAppWithMissingKeys, + testKeylessRemovedAfterEnvAndRestart, + testToggleCollapsePopoverAndClaim, +} from '../../testUtils/keylessHelpers'; const commonSetup = appConfigs.reactRouter.reactRouterNode.clone(); @@ -36,81 +39,17 @@ test.describe('Keyless mode @react-router', () => { }); test('Toggle collapse popover and claim.', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - await u.page.goToAppHome(); - await u.page.waitForClerkJsLoaded(); - await u.po.expect.toBeSignedOut(); - - await u.po.keylessPopover.waitForMounted(); - - expect(await u.po.keylessPopover.isExpanded()).toBe(false); - await u.po.keylessPopover.toggle(); - expect(await u.po.keylessPopover.isExpanded()).toBe(true); - - const claim = await u.po.keylessPopover.promptsToClaim(); - - const [newPage] = await Promise.all([context.waitForEvent('page'), claim.click()]); - - await newPage.waitForLoadState(); - - await newPage.waitForURL(url => { - const urlToReturnTo = `${dashboardUrl}apps/claim?token=`; - - const signUpForceRedirectUrl = url.searchParams.get('sign_up_force_redirect_url'); - - const signUpForceRedirectUrlCheck = - signUpForceRedirectUrl?.startsWith(urlToReturnTo) || - (signUpForceRedirectUrl?.startsWith(`${dashboardUrl}prepare-account`) && - signUpForceRedirectUrl?.includes(encodeURIComponent('apps/claim?token='))); - - return ( - url.pathname === '/apps/claim/sign-in' && - url.searchParams.get('sign_in_force_redirect_url')?.startsWith(urlToReturnTo) && - signUpForceRedirectUrlCheck - ); - }); + await testToggleCollapsePopoverAndClaim({ page, context, app, dashboardUrl }); }); test('Lands on claimed application with missing explicit keys, expanded by default, click to get keys from dashboard.', async ({ page, context, }) => { - await mockClaimedInstanceEnvironmentCall(page); - const u = createTestUtils({ app, page, context }); - await u.page.goToAppHome(); - await u.page.waitForClerkJsLoaded(); - - await u.po.keylessPopover.waitForMounted(); - expect(await u.po.keylessPopover.isExpanded()).toBe(true); - await expect(u.po.keylessPopover.promptToUseClaimedKeys()).toBeVisible(); - - const [newPage] = await Promise.all([ - context.waitForEvent('page'), - u.po.keylessPopover.promptToUseClaimedKeys().click(), - ]); - - await newPage.waitForLoadState(); - await newPage.waitForURL(url => { - return url.href.startsWith(`${dashboardUrl}sign-in?redirect_url=${encodeURIComponent(dashboardUrl)}apps%2Fapp_`); - }); + await testClaimedAppWithMissingKeys({ page, context, app, dashboardUrl }); }); test('Keyless popover is removed after adding keys to .env and restarting.', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - await u.page.goToAppHome(); - - await u.po.keylessPopover.waitForMounted(); - expect(await u.po.keylessPopover.isExpanded()).toBe(false); - - // Copy keys from keyless.json to .env - await app.keylessToEnv(); - - // Restart the dev server to pick up new env vars (Vite doesn't hot-reload .env) - await app.restart(); - - await u.page.goToAppHome(); - - // Keyless popover should no longer be present since we now have explicit keys - await u.po.keylessPopover.waitForUnmounted(); + await testKeylessRemovedAfterEnvAndRestart({ page, context, app }); }); }); diff --git a/integration/tests/tanstack-start/keyless.test.ts b/integration/tests/tanstack-start/keyless.test.ts index e7eaeea9530..78c52949563 100644 --- a/integration/tests/tanstack-start/keyless.test.ts +++ b/integration/tests/tanstack-start/keyless.test.ts @@ -1,9 +1,12 @@ -import { expect, test } from '@playwright/test'; +import { test } from '@playwright/test'; import type { Application } from '../../models/application'; import { appConfigs } from '../../presets'; -import { createTestUtils } from '../../testUtils'; -import { mockClaimedInstanceEnvironmentCall } from '../../testUtils/keylessHelpers'; +import { + testClaimedAppWithMissingKeys, + testKeylessRemovedAfterEnvAndRestart, + testToggleCollapsePopoverAndClaim, +} from '../../testUtils/keylessHelpers'; const commonSetup = appConfigs.tanstack.reactStart.clone(); @@ -36,81 +39,17 @@ test.describe('Keyless mode @tanstack-react-start', () => { }); test('Toggle collapse popover and claim.', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - await u.page.goToAppHome(); - await u.page.waitForClerkJsLoaded(); - await u.po.expect.toBeSignedOut(); - - await u.po.keylessPopover.waitForMounted(); - - expect(await u.po.keylessPopover.isExpanded()).toBe(false); - await u.po.keylessPopover.toggle(); - expect(await u.po.keylessPopover.isExpanded()).toBe(true); - - const claim = await u.po.keylessPopover.promptsToClaim(); - - const [newPage] = await Promise.all([context.waitForEvent('page'), claim.click()]); - - await newPage.waitForLoadState(); - - await newPage.waitForURL(url => { - const urlToReturnTo = `${dashboardUrl}apps/claim?token=`; - - const signUpForceRedirectUrl = url.searchParams.get('sign_up_force_redirect_url'); - - const signUpForceRedirectUrlCheck = - signUpForceRedirectUrl?.startsWith(urlToReturnTo) || - (signUpForceRedirectUrl?.startsWith(`${dashboardUrl}prepare-account`) && - signUpForceRedirectUrl?.includes(encodeURIComponent('apps/claim?token='))); - - return ( - url.pathname === '/apps/claim/sign-in' && - url.searchParams.get('sign_in_force_redirect_url')?.startsWith(urlToReturnTo) && - signUpForceRedirectUrlCheck - ); - }); + await testToggleCollapsePopoverAndClaim({ page, context, app, dashboardUrl }); }); test('Lands on claimed application with missing explicit keys, expanded by default, click to get keys from dashboard.', async ({ page, context, }) => { - await mockClaimedInstanceEnvironmentCall(page); - const u = createTestUtils({ app, page, context }); - await u.page.goToAppHome(); - await u.page.waitForClerkJsLoaded(); - - await u.po.keylessPopover.waitForMounted(); - expect(await u.po.keylessPopover.isExpanded()).toBe(true); - await expect(u.po.keylessPopover.promptToUseClaimedKeys()).toBeVisible(); - - const [newPage] = await Promise.all([ - context.waitForEvent('page'), - u.po.keylessPopover.promptToUseClaimedKeys().click(), - ]); - - await newPage.waitForLoadState(); - await newPage.waitForURL(url => { - return url.href.startsWith(`${dashboardUrl}sign-in?redirect_url=${encodeURIComponent(dashboardUrl)}apps%2Fapp_`); - }); + await testClaimedAppWithMissingKeys({ page, context, app, dashboardUrl }); }); test('Keyless popover is removed after adding keys to .env and restarting.', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - await u.page.goToAppHome(); - - await u.po.keylessPopover.waitForMounted(); - expect(await u.po.keylessPopover.isExpanded()).toBe(false); - - // Copy keys from keyless.json to .env - await app.keylessToEnv(); - - // Restart the dev server to pick up new env vars (Vite doesn't hot-reload .env) - await app.restart(); - - await u.page.goToAppHome(); - - // Keyless popover should no longer be present since we now have explicit keys - await u.po.keylessPopover.waitForUnmounted(); + await testKeylessRemovedAfterEnvAndRestart({ page, context, app }); }); }); diff --git a/packages/react-router/src/server/keyless/index.ts b/packages/react-router/src/server/keyless/index.ts index 257e7384330..a0ec434b19a 100644 --- a/packages/react-router/src/server/keyless/index.ts +++ b/packages/react-router/src/server/keyless/index.ts @@ -21,7 +21,7 @@ function canUseFileSystem(): boolean { * Returns null for non-Node.js runtimes (e.g., Cloudflare Workers). */ export async function keyless( - args?: DataFunctionArgs, + args: DataFunctionArgs, options?: ClerkMiddlewareOptions, ): Promise | null> { if (!canUseFileSystem()) { @@ -45,8 +45,10 @@ export async function keyless( api: { async createAccountlessApplication(requestHeaders?: Headers) { try { - const client = args ? clerkClient(args, options) : clerkClient({} as any, options); - return await client.__experimental_accountlessApplications.createAccountlessApplication({ + return await clerkClient( + args, + options, + ).__experimental_accountlessApplications.createAccountlessApplication({ requestHeaders, }); } catch { @@ -55,8 +57,10 @@ export async function keyless( }, async completeOnboarding(requestHeaders?: Headers) { try { - const client = args ? clerkClient(args, options) : clerkClient({} as any, options); - return await client.__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({ + return await clerkClient( + args, + options, + ).__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({ requestHeaders, }); } catch { diff --git a/packages/react-router/src/server/keyless/utils.ts b/packages/react-router/src/server/keyless/utils.ts index 48eb8712733..138a30d22cd 100644 --- a/packages/react-router/src/server/keyless/utils.ts +++ b/packages/react-router/src/server/keyless/utils.ts @@ -1,84 +1,25 @@ -import type { AccountlessApplication } from '@clerk/shared/keyless'; -import { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } from '@clerk/shared/keyless'; +import { resolveKeysWithKeylessFallback as sharedResolveKeysWithKeylessFallback } from '@clerk/shared/keyless'; +export type { KeylessResult } from '@clerk/shared/keyless'; import { canUseKeyless } from '../../utils/feature-flags'; import type { DataFunctionArgs } from '../loadOptions'; import type { ClerkMiddlewareOptions } from '../types'; import { keyless } from './index'; -export interface KeylessResult { - publishableKey: string | undefined; - secretKey: string | undefined; - claimUrl: string | undefined; - apiKeysUrl: string | undefined; -} - /** * Resolves Clerk keys, falling back to keyless mode in development if configured keys are missing. */ export async function resolveKeysWithKeylessFallback( configuredPublishableKey: string | undefined, configuredSecretKey: string | undefined, - args?: DataFunctionArgs, + args: DataFunctionArgs, options?: ClerkMiddlewareOptions, -): Promise { - let publishableKey = configuredPublishableKey; - let secretKey = configuredSecretKey; - let claimUrl: string | undefined; - let apiKeysUrl: string | undefined; - - if (!canUseKeyless) { - return { publishableKey, secretKey, claimUrl, apiKeysUrl }; - } - - try { - const keylessService = await keyless(args, options); - - if (!keylessService) { - return { publishableKey, secretKey, claimUrl, apiKeysUrl }; - } - - const locallyStoredKeys = keylessService.readKeys(); - - const runningWithClaimedKeys = - Boolean(configuredPublishableKey) && configuredPublishableKey === locallyStoredKeys?.publishableKey; - - if (runningWithClaimedKeys && locallyStoredKeys) { - try { - await clerkDevelopmentCache?.run(() => keylessService.completeOnboarding(), { - cacheKey: `${locallyStoredKeys.publishableKey}_complete`, - onSuccessStale: 24 * 60 * 60 * 1000, - }); - } catch { - // noop - } - - clerkDevelopmentCache?.log({ - cacheKey: `${locallyStoredKeys.publishableKey}_claimed`, - msg: createConfirmationMessage(), - }); - - return { publishableKey, secretKey, claimUrl, apiKeysUrl }; - } - - if (!publishableKey && !secretKey) { - const keylessApp: AccountlessApplication | null = await keylessService.getOrCreateKeys(); - - if (keylessApp) { - publishableKey = keylessApp.publishableKey; - secretKey = keylessApp.secretKey; - claimUrl = keylessApp.claimUrl; - apiKeysUrl = keylessApp.apiKeysUrl; - - clerkDevelopmentCache?.log({ - cacheKey: keylessApp.publishableKey, - msg: createKeylessModeMessage(keylessApp), - }); - } - } - } catch (error) { - console.warn('[Clerk] Keyless resolution failed:', error); - } - - return { publishableKey, secretKey, claimUrl, apiKeysUrl }; +) { + const keylessService = await keyless(args, options); + return sharedResolveKeysWithKeylessFallback( + configuredPublishableKey, + configuredSecretKey, + keylessService, + canUseKeyless, + ); } diff --git a/packages/shared/src/keyless/index.ts b/packages/shared/src/keyless/index.ts index 42c0089949d..75e2cf16c91 100644 --- a/packages/shared/src/keyless/index.ts +++ b/packages/shared/src/keyless/index.ts @@ -12,4 +12,7 @@ export type { FileSystemAdapter, NodeFileStorageOptions, PathAdapter } from './n export { createKeylessService } from './service'; export type { KeylessAPI, KeylessService, KeylessServiceOptions, KeylessStorage } from './service'; +export { resolveKeysWithKeylessFallback } from './resolveKeysWithKeylessFallback'; +export type { KeylessResult } from './resolveKeysWithKeylessFallback'; + export type { AccountlessApplication, PublicKeylessApplication } from './types'; diff --git a/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts b/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts new file mode 100644 index 00000000000..fc923ae2a2f --- /dev/null +++ b/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts @@ -0,0 +1,87 @@ +import { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } from './devCache'; +import type { KeylessService } from './service'; +import type { AccountlessApplication } from './types'; + +export interface KeylessResult { + publishableKey: string | undefined; + secretKey: string | undefined; + claimUrl: string | undefined; + apiKeysUrl: string | undefined; +} + +/** + * 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 + * @param keylessService - The keyless service instance (or null if unavailable) + * @param canUseKeyless - Whether keyless mode is enabled in the current environment + * @returns The resolved keys (either configured or from keyless mode) + */ +export async function resolveKeysWithKeylessFallback( + configuredPublishableKey: string | undefined, + configuredSecretKey: string | undefined, + keylessService: KeylessService | null, + canUseKeyless: boolean, +): Promise { + let publishableKey = configuredPublishableKey; + let secretKey = configuredSecretKey; + let claimUrl: string | undefined; + let apiKeysUrl: string | undefined; + + if (!canUseKeyless) { + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; + } + + if (!keylessService) { + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; + } + + try { + const locallyStoredKeys = keylessService.readKeys(); + + // Check if running with claimed keys (configured keys match locally stored keyless keys) + const runningWithClaimedKeys = + 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(), + }); + + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; + } + + // In keyless mode, try to read/create keys from the file system + if (!publishableKey && !secretKey) { + const keylessApp: AccountlessApplication | null = await keylessService.getOrCreateKeys(); + + if (keylessApp) { + publishableKey = keylessApp.publishableKey; + secretKey = keylessApp.secretKey; + claimUrl = keylessApp.claimUrl; + apiKeysUrl = keylessApp.apiKeysUrl; + + clerkDevelopmentCache?.log({ + cacheKey: keylessApp.publishableKey, + msg: createKeylessModeMessage(keylessApp), + }); + } + } + } catch { + // noop - fall through to return whatever keys we have + } + + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; +} diff --git a/packages/tanstack-react-start/src/server/keyless/utils.ts b/packages/tanstack-react-start/src/server/keyless/utils.ts index 3a22f0aae86..ab4896cadb5 100644 --- a/packages/tanstack-react-start/src/server/keyless/utils.ts +++ b/packages/tanstack-react-start/src/server/keyless/utils.ts @@ -1,16 +1,9 @@ -import type { AccountlessApplication } from '@clerk/shared/keyless'; -import { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } from '@clerk/shared/keyless'; +import { resolveKeysWithKeylessFallback as sharedResolveKeysWithKeylessFallback } from '@clerk/shared/keyless'; +export type { KeylessResult } from '@clerk/shared/keyless'; import { canUseKeyless } from '../../utils/feature-flags'; import { keyless } from './index'; -export interface KeylessResult { - publishableKey: string | undefined; - secretKey: string | undefined; - claimUrl: string | undefined; - apiKeysUrl: string | undefined; -} - /** * Resolves Clerk keys, falling back to keyless mode in development if configured keys are missing. * @@ -18,61 +11,9 @@ export interface KeylessResult { * @param configuredSecretKey - The secret key from options or environment * @returns The resolved keys (either configured or from keyless mode) */ -export async function resolveKeysWithKeylessFallback( +export function resolveKeysWithKeylessFallback( configuredPublishableKey: string | undefined, configuredSecretKey: string | undefined, -): Promise { - let publishableKey = configuredPublishableKey; - let secretKey = configuredSecretKey; - let claimUrl: string | undefined; - let apiKeysUrl: string | undefined; - - if (!canUseKeyless) { - return { publishableKey, secretKey, claimUrl, apiKeysUrl }; - } - - const keylessService = keyless(); - const locallyStoredKeys = keylessService.readKeys(); - - // Check if running with claimed keys (configured keys match locally stored keyless keys) - const runningWithClaimedKeys = - 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(), - }); - - return { publishableKey, secretKey, claimUrl, apiKeysUrl }; - } - - // In keyless mode, try to read/create keys from the file system - if (!publishableKey || !secretKey) { - const keylessApp: AccountlessApplication | null = await keylessService.getOrCreateKeys(); - - if (keylessApp) { - publishableKey = publishableKey || keylessApp.publishableKey; - secretKey = secretKey || keylessApp.secretKey; - claimUrl = keylessApp.claimUrl; - apiKeysUrl = keylessApp.apiKeysUrl; - - clerkDevelopmentCache?.log({ - cacheKey: keylessApp.publishableKey, - msg: createKeylessModeMessage(keylessApp), - }); - } - } - - return { publishableKey, secretKey, claimUrl, apiKeysUrl }; +) { + return sharedResolveKeysWithKeylessFallback(configuredPublishableKey, configuredSecretKey, keyless(), canUseKeyless); } From ae6c168c23e78b9714d9736eaa8bc55a3ae15ca1 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Sun, 8 Feb 2026 14:10:34 -0800 Subject: [PATCH 10/21] chore: delete md file --- docs/KEYLESS_MODE.md | 467 ------------------------------------------- 1 file changed, 467 deletions(-) delete mode 100644 docs/KEYLESS_MODE.md diff --git a/docs/KEYLESS_MODE.md b/docs/KEYLESS_MODE.md deleted file mode 100644 index 15f90a3d89c..00000000000 --- a/docs/KEYLESS_MODE.md +++ /dev/null @@ -1,467 +0,0 @@ -# Clerk Keyless Mode: Cross-Framework Implementation - -## What is Keyless Mode? - -**Keyless mode** is a zero-configuration development experience that allows developers to start using Clerk without manually creating API keys. When a developer runs their app in development without configured keys, Clerk automatically: - -1. Generates temporary API keys (publishable + secret) -2. Creates a temporary "accountless application" -3. Displays a banner with a "claim URL" to associate keys with their Clerk account -4. Stores keys locally so they persist across restarts - -**Key benefits:** - -- Instant start for new developers (no dashboard visit required) -- Reduces onboarding friction -- Keys can be claimed later and associated with production account - -## Architecture Overview - -### Core Shared Code (`@clerk/shared/keyless`) - -**Location:** `packages/shared/src/keyless/` - -**Key files:** - -- `service.ts` - Core keyless service that communicates with backend -- `storage.ts` - Abstract storage interface for persisting keys -- `types.ts` - TypeScript types (AccountlessApplication, etc.) -- `messages.ts` - Console banner messages shown to developers - -**Key function:** - -```typescript -export function createKeylessService(options: KeylessServiceOptions): KeylessService { - const { storage, api, framework, frameworkVersion } = options; - - return { - async getOrCreateKeys(): Promise { - // Check local storage first - const existingKeys = storage.readKeys(); - if (existingKeys) return existingKeys; - - // Create headers with framework info - const headers = new Headers(); - if (framework) { - headers.set('Clerk-Framework', framework); // ← Sent to backend - } - - // Call backend to create new accountless application - const app = await api.createAccountlessApplication(headers); - storage.writeKeys(app); - return app; - }, - // ... other methods - }; -} -``` - -### Backend (Go) - -**Location:** `clerk_go/api/bapi/v1/accountless_applications/` - -**Endpoints:** - -- `POST /v1/accountless_applications` - Create new accountless app -- `POST /v1/accountless_applications/complete` - Mark onboarding complete - -**Key feature: Framework-aware claim URLs** - -```go -// Read framework from header -if framework := r.Header.Get("Clerk-Framework"); framework != "" { - params.Framework = &framework -} - -// Build claim URL with framework parameter -func buildClaimURL(token string, framework *string) (string, error) { - query.Add("token", token) - if framework != nil && *framework != "" { - query.Add("framework", *framework) // ← Dashboard uses this - } - return link.String(), nil -} -``` - -**Why this matters:** Dashboard reads the `framework` query param and shows correct environment variable names for each SDK (e.g., `PUBLIC_CLERK_PUBLISHABLE_KEY` for Astro vs `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` for Next.js). - -### Dashboard - -**Location:** `apps/dashboard/app/(routes)/apps/claim/page.tsx` - -**Already supports framework parameter:** - -```typescript -const { - token: claimToken, - framework: frameworkId = 'nextjs', // ← Defaults to Next.js if not provided -} = await searchParams; - -// Uses getKeyValueTemplate() to show framework-specific env vars - -``` - -## Framework-Specific Implementations - -Each framework has its own keyless wrapper due to different runtime environments, bundling requirements, and lifecycle hooks. - -### 1. Next.js (Original Implementation) - -**Location:** `packages/nextjs/src/server/keyless/` - -**Characteristics:** - -- Uses **conditional exports** (`#safe-node-apis`) for Node vs Edge runtime compatibility -- Storage: `createNodeFileStorage()` with `nodeFsOrThrow()`, `nodePathOrThrow()` -- Why: Server Actions bundle aggressively, need explicit runtime detection - -**Key files:** - -``` -keyless/ - index.ts # Keyless service singleton - fileStorage.ts # Node.js file storage with fs/path checks - utils.ts # resolveKeysWithKeylessFallback() -``` - -**Framework ID sent:** `'nextjs'` - -### 2. TanStack Start (Introduced Shared Keyless) - -**PR:** https://github.com/clerk/javascript/pull/7518 - -**Location:** `packages/tanstack-start/src/server/keyless/` - -**Characteristics:** - -- Uses **static imports** (works due to Vite tree-shaking) -- Storage: Direct import from `@clerk/shared/keyless/node` -- Simpler than Next.js since Vite handles dead code elimination better - -**Framework ID sent:** `'tanstack-react-start'` - -### 3. React Router - -**PR:** https://github.com/clerk/javascript/pull/7794 (branch: `rob/react-router-keyless`) - -**Location:** `packages/react-router/src/server/keyless/` - -**Characteristics:** - -- Uses **runtime checks** (`canUseFileSystem()`) for Cloudflare Workers support -- Storage: Conditional based on runtime environment -- Why: Must support both Node.js and Cloudflare Workers (no filesystem) - -**Framework ID sent:** `'react-router'` - -### 4. Astro (Current Implementation) - -**Branch:** `rob/astro-keyless-mode` - -**Location:** `packages/astro/src/server/keyless/` - -**Characteristics:** - -- Uses **dynamic imports** + `hasFileSystemSupport()` check -- Moved from compile-time (integration API) to runtime (middleware) -- Storage: `createNodeFileStorage()` imported dynamically - -**Framework ID sent:** `'astro'` (changed from `'@clerk/astro'`) - -**Major refactor completed:** - -- **Before:** Keyless resolved once at server startup, injected via `vite.define` -- **After:** Keyless resolved per-request in middleware, injected via script tag -- **Benefit:** No browser caching issues, instant updates when switching modes - -## Why Code Duplication Exists - -### What's Shared - -- Core keyless service logic (`createKeylessService`) -- Storage interfaces and Node.js implementation -- Type definitions -- Console messages - -### What's Per-Framework - -- **Keyless service wrapper** (`keyless()` function) - - Different singleton patterns - - Different runtime environment checks - -- **File storage creation** (`createFileStorage()`) - - Next.js: Conditional exports for Server Actions - - React Router: Runtime checks for Cloudflare Workers - - TanStack/Astro: Direct imports work fine - -- **Key resolution** (`resolveKeysWithKeylessFallback()`) - - Different integration points (middleware, API routes, etc.) - - Different context objects - - Framework-specific caching strategies - -**Conclusion:** The duplication is intentional and correct. Each framework has unique runtime constraints that require custom handling. - -## Common Patterns - -### Pattern 1: Feature Flag Check - -All frameworks check if keyless can be used: - -```typescript -export const canUseKeyless = - isDevelopmentEnvironment() && // Only in dev mode - !KEYLESS_DISABLED && // Not explicitly disabled - hasFileSystemSupport(); // Runtime has filesystem -``` - -### Pattern 2: Keyless Service Singleton - -```typescript -let keylessInstance: KeylessService | null = null; - -export async function keyless(): Promise { - if (!keylessInstance) { - const storage = await createFileStorage(); - keylessInstance = createKeylessService({ - storage, - api: clerkClient(), - framework: 'framework-name', // ← Framework-specific - frameworkVersion: PACKAGE_VERSION, - }); - } - return keylessInstance; -} -``` - -### Pattern 3: Key Resolution with Fallback - -```typescript -export async function resolveKeysWithKeylessFallback( - configuredPublishableKey: string | undefined, - configuredSecretKey: string | undefined, -): Promise { - // Early return if keyless not available - if (!canUseKeyless) { - return { publishableKey, secretKey, claimUrl, apiKeysUrl }; - } - - // Skip keyless if both keys configured - if (publishableKey && secretKey) { - return { publishableKey, secretKey, claimUrl, apiKeysUrl }; - } - - // Try keyless mode - const keylessService = await keyless(); - const keylessApp = await keylessService.getOrCreateKeys(); - - return { - publishableKey: keylessApp.publishableKey, - secretKey: keylessApp.secretKey, - claimUrl: keylessApp.claimUrl, - apiKeysUrl: keylessApp.apiKeysUrl, - }; -} -``` - -## Recent Improvements - -### 1. Framework-Aware Claim URLs (Completed) - -**Problem:** Dashboard always showed Next.js environment variable names, confusing for other frameworks. - -**Solution:** - -- SDKs send framework ID via `Clerk-Framework` header -- Backend appends to claim URL: `?token=xxx&framework=astro` -- Dashboard shows correct env vars for each framework - -**Implementation:** - -- Backend: Read header, append to claim URL -- All SDKs: Send correct framework ID (not package name) -- Tests: Added backend tests for framework parameter - -### 2. Astro Runtime Resolution (Completed) - -**Problem:** Compile-time injection via `vite.define` caused browser caching issues. - -**Solution:** - -- Moved keyless resolution from integration API to middleware -- Inject URLs via runtime script tag instead of `import.meta.env` -- Client reads from server-injected data - -**Benefits:** - -- No hard reload needed when switching keyless ↔ configured modes -- Matches patterns from TanStack Start and React Router -- Cleaner separation of concerns - -## File Organization - -``` -packages/ -├── shared/ -│ └── src/ -│ └── keyless/ -│ ├── service.ts # Core shared logic -│ ├── storage.ts # Abstract storage interface -│ ├── node.ts # Node.js file storage -│ ├── types.ts # Shared types -│ └── messages.ts # Console messages -│ -├── nextjs/ -│ └── src/ -│ └── server/ -│ └── keyless/ -│ ├── index.ts # Next.js wrapper -│ ├── fileStorage.ts # Conditional exports -│ └── utils.ts # Next.js key resolution -│ -├── tanstack-start/ -│ └── src/ -│ └── server/ -│ └── keyless/ -│ ├── index.ts # TanStack wrapper -│ └── utils.ts # TanStack key resolution -│ -├── react-router/ -│ └── src/ -│ └── server/ -│ └── keyless/ -│ ├── index.ts # React Router wrapper -│ ├── fileStorage.ts # Runtime checks -│ └── utils.ts # React Router key resolution -│ -└── astro/ - └── src/ - └── server/ - └── keyless/ - ├── index.ts # Astro wrapper - ├── fileStorage.ts # Dynamic imports - └── utils.ts # Astro key resolution -``` - -## Testing Strategy - -### Backend Tests - -**Location:** `clerk_go/tests/bapi/accountless_application_test.go` - -```go -func TestCreateAccountlessApplicationWithFrameworkHeader(t *testing.T) { - // Test that Clerk-Framework header is appended to claim URL -} - -func TestCreateAccountlessApplicationWithoutFrameworkHeader(t *testing.T) { - // Test backward compatibility without header -} -``` - -### SDK Tests (Per Framework) - -- Unit tests: Key resolution logic -- Integration tests: Keyless flow with/without configured keys -- E2E tests: Full dev flow with claim URL - -## Development Workflow - -### Testing Keyless Locally - -1. **Fresh start (no keys):** - -```bash -# Remove local storage -rm -rf .clerk/ - -# Remove env vars -rm .env.local # or comment out CLERK_* vars - -# Start dev server -npm run dev -``` - -Expected: Keyless banner appears with claim URL - -2. **Switching to configured keys:** - -```bash -# Add keys to .env.local -echo "PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxx" >> .env.local -echo "CLERK_SECRET_KEY=sk_test_xxx" >> .env.local - -# Refresh page (normal refresh, not hard reload) -``` - -Expected: Banner disappears immediately (no caching issues) - -3. **Claiming keys:** - -- Click claim URL in banner -- Dashboard shows framework-specific env vars -- Add claimed keys to `.env.local` -- Restart dev server -- See confirmation message - -## Key Differences Between Frameworks - -| Framework | Resolution Timing | Storage Method | Runtime Checks | Bundling Consideration | -| ---------------- | ------------------------ | ------------------- | ------------------------ | ---------------------- | -| **Next.js** | Server startup | Conditional exports | `nodeFsOrThrow()` | Server Actions | -| **TanStack** | Server startup | Static import | None needed | Vite tree-shaking | -| **React Router** | Server startup | Runtime conditional | `canUseFileSystem()` | Cloudflare Workers | -| **Astro** | Per-request (middleware) | Dynamic import | `hasFileSystemSupport()` | Vite + SSR adapters | - -## Environment Detection - -All frameworks check for development mode via: - -```typescript -// packages/shared/src/utils/runtimeEnvironment.ts -export const isDevelopmentEnvironment = (): boolean => { - try { - return process.env.NODE_ENV === 'development'; - } catch {} - - // TODO: add support for import.meta.env.DEV (Vite) - return false; -}; -``` - -**Note:** There's a TODO to support `import.meta.env.DEV` for Vite-based frameworks, but currently all frameworks use `process.env.NODE_ENV`. - -## Current Status - -### Completed - -- Next.js keyless (original implementation) -- TanStack Start keyless (PR #7518) -- React Router keyless (PR #7794, branch: `rob/react-router-keyless`) -- Astro keyless (branch: `rob/astro-keyless-mode`) -- Framework-aware claim URLs (backend + all SDKs) - -### In Progress - -- Astro keyless PR needs to be created and merged - -### Future Considerations - -- Support `import.meta.env.DEV` for Vite frameworks -- Potentially support keyless in preview/staging environments -- Consider keyless for other frameworks (Vue, Svelte, etc.) - -## Quick Reference: Framework IDs - -| SDK Package | Framework ID Sent | Env Var Prefix | -| ----------------------- | ---------------------- | -------------- | -| `@clerk/nextjs` | `nextjs` | `NEXT_PUBLIC_` | -| `@clerk/tanstack-start` | `tanstack-react-start` | `VITE_` | -| `@clerk/react-router` | `react-router` | `VITE_` | -| `@clerk/astro` | `astro` | `PUBLIC_` | - -**Important:** SDKs should send framework IDs (not package names). Backend passes these directly to dashboard without mapping. - -## Related Documentation - -- Astro env vars: https://docs.astro.build/en/guides/environment-variables/ -- Backend API spec: `clerk_go/api/bapi/v1/accountless_applications/` From 818352a973bc39a5a2d575de578fd7d166b65894 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Sun, 8 Feb 2026 19:45:59 -0800 Subject: [PATCH 11/21] chore: extract reusable file storage create function --- .changeset/curvy-jobs-pump.md | 4 +++ .../src/server/keyless/fileStorage.ts | 30 ++++------------ .../react-router/src/server/keyless/index.ts | 2 +- .../shared/src/keyless/createFileStorage.ts | 34 +++++++++++++++++++ packages/shared/src/keyless/index.ts | 3 ++ .../src/server/keyless/fileStorage.ts | 24 +++++-------- 6 files changed, 56 insertions(+), 41 deletions(-) create mode 100644 packages/shared/src/keyless/createFileStorage.ts diff --git a/.changeset/curvy-jobs-pump.md b/.changeset/curvy-jobs-pump.md index 3161921deae..eb888920b14 100644 --- a/.changeset/curvy-jobs-pump.md +++ b/.changeset/curvy-jobs-pump.md @@ -1,5 +1,9 @@ --- "@clerk/react-router": minor +"@clerk/shared": patch +"@clerk/tanstack-react-start": patch --- Introduce Keyless quickstart for React Router. This allows the Clerk SDK to be used without having to sign up and paste your keys manually. + +Additionally, extract `createFileStorage` to `@clerk/shared/keyless` to reduce code duplication across Vite-based frameworks (TanStack Start and React Router now use the shared implementation). diff --git a/packages/react-router/src/server/keyless/fileStorage.ts b/packages/react-router/src/server/keyless/fileStorage.ts index 35f2bfc2b69..6c7ee468ce6 100644 --- a/packages/react-router/src/server/keyless/fileStorage.ts +++ b/packages/react-router/src/server/keyless/fileStorage.ts @@ -1,29 +1,11 @@ -import { createNodeFileStorage, type KeylessStorage } from '@clerk/shared/keyless'; - -export type { KeylessStorage }; - -export interface FileStorageOptions { - cwd?: () => string; -} +import { createFileStorage as sharedCreateFileStorage } from '@clerk/shared/keyless'; +export type { KeylessStorage } from '@clerk/shared/keyless'; /** * Creates a file-based storage adapter for keyless mode. - * Uses dynamic imports to avoid bundler issues with edge runtimes. */ -export async function createFileStorage(options: FileStorageOptions = {}): Promise { - const { cwd = () => process.cwd() } = options; - - try { - const [fs, path] = await Promise.all([import('node:fs'), import('node:path')]); - - return createNodeFileStorage(fs, path, { - cwd, - frameworkPackageName: '@clerk/react-router', - }); - } catch { - throw new Error( - 'Keyless mode requires a Node.js runtime with file system access. ' + - 'Set VITE_CLERK_KEYLESS_DISABLED=1 to disable keyless mode.', - ); - } +export function createFileStorage() { + return sharedCreateFileStorage({ + frameworkPackageName: '@clerk/react-router', + }); } diff --git a/packages/react-router/src/server/keyless/index.ts b/packages/react-router/src/server/keyless/index.ts index a0ec434b19a..a8804416fba 100644 --- a/packages/react-router/src/server/keyless/index.ts +++ b/packages/react-router/src/server/keyless/index.ts @@ -38,7 +38,7 @@ export async function keyless( keylessInitPromise = (async () => { try { - const storage = await createFileStorage(); + const storage = createFileStorage(); const service = createKeylessService({ storage, diff --git a/packages/shared/src/keyless/createFileStorage.ts b/packages/shared/src/keyless/createFileStorage.ts new file mode 100644 index 00000000000..6bc879117fc --- /dev/null +++ b/packages/shared/src/keyless/createFileStorage.ts @@ -0,0 +1,34 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { createNodeFileStorage } from './nodeFileStorage'; +import type { KeylessStorage } from './service'; + +export interface FileStorageOptions { + cwd?: () => string; + frameworkPackageName: string; +} + +/** + * Creates a file-based storage adapter for keyless mode. + * Uses Node.js filesystem modules - only call in Node.js runtimes. + * + * @param options - Configuration options including framework package name + * @returns KeylessStorage implementation + * @throws Error if filesystem modules are unavailable + */ +export function createFileStorage(options: FileStorageOptions): KeylessStorage { + const { cwd = () => process.cwd(), frameworkPackageName } = options; + + try { + return createNodeFileStorage(fs, path, { + cwd, + frameworkPackageName, + }); + } catch { + throw new Error( + `Keyless mode requires a Node.js runtime with file system access. ` + + `Set CLERK_KEYLESS_DISABLED=1 to disable keyless mode.`, + ); + } +} diff --git a/packages/shared/src/keyless/index.ts b/packages/shared/src/keyless/index.ts index 75e2cf16c91..0234e1ff0aa 100644 --- a/packages/shared/src/keyless/index.ts +++ b/packages/shared/src/keyless/index.ts @@ -6,6 +6,9 @@ export { } from './devCache'; export type { ClerkDevCache } from './devCache'; +export { createFileStorage } from './createFileStorage'; +export type { FileStorageOptions } from './createFileStorage'; + export { createNodeFileStorage } from './nodeFileStorage'; export type { FileSystemAdapter, NodeFileStorageOptions, PathAdapter } from './nodeFileStorage'; diff --git a/packages/tanstack-react-start/src/server/keyless/fileStorage.ts b/packages/tanstack-react-start/src/server/keyless/fileStorage.ts index 24929cc7ebd..8d4e92db7bb 100644 --- a/packages/tanstack-react-start/src/server/keyless/fileStorage.ts +++ b/packages/tanstack-react-start/src/server/keyless/fileStorage.ts @@ -1,19 +1,11 @@ -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, +import { createFileStorage as sharedCreateFileStorage } from '@clerk/shared/keyless'; +export type { KeylessStorage } from '@clerk/shared/keyless'; + +/** + * Creates a file-based storage adapter for keyless mode. + */ +export function createFileStorage() { + return sharedCreateFileStorage({ frameworkPackageName: '@clerk/tanstack-react-start', }); } From 57a804989f68ca5719dd6956b77961ad85070e03 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Sun, 8 Feb 2026 19:46:23 -0800 Subject: [PATCH 12/21] Add Keyless quickstart and refactor createFileStorage Introduce Keyless quickstart for React Router and extract createFileStorage to reduce code duplication. --- .changeset/curvy-jobs-pump.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/.changeset/curvy-jobs-pump.md b/.changeset/curvy-jobs-pump.md index eb888920b14..07eda25e738 100644 --- a/.changeset/curvy-jobs-pump.md +++ b/.changeset/curvy-jobs-pump.md @@ -5,5 +5,3 @@ --- Introduce Keyless quickstart for React Router. This allows the Clerk SDK to be used without having to sign up and paste your keys manually. - -Additionally, extract `createFileStorage` to `@clerk/shared/keyless` to reduce code duplication across Vite-based frameworks (TanStack Start and React Router now use the shared implementation). From 834c6eb439bfbba40904c7b8f1b66cbe2d522fdb Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Sun, 8 Feb 2026 20:37:47 -0800 Subject: [PATCH 13/21] chore: revert --- .changeset/curvy-jobs-pump.md | 2 -- .../src/server/keyless/fileStorage.ts | 30 ++++++++++++---- .../react-router/src/server/keyless/index.ts | 2 +- .../shared/src/keyless/createFileStorage.ts | 34 ------------------- packages/shared/src/keyless/index.ts | 3 -- .../src/server/keyless/fileStorage.ts | 24 ++++++++----- 6 files changed, 41 insertions(+), 54 deletions(-) delete mode 100644 packages/shared/src/keyless/createFileStorage.ts diff --git a/.changeset/curvy-jobs-pump.md b/.changeset/curvy-jobs-pump.md index 07eda25e738..3161921deae 100644 --- a/.changeset/curvy-jobs-pump.md +++ b/.changeset/curvy-jobs-pump.md @@ -1,7 +1,5 @@ --- "@clerk/react-router": minor -"@clerk/shared": patch -"@clerk/tanstack-react-start": patch --- Introduce Keyless quickstart for React Router. This allows the Clerk SDK to be used without having to sign up and paste your keys manually. diff --git a/packages/react-router/src/server/keyless/fileStorage.ts b/packages/react-router/src/server/keyless/fileStorage.ts index 6c7ee468ce6..35f2bfc2b69 100644 --- a/packages/react-router/src/server/keyless/fileStorage.ts +++ b/packages/react-router/src/server/keyless/fileStorage.ts @@ -1,11 +1,29 @@ -import { createFileStorage as sharedCreateFileStorage } from '@clerk/shared/keyless'; -export type { KeylessStorage } from '@clerk/shared/keyless'; +import { createNodeFileStorage, type KeylessStorage } from '@clerk/shared/keyless'; + +export type { KeylessStorage }; + +export interface FileStorageOptions { + cwd?: () => string; +} /** * Creates a file-based storage adapter for keyless mode. + * Uses dynamic imports to avoid bundler issues with edge runtimes. */ -export function createFileStorage() { - return sharedCreateFileStorage({ - frameworkPackageName: '@clerk/react-router', - }); +export async function createFileStorage(options: FileStorageOptions = {}): Promise { + const { cwd = () => process.cwd() } = options; + + try { + const [fs, path] = await Promise.all([import('node:fs'), import('node:path')]); + + return createNodeFileStorage(fs, path, { + cwd, + frameworkPackageName: '@clerk/react-router', + }); + } catch { + throw new Error( + 'Keyless mode requires a Node.js runtime with file system access. ' + + 'Set VITE_CLERK_KEYLESS_DISABLED=1 to disable keyless mode.', + ); + } } diff --git a/packages/react-router/src/server/keyless/index.ts b/packages/react-router/src/server/keyless/index.ts index a8804416fba..a0ec434b19a 100644 --- a/packages/react-router/src/server/keyless/index.ts +++ b/packages/react-router/src/server/keyless/index.ts @@ -38,7 +38,7 @@ export async function keyless( keylessInitPromise = (async () => { try { - const storage = createFileStorage(); + const storage = await createFileStorage(); const service = createKeylessService({ storage, diff --git a/packages/shared/src/keyless/createFileStorage.ts b/packages/shared/src/keyless/createFileStorage.ts deleted file mode 100644 index 6bc879117fc..00000000000 --- a/packages/shared/src/keyless/createFileStorage.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; - -import { createNodeFileStorage } from './nodeFileStorage'; -import type { KeylessStorage } from './service'; - -export interface FileStorageOptions { - cwd?: () => string; - frameworkPackageName: string; -} - -/** - * Creates a file-based storage adapter for keyless mode. - * Uses Node.js filesystem modules - only call in Node.js runtimes. - * - * @param options - Configuration options including framework package name - * @returns KeylessStorage implementation - * @throws Error if filesystem modules are unavailable - */ -export function createFileStorage(options: FileStorageOptions): KeylessStorage { - const { cwd = () => process.cwd(), frameworkPackageName } = options; - - try { - return createNodeFileStorage(fs, path, { - cwd, - frameworkPackageName, - }); - } catch { - throw new Error( - `Keyless mode requires a Node.js runtime with file system access. ` + - `Set CLERK_KEYLESS_DISABLED=1 to disable keyless mode.`, - ); - } -} diff --git a/packages/shared/src/keyless/index.ts b/packages/shared/src/keyless/index.ts index 0234e1ff0aa..75e2cf16c91 100644 --- a/packages/shared/src/keyless/index.ts +++ b/packages/shared/src/keyless/index.ts @@ -6,9 +6,6 @@ export { } from './devCache'; export type { ClerkDevCache } from './devCache'; -export { createFileStorage } from './createFileStorage'; -export type { FileStorageOptions } from './createFileStorage'; - export { createNodeFileStorage } from './nodeFileStorage'; export type { FileSystemAdapter, NodeFileStorageOptions, PathAdapter } from './nodeFileStorage'; diff --git a/packages/tanstack-react-start/src/server/keyless/fileStorage.ts b/packages/tanstack-react-start/src/server/keyless/fileStorage.ts index 8d4e92db7bb..24929cc7ebd 100644 --- a/packages/tanstack-react-start/src/server/keyless/fileStorage.ts +++ b/packages/tanstack-react-start/src/server/keyless/fileStorage.ts @@ -1,11 +1,19 @@ -import { createFileStorage as sharedCreateFileStorage } from '@clerk/shared/keyless'; -export type { KeylessStorage } from '@clerk/shared/keyless'; - -/** - * Creates a file-based storage adapter for keyless mode. - */ -export function createFileStorage() { - return sharedCreateFileStorage({ +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', }); } From 32bec512cf8c440f0235c9a078b86fbb2d6129c6 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 9 Feb 2026 10:28:04 -0800 Subject: [PATCH 14/21] chore: Make resolveKeysWithKeylessFallback function a method on the keyless service instead of a standalone function --- .../react-router/src/server/keyless/utils.ts | 19 ++-- packages/shared/src/keyless/index.ts | 5 +- .../keyless/resolveKeysWithKeylessFallback.ts | 87 ------------------- packages/shared/src/keyless/service.ts | 81 +++++++++++++++++ .../src/server/keyless/utils.ts | 15 +++- 5 files changed, 107 insertions(+), 100 deletions(-) delete mode 100644 packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts diff --git a/packages/react-router/src/server/keyless/utils.ts b/packages/react-router/src/server/keyless/utils.ts index 138a30d22cd..a401fe42e0b 100644 --- a/packages/react-router/src/server/keyless/utils.ts +++ b/packages/react-router/src/server/keyless/utils.ts @@ -1,4 +1,3 @@ -import { resolveKeysWithKeylessFallback as sharedResolveKeysWithKeylessFallback } from '@clerk/shared/keyless'; export type { KeylessResult } from '@clerk/shared/keyless'; import { canUseKeyless } from '../../utils/feature-flags'; @@ -16,10 +15,16 @@ export async function resolveKeysWithKeylessFallback( options?: ClerkMiddlewareOptions, ) { const keylessService = await keyless(args, options); - return sharedResolveKeysWithKeylessFallback( - configuredPublishableKey, - configuredSecretKey, - keylessService, - canUseKeyless, - ); + + // If keyless is not available or not enabled, return configured keys as-is + if (!keylessService || !canUseKeyless) { + return { + publishableKey: configuredPublishableKey, + secretKey: configuredSecretKey, + claimUrl: undefined, + apiKeysUrl: undefined, + }; + } + + return keylessService.resolveKeysWithKeylessFallback(configuredPublishableKey, configuredSecretKey); } diff --git a/packages/shared/src/keyless/index.ts b/packages/shared/src/keyless/index.ts index 75e2cf16c91..6e4301b8f81 100644 --- a/packages/shared/src/keyless/index.ts +++ b/packages/shared/src/keyless/index.ts @@ -10,9 +10,6 @@ export { createNodeFileStorage } from './nodeFileStorage'; export type { FileSystemAdapter, NodeFileStorageOptions, PathAdapter } from './nodeFileStorage'; export { createKeylessService } from './service'; -export type { KeylessAPI, KeylessService, KeylessServiceOptions, KeylessStorage } from './service'; - -export { resolveKeysWithKeylessFallback } from './resolveKeysWithKeylessFallback'; -export type { KeylessResult } from './resolveKeysWithKeylessFallback'; +export type { KeylessAPI, KeylessResult, KeylessService, KeylessServiceOptions, KeylessStorage } from './service'; export type { AccountlessApplication, PublicKeylessApplication } from './types'; diff --git a/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts b/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts deleted file mode 100644 index fc923ae2a2f..00000000000 --- a/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } from './devCache'; -import type { KeylessService } from './service'; -import type { AccountlessApplication } from './types'; - -export interface KeylessResult { - publishableKey: string | undefined; - secretKey: string | undefined; - claimUrl: string | undefined; - apiKeysUrl: string | undefined; -} - -/** - * 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 - * @param keylessService - The keyless service instance (or null if unavailable) - * @param canUseKeyless - Whether keyless mode is enabled in the current environment - * @returns The resolved keys (either configured or from keyless mode) - */ -export async function resolveKeysWithKeylessFallback( - configuredPublishableKey: string | undefined, - configuredSecretKey: string | undefined, - keylessService: KeylessService | null, - canUseKeyless: boolean, -): Promise { - let publishableKey = configuredPublishableKey; - let secretKey = configuredSecretKey; - let claimUrl: string | undefined; - let apiKeysUrl: string | undefined; - - if (!canUseKeyless) { - return { publishableKey, secretKey, claimUrl, apiKeysUrl }; - } - - if (!keylessService) { - return { publishableKey, secretKey, claimUrl, apiKeysUrl }; - } - - try { - const locallyStoredKeys = keylessService.readKeys(); - - // Check if running with claimed keys (configured keys match locally stored keyless keys) - const runningWithClaimedKeys = - 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(), - }); - - return { publishableKey, secretKey, claimUrl, apiKeysUrl }; - } - - // In keyless mode, try to read/create keys from the file system - if (!publishableKey && !secretKey) { - const keylessApp: AccountlessApplication | null = await keylessService.getOrCreateKeys(); - - if (keylessApp) { - publishableKey = keylessApp.publishableKey; - secretKey = keylessApp.secretKey; - claimUrl = keylessApp.claimUrl; - apiKeysUrl = keylessApp.apiKeysUrl; - - clerkDevelopmentCache?.log({ - cacheKey: keylessApp.publishableKey, - msg: createKeylessModeMessage(keylessApp), - }); - } - } - } catch { - // noop - fall through to return whatever keys we have - } - - return { publishableKey, secretKey, claimUrl, apiKeysUrl }; -} diff --git a/packages/shared/src/keyless/service.ts b/packages/shared/src/keyless/service.ts index 903c8fa65b8..20b989ff364 100644 --- a/packages/shared/src/keyless/service.ts +++ b/packages/shared/src/keyless/service.ts @@ -1,3 +1,4 @@ +import { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } from './devCache'; import type { AccountlessApplication } from './types'; /** @@ -75,6 +76,16 @@ export interface KeylessServiceOptions { frameworkVersion?: string; } +/** + * Result type for key resolution. + */ +export interface KeylessResult { + publishableKey: string | undefined; + secretKey: string | undefined; + claimUrl: string | undefined; + apiKeysUrl: string | undefined; +} + /** * The keyless service interface. */ @@ -104,6 +115,18 @@ export interface KeylessService { * Logs a keyless mode message to the console (throttled to once per process). */ logKeylessMessage: (claimUrl: string) => void; + + /** + * Resolves Clerk keys, falling back to keyless mode 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) + */ + resolveKeysWithKeylessFallback: ( + configuredPublishableKey: string | undefined, + configuredSecretKey: string | undefined, + ) => Promise; } /** @@ -202,5 +225,63 @@ export function createKeylessService(options: KeylessServiceOptions): KeylessSer console.log(`[Clerk]: Running in keyless mode. Claim your keys at: ${claimUrl}`); } }, + + async resolveKeysWithKeylessFallback( + configuredPublishableKey: string | undefined, + configuredSecretKey: string | undefined, + ): Promise { + let publishableKey = configuredPublishableKey; + let secretKey = configuredSecretKey; + let claimUrl: string | undefined; + let apiKeysUrl: string | undefined; + + try { + const locallyStoredKeys = safeParseConfig(); + + // Check if running with claimed keys (configured keys match locally stored keyless keys) + const runningWithClaimedKeys = + 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(), + }); + + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; + } + + // In keyless mode, try to read/create keys from the file system + if (!publishableKey && !secretKey) { + const keylessApp: AccountlessApplication | null = await this.getOrCreateKeys(); + + if (keylessApp) { + publishableKey = keylessApp.publishableKey; + secretKey = keylessApp.secretKey; + claimUrl = keylessApp.claimUrl; + apiKeysUrl = keylessApp.apiKeysUrl; + + clerkDevelopmentCache?.log({ + cacheKey: keylessApp.publishableKey, + msg: createKeylessModeMessage(keylessApp), + }); + } + } + } catch { + // noop - fall through to return whatever keys we have + } + + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; + }, }; } diff --git a/packages/tanstack-react-start/src/server/keyless/utils.ts b/packages/tanstack-react-start/src/server/keyless/utils.ts index ab4896cadb5..adc8e726f66 100644 --- a/packages/tanstack-react-start/src/server/keyless/utils.ts +++ b/packages/tanstack-react-start/src/server/keyless/utils.ts @@ -1,4 +1,3 @@ -import { resolveKeysWithKeylessFallback as sharedResolveKeysWithKeylessFallback } from '@clerk/shared/keyless'; export type { KeylessResult } from '@clerk/shared/keyless'; import { canUseKeyless } from '../../utils/feature-flags'; @@ -15,5 +14,17 @@ export function resolveKeysWithKeylessFallback( configuredPublishableKey: string | undefined, configuredSecretKey: string | undefined, ) { - return sharedResolveKeysWithKeylessFallback(configuredPublishableKey, configuredSecretKey, keyless(), canUseKeyless); + const keylessService = keyless(); + + // If keyless is not available or not enabled, return configured keys as-is + if (!keylessService || !canUseKeyless) { + return Promise.resolve({ + publishableKey: configuredPublishableKey, + secretKey: configuredSecretKey, + claimUrl: undefined, + apiKeysUrl: undefined, + }); + } + + return keylessService.resolveKeysWithKeylessFallback(configuredPublishableKey, configuredSecretKey); } From 1c39d21a4509594b0679b9193e93170cf4e47c8c Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Mon, 9 Feb 2026 16:39:51 -0800 Subject: [PATCH 15/21] fix(repo): Handle framework query param in keyless claim URLs integration tests (#7808) --- .changeset/wild-clocks-stare.md | 2 + .../tests/next-quickstart-keyless.test.ts | 30 ++++-- .../tests/tanstack-start/keyless.test.ts | 102 ++++++++++++++++-- 3 files changed, 114 insertions(+), 20 deletions(-) create mode 100644 .changeset/wild-clocks-stare.md diff --git a/.changeset/wild-clocks-stare.md b/.changeset/wild-clocks-stare.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/wild-clocks-stare.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/integration/tests/next-quickstart-keyless.test.ts b/integration/tests/next-quickstart-keyless.test.ts index 52c0e059f63..0e9c4f50a19 100644 --- a/integration/tests/next-quickstart-keyless.test.ts +++ b/integration/tests/next-quickstart-keyless.test.ts @@ -74,20 +74,28 @@ test.describe('Keyless mode @quickstart', () => { await newPage.waitForLoadState(); await newPage.waitForURL(url => { - const urlToReturnTo = `${dashboardUrl}apps/claim?token=`; - + const signInForceRedirectUrl = url.searchParams.get('sign_in_force_redirect_url'); const signUpForceRedirectUrl = url.searchParams.get('sign_up_force_redirect_url'); - const signUpForceRedirectUrlCheck = - signUpForceRedirectUrl?.startsWith(urlToReturnTo) || - (signUpForceRedirectUrl?.startsWith(`${dashboardUrl}prepare-account`) && - signUpForceRedirectUrl?.includes(encodeURIComponent('apps/claim?token='))); + const signInHasRequiredParams = + signInForceRedirectUrl?.includes(`${dashboardUrl}apps/claim`) && + signInForceRedirectUrl?.includes('token=') && + signInForceRedirectUrl?.includes('framework=nextjs'); + + const signUpRegularCase = + signUpForceRedirectUrl?.includes(`${dashboardUrl}apps/claim`) && + signUpForceRedirectUrl?.includes('token=') && + signUpForceRedirectUrl?.includes('framework=nextjs'); + + const signUpPrepareAccountCase = + signUpForceRedirectUrl?.startsWith(`${dashboardUrl}prepare-account`) && + signUpForceRedirectUrl?.includes(encodeURIComponent('apps/claim')) && + signUpForceRedirectUrl?.includes(encodeURIComponent('token=')) && + signUpForceRedirectUrl?.includes(encodeURIComponent('framework=nextjs')); + + const signUpHasRequiredParams = signUpRegularCase || signUpPrepareAccountCase; - return ( - url.pathname === '/apps/claim/sign-in' && - url.searchParams.get('sign_in_force_redirect_url')?.startsWith(urlToReturnTo) && - signUpForceRedirectUrlCheck - ); + return url.pathname === '/apps/claim/sign-in' && signInHasRequiredParams && signUpHasRequiredParams; }); }); diff --git a/integration/tests/tanstack-start/keyless.test.ts b/integration/tests/tanstack-start/keyless.test.ts index 78c52949563..284aca2f3e8 100644 --- a/integration/tests/tanstack-start/keyless.test.ts +++ b/integration/tests/tanstack-start/keyless.test.ts @@ -1,15 +1,27 @@ -import { test } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; import type { Application } from '../../models/application'; import { appConfigs } from '../../presets'; -import { - testClaimedAppWithMissingKeys, - testKeylessRemovedAfterEnvAndRestart, - testToggleCollapsePopoverAndClaim, -} from '../../testUtils/keylessHelpers'; +import { createTestUtils } from '../../testUtils'; const commonSetup = appConfigs.tanstack.reactStart.clone(); +const mockClaimedInstanceEnvironmentCall = async (page: Page) => { + await page.route('*/**/v1/environment*', async route => { + const response = await route.fetch(); + const json = await response.json(); + const newJson = { + ...json, + auth_config: { + ...json.auth_config, + claimed_at: Date.now(), + }, + }; + await route.fulfill({ response, json: newJson }); + }); +}; + test.describe('Keyless mode @tanstack-react-start', () => { test.describe.configure({ mode: 'serial' }); test.setTimeout(90_000); @@ -39,17 +51,89 @@ test.describe('Keyless mode @tanstack-react-start', () => { }); test('Toggle collapse popover and claim.', async ({ page, context }) => { - await testToggleCollapsePopoverAndClaim({ page, context, app, dashboardUrl }); + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + await u.po.expect.toBeSignedOut(); + + await u.po.keylessPopover.waitForMounted(); + + expect(await u.po.keylessPopover.isExpanded()).toBe(false); + await u.po.keylessPopover.toggle(); + expect(await u.po.keylessPopover.isExpanded()).toBe(true); + + const claim = await u.po.keylessPopover.promptsToClaim(); + + const [newPage] = await Promise.all([context.waitForEvent('page'), claim.click()]); + + await newPage.waitForLoadState(); + + await newPage.waitForURL(url => { + const signInForceRedirectUrl = url.searchParams.get('sign_in_force_redirect_url'); + const signUpForceRedirectUrl = url.searchParams.get('sign_up_force_redirect_url'); + + const signInHasRequiredParams = + signInForceRedirectUrl?.includes(`${dashboardUrl}apps/claim`) && + signInForceRedirectUrl?.includes('token=') && + signInForceRedirectUrl?.includes('framework=tanstack-react-start'); + + const signUpRegularCase = + signUpForceRedirectUrl?.includes(`${dashboardUrl}apps/claim`) && + signUpForceRedirectUrl?.includes('token=') && + signUpForceRedirectUrl?.includes('framework=tanstack-react-start'); + + const signUpPrepareAccountCase = + signUpForceRedirectUrl?.startsWith(`${dashboardUrl}prepare-account`) && + signUpForceRedirectUrl?.includes(encodeURIComponent('apps/claim')) && + signUpForceRedirectUrl?.includes(encodeURIComponent('token=')) && + signUpForceRedirectUrl?.includes(encodeURIComponent('framework=tanstack-react-start')); + + const signUpHasRequiredParams = signUpRegularCase || signUpPrepareAccountCase; + + return url.pathname === '/apps/claim/sign-in' && signInHasRequiredParams && signUpHasRequiredParams; + }); }); test('Lands on claimed application with missing explicit keys, expanded by default, click to get keys from dashboard.', async ({ page, context, }) => { - await testClaimedAppWithMissingKeys({ page, context, app, dashboardUrl }); + await mockClaimedInstanceEnvironmentCall(page); + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + + await u.po.keylessPopover.waitForMounted(); + expect(await u.po.keylessPopover.isExpanded()).toBe(true); + await expect(u.po.keylessPopover.promptToUseClaimedKeys()).toBeVisible(); + + const [newPage] = await Promise.all([ + context.waitForEvent('page'), + u.po.keylessPopover.promptToUseClaimedKeys().click(), + ]); + + await newPage.waitForLoadState(); + await newPage.waitForURL(url => { + return url.href.startsWith(`${dashboardUrl}sign-in?redirect_url=${encodeURIComponent(dashboardUrl)}apps%2Fapp_`); + }); }); test('Keyless popover is removed after adding keys to .env and restarting.', async ({ page, context }) => { - await testKeylessRemovedAfterEnvAndRestart({ page, context, app }); + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + + await u.po.keylessPopover.waitForMounted(); + expect(await u.po.keylessPopover.isExpanded()).toBe(false); + + // Copy keys from keyless.json to .env + await app.keylessToEnv(); + + // Restart the dev server to pick up new env vars (Vite doesn't hot-reload .env) + await app.restart(); + + await u.page.goToAppHome(); + + // Keyless popover should no longer be present since we now have explicit keys + await u.po.keylessPopover.waitForUnmounted(); }); }); From 6b04f9fef385997e5c1c1b8d3874f6268dd38d10 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Sun, 8 Feb 2026 14:10:19 -0800 Subject: [PATCH 16/21] chore: share main keyless fallback function --- docs/KEYLESS_MODE.md | 467 ++++++++++++++++++ integration/testUtils/keylessHelpers.ts | 32 +- .../tests/tanstack-start/keyless.test.ts | 102 +--- .../react-router/src/server/keyless/utils.ts | 19 +- packages/shared/src/keyless/index.ts | 3 + .../keyless/resolveKeysWithKeylessFallback.ts | 87 ++++ .../src/server/keyless/utils.ts | 15 +- 7 files changed, 596 insertions(+), 129 deletions(-) create mode 100644 docs/KEYLESS_MODE.md create mode 100644 packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts diff --git a/docs/KEYLESS_MODE.md b/docs/KEYLESS_MODE.md new file mode 100644 index 00000000000..15f90a3d89c --- /dev/null +++ b/docs/KEYLESS_MODE.md @@ -0,0 +1,467 @@ +# Clerk Keyless Mode: Cross-Framework Implementation + +## What is Keyless Mode? + +**Keyless mode** is a zero-configuration development experience that allows developers to start using Clerk without manually creating API keys. When a developer runs their app in development without configured keys, Clerk automatically: + +1. Generates temporary API keys (publishable + secret) +2. Creates a temporary "accountless application" +3. Displays a banner with a "claim URL" to associate keys with their Clerk account +4. Stores keys locally so they persist across restarts + +**Key benefits:** + +- Instant start for new developers (no dashboard visit required) +- Reduces onboarding friction +- Keys can be claimed later and associated with production account + +## Architecture Overview + +### Core Shared Code (`@clerk/shared/keyless`) + +**Location:** `packages/shared/src/keyless/` + +**Key files:** + +- `service.ts` - Core keyless service that communicates with backend +- `storage.ts` - Abstract storage interface for persisting keys +- `types.ts` - TypeScript types (AccountlessApplication, etc.) +- `messages.ts` - Console banner messages shown to developers + +**Key function:** + +```typescript +export function createKeylessService(options: KeylessServiceOptions): KeylessService { + const { storage, api, framework, frameworkVersion } = options; + + return { + async getOrCreateKeys(): Promise { + // Check local storage first + const existingKeys = storage.readKeys(); + if (existingKeys) return existingKeys; + + // Create headers with framework info + const headers = new Headers(); + if (framework) { + headers.set('Clerk-Framework', framework); // ← Sent to backend + } + + // Call backend to create new accountless application + const app = await api.createAccountlessApplication(headers); + storage.writeKeys(app); + return app; + }, + // ... other methods + }; +} +``` + +### Backend (Go) + +**Location:** `clerk_go/api/bapi/v1/accountless_applications/` + +**Endpoints:** + +- `POST /v1/accountless_applications` - Create new accountless app +- `POST /v1/accountless_applications/complete` - Mark onboarding complete + +**Key feature: Framework-aware claim URLs** + +```go +// Read framework from header +if framework := r.Header.Get("Clerk-Framework"); framework != "" { + params.Framework = &framework +} + +// Build claim URL with framework parameter +func buildClaimURL(token string, framework *string) (string, error) { + query.Add("token", token) + if framework != nil && *framework != "" { + query.Add("framework", *framework) // ← Dashboard uses this + } + return link.String(), nil +} +``` + +**Why this matters:** Dashboard reads the `framework` query param and shows correct environment variable names for each SDK (e.g., `PUBLIC_CLERK_PUBLISHABLE_KEY` for Astro vs `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` for Next.js). + +### Dashboard + +**Location:** `apps/dashboard/app/(routes)/apps/claim/page.tsx` + +**Already supports framework parameter:** + +```typescript +const { + token: claimToken, + framework: frameworkId = 'nextjs', // ← Defaults to Next.js if not provided +} = await searchParams; + +// Uses getKeyValueTemplate() to show framework-specific env vars + +``` + +## Framework-Specific Implementations + +Each framework has its own keyless wrapper due to different runtime environments, bundling requirements, and lifecycle hooks. + +### 1. Next.js (Original Implementation) + +**Location:** `packages/nextjs/src/server/keyless/` + +**Characteristics:** + +- Uses **conditional exports** (`#safe-node-apis`) for Node vs Edge runtime compatibility +- Storage: `createNodeFileStorage()` with `nodeFsOrThrow()`, `nodePathOrThrow()` +- Why: Server Actions bundle aggressively, need explicit runtime detection + +**Key files:** + +``` +keyless/ + index.ts # Keyless service singleton + fileStorage.ts # Node.js file storage with fs/path checks + utils.ts # resolveKeysWithKeylessFallback() +``` + +**Framework ID sent:** `'nextjs'` + +### 2. TanStack Start (Introduced Shared Keyless) + +**PR:** https://github.com/clerk/javascript/pull/7518 + +**Location:** `packages/tanstack-start/src/server/keyless/` + +**Characteristics:** + +- Uses **static imports** (works due to Vite tree-shaking) +- Storage: Direct import from `@clerk/shared/keyless/node` +- Simpler than Next.js since Vite handles dead code elimination better + +**Framework ID sent:** `'tanstack-react-start'` + +### 3. React Router + +**PR:** https://github.com/clerk/javascript/pull/7794 (branch: `rob/react-router-keyless`) + +**Location:** `packages/react-router/src/server/keyless/` + +**Characteristics:** + +- Uses **runtime checks** (`canUseFileSystem()`) for Cloudflare Workers support +- Storage: Conditional based on runtime environment +- Why: Must support both Node.js and Cloudflare Workers (no filesystem) + +**Framework ID sent:** `'react-router'` + +### 4. Astro (Current Implementation) + +**Branch:** `rob/astro-keyless-mode` + +**Location:** `packages/astro/src/server/keyless/` + +**Characteristics:** + +- Uses **dynamic imports** + `hasFileSystemSupport()` check +- Moved from compile-time (integration API) to runtime (middleware) +- Storage: `createNodeFileStorage()` imported dynamically + +**Framework ID sent:** `'astro'` (changed from `'@clerk/astro'`) + +**Major refactor completed:** + +- **Before:** Keyless resolved once at server startup, injected via `vite.define` +- **After:** Keyless resolved per-request in middleware, injected via script tag +- **Benefit:** No browser caching issues, instant updates when switching modes + +## Why Code Duplication Exists + +### What's Shared + +- Core keyless service logic (`createKeylessService`) +- Storage interfaces and Node.js implementation +- Type definitions +- Console messages + +### What's Per-Framework + +- **Keyless service wrapper** (`keyless()` function) + - Different singleton patterns + - Different runtime environment checks + +- **File storage creation** (`createFileStorage()`) + - Next.js: Conditional exports for Server Actions + - React Router: Runtime checks for Cloudflare Workers + - TanStack/Astro: Direct imports work fine + +- **Key resolution** (`resolveKeysWithKeylessFallback()`) + - Different integration points (middleware, API routes, etc.) + - Different context objects + - Framework-specific caching strategies + +**Conclusion:** The duplication is intentional and correct. Each framework has unique runtime constraints that require custom handling. + +## Common Patterns + +### Pattern 1: Feature Flag Check + +All frameworks check if keyless can be used: + +```typescript +export const canUseKeyless = + isDevelopmentEnvironment() && // Only in dev mode + !KEYLESS_DISABLED && // Not explicitly disabled + hasFileSystemSupport(); // Runtime has filesystem +``` + +### Pattern 2: Keyless Service Singleton + +```typescript +let keylessInstance: KeylessService | null = null; + +export async function keyless(): Promise { + if (!keylessInstance) { + const storage = await createFileStorage(); + keylessInstance = createKeylessService({ + storage, + api: clerkClient(), + framework: 'framework-name', // ← Framework-specific + frameworkVersion: PACKAGE_VERSION, + }); + } + return keylessInstance; +} +``` + +### Pattern 3: Key Resolution with Fallback + +```typescript +export async function resolveKeysWithKeylessFallback( + configuredPublishableKey: string | undefined, + configuredSecretKey: string | undefined, +): Promise { + // Early return if keyless not available + if (!canUseKeyless) { + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; + } + + // Skip keyless if both keys configured + if (publishableKey && secretKey) { + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; + } + + // Try keyless mode + const keylessService = await keyless(); + const keylessApp = await keylessService.getOrCreateKeys(); + + return { + publishableKey: keylessApp.publishableKey, + secretKey: keylessApp.secretKey, + claimUrl: keylessApp.claimUrl, + apiKeysUrl: keylessApp.apiKeysUrl, + }; +} +``` + +## Recent Improvements + +### 1. Framework-Aware Claim URLs (Completed) + +**Problem:** Dashboard always showed Next.js environment variable names, confusing for other frameworks. + +**Solution:** + +- SDKs send framework ID via `Clerk-Framework` header +- Backend appends to claim URL: `?token=xxx&framework=astro` +- Dashboard shows correct env vars for each framework + +**Implementation:** + +- Backend: Read header, append to claim URL +- All SDKs: Send correct framework ID (not package name) +- Tests: Added backend tests for framework parameter + +### 2. Astro Runtime Resolution (Completed) + +**Problem:** Compile-time injection via `vite.define` caused browser caching issues. + +**Solution:** + +- Moved keyless resolution from integration API to middleware +- Inject URLs via runtime script tag instead of `import.meta.env` +- Client reads from server-injected data + +**Benefits:** + +- No hard reload needed when switching keyless ↔ configured modes +- Matches patterns from TanStack Start and React Router +- Cleaner separation of concerns + +## File Organization + +``` +packages/ +├── shared/ +│ └── src/ +│ └── keyless/ +│ ├── service.ts # Core shared logic +│ ├── storage.ts # Abstract storage interface +│ ├── node.ts # Node.js file storage +│ ├── types.ts # Shared types +│ └── messages.ts # Console messages +│ +├── nextjs/ +│ └── src/ +│ └── server/ +│ └── keyless/ +│ ├── index.ts # Next.js wrapper +│ ├── fileStorage.ts # Conditional exports +│ └── utils.ts # Next.js key resolution +│ +├── tanstack-start/ +│ └── src/ +│ └── server/ +│ └── keyless/ +│ ├── index.ts # TanStack wrapper +│ └── utils.ts # TanStack key resolution +│ +├── react-router/ +│ └── src/ +│ └── server/ +│ └── keyless/ +│ ├── index.ts # React Router wrapper +│ ├── fileStorage.ts # Runtime checks +│ └── utils.ts # React Router key resolution +│ +└── astro/ + └── src/ + └── server/ + └── keyless/ + ├── index.ts # Astro wrapper + ├── fileStorage.ts # Dynamic imports + └── utils.ts # Astro key resolution +``` + +## Testing Strategy + +### Backend Tests + +**Location:** `clerk_go/tests/bapi/accountless_application_test.go` + +```go +func TestCreateAccountlessApplicationWithFrameworkHeader(t *testing.T) { + // Test that Clerk-Framework header is appended to claim URL +} + +func TestCreateAccountlessApplicationWithoutFrameworkHeader(t *testing.T) { + // Test backward compatibility without header +} +``` + +### SDK Tests (Per Framework) + +- Unit tests: Key resolution logic +- Integration tests: Keyless flow with/without configured keys +- E2E tests: Full dev flow with claim URL + +## Development Workflow + +### Testing Keyless Locally + +1. **Fresh start (no keys):** + +```bash +# Remove local storage +rm -rf .clerk/ + +# Remove env vars +rm .env.local # or comment out CLERK_* vars + +# Start dev server +npm run dev +``` + +Expected: Keyless banner appears with claim URL + +2. **Switching to configured keys:** + +```bash +# Add keys to .env.local +echo "PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxx" >> .env.local +echo "CLERK_SECRET_KEY=sk_test_xxx" >> .env.local + +# Refresh page (normal refresh, not hard reload) +``` + +Expected: Banner disappears immediately (no caching issues) + +3. **Claiming keys:** + +- Click claim URL in banner +- Dashboard shows framework-specific env vars +- Add claimed keys to `.env.local` +- Restart dev server +- See confirmation message + +## Key Differences Between Frameworks + +| Framework | Resolution Timing | Storage Method | Runtime Checks | Bundling Consideration | +| ---------------- | ------------------------ | ------------------- | ------------------------ | ---------------------- | +| **Next.js** | Server startup | Conditional exports | `nodeFsOrThrow()` | Server Actions | +| **TanStack** | Server startup | Static import | None needed | Vite tree-shaking | +| **React Router** | Server startup | Runtime conditional | `canUseFileSystem()` | Cloudflare Workers | +| **Astro** | Per-request (middleware) | Dynamic import | `hasFileSystemSupport()` | Vite + SSR adapters | + +## Environment Detection + +All frameworks check for development mode via: + +```typescript +// packages/shared/src/utils/runtimeEnvironment.ts +export const isDevelopmentEnvironment = (): boolean => { + try { + return process.env.NODE_ENV === 'development'; + } catch {} + + // TODO: add support for import.meta.env.DEV (Vite) + return false; +}; +``` + +**Note:** There's a TODO to support `import.meta.env.DEV` for Vite-based frameworks, but currently all frameworks use `process.env.NODE_ENV`. + +## Current Status + +### Completed + +- Next.js keyless (original implementation) +- TanStack Start keyless (PR #7518) +- React Router keyless (PR #7794, branch: `rob/react-router-keyless`) +- Astro keyless (branch: `rob/astro-keyless-mode`) +- Framework-aware claim URLs (backend + all SDKs) + +### In Progress + +- Astro keyless PR needs to be created and merged + +### Future Considerations + +- Support `import.meta.env.DEV` for Vite frameworks +- Potentially support keyless in preview/staging environments +- Consider keyless for other frameworks (Vue, Svelte, etc.) + +## Quick Reference: Framework IDs + +| SDK Package | Framework ID Sent | Env Var Prefix | +| ----------------------- | ---------------------- | -------------- | +| `@clerk/nextjs` | `nextjs` | `NEXT_PUBLIC_` | +| `@clerk/tanstack-start` | `tanstack-react-start` | `VITE_` | +| `@clerk/react-router` | `react-router` | `VITE_` | +| `@clerk/astro` | `astro` | `PUBLIC_` | + +**Important:** SDKs should send framework IDs (not package names). Backend passes these directly to dashboard without mapping. + +## Related Documentation + +- Astro env vars: https://docs.astro.build/en/guides/environment-variables/ +- Backend API spec: `clerk_go/api/bapi/v1/accountless_applications/` diff --git a/integration/testUtils/keylessHelpers.ts b/integration/testUtils/keylessHelpers.ts index 941885ce346..e4deaf5813f 100644 --- a/integration/testUtils/keylessHelpers.ts +++ b/integration/testUtils/keylessHelpers.ts @@ -31,11 +31,13 @@ export async function testToggleCollapsePopoverAndClaim({ context, app, dashboardUrl, + framework, }: { page: Page; context: BrowserContext; app: Application; dashboardUrl: string; + framework: string; }): Promise { const u = createTestUtils({ app, page, context }); await u.page.goToAppHome(); @@ -55,20 +57,28 @@ export async function testToggleCollapsePopoverAndClaim({ await newPage.waitForLoadState(); await newPage.waitForURL(url => { - const urlToReturnTo = `${dashboardUrl}apps/claim?token=`; - + const signInForceRedirectUrl = url.searchParams.get('sign_in_force_redirect_url'); const signUpForceRedirectUrl = url.searchParams.get('sign_up_force_redirect_url'); - const signUpForceRedirectUrlCheck = - signUpForceRedirectUrl?.startsWith(urlToReturnTo) || - (signUpForceRedirectUrl?.startsWith(`${dashboardUrl}prepare-account`) && - signUpForceRedirectUrl?.includes(encodeURIComponent('apps/claim?token='))); + const signInHasRequiredParams = + signInForceRedirectUrl?.includes(`${dashboardUrl}apps/claim`) && + signInForceRedirectUrl?.includes('token=') && + signInForceRedirectUrl?.includes(`framework=${framework}`); + + const signUpRegularCase = + signUpForceRedirectUrl?.includes(`${dashboardUrl}apps/claim`) && + signUpForceRedirectUrl?.includes('token=') && + signUpForceRedirectUrl?.includes(`framework=${framework}`); + + const signUpPrepareAccountCase = + signUpForceRedirectUrl?.startsWith(`${dashboardUrl}prepare-account`) && + signUpForceRedirectUrl?.includes(encodeURIComponent('apps/claim')) && + signUpForceRedirectUrl?.includes(encodeURIComponent('token=')) && + signUpForceRedirectUrl?.includes(encodeURIComponent(`framework=${framework}`)); + + const signUpHasRequiredParams = signUpRegularCase || signUpPrepareAccountCase; - return ( - url.pathname === '/apps/claim/sign-in' && - url.searchParams.get('sign_in_force_redirect_url')?.startsWith(urlToReturnTo) && - signUpForceRedirectUrlCheck - ); + return url.pathname === '/apps/claim/sign-in' && signInHasRequiredParams && signUpHasRequiredParams; }); } diff --git a/integration/tests/tanstack-start/keyless.test.ts b/integration/tests/tanstack-start/keyless.test.ts index 284aca2f3e8..27193ffa611 100644 --- a/integration/tests/tanstack-start/keyless.test.ts +++ b/integration/tests/tanstack-start/keyless.test.ts @@ -1,27 +1,15 @@ -import type { Page } from '@playwright/test'; -import { expect, test } from '@playwright/test'; +import { test } from '@playwright/test'; import type { Application } from '../../models/application'; import { appConfigs } from '../../presets'; -import { createTestUtils } from '../../testUtils'; +import { + testClaimedAppWithMissingKeys, + testKeylessRemovedAfterEnvAndRestart, + testToggleCollapsePopoverAndClaim, +} from '../../testUtils/keylessHelpers'; const commonSetup = appConfigs.tanstack.reactStart.clone(); -const mockClaimedInstanceEnvironmentCall = async (page: Page) => { - await page.route('*/**/v1/environment*', async route => { - const response = await route.fetch(); - const json = await response.json(); - const newJson = { - ...json, - auth_config: { - ...json.auth_config, - claimed_at: Date.now(), - }, - }; - await route.fulfill({ response, json: newJson }); - }); -}; - test.describe('Keyless mode @tanstack-react-start', () => { test.describe.configure({ mode: 'serial' }); test.setTimeout(90_000); @@ -51,89 +39,17 @@ test.describe('Keyless mode @tanstack-react-start', () => { }); test('Toggle collapse popover and claim.', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - await u.page.goToAppHome(); - await u.page.waitForClerkJsLoaded(); - await u.po.expect.toBeSignedOut(); - - await u.po.keylessPopover.waitForMounted(); - - expect(await u.po.keylessPopover.isExpanded()).toBe(false); - await u.po.keylessPopover.toggle(); - expect(await u.po.keylessPopover.isExpanded()).toBe(true); - - const claim = await u.po.keylessPopover.promptsToClaim(); - - const [newPage] = await Promise.all([context.waitForEvent('page'), claim.click()]); - - await newPage.waitForLoadState(); - - await newPage.waitForURL(url => { - const signInForceRedirectUrl = url.searchParams.get('sign_in_force_redirect_url'); - const signUpForceRedirectUrl = url.searchParams.get('sign_up_force_redirect_url'); - - const signInHasRequiredParams = - signInForceRedirectUrl?.includes(`${dashboardUrl}apps/claim`) && - signInForceRedirectUrl?.includes('token=') && - signInForceRedirectUrl?.includes('framework=tanstack-react-start'); - - const signUpRegularCase = - signUpForceRedirectUrl?.includes(`${dashboardUrl}apps/claim`) && - signUpForceRedirectUrl?.includes('token=') && - signUpForceRedirectUrl?.includes('framework=tanstack-react-start'); - - const signUpPrepareAccountCase = - signUpForceRedirectUrl?.startsWith(`${dashboardUrl}prepare-account`) && - signUpForceRedirectUrl?.includes(encodeURIComponent('apps/claim')) && - signUpForceRedirectUrl?.includes(encodeURIComponent('token=')) && - signUpForceRedirectUrl?.includes(encodeURIComponent('framework=tanstack-react-start')); - - const signUpHasRequiredParams = signUpRegularCase || signUpPrepareAccountCase; - - return url.pathname === '/apps/claim/sign-in' && signInHasRequiredParams && signUpHasRequiredParams; - }); + await testToggleCollapsePopoverAndClaim({ page, context, app, dashboardUrl, framework: 'tanstack-react-start' }); }); test('Lands on claimed application with missing explicit keys, expanded by default, click to get keys from dashboard.', async ({ page, context, }) => { - await mockClaimedInstanceEnvironmentCall(page); - const u = createTestUtils({ app, page, context }); - await u.page.goToAppHome(); - await u.page.waitForClerkJsLoaded(); - - await u.po.keylessPopover.waitForMounted(); - expect(await u.po.keylessPopover.isExpanded()).toBe(true); - await expect(u.po.keylessPopover.promptToUseClaimedKeys()).toBeVisible(); - - const [newPage] = await Promise.all([ - context.waitForEvent('page'), - u.po.keylessPopover.promptToUseClaimedKeys().click(), - ]); - - await newPage.waitForLoadState(); - await newPage.waitForURL(url => { - return url.href.startsWith(`${dashboardUrl}sign-in?redirect_url=${encodeURIComponent(dashboardUrl)}apps%2Fapp_`); - }); + await testClaimedAppWithMissingKeys({ page, context, app, dashboardUrl }); }); test('Keyless popover is removed after adding keys to .env and restarting.', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - await u.page.goToAppHome(); - - await u.po.keylessPopover.waitForMounted(); - expect(await u.po.keylessPopover.isExpanded()).toBe(false); - - // Copy keys from keyless.json to .env - await app.keylessToEnv(); - - // Restart the dev server to pick up new env vars (Vite doesn't hot-reload .env) - await app.restart(); - - await u.page.goToAppHome(); - - // Keyless popover should no longer be present since we now have explicit keys - await u.po.keylessPopover.waitForUnmounted(); + await testKeylessRemovedAfterEnvAndRestart({ page, context, app }); }); }); diff --git a/packages/react-router/src/server/keyless/utils.ts b/packages/react-router/src/server/keyless/utils.ts index a401fe42e0b..138a30d22cd 100644 --- a/packages/react-router/src/server/keyless/utils.ts +++ b/packages/react-router/src/server/keyless/utils.ts @@ -1,3 +1,4 @@ +import { resolveKeysWithKeylessFallback as sharedResolveKeysWithKeylessFallback } from '@clerk/shared/keyless'; export type { KeylessResult } from '@clerk/shared/keyless'; import { canUseKeyless } from '../../utils/feature-flags'; @@ -15,16 +16,10 @@ export async function resolveKeysWithKeylessFallback( options?: ClerkMiddlewareOptions, ) { const keylessService = await keyless(args, options); - - // If keyless is not available or not enabled, return configured keys as-is - if (!keylessService || !canUseKeyless) { - return { - publishableKey: configuredPublishableKey, - secretKey: configuredSecretKey, - claimUrl: undefined, - apiKeysUrl: undefined, - }; - } - - return keylessService.resolveKeysWithKeylessFallback(configuredPublishableKey, configuredSecretKey); + return sharedResolveKeysWithKeylessFallback( + configuredPublishableKey, + configuredSecretKey, + keylessService, + canUseKeyless, + ); } diff --git a/packages/shared/src/keyless/index.ts b/packages/shared/src/keyless/index.ts index 6e4301b8f81..f1cf62a323e 100644 --- a/packages/shared/src/keyless/index.ts +++ b/packages/shared/src/keyless/index.ts @@ -12,4 +12,7 @@ export type { FileSystemAdapter, NodeFileStorageOptions, PathAdapter } from './n export { createKeylessService } from './service'; export type { KeylessAPI, KeylessResult, KeylessService, KeylessServiceOptions, KeylessStorage } from './service'; +export { resolveKeysWithKeylessFallback } from './resolveKeysWithKeylessFallback'; +export type { KeylessResult } from './resolveKeysWithKeylessFallback'; + export type { AccountlessApplication, PublicKeylessApplication } from './types'; diff --git a/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts b/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts new file mode 100644 index 00000000000..fc923ae2a2f --- /dev/null +++ b/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts @@ -0,0 +1,87 @@ +import { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } from './devCache'; +import type { KeylessService } from './service'; +import type { AccountlessApplication } from './types'; + +export interface KeylessResult { + publishableKey: string | undefined; + secretKey: string | undefined; + claimUrl: string | undefined; + apiKeysUrl: string | undefined; +} + +/** + * 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 + * @param keylessService - The keyless service instance (or null if unavailable) + * @param canUseKeyless - Whether keyless mode is enabled in the current environment + * @returns The resolved keys (either configured or from keyless mode) + */ +export async function resolveKeysWithKeylessFallback( + configuredPublishableKey: string | undefined, + configuredSecretKey: string | undefined, + keylessService: KeylessService | null, + canUseKeyless: boolean, +): Promise { + let publishableKey = configuredPublishableKey; + let secretKey = configuredSecretKey; + let claimUrl: string | undefined; + let apiKeysUrl: string | undefined; + + if (!canUseKeyless) { + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; + } + + if (!keylessService) { + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; + } + + try { + const locallyStoredKeys = keylessService.readKeys(); + + // Check if running with claimed keys (configured keys match locally stored keyless keys) + const runningWithClaimedKeys = + 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(), + }); + + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; + } + + // In keyless mode, try to read/create keys from the file system + if (!publishableKey && !secretKey) { + const keylessApp: AccountlessApplication | null = await keylessService.getOrCreateKeys(); + + if (keylessApp) { + publishableKey = keylessApp.publishableKey; + secretKey = keylessApp.secretKey; + claimUrl = keylessApp.claimUrl; + apiKeysUrl = keylessApp.apiKeysUrl; + + clerkDevelopmentCache?.log({ + cacheKey: keylessApp.publishableKey, + msg: createKeylessModeMessage(keylessApp), + }); + } + } + } catch { + // noop - fall through to return whatever keys we have + } + + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; +} diff --git a/packages/tanstack-react-start/src/server/keyless/utils.ts b/packages/tanstack-react-start/src/server/keyless/utils.ts index adc8e726f66..ab4896cadb5 100644 --- a/packages/tanstack-react-start/src/server/keyless/utils.ts +++ b/packages/tanstack-react-start/src/server/keyless/utils.ts @@ -1,3 +1,4 @@ +import { resolveKeysWithKeylessFallback as sharedResolveKeysWithKeylessFallback } from '@clerk/shared/keyless'; export type { KeylessResult } from '@clerk/shared/keyless'; import { canUseKeyless } from '../../utils/feature-flags'; @@ -14,17 +15,5 @@ export function resolveKeysWithKeylessFallback( configuredPublishableKey: string | undefined, configuredSecretKey: string | undefined, ) { - const keylessService = keyless(); - - // If keyless is not available or not enabled, return configured keys as-is - if (!keylessService || !canUseKeyless) { - return Promise.resolve({ - publishableKey: configuredPublishableKey, - secretKey: configuredSecretKey, - claimUrl: undefined, - apiKeysUrl: undefined, - }); - } - - return keylessService.resolveKeysWithKeylessFallback(configuredPublishableKey, configuredSecretKey); + return sharedResolveKeysWithKeylessFallback(configuredPublishableKey, configuredSecretKey, keyless(), canUseKeyless); } From 567b5a527d357eb7126c00197ac6946a63c82a22 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 9 Feb 2026 17:09:30 -0800 Subject: [PATCH 17/21] refactor: use shared helper for Next.js keyless test Uses the testToggleCollapsePopoverAndClaim helper function instead of inline implementation for consistency with other framework tests. --- .../tests/next-quickstart-keyless.test.ts | 44 +------------------ 1 file changed, 2 insertions(+), 42 deletions(-) diff --git a/integration/tests/next-quickstart-keyless.test.ts b/integration/tests/next-quickstart-keyless.test.ts index 0e9c4f50a19..ff4fac4e5a9 100644 --- a/integration/tests/next-quickstart-keyless.test.ts +++ b/integration/tests/next-quickstart-keyless.test.ts @@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test'; import type { Application } from '../models/application'; import { appConfigs } from '../presets'; import { createTestUtils } from '../testUtils'; -import { mockClaimedInstanceEnvironmentCall } from '../testUtils/keylessHelpers'; +import { mockClaimedInstanceEnvironmentCall, testToggleCollapsePopoverAndClaim } from '../testUtils/keylessHelpers'; const commonSetup = appConfigs.next.appRouterQuickstart.clone(); @@ -56,47 +56,7 @@ test.describe('Keyless mode @quickstart', () => { }); test('Toggle collapse popover and claim.', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - await u.page.goToAppHome(); - await u.page.waitForClerkJsLoaded(); - await u.po.expect.toBeSignedOut(); - - await u.po.keylessPopover.waitForMounted(); - - expect(await u.po.keylessPopover.isExpanded()).toBe(false); - await u.po.keylessPopover.toggle(); - expect(await u.po.keylessPopover.isExpanded()).toBe(true); - - const claim = await u.po.keylessPopover.promptsToClaim(); - - const [newPage] = await Promise.all([context.waitForEvent('page'), claim.click()]); - - await newPage.waitForLoadState(); - - await newPage.waitForURL(url => { - const signInForceRedirectUrl = url.searchParams.get('sign_in_force_redirect_url'); - const signUpForceRedirectUrl = url.searchParams.get('sign_up_force_redirect_url'); - - const signInHasRequiredParams = - signInForceRedirectUrl?.includes(`${dashboardUrl}apps/claim`) && - signInForceRedirectUrl?.includes('token=') && - signInForceRedirectUrl?.includes('framework=nextjs'); - - const signUpRegularCase = - signUpForceRedirectUrl?.includes(`${dashboardUrl}apps/claim`) && - signUpForceRedirectUrl?.includes('token=') && - signUpForceRedirectUrl?.includes('framework=nextjs'); - - const signUpPrepareAccountCase = - signUpForceRedirectUrl?.startsWith(`${dashboardUrl}prepare-account`) && - signUpForceRedirectUrl?.includes(encodeURIComponent('apps/claim')) && - signUpForceRedirectUrl?.includes(encodeURIComponent('token=')) && - signUpForceRedirectUrl?.includes(encodeURIComponent('framework=nextjs')); - - const signUpHasRequiredParams = signUpRegularCase || signUpPrepareAccountCase; - - return url.pathname === '/apps/claim/sign-in' && signInHasRequiredParams && signUpHasRequiredParams; - }); + await testToggleCollapsePopoverAndClaim({ page, context, app, dashboardUrl, framework: 'nextjs' }); }); test('Lands on claimed application with missing explicit keys, expanded by default, click to get keys from dashboard.', async ({ From 0049b7fb93aa3c8f14fc30f23762f9e7bfd17e6e Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 9 Feb 2026 17:17:13 -0800 Subject: [PATCH 18/21] delete doc --- docs/KEYLESS_MODE.md | 467 ------------------------------------------- 1 file changed, 467 deletions(-) delete mode 100644 docs/KEYLESS_MODE.md diff --git a/docs/KEYLESS_MODE.md b/docs/KEYLESS_MODE.md deleted file mode 100644 index 15f90a3d89c..00000000000 --- a/docs/KEYLESS_MODE.md +++ /dev/null @@ -1,467 +0,0 @@ -# Clerk Keyless Mode: Cross-Framework Implementation - -## What is Keyless Mode? - -**Keyless mode** is a zero-configuration development experience that allows developers to start using Clerk without manually creating API keys. When a developer runs their app in development without configured keys, Clerk automatically: - -1. Generates temporary API keys (publishable + secret) -2. Creates a temporary "accountless application" -3. Displays a banner with a "claim URL" to associate keys with their Clerk account -4. Stores keys locally so they persist across restarts - -**Key benefits:** - -- Instant start for new developers (no dashboard visit required) -- Reduces onboarding friction -- Keys can be claimed later and associated with production account - -## Architecture Overview - -### Core Shared Code (`@clerk/shared/keyless`) - -**Location:** `packages/shared/src/keyless/` - -**Key files:** - -- `service.ts` - Core keyless service that communicates with backend -- `storage.ts` - Abstract storage interface for persisting keys -- `types.ts` - TypeScript types (AccountlessApplication, etc.) -- `messages.ts` - Console banner messages shown to developers - -**Key function:** - -```typescript -export function createKeylessService(options: KeylessServiceOptions): KeylessService { - const { storage, api, framework, frameworkVersion } = options; - - return { - async getOrCreateKeys(): Promise { - // Check local storage first - const existingKeys = storage.readKeys(); - if (existingKeys) return existingKeys; - - // Create headers with framework info - const headers = new Headers(); - if (framework) { - headers.set('Clerk-Framework', framework); // ← Sent to backend - } - - // Call backend to create new accountless application - const app = await api.createAccountlessApplication(headers); - storage.writeKeys(app); - return app; - }, - // ... other methods - }; -} -``` - -### Backend (Go) - -**Location:** `clerk_go/api/bapi/v1/accountless_applications/` - -**Endpoints:** - -- `POST /v1/accountless_applications` - Create new accountless app -- `POST /v1/accountless_applications/complete` - Mark onboarding complete - -**Key feature: Framework-aware claim URLs** - -```go -// Read framework from header -if framework := r.Header.Get("Clerk-Framework"); framework != "" { - params.Framework = &framework -} - -// Build claim URL with framework parameter -func buildClaimURL(token string, framework *string) (string, error) { - query.Add("token", token) - if framework != nil && *framework != "" { - query.Add("framework", *framework) // ← Dashboard uses this - } - return link.String(), nil -} -``` - -**Why this matters:** Dashboard reads the `framework` query param and shows correct environment variable names for each SDK (e.g., `PUBLIC_CLERK_PUBLISHABLE_KEY` for Astro vs `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` for Next.js). - -### Dashboard - -**Location:** `apps/dashboard/app/(routes)/apps/claim/page.tsx` - -**Already supports framework parameter:** - -```typescript -const { - token: claimToken, - framework: frameworkId = 'nextjs', // ← Defaults to Next.js if not provided -} = await searchParams; - -// Uses getKeyValueTemplate() to show framework-specific env vars - -``` - -## Framework-Specific Implementations - -Each framework has its own keyless wrapper due to different runtime environments, bundling requirements, and lifecycle hooks. - -### 1. Next.js (Original Implementation) - -**Location:** `packages/nextjs/src/server/keyless/` - -**Characteristics:** - -- Uses **conditional exports** (`#safe-node-apis`) for Node vs Edge runtime compatibility -- Storage: `createNodeFileStorage()` with `nodeFsOrThrow()`, `nodePathOrThrow()` -- Why: Server Actions bundle aggressively, need explicit runtime detection - -**Key files:** - -``` -keyless/ - index.ts # Keyless service singleton - fileStorage.ts # Node.js file storage with fs/path checks - utils.ts # resolveKeysWithKeylessFallback() -``` - -**Framework ID sent:** `'nextjs'` - -### 2. TanStack Start (Introduced Shared Keyless) - -**PR:** https://github.com/clerk/javascript/pull/7518 - -**Location:** `packages/tanstack-start/src/server/keyless/` - -**Characteristics:** - -- Uses **static imports** (works due to Vite tree-shaking) -- Storage: Direct import from `@clerk/shared/keyless/node` -- Simpler than Next.js since Vite handles dead code elimination better - -**Framework ID sent:** `'tanstack-react-start'` - -### 3. React Router - -**PR:** https://github.com/clerk/javascript/pull/7794 (branch: `rob/react-router-keyless`) - -**Location:** `packages/react-router/src/server/keyless/` - -**Characteristics:** - -- Uses **runtime checks** (`canUseFileSystem()`) for Cloudflare Workers support -- Storage: Conditional based on runtime environment -- Why: Must support both Node.js and Cloudflare Workers (no filesystem) - -**Framework ID sent:** `'react-router'` - -### 4. Astro (Current Implementation) - -**Branch:** `rob/astro-keyless-mode` - -**Location:** `packages/astro/src/server/keyless/` - -**Characteristics:** - -- Uses **dynamic imports** + `hasFileSystemSupport()` check -- Moved from compile-time (integration API) to runtime (middleware) -- Storage: `createNodeFileStorage()` imported dynamically - -**Framework ID sent:** `'astro'` (changed from `'@clerk/astro'`) - -**Major refactor completed:** - -- **Before:** Keyless resolved once at server startup, injected via `vite.define` -- **After:** Keyless resolved per-request in middleware, injected via script tag -- **Benefit:** No browser caching issues, instant updates when switching modes - -## Why Code Duplication Exists - -### What's Shared - -- Core keyless service logic (`createKeylessService`) -- Storage interfaces and Node.js implementation -- Type definitions -- Console messages - -### What's Per-Framework - -- **Keyless service wrapper** (`keyless()` function) - - Different singleton patterns - - Different runtime environment checks - -- **File storage creation** (`createFileStorage()`) - - Next.js: Conditional exports for Server Actions - - React Router: Runtime checks for Cloudflare Workers - - TanStack/Astro: Direct imports work fine - -- **Key resolution** (`resolveKeysWithKeylessFallback()`) - - Different integration points (middleware, API routes, etc.) - - Different context objects - - Framework-specific caching strategies - -**Conclusion:** The duplication is intentional and correct. Each framework has unique runtime constraints that require custom handling. - -## Common Patterns - -### Pattern 1: Feature Flag Check - -All frameworks check if keyless can be used: - -```typescript -export const canUseKeyless = - isDevelopmentEnvironment() && // Only in dev mode - !KEYLESS_DISABLED && // Not explicitly disabled - hasFileSystemSupport(); // Runtime has filesystem -``` - -### Pattern 2: Keyless Service Singleton - -```typescript -let keylessInstance: KeylessService | null = null; - -export async function keyless(): Promise { - if (!keylessInstance) { - const storage = await createFileStorage(); - keylessInstance = createKeylessService({ - storage, - api: clerkClient(), - framework: 'framework-name', // ← Framework-specific - frameworkVersion: PACKAGE_VERSION, - }); - } - return keylessInstance; -} -``` - -### Pattern 3: Key Resolution with Fallback - -```typescript -export async function resolveKeysWithKeylessFallback( - configuredPublishableKey: string | undefined, - configuredSecretKey: string | undefined, -): Promise { - // Early return if keyless not available - if (!canUseKeyless) { - return { publishableKey, secretKey, claimUrl, apiKeysUrl }; - } - - // Skip keyless if both keys configured - if (publishableKey && secretKey) { - return { publishableKey, secretKey, claimUrl, apiKeysUrl }; - } - - // Try keyless mode - const keylessService = await keyless(); - const keylessApp = await keylessService.getOrCreateKeys(); - - return { - publishableKey: keylessApp.publishableKey, - secretKey: keylessApp.secretKey, - claimUrl: keylessApp.claimUrl, - apiKeysUrl: keylessApp.apiKeysUrl, - }; -} -``` - -## Recent Improvements - -### 1. Framework-Aware Claim URLs (Completed) - -**Problem:** Dashboard always showed Next.js environment variable names, confusing for other frameworks. - -**Solution:** - -- SDKs send framework ID via `Clerk-Framework` header -- Backend appends to claim URL: `?token=xxx&framework=astro` -- Dashboard shows correct env vars for each framework - -**Implementation:** - -- Backend: Read header, append to claim URL -- All SDKs: Send correct framework ID (not package name) -- Tests: Added backend tests for framework parameter - -### 2. Astro Runtime Resolution (Completed) - -**Problem:** Compile-time injection via `vite.define` caused browser caching issues. - -**Solution:** - -- Moved keyless resolution from integration API to middleware -- Inject URLs via runtime script tag instead of `import.meta.env` -- Client reads from server-injected data - -**Benefits:** - -- No hard reload needed when switching keyless ↔ configured modes -- Matches patterns from TanStack Start and React Router -- Cleaner separation of concerns - -## File Organization - -``` -packages/ -├── shared/ -│ └── src/ -│ └── keyless/ -│ ├── service.ts # Core shared logic -│ ├── storage.ts # Abstract storage interface -│ ├── node.ts # Node.js file storage -│ ├── types.ts # Shared types -│ └── messages.ts # Console messages -│ -├── nextjs/ -│ └── src/ -│ └── server/ -│ └── keyless/ -│ ├── index.ts # Next.js wrapper -│ ├── fileStorage.ts # Conditional exports -│ └── utils.ts # Next.js key resolution -│ -├── tanstack-start/ -│ └── src/ -│ └── server/ -│ └── keyless/ -│ ├── index.ts # TanStack wrapper -│ └── utils.ts # TanStack key resolution -│ -├── react-router/ -│ └── src/ -│ └── server/ -│ └── keyless/ -│ ├── index.ts # React Router wrapper -│ ├── fileStorage.ts # Runtime checks -│ └── utils.ts # React Router key resolution -│ -└── astro/ - └── src/ - └── server/ - └── keyless/ - ├── index.ts # Astro wrapper - ├── fileStorage.ts # Dynamic imports - └── utils.ts # Astro key resolution -``` - -## Testing Strategy - -### Backend Tests - -**Location:** `clerk_go/tests/bapi/accountless_application_test.go` - -```go -func TestCreateAccountlessApplicationWithFrameworkHeader(t *testing.T) { - // Test that Clerk-Framework header is appended to claim URL -} - -func TestCreateAccountlessApplicationWithoutFrameworkHeader(t *testing.T) { - // Test backward compatibility without header -} -``` - -### SDK Tests (Per Framework) - -- Unit tests: Key resolution logic -- Integration tests: Keyless flow with/without configured keys -- E2E tests: Full dev flow with claim URL - -## Development Workflow - -### Testing Keyless Locally - -1. **Fresh start (no keys):** - -```bash -# Remove local storage -rm -rf .clerk/ - -# Remove env vars -rm .env.local # or comment out CLERK_* vars - -# Start dev server -npm run dev -``` - -Expected: Keyless banner appears with claim URL - -2. **Switching to configured keys:** - -```bash -# Add keys to .env.local -echo "PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxx" >> .env.local -echo "CLERK_SECRET_KEY=sk_test_xxx" >> .env.local - -# Refresh page (normal refresh, not hard reload) -``` - -Expected: Banner disappears immediately (no caching issues) - -3. **Claiming keys:** - -- Click claim URL in banner -- Dashboard shows framework-specific env vars -- Add claimed keys to `.env.local` -- Restart dev server -- See confirmation message - -## Key Differences Between Frameworks - -| Framework | Resolution Timing | Storage Method | Runtime Checks | Bundling Consideration | -| ---------------- | ------------------------ | ------------------- | ------------------------ | ---------------------- | -| **Next.js** | Server startup | Conditional exports | `nodeFsOrThrow()` | Server Actions | -| **TanStack** | Server startup | Static import | None needed | Vite tree-shaking | -| **React Router** | Server startup | Runtime conditional | `canUseFileSystem()` | Cloudflare Workers | -| **Astro** | Per-request (middleware) | Dynamic import | `hasFileSystemSupport()` | Vite + SSR adapters | - -## Environment Detection - -All frameworks check for development mode via: - -```typescript -// packages/shared/src/utils/runtimeEnvironment.ts -export const isDevelopmentEnvironment = (): boolean => { - try { - return process.env.NODE_ENV === 'development'; - } catch {} - - // TODO: add support for import.meta.env.DEV (Vite) - return false; -}; -``` - -**Note:** There's a TODO to support `import.meta.env.DEV` for Vite-based frameworks, but currently all frameworks use `process.env.NODE_ENV`. - -## Current Status - -### Completed - -- Next.js keyless (original implementation) -- TanStack Start keyless (PR #7518) -- React Router keyless (PR #7794, branch: `rob/react-router-keyless`) -- Astro keyless (branch: `rob/astro-keyless-mode`) -- Framework-aware claim URLs (backend + all SDKs) - -### In Progress - -- Astro keyless PR needs to be created and merged - -### Future Considerations - -- Support `import.meta.env.DEV` for Vite frameworks -- Potentially support keyless in preview/staging environments -- Consider keyless for other frameworks (Vue, Svelte, etc.) - -## Quick Reference: Framework IDs - -| SDK Package | Framework ID Sent | Env Var Prefix | -| ----------------------- | ---------------------- | -------------- | -| `@clerk/nextjs` | `nextjs` | `NEXT_PUBLIC_` | -| `@clerk/tanstack-start` | `tanstack-react-start` | `VITE_` | -| `@clerk/react-router` | `react-router` | `VITE_` | -| `@clerk/astro` | `astro` | `PUBLIC_` | - -**Important:** SDKs should send framework IDs (not package names). Backend passes these directly to dashboard without mapping. - -## Related Documentation - -- Astro env vars: https://docs.astro.build/en/guides/environment-variables/ -- Backend API spec: `clerk_go/api/bapi/v1/accountless_applications/` From 2e71569d7dc75bf2727e22467834924d3db3ed48 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 9 Feb 2026 18:13:25 -0800 Subject: [PATCH 19/21] chore: add missing framework requirement --- integration/tests/react-router/keyless.test.ts | 2 +- integration/tests/tanstack-start/keyless.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/integration/tests/react-router/keyless.test.ts b/integration/tests/react-router/keyless.test.ts index aafb23cdcda..a2605806778 100644 --- a/integration/tests/react-router/keyless.test.ts +++ b/integration/tests/react-router/keyless.test.ts @@ -39,7 +39,7 @@ test.describe('Keyless mode @react-router', () => { }); test('Toggle collapse popover and claim.', async ({ page, context }) => { - await testToggleCollapsePopoverAndClaim({ page, context, app, dashboardUrl }); + 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 ({ diff --git a/integration/tests/tanstack-start/keyless.test.ts b/integration/tests/tanstack-start/keyless.test.ts index aafb23cdcda..a2605806778 100644 --- a/integration/tests/tanstack-start/keyless.test.ts +++ b/integration/tests/tanstack-start/keyless.test.ts @@ -39,7 +39,7 @@ test.describe('Keyless mode @react-router', () => { }); test('Toggle collapse popover and claim.', async ({ page, context }) => { - await testToggleCollapsePopoverAndClaim({ page, context, app, dashboardUrl }); + 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 ({ From ab36fa8776d36cbb1d2932d26f68850e6f8f3f3b Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 9 Feb 2026 18:14:08 -0800 Subject: [PATCH 20/21] chore: remove redundant export --- packages/shared/src/keyless/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/shared/src/keyless/index.ts b/packages/shared/src/keyless/index.ts index f1cf62a323e..68f909dac3c 100644 --- a/packages/shared/src/keyless/index.ts +++ b/packages/shared/src/keyless/index.ts @@ -13,6 +13,5 @@ export { createKeylessService } from './service'; export type { KeylessAPI, KeylessResult, KeylessService, KeylessServiceOptions, KeylessStorage } from './service'; export { resolveKeysWithKeylessFallback } from './resolveKeysWithKeylessFallback'; -export type { KeylessResult } from './resolveKeysWithKeylessFallback'; export type { AccountlessApplication, PublicKeylessApplication } from './types'; From cda1649e3915443b539b88ab7ae0a307b5e5f126 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 9 Feb 2026 19:00:21 -0800 Subject: [PATCH 21/21] fix type errors --- packages/shared/src/keyless/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/keyless/index.ts b/packages/shared/src/keyless/index.ts index 68f909dac3c..75e2cf16c91 100644 --- a/packages/shared/src/keyless/index.ts +++ b/packages/shared/src/keyless/index.ts @@ -10,8 +10,9 @@ export { createNodeFileStorage } from './nodeFileStorage'; export type { FileSystemAdapter, NodeFileStorageOptions, PathAdapter } from './nodeFileStorage'; export { createKeylessService } from './service'; -export type { KeylessAPI, KeylessResult, KeylessService, KeylessServiceOptions, KeylessStorage } from './service'; +export type { KeylessAPI, KeylessService, KeylessServiceOptions, KeylessStorage } from './service'; export { resolveKeysWithKeylessFallback } from './resolveKeysWithKeylessFallback'; +export type { KeylessResult } from './resolveKeysWithKeylessFallback'; export type { AccountlessApplication, PublicKeylessApplication } from './types';