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. 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 new file mode 100644 index 00000000000..759409b4929 --- /dev/null +++ b/integration/tests/tanstack-start/keyless.test.ts @@ -0,0 +1,131 @@ +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.reactStart.clone(); + +const mockClaimedInstanceEnvironmentCall = async (page: Page) => { + await page.route('*/**/v1/environment*', async route => { + const response = await route.fetch(); + const json = await response.json(); + const newJson = { + ...json, + auth_config: { + ...json.auth_config, + claimed_at: Date.now(), + }, + }; + await route.fulfill({ response, json: newJson }); + }); +}; + +test.describe('Keyless mode @tanstack-react-start', () => { + test.describe.configure({ mode: 'serial' }); + test.setTimeout(90_000); + + test.use({ + extraHTTPHeaders: { + 'x-vercel-protection-bypass': process.env.VERCEL_AUTOMATION_BYPASS_SECRET || '', + }, + }); + + let app: Application; + let dashboardUrl = 'https://dashboard.clerk.com/'; + + test.beforeAll(async () => { + app = await commonSetup.commit(); + await app.setup(); + await app.withEnv(appConfigs.envs.withKeyless); + if (appConfigs.envs.withKeyless.privateVariables.get('CLERK_API_URL')?.includes('clerkstage')) { + dashboardUrl = 'https://dashboard.clerkstage.dev/'; + } + await app.dev(); + }); + + test.afterAll(async () => { + // Keep files for debugging + await app?.teardown(); + }); + + test('Toggle collapse popover and claim.', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + await u.po.expect.toBeSignedOut(); + + await u.po.keylessPopover.waitForMounted(); + + expect(await u.po.keylessPopover.isExpanded()).toBe(false); + await u.po.keylessPopover.toggle(); + expect(await u.po.keylessPopover.isExpanded()).toBe(true); + + const claim = await u.po.keylessPopover.promptsToClaim(); + + const [newPage] = await Promise.all([context.waitForEvent('page'), claim.click()]); + + await newPage.waitForLoadState(); + + await newPage.waitForURL(url => { + const urlToReturnTo = `${dashboardUrl}apps/claim?token=`; + + const signUpForceRedirectUrl = url.searchParams.get('sign_up_force_redirect_url'); + + const signUpForceRedirectUrlCheck = + signUpForceRedirectUrl?.startsWith(urlToReturnTo) || + (signUpForceRedirectUrl?.startsWith(`${dashboardUrl}prepare-account`) && + signUpForceRedirectUrl?.includes(encodeURIComponent('apps/claim?token='))); + + return ( + url.pathname === '/apps/claim/sign-in' && + url.searchParams.get('sign_in_force_redirect_url')?.startsWith(urlToReturnTo) && + signUpForceRedirectUrlCheck + ); + }); + }); + + test('Lands on claimed application with missing explicit keys, expanded by default, click to get keys from dashboard.', async ({ + page, + context, + }) => { + await mockClaimedInstanceEnvironmentCall(page); + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + + await u.po.keylessPopover.waitForMounted(); + expect(await u.po.keylessPopover.isExpanded()).toBe(true); + await expect(u.po.keylessPopover.promptToUseClaimedKeys()).toBeVisible(); + + const [newPage] = await Promise.all([ + context.waitForEvent('page'), + u.po.keylessPopover.promptToUseClaimedKeys().click(), + ]); + + await newPage.waitForLoadState(); + await newPage.waitForURL(url => { + return url.href.startsWith(`${dashboardUrl}sign-in?redirect_url=${encodeURIComponent(dashboardUrl)}apps%2Fapp_`); + }); + }); + + test('Keyless popover is removed after adding keys to .env and restarting.', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + + await u.po.keylessPopover.waitForMounted(); + expect(await u.po.keylessPopover.isExpanded()).toBe(false); + + // Copy keys from keyless.json to .env + await app.keylessToEnv(); + + // Restart the dev server to pick up new env vars (Vite doesn't hot-reload .env) + await app.restart(); + + await u.page.goToAppHome(); + + // Keyless popover should no longer be present since we now have explicit keys + await u.po.keylessPopover.waitForUnmounted(); + }); +}); diff --git a/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/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..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; @@ -54,7 +53,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 +89,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 +42,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,34 +82,16 @@ export const KeylessProvider = async (props: KeylessProviderProps) => { if (runningWithClaimedKeys) { try { - const secretKey = await import('../../server/keyless-node.js').then(mod => mod.safeParseClerkFile()?.secretKey); - if (!secretKey) { - // we will ignore it later - throw new Error('Missing secret key from `.clerk/`'); - } - const client = createClerkClientWithOptions({ - secretKey, - }); - - // Collect metadata - const keylessHeaders = await collectKeylessMetadata() - .then(formatMetadataHeaders) - .catch(() => new Headers()); + 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({ - requestHeaders: keylessHeaders, - }), - { - 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/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..eb01ef20691 100644 --- a/packages/nextjs/src/server/keyless-node.ts +++ b/packages/nextjs/src/server/keyless-node.ts @@ -1,200 +1,50 @@ -import type { AccountlessApplication } from '@clerk/backend'; +import { createKeylessService, createNodeFileStorage } 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'; - -/** - * 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() { + 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`); - } + return createNodeFileStorage(fs, path, { + cwd, + frameworkPackageName: '@clerk/nextjs', + }); } -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'); - -let isCreatingFile = false; - -export function safeParseClerkFile(): AccountlessApplication | undefined { - const { readFileSync } = nodeFsOrThrow(); - try { - const CONFIG_PATH = getKeylessConfigurationPath(); - let fileAsString; - try { - fileAsString = readFileSync(CONFIG_PATH, { encoding: 'utf-8' }) || '{}'; - } catch { - fileAsString = '{}'; - } - 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(); - - /** - * When the configuration file exists, always read the keys from the file - */ - const envVarsMap = safeParseClerkFile(); - if (envVarsMap?.publishableKey && envVarsMap?.secretKey) { - unlockFileWriting(); - - return envVarsMap; - } - - /** - * 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', - }); - - // 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. - `; - - writeFileSync(README_PATH, README_NOTIFICATION, { - encoding: 'utf8', - mode: '0777', - flag: 'w', +// 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 accountlessApplication; + return keylessServiceInstance; } - -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 - } - - /** - * Clean up locks. - */ - unlockFileWriting(); -} - -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/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/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..42c0089949d --- /dev/null +++ b/packages/shared/src/keyless/index.ts @@ -0,0 +1,15 @@ +export { + clerkDevelopmentCache, + createClerkDevCache, + createConfirmationMessage, + createKeylessModeMessage, +} 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'; + +export type { AccountlessApplication, PublicKeylessApplication } from './types'; 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/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..18b13e1e57a --- /dev/null +++ b/packages/shared/src/keyless/types.ts @@ -0,0 +1,19 @@ +/** + * Represents an accountless application created in keyless mode. + * + * 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 { + readonly publishableKey: string; + readonly secretKey: string; + readonly claimUrl: string; + readonly 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}', 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..a994881c43b 100644 --- a/packages/tanstack-react-start/src/server/clerkMiddleware.ts +++ b/packages/tanstack-react-start/src/server/clerkMiddleware.ts @@ -5,15 +5,39 @@ 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 { resolveKeysWithKeylessFallback } from './keyless/utils'; 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)); + + // Load options with resolved keys + const loadedOptions = loadOptions(clerkRequest, { + ...options, + publishableKey: options?.publishableKey, + secretKey: options?.secretKey, + }); + + // Get keys - either from options, env, or keyless mode + const { + publishableKey, + secretKey, + claimUrl: keylessClaimUrl, + apiKeysUrl: keylessApiKeysUrl, + } = await resolveKeysWithKeylessFallback(loadedOptions.publishableKey, loadedOptions.secretKey); + + if (publishableKey) { + loadedOptions.publishableKey = publishableKey; + } + if (secretKey) { + loadedOptions.secretKey = secretKey; + } + const requestState = await clerkClient().authenticateRequest(clerkRequest, { ...loadedOptions, acceptsToken: 'any', @@ -37,7 +61,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..24929cc7ebd --- /dev/null +++ b/packages/tanstack-react-start/src/server/keyless/fileStorage.ts @@ -0,0 +1,19 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { createNodeFileStorage, type KeylessStorage } from '@clerk/shared/keyless'; + +export type { KeylessStorage }; + +export interface FileStorageOptions { + cwd?: () => string; +} + +export function createFileStorage(options: FileStorageOptions = {}): KeylessStorage { + const { cwd = () => process.cwd() } = options; + + return createNodeFileStorage(fs, path, { + cwd, + frameworkPackageName: '@clerk/tanstack-react-start', + }); +} diff --git a/packages/tanstack-react-start/src/server/keyless/index.ts b/packages/tanstack-react-start/src/server/keyless/index.ts new file mode 100644 index 00000000000..590edfa9d84 --- /dev/null +++ b/packages/tanstack-react-start/src/server/keyless/index.ts @@ -0,0 +1,37 @@ +import { createKeylessService } from '@clerk/shared/keyless'; + +import { clerkClient } from '../clerkClient'; +import { createFileStorage } from './fileStorage'; + +// Lazily initialized keyless service singleton +let keylessServiceInstance: ReturnType | null = null; + +export function keyless() { + if (!keylessServiceInstance) { + keylessServiceInstance = createKeylessService({ + storage: createFileStorage(), + api: { + async 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; +} 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..3a22f0aae86 --- /dev/null +++ b/packages/tanstack-react-start/src/server/keyless/utils.ts @@ -0,0 +1,78 @@ +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'; + +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; + + if (!canUseKeyless) { + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; + } + + const keylessService = keyless(); + const locallyStoredKeys = keylessService.readKeys(); + + // Check if running with claimed keys (configured keys match locally stored keyless keys) + const runningWithClaimedKeys = + Boolean(configuredPublishableKey) && configuredPublishableKey === locallyStoredKeys?.publishableKey; + + if (runningWithClaimedKeys && locallyStoredKeys) { + // Complete onboarding when running with claimed keys + try { + await clerkDevelopmentCache?.run(() => keylessService.completeOnboarding(), { + cacheKey: `${locallyStoredKeys.publishableKey}_complete`, + onSuccessStale: 24 * 60 * 60 * 1000, // 24 hours + }); + } catch { + // noop + } + + clerkDevelopmentCache?.log({ + cacheKey: `${locallyStoredKeys.publishableKey}_claimed`, + msg: createConfirmationMessage(), + }); + + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; + } + + // In keyless mode, try to read/create keys from the file system + if (!publishableKey || !secretKey) { + const keylessApp: AccountlessApplication | null = await keylessService.getOrCreateKeys(); + + if (keylessApp) { + publishableKey = publishableKey || keylessApp.publishableKey; + secretKey = secretKey || keylessApp.secretKey; + claimUrl = keylessApp.claimUrl; + apiKeysUrl = keylessApp.apiKeysUrl; + + clerkDevelopmentCache?.log({ + cacheKey: keylessApp.publishableKey, + msg: createKeylessModeMessage(keylessApp), + }); + } + } + + return { publishableKey, secretKey, claimUrl, apiKeysUrl }; +} diff --git a/packages/tanstack-react-start/src/server/loadOptions.ts b/packages/tanstack-react-start/src/server/loadOptions.ts index 5fc6e348618..aba4f85019f 100644 --- a/packages/tanstack-react-start/src/server/loadOptions.ts +++ b/packages/tanstack-react-start/src/server/loadOptions.ts @@ -6,6 +6,7 @@ import { isHttpOrHttps, isProxyUrlRelative } from '@clerk/shared/proxy'; import { handleValueOrFn } from '@clerk/shared/utils'; import { errorThrower } from '../utils'; +import { canUseKeyless } from '../utils/feature-flags'; 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..a2ec57f481f --- /dev/null +++ b/packages/tanstack-react-start/src/utils/feature-flags.ts @@ -0,0 +1,19 @@ +import { getEnvVariable } from '@clerk/shared/getEnvVariable'; +import { isTruthy } from '@clerk/shared/underscore'; +import { isDevelopmentEnvironment } from '@clerk/shared/utils'; + +// Support both Vite-style and generic env var names 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;