From f6d652a4e6ad9ab6d517a0e9c24d43284ec8a44f Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Fri, 19 Dec 2025 15:51:26 -0600 Subject: [PATCH 01/13] 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 072dc5c985e0d0a40fe70abaa54a850c5b346ed3 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Fri, 19 Dec 2025 15:58:40 -0600 Subject: [PATCH 02/13] 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 fbea181dd1ca48bdd2d025e04ca927adce89a2d9 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Fri, 19 Dec 2025 16:19:10 -0600 Subject: [PATCH 03/13] 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 fc34af44c97c815a3c751a178cbb17b3a0560cb6 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Fri, 19 Dec 2025 21:39:33 -0600 Subject: [PATCH 04/13] 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 }; From ff86a23d69dbaad07b8411ca593274ea4330a6fe Mon Sep 17 00:00:00 2001 From: brkalow Date: Mon, 5 Jan 2026 11:48:39 -0600 Subject: [PATCH 05/13] fix(tanstack-react-start,nextjs): fix lint and test issues - Fix import sorting in loadOptions.ts - Remove orphaned keyless-custom-headers.test.ts that referenced deleted module --- .../__tests__/keyless-custom-headers.test.ts | 528 ------------------ .../src/server/loadOptions.ts | 2 +- 2 files changed, 1 insertion(+), 529 deletions(-) delete mode 100644 packages/nextjs/src/__tests__/keyless-custom-headers.test.ts diff --git a/packages/nextjs/src/__tests__/keyless-custom-headers.test.ts b/packages/nextjs/src/__tests__/keyless-custom-headers.test.ts deleted file mode 100644 index 7be75419d7a..00000000000 --- a/packages/nextjs/src/__tests__/keyless-custom-headers.test.ts +++ /dev/null @@ -1,528 +0,0 @@ -import { headers } from 'next/headers'; -import type { MockedFunction } from 'vitest'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { collectKeylessMetadata, formatMetadataHeaders } from '../server/keyless-custom-headers'; - -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', -]; -// Helper function to clear all CI environment variables -function clearAllCIEnvironmentVariables(): void { - CI_ENV_VARS.forEach(indicator => { - vi.stubEnv(indicator, undefined); - }); -} - -// Default mock headers for keyless-custom-headers.ts -const defaultMockHeaders = new Headers({ - 'User-Agent': 'Mozilla/5.0 (Test Browser)', - host: 'test-host.example.com', - 'x-forwarded-port': '3000', - 'x-forwarded-host': 'forwarded-test-host.example.com', - 'x-forwarded-proto': 'https', - 'x-clerk-auth-status': 'signed-out', -}); - -// Mock Next.js headers -vi.mock('next/headers', () => ({ - headers: vi.fn(() => ({ - get: vi.fn((name: string) => { - // Return mock values for headers used in keyless-custom-headers.ts - return defaultMockHeaders.get(name); - }), - has: vi.fn((name: string) => { - return defaultMockHeaders.has(name); - }), - forEach: vi.fn((callback: (value: string, key: string) => void) => { - defaultMockHeaders.forEach(callback); - }), - entries: function* () { - const entries: [string, string][] = []; - defaultMockHeaders.forEach((value, key) => entries.push([key, value])); - for (const entry of entries) { - yield entry; - } - }, - keys: function* () { - const keys: string[] = []; - defaultMockHeaders.forEach((_, key) => keys.push(key)); - for (const key of keys) { - yield key; - } - }, - values: function* () { - const values: string[] = []; - defaultMockHeaders.forEach(value => values.push(value)); - for (const value of values) { - yield value; - } - }, - })), -})); - -type MockHeadersFn = () => MockHeaders | Promise; -const mockHeaders = headers as unknown as MockedFunction; - -// Type for mocking Next.js headers -interface MockHeaders { - get(key: string): string | null; - has(key: string): boolean; - forEach(callback: (value: string, key: string) => void): void; - entries(): IterableIterator<[string, string]>; - keys(): IterableIterator; - values(): IterableIterator; -} - -// Helper function to create custom header mocks for specific tests -function createMockHeaders(customHeaders: Record = {}): MockHeaders { - const defaultHeadersObj: Record = {}; - defaultMockHeaders.forEach((value, key) => { - defaultHeadersObj[key] = value; - }); - const allHeaders = { ...defaultHeadersObj, ...customHeaders }; - - return { - get: vi.fn((name: string) => { - // Use the defaultMockHeaders.get() method for consistent behavior - const defaultValue = defaultMockHeaders.get(name); - const customValue = customHeaders[name]; - return customValue !== undefined ? customValue : defaultValue; - }), - has: vi.fn((name: string) => { - const hasDefault = defaultMockHeaders.has(name); - const hasCustom = Object.prototype.hasOwnProperty.call(customHeaders, name); - return hasDefault || (hasCustom && customHeaders[name] !== null); - }), - forEach: vi.fn((callback: (value: string, key: string) => void) => { - Object.entries(allHeaders).forEach(([key, value]) => { - if (value !== null) { - callback(value, key); - } - }); - }), - entries: vi.fn(() => { - const validEntries: [string, string][] = Object.entries(allHeaders).filter(([, value]) => value !== null) as [ - string, - string, - ][]; - return validEntries[Symbol.iterator](); - }), - keys: vi.fn(() => { - const validKeys = Object.keys(allHeaders).filter(key => allHeaders[key] !== null); - return validKeys[Symbol.iterator](); - }), - values: vi.fn(() => { - const validValues = Object.values(allHeaders).filter(value => value !== null); - return validValues[Symbol.iterator](); - }), - }; -} - -describe('keyless-custom-headers', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockHeaders.mockImplementation(async () => createMockHeaders()); - - // Stub all environment variables that collectKeylessMetadata might access - vi.stubEnv('npm_config_user_agent', undefined); - vi.stubEnv('PORT', undefined); - // Clear all CI environment variables - clearAllCIEnvironmentVariables(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - // Don't use vi.unstubAllEnvs() as it restores real environment variables - // Instead, explicitly stub all environment variables to undefined - vi.stubEnv('npm_config_user_agent', undefined); - vi.stubEnv('PORT', undefined); - clearAllCIEnvironmentVariables(); - mockHeaders.mockReset(); - }); - - describe('formatMetadataHeaders', () => { - it('should format complete metadata object with all fields present', async () => { - const metadata = { - nodeVersion: 'v18.17.0', - nextVersion: 'next-server (v15.4.5)', - npmConfigUserAgent: 'npm/9.8.1 node/v18.17.0 darwin x64 workspaces/false', - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', - port: '3000', - host: 'localhost:3000', - xHost: 'example.com', - xPort: '3000', - xProtocol: 'https', - xClerkAuthStatus: 'signed-out', - isCI: false, - }; - - const result = await formatMetadataHeaders(metadata); - - // Test exact header casing and values - expect(result.get('Clerk-Node-Version')).toBe('v18.17.0'); - expect(result.get('Clerk-Next-Version')).toBe('next-server (v15.4.5)'); - expect(result.get('Clerk-NPM-Config-User-Agent')).toBe('npm/9.8.1 node/v18.17.0 darwin x64 workspaces/false'); - expect(result.get('Clerk-Client-User-Agent')).toBe('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'); - expect(result.get('Clerk-Node-Port')).toBe('3000'); - expect(result.get('Clerk-Client-Host')).toBe('localhost:3000'); - expect(result.get('Clerk-X-Host')).toBe('example.com'); - expect(result.get('Clerk-X-Port')).toBe('3000'); - expect(result.get('Clerk-X-Protocol')).toBe('https'); - expect(result.get('Clerk-Auth-Status')).toBe('signed-out'); - }); - - it('should handle missing optional fields gracefully', async () => { - const metadata = { - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', - host: 'localhost:3000', - xHost: 'example.com', - xPort: '3000', - xProtocol: 'https', - xClerkAuthStatus: 'signed-out', - isCI: false, - // Missing: nodeVersion, nextVersion, npmConfigUserAgent, port - }; - - const result = await formatMetadataHeaders(metadata); - - // Test that only present fields are set - expect(result.get('Clerk-Client-User-Agent')).toBe('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'); - expect(result.get('Clerk-Client-Host')).toBe('localhost:3000'); - expect(result.get('Clerk-X-Host')).toBe('example.com'); - expect(result.get('Clerk-X-Port')).toBe('3000'); - expect(result.get('Clerk-X-Protocol')).toBe('https'); - expect(result.get('Clerk-Auth-Status')).toBe('signed-out'); - - // Test that missing fields are not set - expect(result.get('Clerk-Node-Version')).toBeNull(); - expect(result.get('Clerk-Next-Version')).toBeNull(); - expect(result.get('Clerk-NPM-Config-User-Agent')).toBeNull(); - expect(result.get('Clerk-Node-Port')).toBeNull(); - }); - - it('should handle undefined values for optional fields', async () => { - const metadata = { - nodeVersion: undefined, - nextVersion: undefined, - npmConfigUserAgent: undefined, - userAgent: 'test-user-agent', - port: undefined, - host: 'test-host', - xHost: 'test-x-host', - xPort: 'test-x-port', - xProtocol: 'test-x-protocol', - xClerkAuthStatus: 'test-auth-status', - isCI: false, - }; - - const result = await formatMetadataHeaders(metadata); - - // Test that undefined fields are not set - expect(result.get('Clerk-Node-Version')).toBeNull(); - expect(result.get('Clerk-Next-Version')).toBeNull(); - expect(result.get('Clerk-NPM-Config-User-Agent')).toBeNull(); - expect(result.get('Clerk-Node-Port')).toBeNull(); - - // Test that defined fields are set - expect(result.get('Clerk-Client-User-Agent')).toBe('test-user-agent'); - expect(result.get('Clerk-Client-Host')).toBe('test-host'); - expect(result.get('Clerk-X-Host')).toBe('test-x-host'); - expect(result.get('Clerk-X-Port')).toBe('test-x-port'); - expect(result.get('Clerk-X-Protocol')).toBe('test-x-protocol'); - expect(result.get('Clerk-Auth-Status')).toBe('test-auth-status'); - }); - - it('should handle empty string values', async () => { - const metadata = { - nodeVersion: '', - nextVersion: '', - npmConfigUserAgent: '', - userAgent: '', - port: '', - host: '', - xHost: '', - xPort: '', - xProtocol: '', - xClerkAuthStatus: '', - isCI: false, - }; - - const result = await formatMetadataHeaders(metadata); - - // Empty strings should not be set as headers - expect(result.get('Clerk-Node-Version')).toBeNull(); - expect(result.get('Clerk-Next-Version')).toBeNull(); - expect(result.get('Clerk-NPM-Config-User-Agent')).toBeNull(); - expect(result.get('Clerk-Client-User-Agent')).toBeNull(); - expect(result.get('Clerk-Node-Port')).toBeNull(); - expect(result.get('Clerk-Client-Host')).toBeNull(); - expect(result.get('Clerk-X-Host')).toBeNull(); - expect(result.get('Clerk-X-Port')).toBeNull(); - expect(result.get('Clerk-X-Protocol')).toBeNull(); - expect(result.get('Clerk-Auth-Status')).toBeNull(); - }); - }); - - describe('collectKeylessMetadata', () => { - it('should use default mock headers when no custom headers are specified', async () => { - // Setup environment variables - vi.stubEnv('PORT', '3000'); - vi.stubEnv('npm_config_user_agent', 'npm/9.8.1 node/v18.17.0 darwin x64'); - - // Mock process.version and process.title - const originalVersion = process.version; - const originalTitle = process.title; - Object.defineProperty(process, 'version', { value: 'v18.17.0', configurable: true }); - Object.defineProperty(process, 'title', { value: 'next-server (v15.4.5)', configurable: true }); - - const result = await collectKeylessMetadata(); - - // Should use the default mock headers - expect(result.userAgent).toBe('Mozilla/5.0 (Test Browser)'); - expect(result.host).toBe('test-host.example.com'); - expect(result.xPort).toBe('3000'); - expect(result.xHost).toBe('forwarded-test-host.example.com'); - expect(result.xProtocol).toBe('https'); - expect(result.xClerkAuthStatus).toBe('signed-out'); - - // Should use environment variables and process info - expect(result.nodeVersion).toBe('v18.17.0'); - expect(result.nextVersion).toBe('next-server (v15.4.5)'); - expect(result.npmConfigUserAgent).toBe('npm/9.8.1 node/v18.17.0 darwin x64'); - expect(result.port).toBe('3000'); - - // Restore original values - Object.defineProperty(process, 'version', { value: originalVersion, configurable: true }); - Object.defineProperty(process, 'title', { value: originalTitle, configurable: true }); - }); - - it('should collect metadata with all fields present', async () => { - // Setup environment variables - vi.stubEnv('PORT', '3000'); - vi.stubEnv('npm_config_user_agent', 'npm/9.8.1 node/v18.17.0 darwin x64'); - - // Mock process.version and process.title - const originalVersion = process.version; - const originalTitle = process.title; - Object.defineProperty(process, 'version', { value: 'v18.17.0', configurable: true }); - Object.defineProperty(process, 'title', { value: 'next-server (v15.4.5)', configurable: true }); - - // Mock headers - const mockHeaderStore = new Headers({ - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', - host: 'localhost:3000', - 'x-forwarded-port': '3000', - 'x-forwarded-host': 'example.com', - 'x-forwarded-proto': 'https', - 'x-clerk-auth-status': 'signed-out', - }); - - mockHeaders.mockResolvedValue({ - get: (key: string) => mockHeaderStore.get(key) || null, - has: (key: string) => mockHeaderStore.has(key), - forEach: () => {}, - entries: function* () { - const headerEntries: [string, string][] = []; - mockHeaderStore.forEach((value, key) => headerEntries.push([key, value])); - for (const entry of headerEntries) { - yield entry; - } - }, - keys: function* () { - const headerKeys: string[] = []; - mockHeaderStore.forEach((_, key) => headerKeys.push(key)); - for (const key of headerKeys) { - yield key; - } - }, - values: function* () { - const headerValues: string[] = []; - mockHeaderStore.forEach(value => headerValues.push(value)); - for (const value of headerValues) { - yield value; - } - }, - } as MockHeaders); - - const result = await collectKeylessMetadata(); - - expect(result).toEqual({ - nodeVersion: 'v18.17.0', - nextVersion: 'next-server (v15.4.5)', - npmConfigUserAgent: 'npm/9.8.1 node/v18.17.0 darwin x64', - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', - port: '3000', - host: 'localhost:3000', - xPort: '3000', - xHost: 'example.com', - xProtocol: 'https', - xClerkAuthStatus: 'signed-out', - isCI: false, - }); - - // Restore original values - Object.defineProperty(process, 'version', { value: originalVersion, configurable: true }); - Object.defineProperty(process, 'title', { value: originalTitle, configurable: true }); - }); - - it('should use fallback values when headers are missing', async () => { - // Clear environment variables - vi.stubEnv('PORT', undefined); - vi.stubEnv('npm_config_user_agent', undefined); - - // Mock empty headers using createMockHeaders helper with all null values - mockHeaders.mockResolvedValue( - createMockHeaders({ - 'User-Agent': null, - host: null, - 'x-forwarded-port': null, - 'x-forwarded-host': null, - 'x-forwarded-proto': null, - 'x-clerk-auth-status': null, - }), - ); - - const result = await collectKeylessMetadata(); - - expect(result.userAgent).toBe('unknown user-agent'); - expect(result.host).toBe('unknown host'); - expect(result.xPort).toBe('unknown x-forwarded-port'); - expect(result.xHost).toBe('unknown x-forwarded-host'); - expect(result.xProtocol).toBe('unknown x-forwarded-proto'); - expect(result.xClerkAuthStatus).toBe('unknown x-clerk-auth-status'); - expect(result.port).toBeUndefined(); - expect(result.npmConfigUserAgent).toBeUndefined(); - }); - - it('should handle process.title extraction errors gracefully', async () => { - // Mock process.title to throw an error - const originalTitle = process.title; - Object.defineProperty(process, 'title', { - get: () => { - throw new Error('Process title access error'); - }, - configurable: true, - }); - - mockHeaders.mockResolvedValue({ - get: () => null, - has: () => false, - forEach: () => {}, - entries: function* () {}, - keys: function* () {}, - values: function* () {}, - } as MockHeaders); - - const result = await collectKeylessMetadata(); - - expect(result.nextVersion).toBeUndefined(); - - // Restore original value - Object.defineProperty(process, 'title', { value: originalTitle, configurable: true }); - }); - - it('should demonstrate partial header overrides with createMockHeaders', async () => { - // Only override specific headers, keeping defaults for others - mockHeaders.mockResolvedValue( - createMockHeaders({ - 'User-Agent': 'Partial-Override-Agent/2.0', - 'x-clerk-auth-status': 'signed-out', - // Other headers will use default values from defaultMockHeaders - }), - ); - - const result = await collectKeylessMetadata(); - - // Overridden headers - expect(result.userAgent).toBe('Partial-Override-Agent/2.0'); - expect(result.xClerkAuthStatus).toBe('signed-out'); - - // Default headers (unchanged) - expect(result.host).toBe('test-host.example.com'); - expect(result.xPort).toBe('3000'); - expect(result.xHost).toBe('forwarded-test-host.example.com'); - expect(result.xProtocol).toBe('https'); - }); - }); - - it('should format metadata collected from collectKeylessMetadata correctly', async () => { - // Setup environment - vi.stubEnv('PORT', '4000'); - vi.stubEnv('npm_config_user_agent', 'test-npm-agent'); - - const mockHeaderStore = new Headers({ - 'User-Agent': 'Integration-Test-Agent', - host: 'localhost:4000', - 'x-forwarded-port': '4000', - 'x-forwarded-host': 'integration-forwarded-host', - 'x-forwarded-proto': 'https', - 'x-clerk-auth-status': 'integration-status', - }); - - mockHeaders.mockResolvedValue({ - get: (key: string) => mockHeaderStore.get(key) || null, - has: (key: string) => mockHeaderStore.has(key), - forEach: () => {}, - entries: function* () { - const headerEntries: [string, string][] = []; - mockHeaderStore.forEach((value, key) => headerEntries.push([key, value])); - for (const entry of headerEntries) { - yield entry; - } - }, - keys: function* () { - const headerKeys: string[] = []; - mockHeaderStore.forEach((_, key) => headerKeys.push(key)); - for (const key of headerKeys) { - yield key; - } - }, - values: function* () { - const headerValues: string[] = []; - mockHeaderStore.forEach(value => headerValues.push(value)); - for (const value of headerValues) { - yield value; - } - }, - } as MockHeaders); - - // Collect metadata and format headers - const metadata = await collectKeylessMetadata(); - const headers = await formatMetadataHeaders(metadata); - - // Verify the full pipeline works correctly - expect(headers.get('Clerk-Client-User-Agent')).toBe('Integration-Test-Agent'); - expect(headers.get('Clerk-Client-Host')).toBe('localhost:4000'); - expect(headers.get('Clerk-Node-Port')).toBe('4000'); - expect(headers.get('Clerk-X-Port')).toBe('4000'); - expect(headers.get('Clerk-X-Host')).toBe('integration-forwarded-host'); - expect(headers.get('Clerk-X-Protocol')).toBe('https'); - expect(headers.get('Clerk-Auth-Status')).toBe('integration-status'); - expect(headers.get('Clerk-NPM-Config-User-Agent')).toBe('test-npm-agent'); - }); -}); diff --git a/packages/tanstack-react-start/src/server/loadOptions.ts b/packages/tanstack-react-start/src/server/loadOptions.ts index a5f8a00db19..aba4f85019f 100644 --- a/packages/tanstack-react-start/src/server/loadOptions.ts +++ b/packages/tanstack-react-start/src/server/loadOptions.ts @@ -5,8 +5,8 @@ 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 { canUseKeyless } from '../utils/feature-flags'; import { commonEnvs } from './constants'; import type { LoaderOptions } from './types'; From e8be44252c35ebfd2ae31de44bca5a19bff5fd29 Mon Sep 17 00:00:00 2001 From: brkalow Date: Mon, 5 Jan 2026 12:29:24 -0600 Subject: [PATCH 06/13] Address PR feedback --- packages/nextjs/src/server/keyless-node.ts | 120 +---------------- 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/src/keyless/index.ts | 3 + packages/shared/src/keyless/types.ts | 14 +- .../src/server/clerkMiddleware.ts | 34 ++--- .../src/server/keyless/fileStorage.ts | 121 +----------------- .../src/utils/feature-flags.ts | 10 +- 10 files changed, 191 insertions(+), 259 deletions(-) create mode 100644 packages/shared/docs/use-clerk.md create mode 100644 packages/shared/docs/use-session-list.md create mode 100644 packages/shared/docs/use-session.md create mode 100644 packages/shared/docs/use-user.md diff --git a/packages/nextjs/src/server/keyless-node.ts b/packages/nextjs/src/server/keyless-node.ts index 4d92e74f099..eb01ef20691 100644 --- a/packages/nextjs/src/server/keyless-node.ts +++ b/packages/nextjs/src/server/keyless-node.ts @@ -1,125 +1,17 @@ -import { createKeylessService, type KeylessStorage } from '@clerk/shared/keyless'; +import { createKeylessService, createNodeFileStorage } from '@clerk/shared/keyless'; import { createClerkClientWithOptions } from './createClerkClient'; import { nodeCwdOrThrow, nodeFsOrThrow, nodePathOrThrow } from './fs/utils'; -const CLERK_HIDDEN = '.clerk'; -const CLERK_LOCK = 'clerk.lock'; -const TEMP_DIR_NAME = '.tmp'; -const CONFIG_FILE = 'keyless.json'; -const README_FILE = 'README.md'; - -function createFileStorage(): KeylessStorage { +function createFileStorage() { const fs = nodeFsOrThrow(); const path = nodePathOrThrow(); const cwd = nodeCwdOrThrow(); - 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 = () => { - 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 ''; - } - }, - - 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(); - } - }, - }; + return createNodeFileStorage(fs, path, { + cwd, + frameworkPackageName: '@clerk/nextjs', + }); } // Lazily initialized keyless service singleton diff --git a/packages/shared/docs/use-clerk.md b/packages/shared/docs/use-clerk.md new file mode 100644 index 00000000000..839672b69cf --- /dev/null +++ b/packages/shared/docs/use-clerk.md @@ -0,0 +1,15 @@ + + +```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 new file mode 100644 index 00000000000..c4441a59b95 --- /dev/null +++ b/packages/shared/docs/use-session-list.md @@ -0,0 +1,24 @@ + + +```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 new file mode 100644 index 00000000000..95be5884665 --- /dev/null +++ b/packages/shared/docs/use-session.md @@ -0,0 +1,28 @@ + + +```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 new file mode 100644 index 00000000000..106804ac014 --- /dev/null +++ b/packages/shared/docs/use-user.md @@ -0,0 +1,81 @@ + + +```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/src/keyless/index.ts b/packages/shared/src/keyless/index.ts index be26eef7603..42c0089949d 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 { createNodeFileStorage } from './nodeFileStorage'; +export type { FileSystemAdapter, NodeFileStorageOptions, PathAdapter } from './nodeFileStorage'; + export { createKeylessService } from './service'; export type { KeylessAPI, KeylessService, KeylessServiceOptions, KeylessStorage } from './service'; diff --git a/packages/shared/src/keyless/types.ts b/packages/shared/src/keyless/types.ts index f2ec8075098..18b13e1e57a 100644 --- a/packages/shared/src/keyless/types.ts +++ b/packages/shared/src/keyless/types.ts @@ -1,12 +1,16 @@ /** * Represents an accountless application created in keyless mode. - * This matches the structure returned by the Clerk API. + * + * This interface matches the shape of `AccountlessApplication` from `@clerk/backend`. + * We define it here to avoid a circular dependency (shared cannot depend on backend). + * Framework packages that depend on both shared and backend can use either type + * interchangeably since they have the same structure. */ export interface AccountlessApplication { - publishableKey: string; - secretKey: string; - claimUrl: string; - apiKeysUrl: string; + readonly publishableKey: string; + readonly secretKey: string; + readonly claimUrl: string; + readonly apiKeysUrl: string; } /** diff --git a/packages/tanstack-react-start/src/server/clerkMiddleware.ts b/packages/tanstack-react-start/src/server/clerkMiddleware.ts index 7660014fbab..0cb27cc16c9 100644 --- a/packages/tanstack-react-start/src/server/clerkMiddleware.ts +++ b/packages/tanstack-react-start/src/server/clerkMiddleware.ts @@ -7,7 +7,7 @@ import { createMiddleware, json } from '@tanstack/react-start'; import { canUseKeyless } from '../utils/feature-flags'; import { clerkClient } from './clerkClient'; -import { keyless } from './keyless'; +import { resolveKeysWithKeylessFallback } from './keyless/utils'; import { loadOptions } from './loadOptions'; import type { ClerkMiddlewareOptions } from './types'; import { getResponseClerkState, patchRequest } from './utils'; @@ -17,33 +17,19 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): AnyRequestMid 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 keylessService = keyless(); - const keylessApp = await keylessService.getOrCreateKeys(); - if (keylessApp) { - publishableKey = publishableKey || keylessApp.publishableKey; - secretKey = secretKey || keylessApp.secretKey; - keylessClaimUrl = keylessApp.claimUrl; - keylessApiKeysUrl = keylessApp.apiKeysUrl; - - keylessService.logKeylessMessage(keylessApp.claimUrl); - } - } + const { + publishableKey, + secretKey, + claimUrl: keylessClaimUrl, + apiKeysUrl: keylessApiKeysUrl, + } = await resolveKeysWithKeylessFallback(options?.publishableKey, options?.secretKey); - // Load options with keyless fallback - const effectiveOptions = { + // Load options with resolved keys + const loadedOptions = loadOptions(clerkRequest, { ...options, publishableKey, secretKey, - }; - - const loadedOptions = loadOptions(clerkRequest, effectiveOptions); + }); const requestState = await clerkClient().authenticateRequest(clerkRequest, { ...loadedOptions, diff --git a/packages/tanstack-react-start/src/server/keyless/fileStorage.ts b/packages/tanstack-react-start/src/server/keyless/fileStorage.ts index ad7661931e0..24929cc7ebd 100644 --- a/packages/tanstack-react-start/src/server/keyless/fileStorage.ts +++ b/packages/tanstack-react-start/src/server/keyless/fileStorage.ts @@ -1,13 +1,9 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import type { KeylessStorage } from '@clerk/shared/keyless'; +import { createNodeFileStorage, 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 type { KeylessStorage }; export interface FileStorageOptions { cwd?: () => string; @@ -16,113 +12,8 @@ export interface FileStorageOptions { 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 = () => { - 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, - }); - }; - - 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(); - } - }, - }; + return createNodeFileStorage(fs, path, { + cwd, + frameworkPackageName: '@clerk/tanstack-react-start', + }); } diff --git a/packages/tanstack-react-start/src/utils/feature-flags.ts b/packages/tanstack-react-start/src/utils/feature-flags.ts index 62eaab2c237..a2ec57f481f 100644 --- a/packages/tanstack-react-start/src/utils/feature-flags.ts +++ b/packages/tanstack-react-start/src/utils/feature-flags.ts @@ -2,10 +2,18 @@ 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; +// Support both Vite-style and generic env var names for disabling keyless mode +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. + * + * To disable keyless mode, set either: + * - `VITE_CLERK_KEYLESS_DISABLED=1` (for Vite-based projects) + * - `CLERK_KEYLESS_DISABLED=1` (generic) */ export const canUseKeyless = isDevelopmentEnvironment() && !KEYLESS_DISABLED; From 851143625c2bc0507a1b65c577ebe3ccb0a2cbb2 Mon Sep 17 00:00:00 2001 From: brkalow Date: Mon, 5 Jan 2026 15:08:10 -0600 Subject: [PATCH 07/13] adds missing files --- .../shared/src/keyless/nodeFileStorage.ts | 159 ++++++++++++++++++ .../src/server/keyless/utils.ts | 45 +++++ 2 files changed, 204 insertions(+) create mode 100644 packages/shared/src/keyless/nodeFileStorage.ts create mode 100644 packages/tanstack-react-start/src/server/keyless/utils.ts diff --git a/packages/shared/src/keyless/nodeFileStorage.ts b/packages/shared/src/keyless/nodeFileStorage.ts new file mode 100644 index 00000000000..512747018ba --- /dev/null +++ b/packages/shared/src/keyless/nodeFileStorage.ts @@ -0,0 +1,159 @@ +import type { KeylessStorage } from './service'; + +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 NodeFileStorageOptions { + /** + * Function that returns the current working directory. + * Defaults to process.cwd(). + */ + cwd?: () => string; + + /** + * The framework name for the README message. + * @example '@clerk/nextjs' + */ + frameworkPackageName?: string; +} + +export interface FileSystemAdapter { + existsSync: (path: string) => boolean; + readFileSync: (path: string, options: { encoding: BufferEncoding }) => string; + writeFileSync: (path: string, data: string, options: { encoding: BufferEncoding; mode?: number }) => void; + appendFileSync: (path: string, data: string) => void; + mkdirSync: (path: string, options: { recursive: boolean }) => void; + rmSync: (path: string, options: { force?: boolean; recursive?: boolean }) => void; +} + +export interface PathAdapter { + join: (...paths: string[]) => string; +} + +/** + * Creates a file-based storage adapter for keyless mode. + * This is used by Node.js-based frameworks (Next.js, TanStack Start, etc.) + * to persist keyless configuration to the file system. + * + * @param fs - Node.js fs module or compatible adapter + * @param path - Node.js path module or compatible adapter + * @param options - Configuration options + * @returns A KeylessStorage implementation + */ +export function createNodeFileStorage( + fs: FileSystemAdapter, + path: PathAdapter, + options: NodeFileStorageOptions = {}, +): KeylessStorage { + const { cwd = () => process.cwd(), frameworkPackageName = '@clerk/shared' } = 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, { encoding: '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 \`${frameworkPackageName}\` 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 ''; + } + }, + + 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/utils.ts b/packages/tanstack-react-start/src/server/keyless/utils.ts new file mode 100644 index 00000000000..2271fc8d466 --- /dev/null +++ b/packages/tanstack-react-start/src/server/keyless/utils.ts @@ -0,0 +1,45 @@ +import type { AccountlessApplication } 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. + * + * @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) + */ +export async function resolveKeysWithKeylessFallback( + configuredPublishableKey: string | undefined, + configuredSecretKey: string | undefined, +): Promise { + let publishableKey = configuredPublishableKey; + let secretKey = configuredSecretKey; + let claimUrl: string | undefined; + let apiKeysUrl: string | undefined; + + // In keyless mode, try to read/create keys from the file system + if (canUseKeyless && (!publishableKey || !secretKey)) { + const keylessService = keyless(); + const keylessApp: AccountlessApplication | null = await keylessService.getOrCreateKeys(); + + if (keylessApp) { + publishableKey = publishableKey || keylessApp.publishableKey; + secretKey = secretKey || keylessApp.secretKey; + claimUrl = keylessApp.claimUrl; + apiKeysUrl = keylessApp.apiKeysUrl; + + keylessService.logKeylessMessage(keylessApp.claimUrl); + } + } + + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; +} From c61d23813b754a3c72d821b29064b8acee174f62 Mon Sep 17 00:00:00 2001 From: brkalow Date: Mon, 5 Jan 2026 16:21:55 -0600 Subject: [PATCH 08/13] Adds changeset --- .changeset/shaggy-rocks-lick.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/shaggy-rocks-lick.md diff --git a/.changeset/shaggy-rocks-lick.md b/.changeset/shaggy-rocks-lick.md new file mode 100644 index 00000000000..305bdde85e4 --- /dev/null +++ b/.changeset/shaggy-rocks-lick.md @@ -0,0 +1,6 @@ +--- +'@clerk/tanstack-react-start': minor +'@clerk/shared': minor +--- + +Introduce Keyless quickstart for TanStack. This allows the Clerk SDK to be used without having to sign up and paste your keys manually. From c9a357602de8bb7745ffb802d0c076ca0bbb9f43 Mon Sep 17 00:00:00 2001 From: brkalow Date: Tue, 6 Jan 2026 22:43:36 -0600 Subject: [PATCH 09/13] adds integration test --- .../tests/tanstack-start/keyless.test.ts | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 integration/tests/tanstack-start/keyless.test.ts diff --git a/integration/tests/tanstack-start/keyless.test.ts b/integration/tests/tanstack-start/keyless.test.ts new file mode 100644 index 00000000000..0b258da36bb --- /dev/null +++ b/integration/tests/tanstack-start/keyless.test.ts @@ -0,0 +1,133 @@ +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'; + +const commonSetup = appConfigs.tanstack.reactStartQuickstart.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); + + 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('Claimed application with keys inside .env, on dismiss, keyless prompt is removed.', async ({ + page, + context, + }) => { + await mockClaimedInstanceEnvironmentCall(page); + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + + await u.po.keylessPopover.waitForMounted(); + await expect(await u.po.keylessPopover.promptToUseClaimedKeys()).toBeVisible(); + + await app.keylessToEnv(); + await page.waitForTimeout(5_000); + + await page.reload(); + await u.po.keylessPopover.waitForMounted(); + await u.po.keylessPopover.promptToDismiss().click(); + + await u.po.keylessPopover.waitForUnmounted(); + }); +}); From 18a855890e05b56bf1d1c44ce930910928d58613 Mon Sep 17 00:00:00 2001 From: brkalow Date: Wed, 7 Jan 2026 12:03:19 -0600 Subject: [PATCH 10/13] fix(integration): use correct tanstack preset name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- integration/tests/tanstack-start/keyless.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/tests/tanstack-start/keyless.test.ts b/integration/tests/tanstack-start/keyless.test.ts index 0b258da36bb..9000403afde 100644 --- a/integration/tests/tanstack-start/keyless.test.ts +++ b/integration/tests/tanstack-start/keyless.test.ts @@ -5,7 +5,7 @@ import type { Application } from '../../models/application'; import { appConfigs } from '../../presets'; import { createTestUtils } from '../../testUtils'; -const commonSetup = appConfigs.tanstack.reactStartQuickstart.clone(); +const commonSetup = appConfigs.tanstack.reactStart.clone(); const mockClaimedInstanceEnvironmentCall = async (page: Page) => { await page.route('*/**/v1/environment*', async route => { From 7e41375af363b7089add6e396d6aeb2ce4cb3684 Mon Sep 17 00:00:00 2001 From: brkalow Date: Wed, 7 Jan 2026 23:44:03 -0600 Subject: [PATCH 11/13] fix(integration): resolve multiple React versions and improve keyless tests - Add resolve.dedupe and ssr.noExternal config to vite.config.ts to fix multiple React instances issue when @clerk packages are linked from workspace - Add restart() method to Application model for Vite-based apps that don't hot-reload .env changes - Update keyless test to use restart flow and verify popover is removed after adding keys to .env Co-Authored-By: Claude Opus 4.5 --- integration/models/application.ts | 9 ++++++++- .../tanstack-react-start/vite.config.ts | 6 ++++++ .../tests/tanstack-start/keyless.test.ts | 18 ++++++++---------- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/integration/models/application.ts b/integration/models/application.ts index c4b2b059a92..fd4a4d00fa6 100644 --- a/integration/models/application.ts +++ b/integration/models/application.ts @@ -17,7 +17,7 @@ export const application = ( ) => { const { name, scripts, envWriter, copyKeylessToEnv } = config; const logger = createLogger({ prefix: `${appDirName}` }); - const state = { completedSetup: false, serverUrl: '', env: {} as EnvironmentConfig }; + const state = { completedSetup: false, serverUrl: '', env: {} as EnvironmentConfig, lastDevPort: 0 }; const cleanupFns: { (): unknown }[] = []; const now = Date.now(); const stdoutFilePath = path.resolve(appDirPath, `e2e.${now}.log`); @@ -119,8 +119,15 @@ export const application = ( } } + state.lastDevPort = port; return { port, serverUrl: runtimeServerUrl, pid: proc.pid }; }, + restart: async () => { + const log = logger.child({ prefix: 'restart' }).info; + log('Restarting dev server...'); + await self.stop(); + return self.dev({ port: state.lastDevPort }); + }, build: async () => { const log = logger.child({ prefix: 'build' }).info; await run(scripts.build, { diff --git a/integration/templates/tanstack-react-start/vite.config.ts b/integration/templates/tanstack-react-start/vite.config.ts index bce0dc21dd9..d41323865c4 100644 --- a/integration/templates/tanstack-react-start/vite.config.ts +++ b/integration/templates/tanstack-react-start/vite.config.ts @@ -13,4 +13,10 @@ export default defineConfig({ tailwindcss(), viteReact(), ], + resolve: { + dedupe: ['react', 'react-dom'], + }, + ssr: { + noExternal: [/@clerk\/.*/, 'swr'], + }, }); diff --git a/integration/tests/tanstack-start/keyless.test.ts b/integration/tests/tanstack-start/keyless.test.ts index 9000403afde..759409b4929 100644 --- a/integration/tests/tanstack-start/keyless.test.ts +++ b/integration/tests/tanstack-start/keyless.test.ts @@ -110,24 +110,22 @@ test.describe('Keyless mode @tanstack-react-start', () => { }); }); - test('Claimed application with keys inside .env, on dismiss, keyless prompt is removed.', async ({ - page, - context, - }) => { - await mockClaimedInstanceEnvironmentCall(page); + 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(); - await expect(await u.po.keylessPopover.promptToUseClaimedKeys()).toBeVisible(); + expect(await u.po.keylessPopover.isExpanded()).toBe(false); + // Copy keys from keyless.json to .env await app.keylessToEnv(); - await page.waitForTimeout(5_000); - await page.reload(); - await u.po.keylessPopover.waitForMounted(); - await u.po.keylessPopover.promptToDismiss().click(); + // 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 a8054d34faaf4048f4f2012d5e66f088589b9920 Mon Sep 17 00:00:00 2001 From: brkalow Date: Thu, 8 Jan 2026 15:24:50 -0600 Subject: [PATCH 12/13] use completeOnboarding() --- .../app-router/server/keyless-provider.tsx | 23 +++------- .../src/server/clerkMiddleware.ts | 23 ++++++---- .../src/server/keyless/utils.ts | 42 +++++++++++++++++-- 3 files changed, 61 insertions(+), 27 deletions(-) diff --git a/packages/nextjs/src/app-router/server/keyless-provider.tsx b/packages/nextjs/src/app-router/server/keyless-provider.tsx index 31f3ab5c75d..091aff7b36b 100644 --- a/packages/nextjs/src/app-router/server/keyless-provider.tsx +++ b/packages/nextjs/src/app-router/server/keyless-provider.tsx @@ -4,7 +4,6 @@ import { headers } from 'next/headers'; import type { PropsWithChildren } from 'react'; import React from 'react'; -import { createClerkClientWithOptions } from '../../server/createClerkClient'; import type { NextClerkProviderProps } from '../../types'; import { canUseKeyless } from '../../utils/feature-flags'; import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv'; @@ -83,26 +82,16 @@ export const KeylessProvider = async (props: KeylessProviderProps) => { if (runningWithClaimedKeys) { try { - 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/`'); - } - const client = createClerkClientWithOptions({ - secretKey, - }); + const keylessService = await import('../../server/keyless-node.js').then(mod => mod.keyless()); /** - * Notifying the dashboard the should runs once. We are controlling this behaviour by caching the result of the request. + * Notifying the dashboard should run 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(), - { - cacheKey: `${newOrReadKeys.publishableKey}_complete`, - onSuccessStale: 24 * 60 * 60 * 1000, // 24 hours - }, - ); + await clerkDevelopmentCache?.run(() => keylessService.completeOnboarding(), { + cacheKey: `${newOrReadKeys.publishableKey}_complete`, + onSuccessStale: 24 * 60 * 60 * 1000, // 24 hours + }); } catch { // noop } diff --git a/packages/tanstack-react-start/src/server/clerkMiddleware.ts b/packages/tanstack-react-start/src/server/clerkMiddleware.ts index 0cb27cc16c9..2248cbf7680 100644 --- a/packages/tanstack-react-start/src/server/clerkMiddleware.ts +++ b/packages/tanstack-react-start/src/server/clerkMiddleware.ts @@ -16,20 +16,29 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): AnyRequestMid return createMiddleware().server(async ({ request, next }) => { const clerkRequest = createClerkRequest(patchRequest(request)); + // Load options with resolved keys + const loadedOptions = loadOptions(clerkRequest, { + ...options, + publishableKey: options?.publishableKey, + secretKey: options?.secretKey, + }); + + console.log({ loadedOptions }); + // Get keys - either from options, env, or keyless mode const { publishableKey, secretKey, claimUrl: keylessClaimUrl, apiKeysUrl: keylessApiKeysUrl, - } = await resolveKeysWithKeylessFallback(options?.publishableKey, options?.secretKey); + } = await resolveKeysWithKeylessFallback(loadedOptions.publishableKey, loadedOptions.secretKey); - // Load options with resolved keys - const loadedOptions = loadOptions(clerkRequest, { - ...options, - publishableKey, - secretKey, - }); + if (publishableKey) { + loadedOptions.publishableKey = publishableKey; + } + if (secretKey) { + loadedOptions.secretKey = secretKey; + } const requestState = await clerkClient().authenticateRequest(clerkRequest, { ...loadedOptions, diff --git a/packages/tanstack-react-start/src/server/keyless/utils.ts b/packages/tanstack-react-start/src/server/keyless/utils.ts index 2271fc8d466..4a149f4fbe2 100644 --- a/packages/tanstack-react-start/src/server/keyless/utils.ts +++ b/packages/tanstack-react-start/src/server/keyless/utils.ts @@ -1,4 +1,5 @@ import type { AccountlessApplication } from '@clerk/shared/keyless'; +import { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } from '@clerk/shared/keyless'; import { canUseKeyless } from '../../utils/feature-flags'; import { keyless } from './index'; @@ -26,9 +27,41 @@ export async function resolveKeysWithKeylessFallback( let claimUrl: string | undefined; let apiKeysUrl: string | undefined; + if (!canUseKeyless) { + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; + } + + console.log({ publishableKey, configuredPublishableKey }); + console.log({ secretKey, configuredSecretKey }); + + 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 (canUseKeyless && (!publishableKey || !secretKey)) { - const keylessService = keyless(); + if (!publishableKey || !secretKey) { const keylessApp: AccountlessApplication | null = await keylessService.getOrCreateKeys(); if (keylessApp) { @@ -37,7 +70,10 @@ export async function resolveKeysWithKeylessFallback( claimUrl = keylessApp.claimUrl; apiKeysUrl = keylessApp.apiKeysUrl; - keylessService.logKeylessMessage(keylessApp.claimUrl); + clerkDevelopmentCache?.log({ + cacheKey: keylessApp.publishableKey, + msg: createKeylessModeMessage(keylessApp), + }); } } From 57e621a985822f0d60252026f4b669d79d067a20 Mon Sep 17 00:00:00 2001 From: brkalow Date: Thu, 8 Jan 2026 19:31:26 -0600 Subject: [PATCH 13/13] Remove logs --- packages/tanstack-react-start/src/server/clerkMiddleware.ts | 2 -- packages/tanstack-react-start/src/server/keyless/utils.ts | 3 --- 2 files changed, 5 deletions(-) diff --git a/packages/tanstack-react-start/src/server/clerkMiddleware.ts b/packages/tanstack-react-start/src/server/clerkMiddleware.ts index 2248cbf7680..a994881c43b 100644 --- a/packages/tanstack-react-start/src/server/clerkMiddleware.ts +++ b/packages/tanstack-react-start/src/server/clerkMiddleware.ts @@ -23,8 +23,6 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): AnyRequestMid secretKey: options?.secretKey, }); - console.log({ loadedOptions }); - // Get keys - either from options, env, or keyless mode const { publishableKey, diff --git a/packages/tanstack-react-start/src/server/keyless/utils.ts b/packages/tanstack-react-start/src/server/keyless/utils.ts index 4a149f4fbe2..3a22f0aae86 100644 --- a/packages/tanstack-react-start/src/server/keyless/utils.ts +++ b/packages/tanstack-react-start/src/server/keyless/utils.ts @@ -31,9 +31,6 @@ export async function resolveKeysWithKeylessFallback( return { publishableKey, secretKey, claimUrl, apiKeysUrl }; } - console.log({ publishableKey, configuredPublishableKey }); - console.log({ secretKey, configuredSecretKey }); - const keylessService = keyless(); const locallyStoredKeys = keylessService.readKeys();