From 8b52217ef495064d63afe29b7c21cf47bda86199 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Fri, 19 Dec 2025 15:51:26 -0600 Subject: [PATCH 1/4] abstract out keyless logic into shared, use shared helpers --- .../src/app-router/client/ClerkProvider.tsx | 8 - .../nextjs/src/app-router/keyless-actions.ts | 17 +- .../src/app-router/server/ClerkProvider.tsx | 9 - .../app-router/server/keyless-provider.tsx | 17 +- .../src/server/keyless-custom-headers.ts | 150 --------- .../nextjs/src/server/keyless-log-cache.ts | 74 +---- packages/nextjs/src/server/keyless-node.ts | 304 ++++++++---------- .../nextjs/src/server/keyless-telemetry.ts | 197 ------------ packages/nextjs/src/utils/feature-flags.ts | 5 +- packages/shared/docs/use-clerk.md | 15 - packages/shared/docs/use-session-list.md | 24 -- packages/shared/docs/use-session.md | 28 -- packages/shared/docs/use-user.md | 81 ----- packages/shared/package.json | 10 + packages/shared/src/__tests__/keyless.spec.ts | 140 ++++++++ packages/shared/src/keyless/devCache.ts | 109 +++++++ packages/shared/src/keyless/index.ts | 12 + packages/shared/src/keyless/service.ts | 206 ++++++++++++ packages/shared/src/keyless/types.ts | 15 + packages/shared/tsdown.config.mts | 1 + 20 files changed, 643 insertions(+), 779 deletions(-) delete mode 100644 packages/nextjs/src/server/keyless-custom-headers.ts delete mode 100644 packages/nextjs/src/server/keyless-telemetry.ts delete mode 100644 packages/shared/docs/use-clerk.md delete mode 100644 packages/shared/docs/use-session-list.md delete mode 100644 packages/shared/docs/use-session.md delete mode 100644 packages/shared/docs/use-user.md create mode 100644 packages/shared/src/__tests__/keyless.spec.ts create mode 100644 packages/shared/src/keyless/devCache.ts create mode 100644 packages/shared/src/keyless/index.ts create mode 100644 packages/shared/src/keyless/service.ts create mode 100644 packages/shared/src/keyless/types.ts diff --git a/packages/nextjs/src/app-router/client/ClerkProvider.tsx b/packages/nextjs/src/app-router/client/ClerkProvider.tsx index 262a0c68b80..ee9d6d6c6fe 100644 --- a/packages/nextjs/src/app-router/client/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/client/ClerkProvider.tsx @@ -12,7 +12,6 @@ import { ClerkScripts } from '../../utils/clerk-script'; import { canUseKeyless } from '../../utils/feature-flags'; import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv'; import { RouterTelemetry } from '../../utils/router-telemetry'; -import { detectKeylessEnvDriftAction } from '../keyless-actions'; import { invalidateCacheAction } from '../server-actions'; import { useAwaitablePush } from './useAwaitablePush'; import { useAwaitableReplace } from './useAwaitableReplace'; @@ -31,13 +30,6 @@ const NextClientClerkProvider = (props: NextClerkProviderPr const push = useAwaitablePush(); const replace = useAwaitableReplace(); - // Call drift detection on mount (client-side) - useSafeLayoutEffect(() => { - if (canUseKeyless) { - void detectKeylessEnvDriftAction(); - } - }, []); - // Avoid rendering nested ClerkProviders by checking for the existence of the ClerkNextOptions context provider const isNested = Boolean(useClerkNextOptions()); if (isNested) { diff --git a/packages/nextjs/src/app-router/keyless-actions.ts b/packages/nextjs/src/app-router/keyless-actions.ts index 3b9b1558388..90c88cb3f4f 100644 --- a/packages/nextjs/src/app-router/keyless-actions.ts +++ b/packages/nextjs/src/app-router/keyless-actions.ts @@ -54,7 +54,7 @@ export async function createOrReadKeylessAction(): Promise m.createOrReadKeyless()).catch(() => null); + const result = await import('../server/keyless-node.js').then(m => m.keyless().getOrCreateKeys()).catch(() => null); if (!result) { errorThrower.throwMissingPublishableKeyError(); @@ -90,19 +90,6 @@ export async function deleteKeylessAction() { return; } - await import('../server/keyless-node.js').then(m => m.removeKeyless()).catch(() => {}); + await import('../server/keyless-node.js').then(m => m.keyless().removeKeys()).catch(() => {}); return; } - -export async function detectKeylessEnvDriftAction() { - if (!canUseKeyless) { - return; - } - - try { - const { detectKeylessEnvDrift } = await import('../server/keyless-telemetry.js'); - await detectKeylessEnvDrift(); - } catch { - // ignore - } -} diff --git a/packages/nextjs/src/app-router/server/ClerkProvider.tsx b/packages/nextjs/src/app-router/server/ClerkProvider.tsx index ecb49a099f0..295c80809d8 100644 --- a/packages/nextjs/src/app-router/server/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/server/ClerkProvider.tsx @@ -55,15 +55,6 @@ export async function ClerkProvider( let output: ReactNode; - try { - const detectKeylessEnvDrift = await import('../../server/keyless-telemetry.js').then( - mod => mod.detectKeylessEnvDrift, - ); - await detectKeylessEnvDrift(); - } catch { - // ignore - } - if (shouldRunAsKeyless) { output = ( mod.safeParseClerkFile()?.publishableKey || '') + .then(mod => mod.keyless().readKeys()?.publishableKey || '') .catch(() => ''); runningWithClaimedKeys = Boolean(params.publishableKey) && params.publishableKey === locallyStoredPublishableKey; @@ -44,7 +43,7 @@ export const KeylessProvider = async (props: KeylessProviderProps) => { // NOTE: Create or read keys on every render. Usually this means only on hard refresh or hard navigations. const newOrReadKeys = await import('../../server/keyless-node.js') - .then(mod => mod.createOrReadKeyless()) + .then(mod => mod.keyless().getOrCreateKeys()) .catch(() => null); const { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } = await import( @@ -84,7 +83,7 @@ export const KeylessProvider = async (props: KeylessProviderProps) => { if (runningWithClaimedKeys) { try { - const secretKey = await import('../../server/keyless-node.js').then(mod => mod.safeParseClerkFile()?.secretKey); + const secretKey = await import('../../server/keyless-node.js').then(mod => mod.keyless().readKeys()?.secretKey); if (!secretKey) { // we will ignore it later throw new Error('Missing secret key from `.clerk/`'); @@ -93,20 +92,12 @@ export const KeylessProvider = async (props: KeylessProviderProps) => { secretKey, }); - // Collect metadata - const keylessHeaders = await collectKeylessMetadata() - .then(formatMetadataHeaders) - .catch(() => new Headers()); - /** * Notifying the dashboard the should runs once. We are controlling this behaviour by caching the result of the request. * If the request fails, it will be considered stale after 10 minutes, otherwise it is cached for 24 hours. */ await clerkDevelopmentCache?.run( - () => - client.__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({ - requestHeaders: keylessHeaders, - }), + () => client.__experimental_accountlessApplications.completeAccountlessApplicationOnboarding(), { cacheKey: `${newOrReadKeys.publishableKey}_complete`, onSuccessStale: 24 * 60 * 60 * 1000, // 24 hours diff --git a/packages/nextjs/src/server/keyless-custom-headers.ts b/packages/nextjs/src/server/keyless-custom-headers.ts deleted file mode 100644 index 73ca03837b6..00000000000 --- a/packages/nextjs/src/server/keyless-custom-headers.ts +++ /dev/null @@ -1,150 +0,0 @@ -'use server'; - -import { headers } from 'next/headers'; - -interface MetadataHeaders { - nodeVersion?: string; - nextVersion?: string; - npmConfigUserAgent?: string; - userAgent: string; - port?: string; - host: string; - xHost: string; - xPort: string; - xProtocol: string; - xClerkAuthStatus: string; - isCI: boolean; -} - -/** - * Collects metadata from the environment and request headers - */ -export async function collectKeylessMetadata(): Promise { - const headerStore = await headers(); - - return { - nodeVersion: process.version, - nextVersion: getNextVersion(), - npmConfigUserAgent: process.env.npm_config_user_agent, // eslint-disable-line - userAgent: headerStore.get('User-Agent') ?? 'unknown user-agent', - port: process.env.PORT, // eslint-disable-line - host: headerStore.get('host') ?? 'unknown host', - xPort: headerStore.get('x-forwarded-port') ?? 'unknown x-forwarded-port', - xHost: headerStore.get('x-forwarded-host') ?? 'unknown x-forwarded-host', - xProtocol: headerStore.get('x-forwarded-proto') ?? 'unknown x-forwarded-proto', - xClerkAuthStatus: headerStore.get('x-clerk-auth-status') ?? 'unknown x-clerk-auth-status', - isCI: detectCIEnvironment(), - }; -} - -// Common CI environment variables -const CI_ENV_VARS = [ - 'CI', - 'CONTINUOUS_INTEGRATION', - 'BUILD_NUMBER', - 'BUILD_ID', - 'BUILDKITE', - 'CIRCLECI', - 'GITHUB_ACTIONS', - 'GITLAB_CI', - 'JENKINS_URL', - 'TRAVIS', - 'APPVEYOR', - 'WERCKER', - 'DRONE', - 'CODESHIP', - 'SEMAPHORE', - 'SHIPPABLE', - 'TEAMCITY_VERSION', - 'BAMBOO_BUILDKEY', - 'GO_PIPELINE_NAME', - 'TF_BUILD', - 'SYSTEM_TEAMFOUNDATIONCOLLECTIONURI', - 'BITBUCKET_BUILD_NUMBER', - 'HEROKU_TEST_RUN_ID', - 'VERCEL', - 'NETLIFY', -]; - -/** - * Detects if the application is running in a CI environment - */ -function detectCIEnvironment(): boolean { - const ciIndicators = CI_ENV_VARS; - - const falsyValues = new Set(['', 'false', '0', 'no']); - - return ciIndicators.some(indicator => { - const value = process.env[indicator]; - if (value === undefined) { - return false; - } - - const normalizedValue = value.trim().toLowerCase(); - return !falsyValues.has(normalizedValue); - }); -} - -/** - * Extracts Next.js version from process title - */ -function getNextVersion(): string | undefined { - try { - return process.title ?? 'unknown-process-title'; // 'next-server (v15.4.5)' - } catch { - return undefined; - } -} - -/** - * Converts metadata to HTTP headers - */ -export async function formatMetadataHeaders(metadata: MetadataHeaders): Promise { - const headers = new Headers(); - - if (metadata.nodeVersion) { - headers.set('Clerk-Node-Version', metadata.nodeVersion); - } - - if (metadata.nextVersion) { - headers.set('Clerk-Next-Version', metadata.nextVersion); - } - - if (metadata.npmConfigUserAgent) { - headers.set('Clerk-NPM-Config-User-Agent', metadata.npmConfigUserAgent); - } - - if (metadata.userAgent) { - headers.set('Clerk-Client-User-Agent', metadata.userAgent); - } - - if (metadata.port) { - headers.set('Clerk-Node-Port', metadata.port); - } - - if (metadata.host) { - headers.set('Clerk-Client-Host', metadata.host); - } - - if (metadata.xPort) { - headers.set('Clerk-X-Port', metadata.xPort); - } - - if (metadata.xHost) { - headers.set('Clerk-X-Host', metadata.xHost); - } - - if (metadata.xProtocol) { - headers.set('Clerk-X-Protocol', metadata.xProtocol); - } - - if (metadata.xClerkAuthStatus) { - headers.set('Clerk-Auth-Status', metadata.xClerkAuthStatus); - } - - if (metadata.isCI) { - headers.set('Clerk-Is-CI', 'true'); - } - - return headers; -} diff --git a/packages/nextjs/src/server/keyless-log-cache.ts b/packages/nextjs/src/server/keyless-log-cache.ts index 5a0624227a6..7e0fdb90e34 100644 --- a/packages/nextjs/src/server/keyless-log-cache.ts +++ b/packages/nextjs/src/server/keyless-log-cache.ts @@ -1,64 +1,10 @@ -import type { AccountlessApplication } from '@clerk/backend'; -import { isDevelopmentEnvironment } from '@clerk/shared/utils'; -// 10 minutes in milliseconds -const THROTTLE_DURATION_MS = 10 * 60 * 1000; - -function createClerkDevCache() { - if (!isDevelopmentEnvironment()) { - return; - } - - if (!global.__clerk_internal_keyless_logger) { - global.__clerk_internal_keyless_logger = { - __cache: new Map(), - - log: function ({ cacheKey, msg }) { - if (this.__cache.has(cacheKey) && Date.now() < (this.__cache.get(cacheKey)?.expiresAt || 0)) { - return; - } - - console.log(msg); - - this.__cache.set(cacheKey, { - expiresAt: Date.now() + THROTTLE_DURATION_MS, - }); - }, - run: async function ( - callback, - { cacheKey, onSuccessStale = THROTTLE_DURATION_MS, onErrorStale = THROTTLE_DURATION_MS }, - ) { - if (this.__cache.has(cacheKey) && Date.now() < (this.__cache.get(cacheKey)?.expiresAt || 0)) { - return this.__cache.get(cacheKey)?.data; - } - - try { - const result = await callback(); - - this.__cache.set(cacheKey, { - expiresAt: Date.now() + onSuccessStale, - data: result, - }); - return result; - } catch (e) { - this.__cache.set(cacheKey, { - expiresAt: Date.now() + onErrorStale, - }); - - throw e; - } - }, - }; - } - - return globalThis.__clerk_internal_keyless_logger; -} - -export const createKeylessModeMessage = (keys: AccountlessApplication) => { - return `\n\x1b[35m\n[Clerk]:\x1b[0m You are running in keyless mode.\nYou can \x1b[35mclaim your keys\x1b[0m by visiting ${keys.claimUrl}\n`; -}; - -export const createConfirmationMessage = () => { - return `\n\x1b[35m\n[Clerk]:\x1b[0m Your application is running with your claimed keys.\nYou can safely remove the \x1b[35m.clerk/\x1b[0m from your project.\n`; -}; - -export const clerkDevelopmentCache = createClerkDevCache(); +/** + * Re-export keyless development cache utilities from shared. + * This maintains backward compatibility with existing imports. + */ +export { + clerkDevelopmentCache, + createClerkDevCache, + createConfirmationMessage, + createKeylessModeMessage, +} from '@clerk/shared/keyless'; diff --git a/packages/nextjs/src/server/keyless-node.ts b/packages/nextjs/src/server/keyless-node.ts index 3dbf9887165..4d92e74f099 100644 --- a/packages/nextjs/src/server/keyless-node.ts +++ b/packages/nextjs/src/server/keyless-node.ts @@ -1,200 +1,158 @@ -import type { AccountlessApplication } from '@clerk/backend'; +import { createKeylessService, type KeylessStorage } from '@clerk/shared/keyless'; import { createClerkClientWithOptions } from './createClerkClient'; import { nodeCwdOrThrow, nodeFsOrThrow, nodePathOrThrow } from './fs/utils'; -import { collectKeylessMetadata, formatMetadataHeaders } from './keyless-custom-headers'; -/** - * The Clerk-specific directory name. - */ const CLERK_HIDDEN = '.clerk'; - -/** - * The Clerk-specific lock file that is used to mitigate multiple key creation. - * This is automatically cleaned up. - */ const CLERK_LOCK = 'clerk.lock'; +const TEMP_DIR_NAME = '.tmp'; +const CONFIG_FILE = 'keyless.json'; +const README_FILE = 'README.md'; -/** - * The `.clerk/` directory is NOT safe to be committed as it may include sensitive information about a Clerk instance. - * It may include an instance's secret key and the secret token for claiming that instance. - */ -function updateGitignore() { - const { existsSync, writeFileSync, readFileSync, appendFileSync } = nodeFsOrThrow(); - +function createFileStorage(): KeylessStorage { + const fs = nodeFsOrThrow(); const path = nodePathOrThrow(); const cwd = nodeCwdOrThrow(); - const gitignorePath = path.join(cwd(), '.gitignore'); - if (!existsSync(gitignorePath)) { - writeFileSync(gitignorePath, ''); - } - // Check if `.clerk/` entry exists in .gitignore - const gitignoreContent = readFileSync(gitignorePath, 'utf-8'); - const COMMENT = `# clerk configuration (can include secrets)`; - if (!gitignoreContent.includes(CLERK_HIDDEN + '/')) { - appendFileSync(gitignorePath, `\n${COMMENT}\n/${CLERK_HIDDEN}/\n`); - } -} + let inMemoryLock = false; -const generatePath = (...slugs: string[]) => { - const path = nodePathOrThrow(); - const cwd = nodeCwdOrThrow(); - return path.join(cwd(), CLERK_HIDDEN, ...slugs); -}; - -const _TEMP_DIR_NAME = '.tmp'; -const getKeylessConfigurationPath = () => generatePath(_TEMP_DIR_NAME, 'keyless.json'); -const getKeylessReadMePath = () => generatePath(_TEMP_DIR_NAME, 'README.md'); + const getClerkDir = () => path.join(cwd(), CLERK_HIDDEN); + const getTempDir = () => path.join(getClerkDir(), TEMP_DIR_NAME); + const getConfigPath = () => path.join(getTempDir(), CONFIG_FILE); + const getReadmePath = () => path.join(getTempDir(), README_FILE); + const getLockPath = () => path.join(cwd(), CLERK_LOCK); -let isCreatingFile = false; + const isLocked = (): boolean => inMemoryLock || fs.existsSync(getLockPath()); -export function safeParseClerkFile(): AccountlessApplication | undefined { - const { readFileSync } = nodeFsOrThrow(); - try { - const CONFIG_PATH = getKeylessConfigurationPath(); - let fileAsString; + const lock = (): boolean => { + if (isLocked()) { + return false; + } + inMemoryLock = true; try { - fileAsString = readFileSync(CONFIG_PATH, { encoding: 'utf-8' }) || '{}'; + fs.writeFileSync(getLockPath(), 'This file can be deleted if your app is stuck.', { + encoding: 'utf8', + mode: 0o644, + }); + return true; } catch { - fileAsString = '{}'; + inMemoryLock = false; + return false; } - return JSON.parse(fileAsString) as AccountlessApplication; - } catch { - return undefined; - } -} - -/** - * Using both an in-memory and file system lock seems to be the most effective solution. - */ -const lockFileWriting = () => { - const { writeFileSync } = nodeFsOrThrow(); - - isCreatingFile = true; - - writeFileSync( - CLERK_LOCK, - // In the rare case, the file persists give the developer enough context. - 'This file can be deleted. Please delete this file and refresh your application', - { - encoding: 'utf8', - mode: '0777', - flag: 'w', - }, - ); -}; - -const unlockFileWriting = () => { - const { rmSync } = nodeFsOrThrow(); - - try { - rmSync(CLERK_LOCK, { force: true, recursive: true }); - } catch { - // Simply ignore if the removal of the directory/file fails - } - - isCreatingFile = false; -}; - -const isFileWritingLocked = () => { - const { existsSync } = nodeFsOrThrow(); - return isCreatingFile || existsSync(CLERK_LOCK); -}; - -async function createOrReadKeyless(): Promise { - const { writeFileSync, mkdirSync } = nodeFsOrThrow(); - - /** - * If another request is already in the process of acquiring keys return early. - * Using both an in-memory and file system lock seems to be the most effective solution. - */ - if (isFileWritingLocked()) { - return null; - } - - lockFileWriting(); + }; - const CONFIG_PATH = getKeylessConfigurationPath(); - const README_PATH = getKeylessReadMePath(); - - mkdirSync(generatePath(_TEMP_DIR_NAME), { recursive: true }); - updateGitignore(); + const unlock = (): void => { + inMemoryLock = false; + try { + if (fs.existsSync(getLockPath())) { + fs.rmSync(getLockPath(), { force: true }); + } + } catch { + // Ignore + } + }; - /** - * When the configuration file exists, always read the keys from the file - */ - const envVarsMap = safeParseClerkFile(); - if (envVarsMap?.publishableKey && envVarsMap?.secretKey) { - unlockFileWriting(); + const ensureDirectoryExists = () => { + const tempDir = getTempDir(); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + }; - return envVarsMap; - } + const updateGitignore = () => { + const gitignorePath = path.join(cwd(), '.gitignore'); + const entry = `/${CLERK_HIDDEN}/`; - /** - * At this step, it is safe to create new keys and store them. - */ - const client = createClerkClientWithOptions({}); - - // Collect metadata - const keylessHeaders = await collectKeylessMetadata() - .then(formatMetadataHeaders) - .catch(() => new Headers()); - - const accountlessApplication = await client.__experimental_accountlessApplications - .createAccountlessApplication({ requestHeaders: keylessHeaders }) - .catch(() => null); - - if (accountlessApplication) { - writeFileSync(CONFIG_PATH, JSON.stringify(accountlessApplication), { - encoding: 'utf8', - mode: '0777', - flag: 'w', - }); + if (!fs.existsSync(gitignorePath)) { + fs.writeFileSync(gitignorePath, '', { encoding: 'utf8', mode: 0o644 }); + } - // TODO-KEYLESS: Add link to official documentation. - const README_NOTIFICATION = ` -## DO NOT COMMIT -This directory is auto-generated from \`@clerk/nextjs\` because you are running in Keyless mode. Avoid committing the \`.clerk/\` directory as it includes the secret key of the unclaimed instance. - `; + const content = fs.readFileSync(gitignorePath, 'utf-8'); + if (!content.includes(entry)) { + fs.appendFileSync(gitignorePath, `\n# clerk configuration (can include secrets)\n${entry}\n`); + } + }; + + const writeReadme = () => { + const readme = `## DO NOT COMMIT +This directory is auto-generated from \`@clerk/nextjs\` because you are running in Keyless mode. +Avoid committing the \`.clerk/\` directory as it includes the secret key of the unclaimed instance. +`; + fs.writeFileSync(getReadmePath(), readme, { encoding: 'utf8', mode: 0o600 }); + }; + + return { + read(): string { + try { + if (!fs.existsSync(getConfigPath())) { + return ''; + } + return fs.readFileSync(getConfigPath(), { encoding: 'utf-8' }); + } catch { + return ''; + } + }, - writeFileSync(README_PATH, README_NOTIFICATION, { - encoding: 'utf8', - mode: '0777', - flag: 'w', - }); - } - /** - * Clean up locks. - */ - unlockFileWriting(); + write(data: string): void { + if (!lock()) { + return; + } + try { + ensureDirectoryExists(); + updateGitignore(); + writeReadme(); + fs.writeFileSync(getConfigPath(), data, { encoding: 'utf8', mode: 0o600 }); + } finally { + unlock(); + } + }, - return accountlessApplication; + remove(): void { + if (!lock()) { + return; + } + try { + if (fs.existsSync(getClerkDir())) { + fs.rmSync(getClerkDir(), { recursive: true, force: true }); + } + } finally { + unlock(); + } + }, + }; } -function removeKeyless() { - const { rmSync } = nodeFsOrThrow(); - - /** - * If another request is already in the process of acquiring keys return early. - * Using both an in-memory and file system lock seems to be the most effective solution. - */ - if (isFileWritingLocked()) { - return undefined; - } - - lockFileWriting(); - - try { - rmSync(generatePath(), { force: true, recursive: true }); - } catch { - // Simply ignore if the removal of the directory/file fails +// Lazily initialized keyless service singleton +let keylessServiceInstance: ReturnType | null = null; + +export function keyless() { + if (!keylessServiceInstance) { + const client = createClerkClientWithOptions({}); + + keylessServiceInstance = createKeylessService({ + storage: createFileStorage(), + api: { + async createAccountlessApplication(requestHeaders?: Headers) { + try { + return await client.__experimental_accountlessApplications.createAccountlessApplication({ + requestHeaders, + }); + } catch { + return null; + } + }, + async completeOnboarding(requestHeaders?: Headers) { + try { + return await client.__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({ + requestHeaders, + }); + } catch { + return null; + } + }, + }, + framework: 'nextjs', + }); } - - /** - * Clean up locks. - */ - unlockFileWriting(); + return keylessServiceInstance; } - -export { createOrReadKeyless, removeKeyless }; diff --git a/packages/nextjs/src/server/keyless-telemetry.ts b/packages/nextjs/src/server/keyless-telemetry.ts deleted file mode 100644 index 72b0fb4f3fc..00000000000 --- a/packages/nextjs/src/server/keyless-telemetry.ts +++ /dev/null @@ -1,197 +0,0 @@ -import type { TelemetryEventRaw } from '@clerk/shared/types'; - -import { canUseKeyless } from '../utils/feature-flags'; -import { createClerkClientWithOptions } from './createClerkClient'; -import { nodeFsOrThrow, nodePathOrThrow } from './fs/utils'; - -const EVENT_KEYLESS_ENV_DRIFT_DETECTED = 'KEYLESS_ENV_DRIFT_DETECTED'; -const EVENT_SAMPLING_RATE = 1; // 100% sampling rate -const TELEMETRY_FLAG_FILE = '.clerk/.tmp/telemetry.json'; - -type EventKeylessEnvDriftPayload = { - publicKeyMatch: boolean; - secretKeyMatch: boolean; - envVarsMissing: boolean; - keylessFileHasKeys: boolean; - keylessPublishableKey: string; - envPublishableKey: string; -}; - -/** - * Gets the absolute path to the telemetry flag file. - * - * This file is used to track whether telemetry events have already been fired - * to prevent duplicate event reporting during the application lifecycle. - * - * @returns The absolute path to the telemetry flag file in the project's .clerk/.tmp directory - */ -function getTelemetryFlagFilePath(): string { - const path = nodePathOrThrow(); - return path.join(process.cwd(), TELEMETRY_FLAG_FILE); -} - -/** - * Attempts to create a telemetry flag file to mark that a telemetry event has been fired. - * - * This function uses the 'wx' flag to create the file atomically - it will only succeed - * if the file doesn't already exist. This ensures that telemetry events are only fired - * once per application lifecycle, preventing duplicate event reporting. - * - * @returns Promise - Returns true if the flag file was successfully created (meaning - * the event should be fired), false if the file already exists (meaning the event was - * already fired) or if there was an error creating the file - */ -function tryMarkTelemetryEventAsFired(): boolean { - try { - if (canUseKeyless) { - const { mkdirSync, writeFileSync } = nodeFsOrThrow(); - const path = nodePathOrThrow(); - const flagFilePath = getTelemetryFlagFilePath(); - const flagDirectory = path.dirname(flagFilePath); - - // Ensure the directory exists before attempting to write the file - mkdirSync(flagDirectory, { recursive: true }); - - const flagData = { - firedAt: new Date().toISOString(), - event: EVENT_KEYLESS_ENV_DRIFT_DETECTED, - }; - writeFileSync(flagFilePath, JSON.stringify(flagData, null, 2), { flag: 'wx' }); - return true; - } else { - return false; - } - } catch (error: unknown) { - if ((error as { code?: string })?.code === 'EEXIST') { - return false; - } - console.warn('Failed to create telemetry flag file:', error); - return false; - } -} - -/** - * Detects and reports environment drift between keyless configuration and environment variables. - * - * This function compares the Clerk keys stored in the keyless configuration file (.clerk/clerk.json) - * with the keys set in environment variables (NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY). - * It only reports drift when there's an actual mismatch between existing keys, not when keys are simply missing. - * - * The function handles several scenarios and only reports drift in specific cases: - * - **Normal keyless mode**: env vars missing but keyless file has keys → no drift (expected) - * - **No configuration**: neither env vars nor keyless file have keys → no drift (nothing to compare) - * - **Actual drift**: env vars exist and don't match keyless file keys → drift detected - * - **Empty keyless file**: keyless file exists but has no keys → no drift (nothing to compare) - * - * Drift is only detected when: - * 1. Both environment variables and keyless file contain keys - * 2. The keys in environment variables don't match the keys in the keyless file - * - * Telemetry events are only fired once per application lifecycle using a flag file mechanism - * to prevent duplicate reporting. - * - * @returns Promise - Function completes silently, errors are logged but don't throw - */ -export async function detectKeylessEnvDrift(): Promise { - if (!canUseKeyless) { - return; - } - // Only run on server side - if (typeof window !== 'undefined') { - return; - } - - try { - // Dynamically import server-side dependencies to avoid client-side issues - const { safeParseClerkFile } = await import('./keyless-node.js'); - - // Read the keyless configuration file - const keylessFile = safeParseClerkFile(); - - if (!keylessFile) { - return; - } - - // Get environment variables - const envPublishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; - const envSecretKey = process.env.CLERK_SECRET_KEY; - - // Check the state of environment variables and keyless file - const hasEnvVars = Boolean(envPublishableKey || envSecretKey); - const keylessFileHasKeys = Boolean(keylessFile?.publishableKey && keylessFile?.secretKey); - const envVarsMissing = !envPublishableKey && !envSecretKey; - - // Early return conditions - no drift to detect in these scenarios: - if (!hasEnvVars && !keylessFileHasKeys) { - // Neither env vars nor keyless file have keys - nothing to compare - return; - } - - if (envVarsMissing && keylessFileHasKeys) { - // Environment variables are missing but keyless file has keys - this is normal for keyless mode - return; - } - - if (!keylessFileHasKeys) { - // Keyless file doesn't have keys, so no drift can be detected - return; - } - - // Only proceed with drift detection if we have something meaningful to compare - if (!hasEnvVars) { - return; - } - - // Compare keys only when both sides have values to compare - const publicKeyMatch = Boolean( - envPublishableKey && keylessFile.publishableKey && envPublishableKey === keylessFile.publishableKey, - ); - - const secretKeyMatch = Boolean(envSecretKey && keylessFile.secretKey && envSecretKey === keylessFile.secretKey); - - // Determine if there's an actual drift: - // Drift occurs when we have env vars that don't match the keyless file keys - const hasActualDrift = - (envPublishableKey && keylessFile.publishableKey && !publicKeyMatch) || - (envSecretKey && keylessFile.secretKey && !secretKeyMatch); - - // Only fire telemetry if there's an actual drift (not just missing keys) - if (!hasActualDrift) { - return; - } - - const payload: EventKeylessEnvDriftPayload = { - publicKeyMatch, - secretKeyMatch, - envVarsMissing, - keylessFileHasKeys, - keylessPublishableKey: keylessFile.publishableKey ?? '', - envPublishableKey: envPublishableKey ?? '', - }; - - // Create a clerk client to access telemetry - const clerkClient = createClerkClientWithOptions({ - publishableKey: keylessFile.publishableKey, - secretKey: keylessFile.secretKey, - telemetry: { - samplingRate: 1, - }, - }); - - const shouldFireEvent = tryMarkTelemetryEventAsFired(); - - if (shouldFireEvent) { - // Fire drift detected event only if we successfully created the flag - const driftDetectedEvent: TelemetryEventRaw = { - event: EVENT_KEYLESS_ENV_DRIFT_DETECTED, - eventSamplingRate: EVENT_SAMPLING_RATE, - payload, - }; - - clerkClient.telemetry?.record(driftDetectedEvent); - } - } catch (error) { - // Silently handle errors to avoid breaking the application - console.warn('Failed to detect keyless environment drift:', error); - } -} diff --git a/packages/nextjs/src/utils/feature-flags.ts b/packages/nextjs/src/utils/feature-flags.ts index 86cac903a1b..38578b46423 100644 --- a/packages/nextjs/src/utils/feature-flags.ts +++ b/packages/nextjs/src/utils/feature-flags.ts @@ -1,7 +1,8 @@ -import { isDevelopmentEnvironment } from '@clerk/shared/utils'; +import { canUseKeyless as sharedCanUseKeyless } from '@clerk/shared/keyless'; import { KEYLESS_DISABLED } from '../server/constants'; + // Next.js will inline the value of 'development' or 'production' on the client bundle, so this is client-safe. -const canUseKeyless = isDevelopmentEnvironment() && !KEYLESS_DISABLED; +const canUseKeyless = sharedCanUseKeyless({ disabled: KEYLESS_DISABLED }); export { canUseKeyless }; diff --git a/packages/shared/docs/use-clerk.md b/packages/shared/docs/use-clerk.md deleted file mode 100644 index 839672b69cf..00000000000 --- a/packages/shared/docs/use-clerk.md +++ /dev/null @@ -1,15 +0,0 @@ - - -```tsx {{ filename: 'app/page.tsx' }} -'use client'; - -import { useClerk } from '@clerk/nextjs'; - -export default function HomePage() { - const clerk = useClerk(); - - return ; -} -``` - - diff --git a/packages/shared/docs/use-session-list.md b/packages/shared/docs/use-session-list.md deleted file mode 100644 index c4441a59b95..00000000000 --- a/packages/shared/docs/use-session-list.md +++ /dev/null @@ -1,24 +0,0 @@ - - -```tsx {{ filename: 'app/page.tsx' }} -'use client'; - -import { useSessionList } from '@clerk/nextjs'; - -export default function HomePage() { - const { isLoaded, sessions } = useSessionList(); - - if (!isLoaded) { - // Handle loading state - return null; - } - - return ( -
-

Welcome back. You've been here {sessions.length} times before.

-
- ); -} -``` - - diff --git a/packages/shared/docs/use-session.md b/packages/shared/docs/use-session.md deleted file mode 100644 index 95be5884665..00000000000 --- a/packages/shared/docs/use-session.md +++ /dev/null @@ -1,28 +0,0 @@ - - -```tsx {{ filename: 'app/page.tsx' }} -'use client'; - -import { useSession } from '@clerk/nextjs'; - -export default function HomePage() { - const { isLoaded, session, isSignedIn } = useSession(); - - if (!isLoaded) { - // Handle loading state - return null; - } - if (!isSignedIn) { - // Handle signed out state - return null; - } - - return ( -
-

This session has been active since {session.lastActiveAt.toLocaleString()}

-
- ); -} -``` - - diff --git a/packages/shared/docs/use-user.md b/packages/shared/docs/use-user.md deleted file mode 100644 index 106804ac014..00000000000 --- a/packages/shared/docs/use-user.md +++ /dev/null @@ -1,81 +0,0 @@ - - -```tsx {{ filename: 'app/page.tsx' }} -'use client'; - -import { useUser } from '@clerk/nextjs'; - -export default function HomePage() { - const { isSignedIn, isLoaded, user } = useUser(); - - if (!isLoaded) { - // Handle loading state - return null; - } - - if (!isSignedIn) return null; - - const updateUser = async () => { - await user.update({ - firstName: 'John', - lastName: 'Doe', - }); - }; - - return ( - <> - -

user.firstName: {user.firstName}

-

user.lastName: {user.lastName}

- - ); -} -``` - - - - - -```tsx {{ filename: 'app/page.tsx' }} -'use client'; - -import { useUser } from '@clerk/nextjs'; - -export default function HomePage() { - const { isSignedIn, isLoaded, user } = useUser(); - - if (!isLoaded) { - // Handle loading state - return null; - } - - if (!isSignedIn) return null; - - const updateUser = async () => { - // Update data via an API endpoint - const updateMetadata = await fetch('/api/updateMetadata', { - method: 'POST', - body: JSON.stringify({ - role: 'admin', - }), - }); - - // Check if the update was successful - if ((await updateMetadata.json()).message !== 'success') { - throw new Error('Error updating'); - } - - // If the update was successful, reload the user data - await user.reload(); - }; - - return ( - <> - -

user role: {user.publicMetadata.role}

- - ); -} -``` - - diff --git a/packages/shared/package.json b/packages/shared/package.json index 0ad144e3b78..e37cffdde5f 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -50,6 +50,16 @@ "default": "./dist/runtime/react/index.js" } }, + "./keyless": { + "import": { + "types": "./dist/runtime/keyless/index.d.mts", + "default": "./dist/runtime/keyless/index.mjs" + }, + "require": { + "types": "./dist/runtime/keyless/index.d.ts", + "default": "./dist/runtime/keyless/index.js" + } + }, "./utils": { "import": { "types": "./dist/runtime/utils/index.d.mts", diff --git a/packages/shared/src/__tests__/keyless.spec.ts b/packages/shared/src/__tests__/keyless.spec.ts new file mode 100644 index 00000000000..9b41f7765c1 --- /dev/null +++ b/packages/shared/src/__tests__/keyless.spec.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { + getKeylessCookieName, + parseKeylessCookieValue, + serializeKeylessCookieValue, + canUseKeyless, + createKeylessModeMessage, + createConfirmationMessage, +} from '../keyless'; + +describe('keyless cookie utilities', () => { + describe('getKeylessCookieName', () => { + it('should return a default cookie name when no path is provided', async () => { + const name = await getKeylessCookieName(); + expect(name).toBe('__clerk_keys_0'); + }); + + it('should return a hashed cookie name when path is provided', async () => { + const name = await getKeylessCookieName('/Users/test/projects/my-app'); + expect(name).toMatch(/^__clerk_keys_[a-f0-9]{16}$/); + }); + + it('should return consistent names for the same path', async () => { + const path = '/Users/test/projects/my-app'; + const name1 = await getKeylessCookieName(path); + const name2 = await getKeylessCookieName(path); + expect(name1).toBe(name2); + }); + + it('should return different names for different paths', async () => { + const name1 = await getKeylessCookieName('/Users/test/projects/app1'); + const name2 = await getKeylessCookieName('/Users/test/projects/app2'); + expect(name1).not.toBe(name2); + }); + }); + + describe('parseKeylessCookieValue', () => { + it('should return undefined for null/undefined input', () => { + expect(parseKeylessCookieValue(null)).toBeUndefined(); + expect(parseKeylessCookieValue(undefined)).toBeUndefined(); + expect(parseKeylessCookieValue('')).toBeUndefined(); + }); + + it('should parse valid JSON with required fields', () => { + const value = JSON.stringify({ + publishableKey: 'pk_test_123', + secretKey: 'sk_test_456', + claimUrl: 'https://clerk.com/claim', + apiKeysUrl: 'https://clerk.com/api-keys', + }); + + const result = parseKeylessCookieValue(value); + expect(result).toEqual({ + publishableKey: 'pk_test_123', + secretKey: 'sk_test_456', + claimUrl: 'https://clerk.com/claim', + apiKeysUrl: 'https://clerk.com/api-keys', + }); + }); + + it('should return undefined for invalid JSON', () => { + expect(parseKeylessCookieValue('not json')).toBeUndefined(); + }); + + it('should return undefined for JSON missing required fields', () => { + expect(parseKeylessCookieValue(JSON.stringify({ publishableKey: 'pk_test' }))).toBeUndefined(); + expect(parseKeylessCookieValue(JSON.stringify({ secretKey: 'sk_test' }))).toBeUndefined(); + expect(parseKeylessCookieValue(JSON.stringify({}))).toBeUndefined(); + }); + }); + + describe('serializeKeylessCookieValue', () => { + it('should serialize an AccountlessApplication to JSON', () => { + const app = { + publishableKey: 'pk_test_123', + secretKey: 'sk_test_456', + claimUrl: 'https://clerk.com/claim', + apiKeysUrl: 'https://clerk.com/api-keys', + }; + + const result = serializeKeylessCookieValue(app); + const parsed = JSON.parse(result); + + expect(parsed).toEqual({ + claimUrl: 'https://clerk.com/claim', + publishableKey: 'pk_test_123', + secretKey: 'sk_test_456', + }); + }); + }); +}); + +describe('keyless feature flags', () => { + describe('canUseKeyless', () => { + const originalNodeEnv = process.env.NODE_ENV; + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + }); + + it('should return false when disabled', () => { + expect(canUseKeyless({ disabled: true })).toBe(false); + }); + + it('should return false in production', () => { + process.env.NODE_ENV = 'production'; + expect(canUseKeyless()).toBe(false); + }); + + it('should return true in development when not disabled', () => { + process.env.NODE_ENV = 'development'; + expect(canUseKeyless()).toBe(true); + }); + }); +}); + +describe('keyless messages', () => { + describe('createKeylessModeMessage', () => { + it('should create a message with the claim URL', () => { + const keys = { + publishableKey: 'pk_test_123', + claimUrl: 'https://clerk.com/claim/abc', + apiKeysUrl: 'https://clerk.com/api-keys', + }; + + const message = createKeylessModeMessage(keys); + expect(message).toContain('keyless mode'); + expect(message).toContain('https://clerk.com/claim/abc'); + }); + }); + + describe('createConfirmationMessage', () => { + it('should create a confirmation message', () => { + const message = createConfirmationMessage(); + expect(message).toContain('claimed keys'); + expect(message).toContain('.clerk/'); + }); + }); +}); diff --git a/packages/shared/src/keyless/devCache.ts b/packages/shared/src/keyless/devCache.ts new file mode 100644 index 00000000000..0fbdabd24e1 --- /dev/null +++ b/packages/shared/src/keyless/devCache.ts @@ -0,0 +1,109 @@ +import { isDevelopmentEnvironment } from '../utils/runtimeEnvironment'; +import type { AccountlessApplication, PublicKeylessApplication } from './types'; + +// 10 minutes in milliseconds +const THROTTLE_DURATION_MS = 10 * 60 * 1000; + +export interface ClerkDevCache { + __cache: Map; + /** + * Log a message with throttling to prevent spam. + */ + log: (params: { cacheKey: string; msg: string }) => void; + /** + * Run an async callback with caching. + */ + run: ( + callback: () => Promise, + options: { + cacheKey: string; + onSuccessStale?: number; + onErrorStale?: number; + }, + ) => Promise; +} + +declare global { + var __clerk_internal_keyless_logger: ClerkDevCache | undefined; +} + +/** + * Creates a development-only cache for keyless mode logging and API calls. + * This prevents console spam and duplicate API requests. + * + * @returns The cache instance or undefined in non-development environments + */ +export function createClerkDevCache(): ClerkDevCache | undefined { + if (!isDevelopmentEnvironment()) { + return undefined; + } + + if (!globalThis.__clerk_internal_keyless_logger) { + globalThis.__clerk_internal_keyless_logger = { + __cache: new Map(), + + log: function ({ cacheKey, msg }) { + if (this.__cache.has(cacheKey) && Date.now() < (this.__cache.get(cacheKey)?.expiresAt || 0)) { + return; + } + + console.log(msg); + + this.__cache.set(cacheKey, { + expiresAt: Date.now() + THROTTLE_DURATION_MS, + }); + }, + + run: async function ( + callback, + { cacheKey, onSuccessStale = THROTTLE_DURATION_MS, onErrorStale = THROTTLE_DURATION_MS }, + ) { + if (this.__cache.has(cacheKey) && Date.now() < (this.__cache.get(cacheKey)?.expiresAt || 0)) { + return this.__cache.get(cacheKey)?.data as ReturnType; + } + + try { + const result = await callback(); + + this.__cache.set(cacheKey, { + expiresAt: Date.now() + onSuccessStale, + data: result, + }); + return result; + } catch (e) { + this.__cache.set(cacheKey, { + expiresAt: Date.now() + onErrorStale, + }); + + throw e; + } + }, + }; + } + + return globalThis.__clerk_internal_keyless_logger; +} + +/** + * Creates the console message shown when running in keyless mode. + * + * @param keys - The keyless application keys + * @returns Formatted console message + */ +export function createKeylessModeMessage(keys: AccountlessApplication | PublicKeylessApplication): string { + return `\n\x1b[35m\n[Clerk]:\x1b[0m You are running in keyless mode.\nYou can \x1b[35mclaim your keys\x1b[0m by visiting ${keys.claimUrl}\n`; +} + +/** + * Creates the console message shown when keys have been claimed. + * + * @returns Formatted console message + */ +export function createConfirmationMessage(): string { + return `\n\x1b[35m\n[Clerk]:\x1b[0m Your application is running with your claimed keys.\nYou can safely remove the \x1b[35m.clerk/\x1b[0m from your project.\n`; +} + +/** + * Shared singleton instance of the development cache. + */ +export const clerkDevelopmentCache = createClerkDevCache(); diff --git a/packages/shared/src/keyless/index.ts b/packages/shared/src/keyless/index.ts new file mode 100644 index 00000000000..be26eef7603 --- /dev/null +++ b/packages/shared/src/keyless/index.ts @@ -0,0 +1,12 @@ +export { + clerkDevelopmentCache, + createClerkDevCache, + createConfirmationMessage, + createKeylessModeMessage, +} from './devCache'; +export type { ClerkDevCache } from './devCache'; + +export { createKeylessService } from './service'; +export type { KeylessAPI, KeylessService, KeylessServiceOptions, KeylessStorage } from './service'; + +export type { AccountlessApplication, PublicKeylessApplication } from './types'; diff --git a/packages/shared/src/keyless/service.ts b/packages/shared/src/keyless/service.ts new file mode 100644 index 00000000000..903c8fa65b8 --- /dev/null +++ b/packages/shared/src/keyless/service.ts @@ -0,0 +1,206 @@ +import type { AccountlessApplication } from './types'; + +/** + * Storage adapter interface for keyless mode. + * Implementations can use file system, cookies, or other storage mechanisms. + * + * Implementations are responsible for their own concurrency handling + * (e.g., file locking for file-based storage). + */ +export interface KeylessStorage { + /** + * Reads the stored keyless configuration. + * + * @returns The JSON string of the stored config, or empty string if not found. + */ + read(): string; + + /** + * Writes the keyless configuration to storage. + * + * @param data - The JSON string to store. + */ + write(data: string): void; + + /** + * Removes the keyless configuration from storage. + */ + remove(): void; +} + +/** + * API adapter for keyless mode operations. + * This abstraction allows the service to work without depending on @clerk/backend. + */ +export interface KeylessAPI { + /** + * Creates a new accountless application. + * + * @param requestHeaders - Optional headers to include with the request. + * @returns The created AccountlessApplication or null if failed. + */ + createAccountlessApplication(requestHeaders?: Headers): Promise; + + /** + * Notifies the backend that onboarding is complete (instance has been claimed). + * + * @param requestHeaders - Optional headers to include with the request. + * @returns The updated AccountlessApplication or null if failed. + */ + completeOnboarding(requestHeaders?: Headers): Promise; +} + +/** + * Options for creating a keyless service. + */ +export interface KeylessServiceOptions { + /** + * Storage adapter for reading/writing keyless configuration. + */ + storage: KeylessStorage; + + /** + * API adapter for keyless operations (create application, complete onboarding). + */ + api: KeylessAPI; + + /** + * Optional: Framework name for metadata (e.g., 'Next.js', 'TanStack Start'). + */ + framework?: string; + + /** + * Optional: Framework version for metadata. + */ + frameworkVersion?: string; +} + +/** + * The keyless service interface. + */ +export interface KeylessService { + /** + * Gets existing keyless keys or creates new ones via the API. + */ + getOrCreateKeys: () => Promise; + + /** + * Reads existing keyless keys without creating new ones. + */ + readKeys: () => AccountlessApplication | undefined; + + /** + * Removes the keyless configuration. + */ + removeKeys: () => void; + + /** + * Notifies the backend that the instance has been claimed/onboarded. + * This should be called once when the user claims their instance. + */ + completeOnboarding: () => Promise; + + /** + * Logs a keyless mode message to the console (throttled to once per process). + */ + logKeylessMessage: (claimUrl: string) => void; +} + +/** + * Creates metadata headers for the keyless service. + */ +function createMetadataHeaders(framework?: string, frameworkVersion?: string): Headers { + const headers = new Headers(); + + if (framework) { + headers.set('Clerk-Framework', framework); + } + if (frameworkVersion) { + headers.set('Clerk-Framework-Version', frameworkVersion); + } + + return headers; +} + +/** + * Creates a keyless service that handles accountless application creation and storage. + * This provides a simple API for frameworks to integrate keyless mode. + * + * @param options - Configuration for the service including storage and API adapters + * @returns A keyless service instance + * + * @example + * ```ts + * import { createKeylessService } from '@clerk/shared/keyless'; + * + * const keylessService = createKeylessService({ + * storage: createFileStorage(), + * api: createKeylessAPI({ secretKey }), + * framework: 'TanStack Start', + * }); + * + * const keys = await keylessService.getOrCreateKeys(request); + * if (keys) { + * console.log('Publishable Key:', keys.publishableKey); + * } + * ``` + */ +export function createKeylessService(options: KeylessServiceOptions): KeylessService { + const { storage, api, framework, frameworkVersion } = options; + + let hasLoggedKeylessMessage = false; + + const safeParseConfig = (): AccountlessApplication | undefined => { + try { + const data = storage.read(); + if (!data) { + return undefined; + } + return JSON.parse(data) as AccountlessApplication; + } catch { + return undefined; + } + }; + + return { + async getOrCreateKeys(): Promise { + // Check for existing config first + const existingConfig = safeParseConfig(); + if (existingConfig?.publishableKey && existingConfig?.secretKey) { + return existingConfig; + } + + // Create metadata headers + const headers = createMetadataHeaders(framework, frameworkVersion); + + // Create new keys via the API + const accountlessApplication = await api.createAccountlessApplication(headers); + + if (accountlessApplication) { + storage.write(JSON.stringify(accountlessApplication)); + } + + return accountlessApplication; + }, + + readKeys(): AccountlessApplication | undefined { + return safeParseConfig(); + }, + + removeKeys(): void { + storage.remove(); + }, + + async completeOnboarding(): Promise { + const headers = createMetadataHeaders(framework, frameworkVersion); + return api.completeOnboarding(headers); + }, + + logKeylessMessage(claimUrl: string): void { + if (!hasLoggedKeylessMessage) { + hasLoggedKeylessMessage = true; + console.log(`[Clerk]: Running in keyless mode. Claim your keys at: ${claimUrl}`); + } + }, + }; +} diff --git a/packages/shared/src/keyless/types.ts b/packages/shared/src/keyless/types.ts new file mode 100644 index 00000000000..f2ec8075098 --- /dev/null +++ b/packages/shared/src/keyless/types.ts @@ -0,0 +1,15 @@ +/** + * Represents an accountless application created in keyless mode. + * This matches the structure returned by the Clerk API. + */ +export interface AccountlessApplication { + publishableKey: string; + secretKey: string; + claimUrl: string; + apiKeysUrl: string; +} + +/** + * Public-facing keyless application data (without secret key). + */ +export type PublicKeylessApplication = Omit; diff --git a/packages/shared/tsdown.config.mts b/packages/shared/tsdown.config.mts index 93c58027e0e..87b537d8a07 100644 --- a/packages/shared/tsdown.config.mts +++ b/packages/shared/tsdown.config.mts @@ -49,6 +49,7 @@ export default defineConfig(({ watch }) => { './src/types/index.ts', './src/dom/*.ts', './src/ui/index.ts', + './src/keyless/index.ts', './src/internal/clerk-js/*.ts', './src/internal/clerk-js/**/*.ts', '!./src/**/*.{test,spec}.{ts,tsx}', From 43a048ae5252a29135aec486071cd1a77b9d0de5 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Fri, 19 Dec 2025 15:58:40 -0600 Subject: [PATCH 2/4] add keyless to tanstack-react-start --- .../src/client/ClerkProvider.tsx | 13 +- .../tanstack-react-start/src/client/utils.ts | 6 + .../src/server/clerkMiddleware.ts | 47 ++++++- .../src/server/keyless/fileStorage.ts | 124 ++++++++++++++++++ .../src/server/keyless/index.ts | 22 ++++ .../src/server/loadOptions.ts | 6 +- .../src/utils/feature-flags.ts | 11 ++ 7 files changed, 222 insertions(+), 7 deletions(-) create mode 100644 packages/tanstack-react-start/src/server/keyless/fileStorage.ts create mode 100644 packages/tanstack-react-start/src/server/keyless/index.ts create mode 100644 packages/tanstack-react-start/src/utils/feature-flags.ts diff --git a/packages/tanstack-react-start/src/client/ClerkProvider.tsx b/packages/tanstack-react-start/src/client/ClerkProvider.tsx index 74d4702eeff..5843d3d6183 100644 --- a/packages/tanstack-react-start/src/client/ClerkProvider.tsx +++ b/packages/tanstack-react-start/src/client/ClerkProvider.tsx @@ -33,13 +33,23 @@ export function ClerkProvider({ const clerkInitState = isClient() ? (window as any).__clerk_init_state : clerkInitialState; - const { clerkSsrState, ...restInitState } = pickFromClerkInitState(clerkInitState?.__internal_clerk_state); + const { clerkSsrState, __keylessClaimUrl, __keylessApiKeysUrl, ...restInitState } = pickFromClerkInitState( + clerkInitState?.__internal_clerk_state, + ); const mergedProps = { ...mergeWithPublicEnvs(restInitState), ...providerProps, }; + // Add keyless mode props if present + const keylessProps = __keylessClaimUrl + ? { + __internal_keyless_claimKeylessApplicationUrl: __keylessClaimUrl, + __internal_keyless_copyInstanceKeysUrl: __keylessApiKeysUrl, + } + : {}; + return ( <> {`window.__clerk_init_state = ${JSON.stringify(clerkInitialState)};`} @@ -60,6 +70,7 @@ export function ClerkProvider({ }) } {...mergedProps} + {...keylessProps} > {children} diff --git a/packages/tanstack-react-start/src/client/utils.ts b/packages/tanstack-react-start/src/client/utils.ts index e237b5d8b47..3798f1b212f 100644 --- a/packages/tanstack-react-start/src/client/utils.ts +++ b/packages/tanstack-react-start/src/client/utils.ts @@ -7,6 +7,8 @@ export const pickFromClerkInitState = ( clerkInitState: any, ): TanStackProviderAndInitialProps & { clerkSsrState: any; + __keylessClaimUrl?: string; + __keylessApiKeysUrl?: string; } => { const { __clerk_ssr_state, @@ -25,6 +27,8 @@ export const pickFromClerkInitState = ( __signUpForceRedirectUrl, __signInFallbackRedirectUrl, __signUpFallbackRedirectUrl, + __keylessClaimUrl, + __keylessApiKeysUrl, } = clerkInitState || {}; return { @@ -46,6 +50,8 @@ export const pickFromClerkInitState = ( signUpForceRedirectUrl: __signUpForceRedirectUrl, signInFallbackRedirectUrl: __signInFallbackRedirectUrl, signUpFallbackRedirectUrl: __signUpFallbackRedirectUrl, + __keylessClaimUrl, + __keylessApiKeysUrl, }; }; diff --git a/packages/tanstack-react-start/src/server/clerkMiddleware.ts b/packages/tanstack-react-start/src/server/clerkMiddleware.ts index 661d9705ac2..f0b242881e3 100644 --- a/packages/tanstack-react-start/src/server/clerkMiddleware.ts +++ b/packages/tanstack-react-start/src/server/clerkMiddleware.ts @@ -5,15 +5,45 @@ import type { PendingSessionOptions } from '@clerk/shared/types'; import type { AnyRequestMiddleware } from '@tanstack/react-start'; import { createMiddleware, json } from '@tanstack/react-start'; +import { canUseKeyless } from '../utils/feature-flags'; import { clerkClient } from './clerkClient'; +import { keyless } from './keyless'; import { loadOptions } from './loadOptions'; import type { ClerkMiddlewareOptions } from './types'; import { getResponseClerkState, patchRequest } from './utils'; export const clerkMiddleware = (options?: ClerkMiddlewareOptions): AnyRequestMiddleware => { - return createMiddleware().server(async args => { - const clerkRequest = createClerkRequest(patchRequest(args.request)); - const loadedOptions = loadOptions(clerkRequest, options); + return createMiddleware().server(async ({ request, next }) => { + const clerkRequest = createClerkRequest(patchRequest(request)); + + // Get keys - either from options, env, or keyless mode + let publishableKey = options?.publishableKey; + let secretKey = options?.secretKey; + let keylessClaimUrl: string | undefined; + let keylessApiKeysUrl: string | undefined; + + // In keyless mode, try to read/create keys from the file system + if (canUseKeyless && (!publishableKey || !secretKey)) { + const keylessApp = await keyless.getOrCreateKeys(); + if (keylessApp) { + publishableKey = publishableKey || keylessApp.publishableKey; + secretKey = secretKey || keylessApp.secretKey; + keylessClaimUrl = keylessApp.claimUrl; + keylessApiKeysUrl = keylessApp.apiKeysUrl; + + keyless.logKeylessMessage(keylessApp.claimUrl); + } + } + + // Load options with keyless fallback + const effectiveOptions = { + ...options, + publishableKey, + secretKey, + }; + + const loadedOptions = loadOptions(clerkRequest, effectiveOptions); + const requestState = await clerkClient().authenticateRequest(clerkRequest, { ...loadedOptions, acceptsToken: 'any', @@ -37,7 +67,16 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): AnyRequestMid const clerkInitialState = getResponseClerkState(requestState as RequestState, loadedOptions); - const result = await args.next({ + // Include keyless mode URLs if applicable + if (canUseKeyless && keylessClaimUrl) { + (clerkInitialState as Record).__internal_clerk_state = { + ...((clerkInitialState as Record).__internal_clerk_state as Record), + __keylessClaimUrl: keylessClaimUrl, + __keylessApiKeysUrl: keylessApiKeysUrl, + }; + } + + const result = await next({ context: { clerkInitialState, auth: (opts?: PendingSessionOptions) => requestState.toAuth(opts), diff --git a/packages/tanstack-react-start/src/server/keyless/fileStorage.ts b/packages/tanstack-react-start/src/server/keyless/fileStorage.ts new file mode 100644 index 00000000000..0de091b53fa --- /dev/null +++ b/packages/tanstack-react-start/src/server/keyless/fileStorage.ts @@ -0,0 +1,124 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import type { KeylessStorage } from '@clerk/shared/keyless'; + +const CLERK_HIDDEN = '.clerk'; +const CLERK_LOCK = 'clerk.lock'; +const TEMP_DIR_NAME = '.tmp'; +const CONFIG_FILE = 'keyless.json'; +const README_FILE = 'README.md'; + +export interface FileStorageOptions { + cwd?: () => string; +} + +export function createFileStorage(options: FileStorageOptions = {}): KeylessStorage { + const { cwd = () => process.cwd() } = options; + + let inMemoryLock = false; + + const getClerkDir = () => path.join(cwd(), CLERK_HIDDEN); + const getTempDir = () => path.join(getClerkDir(), TEMP_DIR_NAME); + const getConfigPath = () => path.join(getTempDir(), CONFIG_FILE); + const getReadmePath = () => path.join(getTempDir(), README_FILE); + const getLockPath = () => path.join(cwd(), CLERK_LOCK); + + const isLocked = (): boolean => inMemoryLock || fs.existsSync(getLockPath()); + + const lock = (): boolean => { + if (isLocked()) { + return false; + } + inMemoryLock = true; + try { + fs.writeFileSync(getLockPath(), 'This file can be deleted if your app is stuck.', { + encoding: 'utf8', + mode: 0o644, + }); + return true; + } catch { + inMemoryLock = false; + return false; + } + }; + + const unlock = (): void => { + inMemoryLock = false; + try { + if (fs.existsSync(getLockPath())) { + fs.rmSync(getLockPath(), { force: true }); + } + } catch { + // Ignore + } + }; + + const ensureDirectoryExists = () => { + const tempDir = getTempDir(); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + }; + + const updateGitignore = () => { + const gitignorePath = path.join(cwd(), '.gitignore'); + const entry = `/${CLERK_HIDDEN}/`; + + if (!fs.existsSync(gitignorePath)) { + fs.writeFileSync(gitignorePath, '', { encoding: 'utf8', mode: 0o644 }); + } + + const content = fs.readFileSync(gitignorePath, 'utf-8'); + if (!content.includes(entry)) { + fs.appendFileSync(gitignorePath, `\n# clerk configuration (can include secrets)\n${entry}\n`); + } + }; + + const writeReadme = () => { + fs.writeFileSync(getReadmePath(), `## DO NOT COMMIT\nThis directory contains keyless mode secrets.\n`, { + encoding: 'utf8', + mode: 0o600, + }); + }; + + return { + read(): string { + try { + if (!fs.existsSync(getConfigPath())) { + return ''; + } + return fs.readFileSync(getConfigPath(), { encoding: 'utf-8' }); + } catch { + return ''; + } + }, + + write(data: string): void { + if (!lock()) { + return; + } + try { + ensureDirectoryExists(); + updateGitignore(); + writeReadme(); + fs.writeFileSync(getConfigPath(), data, { encoding: 'utf8', mode: 0o600 }); + } finally { + unlock(); + } + }, + + remove(): void { + if (!lock()) { + return; + } + try { + if (fs.existsSync(getClerkDir())) { + fs.rmSync(getClerkDir(), { recursive: true, force: true }); + } + } finally { + unlock(); + } + }, + }; +} diff --git a/packages/tanstack-react-start/src/server/keyless/index.ts b/packages/tanstack-react-start/src/server/keyless/index.ts new file mode 100644 index 00000000000..bf5a7e4031a --- /dev/null +++ b/packages/tanstack-react-start/src/server/keyless/index.ts @@ -0,0 +1,22 @@ +import { createKeylessService } from '@clerk/shared/keyless'; + +import { clerkClient } from '../clerkClient'; +import { createFileStorage } from './fileStorage'; + +// Create a singleton keyless service for TanStack Start +export const keyless = createKeylessService({ + storage: createFileStorage(), + api: { + createAccountlessApplication: async (requestHeaders?: Headers) => { + return await clerkClient().__experimental_accountlessApplications.createAccountlessApplication({ + requestHeaders, + }); + }, + completeOnboarding: async (requestHeaders?: Headers) => { + return await clerkClient().__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({ + requestHeaders, + }); + }, + }, + framework: 'tanstack-react-start', +}); diff --git a/packages/tanstack-react-start/src/server/loadOptions.ts b/packages/tanstack-react-start/src/server/loadOptions.ts index 5fc6e348618..a5f8a00db19 100644 --- a/packages/tanstack-react-start/src/server/loadOptions.ts +++ b/packages/tanstack-react-start/src/server/loadOptions.ts @@ -5,6 +5,7 @@ import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; import { isHttpOrHttps, isProxyUrlRelative } from '@clerk/shared/proxy'; import { handleValueOrFn } from '@clerk/shared/utils'; +import { canUseKeyless } from '../utils/feature-flags'; import { errorThrower } from '../utils'; import { commonEnvs } from './constants'; import type { LoaderOptions } from './types'; @@ -29,7 +30,8 @@ export const loadOptions = (request: ClerkRequest, overrides: LoaderOptions = {} proxyUrl = relativeOrAbsoluteProxyUrl; } - if (!secretKey) { + // In keyless mode, don't throw if secretKey is missing - ClerkProvider will handle it + if (!secretKey && !canUseKeyless) { // eslint-disable-next-line @typescript-eslint/only-throw-error throw errorThrower.throw('Clerk: no secret key provided'); } @@ -39,7 +41,7 @@ export const loadOptions = (request: ClerkRequest, overrides: LoaderOptions = {} throw errorThrower.throw('Clerk: satellite mode requires a proxy URL or domain'); } - if (isSatellite && !isHttpOrHttps(signInUrl) && isDevelopmentFromSecretKey(secretKey)) { + if (isSatellite && secretKey && !isHttpOrHttps(signInUrl) && isDevelopmentFromSecretKey(secretKey)) { // eslint-disable-next-line @typescript-eslint/only-throw-error throw errorThrower.throw('Clerk: satellite mode requires a sign-in URL in production'); } diff --git a/packages/tanstack-react-start/src/utils/feature-flags.ts b/packages/tanstack-react-start/src/utils/feature-flags.ts new file mode 100644 index 00000000000..62eaab2c237 --- /dev/null +++ b/packages/tanstack-react-start/src/utils/feature-flags.ts @@ -0,0 +1,11 @@ +import { getEnvVariable } from '@clerk/shared/getEnvVariable'; +import { isTruthy } from '@clerk/shared/underscore'; +import { isDevelopmentEnvironment } from '@clerk/shared/utils'; + +const KEYLESS_DISABLED = isTruthy(getEnvVariable('VITE_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 483a9fc0f0393a3b62ef2ceaaf590aeecafbf4d2 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Fri, 19 Dec 2025 16:19:10 -0600 Subject: [PATCH 3/4] initial review --- .../nextjs/src/app-router/keyless-actions.ts | 1 - packages/shared/src/__tests__/keyless.spec.ts | 140 ------------------ .../src/server/clerkMiddleware.ts | 5 +- .../src/server/keyless/fileStorage.ts | 6 +- .../src/server/keyless/index.ts | 49 +++--- 5 files changed, 40 insertions(+), 161 deletions(-) delete mode 100644 packages/shared/src/__tests__/keyless.spec.ts diff --git a/packages/nextjs/src/app-router/keyless-actions.ts b/packages/nextjs/src/app-router/keyless-actions.ts index 90c88cb3f4f..23c55ca2bc8 100644 --- a/packages/nextjs/src/app-router/keyless-actions.ts +++ b/packages/nextjs/src/app-router/keyless-actions.ts @@ -43,7 +43,6 @@ export async function syncKeylessConfigAction(args: AccountlessApplication & { r * Force middleware to execute to read the new keys from the cookies and populate the authentication state correctly. */ redirect(`/clerk-sync-keyless?returnUrl=${returnUrl}`, RedirectType.replace); - return; } return; diff --git a/packages/shared/src/__tests__/keyless.spec.ts b/packages/shared/src/__tests__/keyless.spec.ts deleted file mode 100644 index 9b41f7765c1..00000000000 --- a/packages/shared/src/__tests__/keyless.spec.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -import { - getKeylessCookieName, - parseKeylessCookieValue, - serializeKeylessCookieValue, - canUseKeyless, - createKeylessModeMessage, - createConfirmationMessage, -} from '../keyless'; - -describe('keyless cookie utilities', () => { - describe('getKeylessCookieName', () => { - it('should return a default cookie name when no path is provided', async () => { - const name = await getKeylessCookieName(); - expect(name).toBe('__clerk_keys_0'); - }); - - it('should return a hashed cookie name when path is provided', async () => { - const name = await getKeylessCookieName('/Users/test/projects/my-app'); - expect(name).toMatch(/^__clerk_keys_[a-f0-9]{16}$/); - }); - - it('should return consistent names for the same path', async () => { - const path = '/Users/test/projects/my-app'; - const name1 = await getKeylessCookieName(path); - const name2 = await getKeylessCookieName(path); - expect(name1).toBe(name2); - }); - - it('should return different names for different paths', async () => { - const name1 = await getKeylessCookieName('/Users/test/projects/app1'); - const name2 = await getKeylessCookieName('/Users/test/projects/app2'); - expect(name1).not.toBe(name2); - }); - }); - - describe('parseKeylessCookieValue', () => { - it('should return undefined for null/undefined input', () => { - expect(parseKeylessCookieValue(null)).toBeUndefined(); - expect(parseKeylessCookieValue(undefined)).toBeUndefined(); - expect(parseKeylessCookieValue('')).toBeUndefined(); - }); - - it('should parse valid JSON with required fields', () => { - const value = JSON.stringify({ - publishableKey: 'pk_test_123', - secretKey: 'sk_test_456', - claimUrl: 'https://clerk.com/claim', - apiKeysUrl: 'https://clerk.com/api-keys', - }); - - const result = parseKeylessCookieValue(value); - expect(result).toEqual({ - publishableKey: 'pk_test_123', - secretKey: 'sk_test_456', - claimUrl: 'https://clerk.com/claim', - apiKeysUrl: 'https://clerk.com/api-keys', - }); - }); - - it('should return undefined for invalid JSON', () => { - expect(parseKeylessCookieValue('not json')).toBeUndefined(); - }); - - it('should return undefined for JSON missing required fields', () => { - expect(parseKeylessCookieValue(JSON.stringify({ publishableKey: 'pk_test' }))).toBeUndefined(); - expect(parseKeylessCookieValue(JSON.stringify({ secretKey: 'sk_test' }))).toBeUndefined(); - expect(parseKeylessCookieValue(JSON.stringify({}))).toBeUndefined(); - }); - }); - - describe('serializeKeylessCookieValue', () => { - it('should serialize an AccountlessApplication to JSON', () => { - const app = { - publishableKey: 'pk_test_123', - secretKey: 'sk_test_456', - claimUrl: 'https://clerk.com/claim', - apiKeysUrl: 'https://clerk.com/api-keys', - }; - - const result = serializeKeylessCookieValue(app); - const parsed = JSON.parse(result); - - expect(parsed).toEqual({ - claimUrl: 'https://clerk.com/claim', - publishableKey: 'pk_test_123', - secretKey: 'sk_test_456', - }); - }); - }); -}); - -describe('keyless feature flags', () => { - describe('canUseKeyless', () => { - const originalNodeEnv = process.env.NODE_ENV; - - afterEach(() => { - process.env.NODE_ENV = originalNodeEnv; - }); - - it('should return false when disabled', () => { - expect(canUseKeyless({ disabled: true })).toBe(false); - }); - - it('should return false in production', () => { - process.env.NODE_ENV = 'production'; - expect(canUseKeyless()).toBe(false); - }); - - it('should return true in development when not disabled', () => { - process.env.NODE_ENV = 'development'; - expect(canUseKeyless()).toBe(true); - }); - }); -}); - -describe('keyless messages', () => { - describe('createKeylessModeMessage', () => { - it('should create a message with the claim URL', () => { - const keys = { - publishableKey: 'pk_test_123', - claimUrl: 'https://clerk.com/claim/abc', - apiKeysUrl: 'https://clerk.com/api-keys', - }; - - const message = createKeylessModeMessage(keys); - expect(message).toContain('keyless mode'); - expect(message).toContain('https://clerk.com/claim/abc'); - }); - }); - - describe('createConfirmationMessage', () => { - it('should create a confirmation message', () => { - const message = createConfirmationMessage(); - expect(message).toContain('claimed keys'); - expect(message).toContain('.clerk/'); - }); - }); -}); diff --git a/packages/tanstack-react-start/src/server/clerkMiddleware.ts b/packages/tanstack-react-start/src/server/clerkMiddleware.ts index f0b242881e3..7660014fbab 100644 --- a/packages/tanstack-react-start/src/server/clerkMiddleware.ts +++ b/packages/tanstack-react-start/src/server/clerkMiddleware.ts @@ -24,14 +24,15 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): AnyRequestMid // In keyless mode, try to read/create keys from the file system if (canUseKeyless && (!publishableKey || !secretKey)) { - const keylessApp = await keyless.getOrCreateKeys(); + const keylessService = keyless(); + const keylessApp = await keylessService.getOrCreateKeys(); if (keylessApp) { publishableKey = publishableKey || keylessApp.publishableKey; secretKey = secretKey || keylessApp.secretKey; keylessClaimUrl = keylessApp.claimUrl; keylessApiKeysUrl = keylessApp.apiKeysUrl; - keyless.logKeylessMessage(keylessApp.claimUrl); + keylessService.logKeylessMessage(keylessApp.claimUrl); } } diff --git a/packages/tanstack-react-start/src/server/keyless/fileStorage.ts b/packages/tanstack-react-start/src/server/keyless/fileStorage.ts index 0de091b53fa..ad7661931e0 100644 --- a/packages/tanstack-react-start/src/server/keyless/fileStorage.ts +++ b/packages/tanstack-react-start/src/server/keyless/fileStorage.ts @@ -76,7 +76,11 @@ export function createFileStorage(options: FileStorageOptions = {}): KeylessStor }; const writeReadme = () => { - fs.writeFileSync(getReadmePath(), `## DO NOT COMMIT\nThis directory contains keyless mode secrets.\n`, { + const readme = `## DO NOT COMMIT +This directory is auto-generated from \`@clerk/tanstack-react-start\` because you are running in Keyless mode. +Avoid committing the \`.clerk/\` directory as it includes the secret key of the unclaimed instance. +`; + fs.writeFileSync(getReadmePath(), readme, { encoding: 'utf8', mode: 0o600, }); diff --git a/packages/tanstack-react-start/src/server/keyless/index.ts b/packages/tanstack-react-start/src/server/keyless/index.ts index bf5a7e4031a..590edfa9d84 100644 --- a/packages/tanstack-react-start/src/server/keyless/index.ts +++ b/packages/tanstack-react-start/src/server/keyless/index.ts @@ -3,20 +3,35 @@ import { createKeylessService } from '@clerk/shared/keyless'; import { clerkClient } from '../clerkClient'; import { createFileStorage } from './fileStorage'; -// Create a singleton keyless service for TanStack Start -export const keyless = createKeylessService({ - storage: createFileStorage(), - api: { - createAccountlessApplication: async (requestHeaders?: Headers) => { - return await clerkClient().__experimental_accountlessApplications.createAccountlessApplication({ - requestHeaders, - }); - }, - completeOnboarding: async (requestHeaders?: Headers) => { - return await clerkClient().__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({ - requestHeaders, - }); - }, - }, - framework: 'tanstack-react-start', -}); +// Lazily initialized keyless service singleton +let keylessServiceInstance: ReturnType | null = null; + +export function keyless() { + if (!keylessServiceInstance) { + keylessServiceInstance = createKeylessService({ + storage: createFileStorage(), + api: { + async createAccountlessApplication(requestHeaders?: Headers) { + try { + return await clerkClient().__experimental_accountlessApplications.createAccountlessApplication({ + requestHeaders, + }); + } catch { + return null; + } + }, + async completeOnboarding(requestHeaders?: Headers) { + try { + return await clerkClient().__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({ + requestHeaders, + }); + } catch { + return null; + } + }, + }, + framework: 'tanstack-react-start', + }); + } + return keylessServiceInstance; +} From 8739d13f6abd6fcd921a0b9ed32faebeac8b7e36 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Fri, 19 Dec 2025 21:39:33 -0600 Subject: [PATCH 4/4] fix next util --- packages/nextjs/src/utils/feature-flags.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/nextjs/src/utils/feature-flags.ts b/packages/nextjs/src/utils/feature-flags.ts index 38578b46423..86cac903a1b 100644 --- a/packages/nextjs/src/utils/feature-flags.ts +++ b/packages/nextjs/src/utils/feature-flags.ts @@ -1,8 +1,7 @@ -import { canUseKeyless as sharedCanUseKeyless } from '@clerk/shared/keyless'; +import { isDevelopmentEnvironment } from '@clerk/shared/utils'; import { KEYLESS_DISABLED } from '../server/constants'; - // Next.js will inline the value of 'development' or 'production' on the client bundle, so this is client-safe. -const canUseKeyless = sharedCanUseKeyless({ disabled: KEYLESS_DISABLED }); +const canUseKeyless = isDevelopmentEnvironment() && !KEYLESS_DISABLED; export { canUseKeyless };