diff --git a/.changeset/astro-keyless-cli-init-error.md b/.changeset/astro-keyless-cli-init-error.md new file mode 100644 index 00000000000..0c3cb5ff244 --- /dev/null +++ b/.changeset/astro-keyless-cli-init-error.md @@ -0,0 +1,6 @@ +--- +'@clerk/astro': minor +'@clerk/shared': patch +--- + +In development, missing Clerk keys no longer activate keyless mode. When `PUBLIC_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY` are not set, the SDK now fails with an error directing you to run `npx clerk@latest init`, which provisions a Clerk application and writes the keys to `.env`. Existing apps with configured or claimed keys are unaffected. diff --git a/.changeset/keyless-leftover-cleanup.md b/.changeset/keyless-leftover-cleanup.md new file mode 100644 index 00000000000..5f8afe7695a --- /dev/null +++ b/.changeset/keyless-leftover-cleanup.md @@ -0,0 +1,7 @@ +--- +'@clerk/shared': minor +'@clerk/nextjs': patch +'@clerk/backend': minor +--- + +Remove leftover keyless-mode creation code now that no SDK mints keyless applications. `@clerk/shared/keyless` drops `resolveKeysWithKeylessFallback`, `getOrCreateKeys`, and related exports (internal APIs consumed only by Clerk SDKs); `@clerk/backend` removes the experimental `createAccountlessApplication` method; `@clerk/nextjs` deletes the unused keyless cookie reader and dead keyless middleware parameters, and logs a pointer to existing `.clerk/.tmp/keyless.json` keys when env keys are missing. diff --git a/.changeset/nuxt-keyless-cli-init-error.md b/.changeset/nuxt-keyless-cli-init-error.md new file mode 100644 index 00000000000..cb1f511719e --- /dev/null +++ b/.changeset/nuxt-keyless-cli-init-error.md @@ -0,0 +1,5 @@ +--- +'@clerk/nuxt': minor +--- + +In development, missing Clerk keys no longer activate keyless mode. When `NUXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `NUXT_CLERK_SECRET_KEY` are not set, the SDK now fails with an error directing you to run `npx clerk@latest init`, which provisions a Clerk application and writes the keys to `.env`. Existing apps with configured or claimed keys are unaffected. diff --git a/.changeset/react-router-keyless-cli-init-error.md b/.changeset/react-router-keyless-cli-init-error.md new file mode 100644 index 00000000000..57bbe5ec392 --- /dev/null +++ b/.changeset/react-router-keyless-cli-init-error.md @@ -0,0 +1,5 @@ +--- +'@clerk/react-router': minor +--- + +In development, missing Clerk keys no longer activate keyless mode. When `CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY` are not set, the SDK now fails with an error directing you to run `npx clerk@latest init`, which provisions a Clerk application and writes the keys to `.env`. Existing apps with configured or claimed keys are unaffected. diff --git a/.changeset/tanstack-keyless-cli-init-error.md b/.changeset/tanstack-keyless-cli-init-error.md new file mode 100644 index 00000000000..c4b841e844c --- /dev/null +++ b/.changeset/tanstack-keyless-cli-init-error.md @@ -0,0 +1,5 @@ +--- +'@clerk/tanstack-react-start': minor +--- + +In development, missing Clerk keys no longer activate keyless mode. When `CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY` are not set, the SDK now fails with an error directing you to run `npx clerk@latest init`, which provisions a Clerk application and writes the keys to `.env`. Existing apps with configured or claimed keys are unaffected. diff --git a/integration/scripts/waitForServer.ts b/integration/scripts/waitForServer.ts index 817f9454541..2fd6ca6c83a 100644 --- a/integration/scripts/waitForServer.ts +++ b/integration/scripts/waitForServer.ts @@ -6,7 +6,7 @@ type WaitForServerArgsType = { acceptAnyResponse?: boolean; }; -// Poll a url until it returns a 200 status code +// Poll a url until it returns 2xx (or any HTTP response when acceptAnyResponse is set) export const waitForServer = async (url: string, opts: WaitForServerArgsType) => { const { log, delayInMs = 1000, maxAttempts = 20, shouldExit = () => false, acceptAnyResponse = false } = opts; let attempts = 0; diff --git a/integration/testUtils/keylessHelpers.ts b/integration/testUtils/keylessHelpers.ts index bd737498d30..4c941e64f62 100644 --- a/integration/testUtils/keylessHelpers.ts +++ b/integration/testUtils/keylessHelpers.ts @@ -1,8 +1,4 @@ -import type { BrowserContext, Page } from '@playwright/test'; -import { expect } from '@playwright/test'; - -import type { Application } from '../models/application'; -import { createTestUtils } from './index'; +import type { Page } from '@playwright/test'; /** * Mocks the environment API call to return a claimed instance. @@ -22,97 +18,3 @@ export const mockClaimedInstanceEnvironmentCall = async (page: Page): Promise { - const u = createTestUtils({ app, page, context }); - await u.page.goToAppHome(); - await u.page.waitForClerkJsLoaded(); - await u.po.expect.toBeSignedOut(); - - await u.po.keylessPopover.waitForMounted(); - - const claim = u.po.keylessPopover.promptsToClaim(); - - const href = await claim.getAttribute('href'); - expect(href).toBeTruthy(); - - const claimUrl = new URL(href!); - expect(claimUrl.origin + '/').toBe(dashboardUrl); - expect(claimUrl.pathname).toBe('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/apps/claim'); - expect(claimUrl.searchParams.get('framework')).toBe(framework); - expect(claimUrl.searchParams.has('token')).toBe(true); - expect(claimUrl.searchParams.has('return_url')).toBe(true); -} - -/** - * Tests that a claimed application with missing explicit keys shows the popover expanded - * with a prompt to get keys from the dashboard. - */ -export async function testClaimedAppWithMissingKeys({ - page, - context, - app, - dashboardUrl, -}: { - page: Page; - context: BrowserContext; - app: Application; - dashboardUrl: string; -}): Promise { - await mockClaimedInstanceEnvironmentCall(page); - const u = createTestUtils({ app, page, context }); - await u.page.goToAppHome(); - await u.page.waitForClerkJsLoaded(); - - await u.po.keylessPopover.waitForMounted(); - expect(await u.po.keylessPopover.isExpanded()).toBe(true); - await expect(u.po.keylessPopover.promptToUseClaimedKeys()).toBeVisible(); - - const href = await u.po.keylessPopover.promptToUseClaimedKeys().getAttribute('href'); - expect(href).toBeTruthy(); - expect(href).toContain(dashboardUrl); -} - -/** - * Tests that the keyless popover is removed after adding keys to .env and restarting the dev server. - */ -export async function testKeylessRemovedAfterEnvAndRestart({ - page, - context, - app, -}: { - page: Page; - context: BrowserContext; - app: Application; -}): Promise { - const u = createTestUtils({ app, page, context }); - await u.page.goToAppHome(); - - await u.po.keylessPopover.waitForMounted(); - - // Copy keys from keyless.json to .env - await app.keylessToEnv(); - - // Restart the dev server to pick up new env vars (Vite doesn't hot-reload .env) - await app.restart(); - - await u.page.goToAppHome(); - - // Keyless popover should no longer be present since we now have explicit keys - await u.po.keylessPopover.waitForUnmounted(); -} diff --git a/integration/tests/astro/keyless.test.ts b/integration/tests/astro/keyless.test.ts index c0808437173..1a336b6e122 100644 --- a/integration/tests/astro/keyless.test.ts +++ b/integration/tests/astro/keyless.test.ts @@ -1,12 +1,11 @@ -import { test } from '@playwright/test'; +import * as path from 'node:path'; + +import { expect, test } from '@playwright/test'; import type { Application } from '../../models/application'; import { appConfigs } from '../../presets'; -import { - testClaimedAppWithMissingKeys, - testKeylessRemovedAfterEnvAndRestart, - testToggleCollapsePopoverAndClaim, -} from '../../testUtils/keylessHelpers'; +import { fs } from '../../scripts'; +import { createTestUtils } from '../../testUtils'; const commonSetup = appConfigs.astro.node.clone(); @@ -21,34 +20,54 @@ test.describe('Keyless mode @astro', () => { }); let app: Application; - let dashboardUrl = 'https://dashboard.clerk.com/'; test.beforeAll(async () => { app = await commonSetup.commit(); await app.setup(); await app.withEnv(appConfigs.envs.withKeyless); - if (appConfigs.envs.withKeyless.privateVariables.get('CLERK_API_URL')?.includes('clerkstage')) { - dashboardUrl = 'https://dashboard.clerkstage.dev/'; - } - await app.dev(); + // Without keys the app 500s on every request, so readiness can't wait for a 2xx + await app.dev({ acceptAnyResponse: true }); }); test.afterAll(async () => { await app?.teardown(); }); - test('Toggle collapse popover and claim.', async ({ page, context }) => { - await testToggleCollapsePopoverAndClaim({ page, context, app, dashboardUrl, framework: 'astro' }); - }); - - test('Lands on claimed application with missing explicit keys, expanded by default, click to get keys from dashboard.', async ({ + test('Without keys, requests fail with the missing env vars error instead of keyless bootstrap.', async ({ page, - context, }) => { - await testClaimedAppWithMissingKeys({ page, context, app, dashboardUrl }); + const response = await page.goto(`${app.serverUrl}/`); + expect(response?.status()).toBe(500); + // The Astro dev error overlay renders inside shadow DOM, which page.content() does not + // include — locators pierce open shadow roots. + await expect(page.getByText('Publishable key is missing').first()).toBeVisible(); + await expect(page.getByText('npx clerk@latest init').first()).toBeVisible(); }); - test('Keyless popover is removed after adding keys to .env and restarting.', async ({ page, context }) => { - await testKeylessRemovedAfterEnvAndRestart({ page, context, app }); + test('Claimed application with keys inside .env boots and serves the app.', async ({ page, context }) => { + /** + * Seed claimed keyless state directly: the SDK no longer mints keys, so write the + * keys fixture to `.clerk/.tmp/keyless.json` and configure the matching environment + * (keys AND api url, so the server-side onboarding-completion call targets the right + * instance). The completion request itself is BAPI-bound from the server, invisible + * to Playwright — its logic is covered by packages/astro keyless unit tests. + */ + const publishableKey = appConfigs.envs.withEmailCodes.publicVariables.get('CLERK_PUBLISHABLE_KEY'); + const secretKey = appConfigs.envs.withEmailCodes.privateVariables.get('CLERK_SECRET_KEY'); + await fs.ensureDir(path.join(app.appDir, '.clerk', '.tmp')); + await fs.writeJSON(path.join(app.appDir, '.clerk', '.tmp', 'keyless.json'), { + publishableKey, + secretKey, + claimUrl: 'https://dashboard.clerk.com/apps/claim', + apiKeysUrl: 'https://dashboard.clerk.com/last-active?path=api-keys', + }); + await app.withEnv(appConfigs.envs.withEmailCodes); + // Restart the dev server to pick up new env vars (Vite doesn't hot-reload .env) + await app.restart(); + + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + await u.po.expect.toBeSignedOut(); }); }); diff --git a/integration/tests/nuxt/keyless.test.ts b/integration/tests/nuxt/keyless.test.ts index 6a2cad13033..f6ca8807cc5 100644 --- a/integration/tests/nuxt/keyless.test.ts +++ b/integration/tests/nuxt/keyless.test.ts @@ -1,12 +1,11 @@ -import { test } from '@playwright/test'; +import * as path from 'node:path'; + +import { expect, test } from '@playwright/test'; import type { Application } from '../../models/application'; import { appConfigs } from '../../presets'; -import { - testClaimedAppWithMissingKeys, - testKeylessRemovedAfterEnvAndRestart, - testToggleCollapsePopoverAndClaim, -} from '../../testUtils/keylessHelpers'; +import { fs } from '../../scripts'; +import { createTestUtils } from '../../testUtils'; const commonSetup = appConfigs.nuxt.node.clone(); @@ -21,35 +20,52 @@ test.describe('Keyless mode @nuxt', () => { }); let app: Application; - let dashboardUrl = 'https://dashboard.clerk.com/'; test.beforeAll(async () => { app = await commonSetup.commit(); await app.setup(); await app.withEnv(appConfigs.envs.withKeyless); - if (appConfigs.envs.withKeyless.privateVariables.get('CLERK_API_URL')?.includes('clerkstage')) { - dashboardUrl = 'https://dashboard.clerkstage.dev/'; - } - await app.dev(); + // Without keys the app 500s on every request, so readiness can't wait for a 2xx + await app.dev({ acceptAnyResponse: true }); }); test.afterAll(async () => { - // Keep files for debugging await app?.teardown(); }); - test('Toggle collapse popover and claim.', async ({ page, context }) => { - await testToggleCollapsePopoverAndClaim({ page, context, app, dashboardUrl, framework: 'nuxt' }); - }); - - test('Lands on claimed application with missing explicit keys, expanded by default, click to get keys from dashboard.', async ({ + test('Without keys, requests fail with the missing env vars error instead of keyless bootstrap.', async ({ page, - context, }) => { - await testClaimedAppWithMissingKeys({ page, context, app, dashboardUrl }); + const response = await page.goto(`${app.serverUrl}/`); + expect(response?.status()).toBe(500); + await expect(page.getByText('Publishable key is missing').first()).toBeVisible(); + await expect(page.getByText('npx clerk@latest init').first()).toBeVisible(); }); - test('Keyless popover is removed after adding keys to .env and restarting.', async ({ page, context }) => { - await testKeylessRemovedAfterEnvAndRestart({ page, context, app }); + test('Claimed application with keys inside .env boots and serves the app.', async ({ page, context }) => { + /** + * Seed claimed keyless state directly: the SDK no longer mints keys, so write the + * keys fixture to `.clerk/.tmp/keyless.json` and configure the matching environment + * (keys AND api url, so the server-side onboarding-completion call targets the right + * instance). The completion request itself is BAPI-bound from the server, invisible + * to Playwright — its logic is covered by packages/nuxt keyless unit tests. + */ + const publishableKey = appConfigs.envs.withEmailCodes.publicVariables.get('CLERK_PUBLISHABLE_KEY'); + const secretKey = appConfigs.envs.withEmailCodes.privateVariables.get('CLERK_SECRET_KEY'); + await fs.ensureDir(path.join(app.appDir, '.clerk', '.tmp')); + await fs.writeJSON(path.join(app.appDir, '.clerk', '.tmp', 'keyless.json'), { + publishableKey, + secretKey, + claimUrl: 'https://dashboard.clerk.com/apps/claim', + apiKeysUrl: 'https://dashboard.clerk.com/last-active?path=api-keys', + }); + await app.withEnv(appConfigs.envs.withEmailCodes); + // Restart the dev server to pick up new env vars + await app.restart(); + + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + await u.po.expect.toBeSignedOut(); }); }); diff --git a/integration/tests/react-router/keyless.test.ts b/integration/tests/react-router/keyless.test.ts index a2605806778..c68b1f46c9f 100644 --- a/integration/tests/react-router/keyless.test.ts +++ b/integration/tests/react-router/keyless.test.ts @@ -1,12 +1,11 @@ -import { test } from '@playwright/test'; +import * as path from 'node:path'; + +import { expect, test } from '@playwright/test'; import type { Application } from '../../models/application'; import { appConfigs } from '../../presets'; -import { - testClaimedAppWithMissingKeys, - testKeylessRemovedAfterEnvAndRestart, - testToggleCollapsePopoverAndClaim, -} from '../../testUtils/keylessHelpers'; +import { fs } from '../../scripts'; +import { createTestUtils } from '../../testUtils'; const commonSetup = appConfigs.reactRouter.reactRouterNode.clone(); @@ -21,35 +20,52 @@ test.describe('Keyless mode @react-router', () => { }); let app: Application; - let dashboardUrl = 'https://dashboard.clerk.com/'; test.beforeAll(async () => { app = await commonSetup.commit(); await app.setup(); await app.withEnv(appConfigs.envs.withKeyless); - if (appConfigs.envs.withKeyless.privateVariables.get('CLERK_API_URL')?.includes('clerkstage')) { - dashboardUrl = 'https://dashboard.clerkstage.dev/'; - } - await app.dev(); + // Without keys the app 500s on every request, so readiness can't wait for a 2xx + await app.dev({ acceptAnyResponse: true }); }); test.afterAll(async () => { - // Keep files for debugging await app?.teardown(); }); - test('Toggle collapse popover and claim.', async ({ page, context }) => { - await testToggleCollapsePopoverAndClaim({ page, context, app, dashboardUrl, framework: 'react-router' }); - }); - - test('Lands on claimed application with missing explicit keys, expanded by default, click to get keys from dashboard.', async ({ + test('Without keys, requests fail with the missing env vars error instead of keyless bootstrap.', async ({ page, - context, }) => { - await testClaimedAppWithMissingKeys({ page, context, app, dashboardUrl }); + const response = await page.goto(`${app.serverUrl}/`); + expect(response?.status()).toBe(500); + await expect(page.getByText('Publishable key is missing').first()).toBeVisible(); + await expect(page.getByText('npx clerk@latest init').first()).toBeVisible(); }); - test('Keyless popover is removed after adding keys to .env and restarting.', async ({ page, context }) => { - await testKeylessRemovedAfterEnvAndRestart({ page, context, app }); + test('Claimed application with keys inside .env boots and serves the app.', async ({ page, context }) => { + /** + * Seed claimed keyless state directly: the SDK no longer mints keys, so write the + * keys fixture to `.clerk/.tmp/keyless.json` and configure the matching environment + * (keys AND api url, so the server-side onboarding-completion call targets the right + * instance). The completion request itself is BAPI-bound from the server, invisible + * to Playwright — its logic is covered by packages/react-router keyless unit tests. + */ + const publishableKey = appConfigs.envs.withEmailCodes.publicVariables.get('CLERK_PUBLISHABLE_KEY'); + const secretKey = appConfigs.envs.withEmailCodes.privateVariables.get('CLERK_SECRET_KEY'); + await fs.ensureDir(path.join(app.appDir, '.clerk', '.tmp')); + await fs.writeJSON(path.join(app.appDir, '.clerk', '.tmp', 'keyless.json'), { + publishableKey, + secretKey, + claimUrl: 'https://dashboard.clerk.com/apps/claim', + apiKeysUrl: 'https://dashboard.clerk.com/last-active?path=api-keys', + }); + await app.withEnv(appConfigs.envs.withEmailCodes); + // Restart the dev server to pick up new env vars + await app.restart(); + + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + await u.po.expect.toBeSignedOut(); }); }); diff --git a/integration/tests/tanstack-start/keyless.test.ts b/integration/tests/tanstack-start/keyless.test.ts index a2605806778..17ff4eecaf6 100644 --- a/integration/tests/tanstack-start/keyless.test.ts +++ b/integration/tests/tanstack-start/keyless.test.ts @@ -1,16 +1,15 @@ -import { test } from '@playwright/test'; +import * as path from 'node:path'; + +import { expect, test } from '@playwright/test'; import type { Application } from '../../models/application'; import { appConfigs } from '../../presets'; -import { - testClaimedAppWithMissingKeys, - testKeylessRemovedAfterEnvAndRestart, - testToggleCollapsePopoverAndClaim, -} from '../../testUtils/keylessHelpers'; +import { fs } from '../../scripts'; +import { createTestUtils } from '../../testUtils'; -const commonSetup = appConfigs.reactRouter.reactRouterNode.clone(); +const commonSetup = appConfigs.tanstack.reactStart.clone(); -test.describe('Keyless mode @react-router', () => { +test.describe('Keyless mode @tanstack-react-start', () => { test.describe.configure({ mode: 'serial' }); test.setTimeout(90_000); @@ -21,35 +20,52 @@ test.describe('Keyless mode @react-router', () => { }); let app: Application; - let dashboardUrl = 'https://dashboard.clerk.com/'; test.beforeAll(async () => { app = await commonSetup.commit(); await app.setup(); await app.withEnv(appConfigs.envs.withKeyless); - if (appConfigs.envs.withKeyless.privateVariables.get('CLERK_API_URL')?.includes('clerkstage')) { - dashboardUrl = 'https://dashboard.clerkstage.dev/'; - } - await app.dev(); + // Without keys the app 500s on every request, so readiness can't wait for a 2xx + await app.dev({ acceptAnyResponse: true }); }); test.afterAll(async () => { - // Keep files for debugging await app?.teardown(); }); - test('Toggle collapse popover and claim.', async ({ page, context }) => { - await testToggleCollapsePopoverAndClaim({ page, context, app, dashboardUrl, framework: 'react-router' }); - }); - - test('Lands on claimed application with missing explicit keys, expanded by default, click to get keys from dashboard.', async ({ + test('Without keys, requests fail with the missing env vars error instead of keyless bootstrap.', async ({ page, - context, }) => { - await testClaimedAppWithMissingKeys({ page, context, app, dashboardUrl }); + const response = await page.goto(`${app.serverUrl}/`); + expect(response?.status()).toBe(500); + await expect(page.getByText('Publishable key is missing').first()).toBeVisible(); + await expect(page.getByText('npx clerk@latest init').first()).toBeVisible(); }); - test('Keyless popover is removed after adding keys to .env and restarting.', async ({ page, context }) => { - await testKeylessRemovedAfterEnvAndRestart({ page, context, app }); + test('Claimed application with keys inside .env boots and serves the app.', async ({ page, context }) => { + /** + * Seed claimed keyless state directly: the SDK no longer mints keys, so write the + * keys fixture to `.clerk/.tmp/keyless.json` and configure the matching environment + * (keys AND api url, so the server-side onboarding-completion call targets the right + * instance). The completion request itself is BAPI-bound from the server, invisible + * to Playwright — its logic is covered by packages/tanstack-react-start keyless unit tests. + */ + const publishableKey = appConfigs.envs.withEmailCodes.publicVariables.get('CLERK_PUBLISHABLE_KEY'); + const secretKey = appConfigs.envs.withEmailCodes.privateVariables.get('CLERK_SECRET_KEY'); + await fs.ensureDir(path.join(app.appDir, '.clerk', '.tmp')); + await fs.writeJSON(path.join(app.appDir, '.clerk', '.tmp', 'keyless.json'), { + publishableKey, + secretKey, + claimUrl: 'https://dashboard.clerk.com/apps/claim', + apiKeysUrl: 'https://dashboard.clerk.com/last-active?path=api-keys', + }); + await app.withEnv(appConfigs.envs.withEmailCodes); + // Restart the dev server to pick up new env vars + await app.restart(); + + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + await u.po.expect.toBeSignedOut(); }); }); diff --git a/packages/astro/src/env.d.ts b/packages/astro/src/env.d.ts index dc5c6d3303d..0544664af55 100644 --- a/packages/astro/src/env.d.ts +++ b/packages/astro/src/env.d.ts @@ -31,9 +31,6 @@ interface ImportMeta { declare namespace App { interface Locals { runtime?: { env: InternalEnv }; - keylessClaimUrl?: string; - keylessApiKeysUrl?: string; - keylessPublishableKey?: string; } } diff --git a/packages/astro/src/internal/create-clerk-instance.ts b/packages/astro/src/internal/create-clerk-instance.ts index 73bd5d7dd9c..5c55708c0ac 100644 --- a/packages/astro/src/internal/create-clerk-instance.ts +++ b/packages/astro/src/internal/create-clerk-instance.ts @@ -9,7 +9,7 @@ import type { Ui } from '@clerk/ui/internal'; import { $clerkStore } from '../stores/external'; import { $clerk, $csrState } from '../stores/internal'; -import type { AstroClerkCreateInstanceParams, AstroClerkUpdateOptions, InternalRuntimeOptions } from '../types'; +import type { AstroClerkCreateInstanceParams, AstroClerkUpdateOptions } from '../types'; import { invokeClerkAstroJSFunctions } from './invoke-clerk-astro-js-functions'; import { mountAllClerkAstroJSComponents } from './mount-clerk-astro-js-components'; import { runOnce } from './run-once'; @@ -54,18 +54,12 @@ async function createClerkInstanceInternal(options?: AstroC $clerk.set(clerkJSInstance); } - const internalOptions = options as AstroClerkCreateInstanceParams & InternalRuntimeOptions; - const keylessClaimUrl = internalOptions.__internal_keylessClaimUrl; - const keylessApiKeysUrl = internalOptions.__internal_keylessApiKeysUrl; - const clerkOptions = { routerPush: createNavigationHandler(window.history.pushState.bind(window.history)), routerReplace: createNavigationHandler(window.history.replaceState.bind(window.history)), ...options, // Pass the clerk-ui constructor promise to clerk.load() ui: { ...options?.ui, ClerkUI }, - ...(keylessClaimUrl && { __internal_keyless_claimKeylessApplicationUrl: keylessClaimUrl }), - ...(keylessApiKeysUrl && { __internal_keyless_copyInstanceKeysUrl: keylessApiKeysUrl }), } as unknown as ClerkOptions; initOptions = clerkOptions; diff --git a/packages/astro/src/internal/merge-env-vars-with-params.ts b/packages/astro/src/internal/merge-env-vars-with-params.ts index 2350c34975b..362bce2d5fe 100644 --- a/packages/astro/src/internal/merge-env-vars-with-params.ts +++ b/packages/astro/src/internal/merge-env-vars-with-params.ts @@ -1,7 +1,7 @@ import type { InternalClerkScriptProps } from '@clerk/shared/types'; import { isTruthy } from '@clerk/shared/underscore'; -import type { AstroClerkIntegrationParams, InternalRuntimeOptions } from '../types'; +import type { AstroClerkIntegrationParams } from '../types'; /** * Merges `prefetchUI` param with env vars. @@ -27,7 +27,7 @@ function mergePrefetchUIConfig(paramPrefetchUI: AstroClerkIntegrationParams['pre * @internal */ const mergeEnvVarsWithParams = ( - params?: AstroClerkIntegrationParams & InternalRuntimeOptions & InternalClerkScriptProps, + params?: AstroClerkIntegrationParams & { publishableKey?: string } & InternalClerkScriptProps, ) => { const { signInUrl: paramSignIn, @@ -46,17 +46,13 @@ const mergeEnvVarsWithParams = ( ...rest } = params || {}; - const internalOptions = params; - return { signInUrl: paramSignIn || import.meta.env.PUBLIC_CLERK_SIGN_IN_URL, signUpUrl: paramSignUp || import.meta.env.PUBLIC_CLERK_SIGN_UP_URL, isSatellite: paramSatellite || import.meta.env.PUBLIC_CLERK_IS_SATELLITE, proxyUrl: paramProxy || import.meta.env.PUBLIC_CLERK_PROXY_URL, domain: paramDomain || import.meta.env.PUBLIC_CLERK_DOMAIN, - // In keyless mode, use server-injected publishableKey from params - publishableKey: - paramPublishableKey || internalOptions?.publishableKey || import.meta.env.PUBLIC_CLERK_PUBLISHABLE_KEY || '', + publishableKey: paramPublishableKey || import.meta.env.PUBLIC_CLERK_PUBLISHABLE_KEY || '', __internal_clerkJSUrl: paramClerkJSUrl || import.meta.env.PUBLIC_CLERK_JS_URL, __internal_clerkJSVersion: paramClerkJSVersion || import.meta.env.PUBLIC_CLERK_JS_VERSION, __internal_clerkUIUrl: paramClerkUIUrl || import.meta.env.PUBLIC_CLERK_UI_URL, @@ -69,10 +65,6 @@ const mergeEnvVarsWithParams = ( unsafe_disableDevelopmentModeConsoleWarning: paramUnsafeDisableDevelopmentModeConsoleWarning ?? isTruthy(import.meta.env.PUBLIC_CLERK_UNSAFE_DISABLE_DEVELOPMENT_MODE_CONSOLE_WARNING), - // Read from params (server-injected via __CLERK_ASTRO_SAFE_VARS__) - // These are dynamically resolved by middleware, not from env vars - __internal_keylessClaimUrl: internalOptions?.keylessClaimUrl, - __internal_keylessApiKeysUrl: internalOptions?.keylessApiKeysUrl, ...rest, }; }; diff --git a/packages/astro/src/server/__tests__/get-safe-env.test.ts b/packages/astro/src/server/__tests__/get-safe-env.test.ts index 0a57f8ab8fe..1291a9bf5cd 100644 --- a/packages/astro/src/server/__tests__/get-safe-env.test.ts +++ b/packages/astro/src/server/__tests__/get-safe-env.test.ts @@ -211,20 +211,6 @@ describe('getSafeEnv', () => { expect(env.pk).toBeUndefined(); expect(env.sk).toBeUndefined(); }); - - it('prefers keylessPublishableKey over all env sources', () => { - process.env.PUBLIC_CLERK_PUBLISHABLE_KEY = 'pk_from_process'; - - const locals = createLocals({ - runtime: { env: undefined as unknown as InternalEnv }, - keylessPublishableKey: 'pk_keyless', - }); - const env = getSafeEnv(locals); - - expect(env.pk).toBe('pk_keyless'); - - delete process.env.PUBLIC_CLERK_PUBLISHABLE_KEY; - }); }); describe('getClientSafeEnv', () => { diff --git a/packages/astro/src/server/clerk-middleware.ts b/packages/astro/src/server/clerk-middleware.ts index 6bbeb09f7dd..2e50db9bb88 100644 --- a/packages/astro/src/server/clerk-middleware.ts +++ b/packages/astro/src/server/clerk-middleware.ts @@ -32,7 +32,7 @@ import { buildClerkHotloadScript } from './build-clerk-hotload-script'; import { clerkClient } from './clerk-client'; import { createCurrentUser } from './current-user'; import { getClientSafeEnv, getSafeEnv, initCloudflareEnv } from './get-safe-env'; -import { resolveKeysWithKeylessFallback } from './keyless/utils'; +import { completeOnboardingIfClaimed } from './keyless/utils'; import { serverRedirectWithAuth } from './server-redirect-with-auth'; import type { AstroMiddleware, @@ -87,42 +87,18 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]): any => { const patchedRequest = patchRequest(context.request); const clerkRequest = createClerkRequest(patchedRequest); - // Resolve keyless URLs per-request in development - let keylessClaimUrl: string | undefined; - let keylessApiKeysUrl: string | undefined; - let keylessOptions = options; - if (canUseKeyless) { try { const env = getSafeEnv(context); - const configuredPublishableKey = options?.publishableKey || env.pk; - const configuredSecretKey = options?.secretKey || env.sk; - - const keylessResult = await resolveKeysWithKeylessFallback( - configuredPublishableKey, - configuredSecretKey, - context, - ); - - keylessClaimUrl = keylessResult.claimUrl; - keylessApiKeysUrl = keylessResult.apiKeysUrl; - - // Override keys with keyless values if returned - if (keylessResult.publishableKey || keylessResult.secretKey) { - keylessOptions = { - ...options, - ...(keylessResult.publishableKey && { publishableKey: keylessResult.publishableKey }), - ...(keylessResult.secretKey && { secretKey: keylessResult.secretKey }), - }; - } + await completeOnboardingIfClaimed(options?.publishableKey || env.pk, context); } catch { - // Silently fail - continue without keyless + // Silently fail - claimed-keys onboarding must not break requests } } const requestState = await clerkClient(context).authenticateRequest( clerkRequest, - createAuthenticateRequestOptions(clerkRequest, keylessOptions, context), + createAuthenticateRequestOptions(clerkRequest, options, context), ); const locationHeader = requestState.headers.get(constants.Headers.Location); @@ -145,16 +121,6 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]): any => { decorateAstroLocal(clerkRequest, authObjectFn, context, requestState); - // Store keyless data for injection into client - if (keylessClaimUrl || keylessApiKeysUrl) { - context.locals.keylessClaimUrl = keylessClaimUrl; - context.locals.keylessApiKeysUrl = keylessApiKeysUrl; - // Also store the resolved publishable key so client can use it - if (keylessOptions?.publishableKey) { - context.locals.keylessPublishableKey = keylessOptions.publishableKey; - } - } - /** * ALS is crucial for guaranteeing SSR in UI frameworks like React. * This currently powers the `useAuth()` React hook and any other hook or Component that depends on it. diff --git a/packages/astro/src/server/get-safe-env.ts b/packages/astro/src/server/get-safe-env.ts index 1d918853592..99abf8b401c 100644 --- a/packages/astro/src/server/get-safe-env.ts +++ b/packages/astro/src/server/get-safe-env.ts @@ -73,14 +73,11 @@ function getContextEnvVar(envVarName: keyof InternalEnv, contextOrLocals: Contex * @internal */ function getSafeEnv(context: ContextOrLocals) { - const locals = 'locals' in context ? context.locals : context; - return { domain: getContextEnvVar('PUBLIC_CLERK_DOMAIN', context), isSatellite: getContextEnvVar('PUBLIC_CLERK_IS_SATELLITE', context) === 'true', proxyUrl: getContextEnvVar('PUBLIC_CLERK_PROXY_URL', context), - // Use keyless publishable key if available, otherwise read from env - pk: locals.keylessPublishableKey || getContextEnvVar('PUBLIC_CLERK_PUBLISHABLE_KEY', context), + pk: getContextEnvVar('PUBLIC_CLERK_PUBLISHABLE_KEY', context), sk: getContextEnvVar('CLERK_SECRET_KEY', context), machineSecretKey: getContextEnvVar('CLERK_MACHINE_SECRET_KEY', context), signInUrl: getContextEnvVar('PUBLIC_CLERK_SIGN_IN_URL', context), @@ -94,9 +91,6 @@ function getSafeEnv(context: ContextOrLocals) { apiUrl: getContextEnvVar('CLERK_API_URL', context), telemetryDisabled: isTruthy(getContextEnvVar('PUBLIC_CLERK_TELEMETRY_DISABLED', context)), telemetryDebug: isTruthy(getContextEnvVar('PUBLIC_CLERK_TELEMETRY_DEBUG', context)), - // Read from locals (set by middleware) instead of env vars - keylessClaimUrl: locals.keylessClaimUrl, - keylessApiKeysUrl: locals.keylessApiKeysUrl, }; } @@ -108,19 +102,13 @@ function getSafeEnv(context: ContextOrLocals) { * This is a way to get around it. */ function getClientSafeEnv(context: ContextOrLocals) { - const locals = 'locals' in context ? context.locals : context; - return { domain: getContextEnvVar('PUBLIC_CLERK_DOMAIN', context), isSatellite: getContextEnvVar('PUBLIC_CLERK_IS_SATELLITE', context) === 'true', proxyUrl: getContextEnvVar('PUBLIC_CLERK_PROXY_URL', context), signInUrl: getContextEnvVar('PUBLIC_CLERK_SIGN_IN_URL', context), signUpUrl: getContextEnvVar('PUBLIC_CLERK_SIGN_UP_URL', context), - // In keyless mode, pass the resolved publishable key to client - publishableKey: locals.keylessPublishableKey || getContextEnvVar('PUBLIC_CLERK_PUBLISHABLE_KEY', context), - // Read from locals (set by middleware) instead of env vars - keylessClaimUrl: locals.keylessClaimUrl, - keylessApiKeysUrl: locals.keylessApiKeysUrl, + publishableKey: getContextEnvVar('PUBLIC_CLERK_PUBLISHABLE_KEY', context), }; } diff --git a/packages/astro/src/server/keyless/__tests__/utils.test.ts b/packages/astro/src/server/keyless/__tests__/utils.test.ts new file mode 100644 index 00000000000..f95f0df677c --- /dev/null +++ b/packages/astro/src/server/keyless/__tests__/utils.test.ts @@ -0,0 +1,78 @@ +import type { APIContext } from 'astro'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { completeOnboardingIfClaimed } from '../utils'; + +const { completeClaimedOnboarding, log, readKeys, completeOnboarding } = vi.hoisted(() => ({ + completeClaimedOnboarding: vi.fn(() => Promise.resolve()), + log: vi.fn(), + readKeys: vi.fn(), + completeOnboarding: vi.fn(), +})); + +vi.mock('@clerk/shared/keyless', () => ({ + completeClaimedOnboarding, + clerkDevelopmentCache: { log }, +})); + +vi.mock('../index', () => ({ + keyless: () => ({ readKeys, completeOnboarding }), +})); + +const context = {} as APIContext; + +const storedKeys = { + publishableKey: 'pk_test_stored', + secretKey: 'sk_test_stored', + claimUrl: 'https://dashboard.clerk.com/apps/claim', + apiKeysUrl: 'https://dashboard.clerk.com/last-active?path=api-keys', +}; + +describe('completeOnboardingIfClaimed', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('completes onboarding when the configured key matches the stored keyless keys', async () => { + readKeys.mockReturnValue(storedKeys); + + await completeOnboardingIfClaimed('pk_test_stored', context); + + expect(completeClaimedOnboarding).toHaveBeenCalledTimes(1); + expect(completeClaimedOnboarding).toHaveBeenCalledWith('pk_test_stored', expect.objectContaining({ readKeys })); + }); + + it('does nothing when the configured key does not match the stored keys', async () => { + readKeys.mockReturnValue(storedKeys); + + await completeOnboardingIfClaimed('pk_test_other', context); + + expect(completeClaimedOnboarding).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + }); + + it('does nothing when no keyless keys are stored', async () => { + readKeys.mockReturnValue(undefined); + + await completeOnboardingIfClaimed('pk_test_stored', context); + + expect(completeClaimedOnboarding).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + }); + + it('logs a pointer to the stored keys when no key is configured, without completing onboarding', async () => { + readKeys.mockReturnValue(storedKeys); + + await completeOnboardingIfClaimed(undefined, context); + + expect(completeClaimedOnboarding).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ + cacheKey: 'pk_test_stored_stored', + msg: expect.stringContaining('.clerk/.tmp/keyless.json'), + }), + ); + expect(log).toHaveBeenCalledWith(expect.objectContaining({ msg: expect.stringContaining(storedKeys.claimUrl) })); + }); +}); diff --git a/packages/astro/src/server/keyless/index.ts b/packages/astro/src/server/keyless/index.ts index 15df38e46b6..eca1c5cb560 100644 --- a/packages/astro/src/server/keyless/index.ts +++ b/packages/astro/src/server/keyless/index.ts @@ -12,16 +12,6 @@ export function keyless(context: APIContext) { keylessServiceInstance = createKeylessService({ storage: createFileStorage(), api: { - async createAccountlessApplication(requestHeaders?: Headers, source?: string) { - try { - return await clerkClient(context).__experimental_accountlessApplications.createAccountlessApplication({ - requestHeaders, - source, - }); - } catch { - return null; - } - }, async completeOnboarding(requestHeaders?: Headers, source?: string) { try { return await clerkClient( diff --git a/packages/astro/src/server/keyless/utils.ts b/packages/astro/src/server/keyless/utils.ts index f0f0d0e0898..3dd2f27e99a 100644 --- a/packages/astro/src/server/keyless/utils.ts +++ b/packages/astro/src/server/keyless/utils.ts @@ -1,23 +1,34 @@ -import { resolveKeysWithKeylessFallback as sharedResolveKeysWithKeylessFallback } from '@clerk/shared/keyless'; +import { clerkDevelopmentCache, completeClaimedOnboarding } from '@clerk/shared/keyless'; import type { APIContext } from 'astro'; -export type { KeylessResult } from '@clerk/shared/keyless'; -import { canUseKeyless } from '../../utils/feature-flags'; import { keyless } from './index'; /** - * Resolves Clerk keys, falling back to keyless mode in development if configured keys are missing. + * Notifies the dashboard that a claimed keyless application is now running with its + * keys configured. When no key is configured but stored keyless keys exist, logs a + * one-time pointer to them instead (the missing-key error throws downstream). */ -export async function resolveKeysWithKeylessFallback( +export async function completeOnboardingIfClaimed( configuredPublishableKey: string | undefined, - configuredSecretKey: string | undefined, context: APIContext, -) { - const keylessService = await keyless(context); - return sharedResolveKeysWithKeylessFallback( - configuredPublishableKey, - configuredSecretKey, - keylessService, - canUseKeyless, - ); +): Promise { + const keylessService = keyless(context); + const locallyStoredKeys = keylessService.readKeys(); + if (!locallyStoredKeys) { + return; + } + + if (!configuredPublishableKey) { + clerkDevelopmentCache?.log({ + cacheKey: `${locallyStoredKeys.publishableKey}_stored`, + msg: `[Clerk]: Found existing keyless-mode keys in .clerk/.tmp/keyless.json. Copy the publishableKey and secretKey into .env (PUBLIC_CLERK_PUBLISHABLE_KEY / CLERK_SECRET_KEY) to keep using that application, or claim it at ${locallyStoredKeys.claimUrl}`, + }); + return; + } + + if (locallyStoredKeys.publishableKey !== configuredPublishableKey) { + return; + } + + await completeClaimedOnboarding(locallyStoredKeys.publishableKey, keylessService); } diff --git a/packages/astro/src/types.ts b/packages/astro/src/types.ts index c6c563a68da..702bd23beea 100644 --- a/packages/astro/src/types.ts +++ b/packages/astro/src/types.ts @@ -43,33 +43,6 @@ type AstroClerkCreateInstanceParams = AstroClerkIntegration publishableKey: string; }; -/** - * @internal - * Internal runtime options injected by the server for keyless mode support. - */ -export type InternalRuntimeOptions = { - /** - * Server-injected publishable key from keyless mode or context.locals - */ - publishableKey?: string; - /** - * Keyless claim URL injected by middleware for the client-side banner - */ - keylessClaimUrl?: string; - /** - * Keyless API keys URL injected by middleware for the client-side banner - */ - keylessApiKeysUrl?: string; - /** - * Internal keyless claim URL passed to Clerk.load() - */ - __internal_keylessClaimUrl?: string; - /** - * Internal keyless API keys URL passed to Clerk.load() - */ - __internal_keylessApiKeysUrl?: string; -}; - // Copied from `@clerk/react` export interface HeadlessBrowserClerk extends Clerk { load: (opts?: ClerkOptions) => Promise; diff --git a/packages/backend/src/api/__tests__/AccountlessApplicationsApi.test.ts b/packages/backend/src/api/__tests__/AccountlessApplicationsApi.test.ts index 98f0e1c9c19..5ae3bad02ce 100644 --- a/packages/backend/src/api/__tests__/AccountlessApplicationsApi.test.ts +++ b/packages/backend/src/api/__tests__/AccountlessApplicationsApi.test.ts @@ -13,48 +13,6 @@ describe('AccountlessApplications', () => { api_keys_url: 'https://dashboard.clerk.com/api-keys', }; - it('creates an accountless application with a source query parameter', async () => { - const apiClient = createBackendApiClient({ - apiUrl: 'https://api.clerk.test', - }); - - server.use( - http.post('https://api.clerk.test/v1/accountless_applications', ({ request }) => { - const url = new URL(request.url); - expect(url.searchParams.get('source')).toBe('nextjs'); - expect(request.headers.get('Clerk-API-Version')).toBeTruthy(); - expect(request.headers.get('User-Agent')).toBe('@clerk/backend@0.0.0-test'); - - return HttpResponse.json(mockAccountlessApplication); - }), - ); - - const response = await apiClient.__experimental_accountlessApplications.createAccountlessApplication({ - source: 'nextjs', - }); - - expect(response.publishableKey).toBe('pk_test_keyless'); - }); - - it('creates an accountless application without a source query parameter when source is omitted', async () => { - const apiClient = createBackendApiClient({ - apiUrl: 'https://api.clerk.test', - }); - - server.use( - http.post('https://api.clerk.test/v1/accountless_applications', ({ request }) => { - const url = new URL(request.url); - expect(url.searchParams.has('source')).toBe(false); - - return HttpResponse.json(mockAccountlessApplication); - }), - ); - - const response = await apiClient.__experimental_accountlessApplications.createAccountlessApplication(); - - expect(response.publishableKey).toBe('pk_test_keyless'); - }); - it('completes accountless application onboarding with a source query parameter', async () => { const apiClient = createBackendApiClient({ apiUrl: 'https://api.clerk.test', diff --git a/packages/backend/src/api/endpoints/AccountlessApplicationsAPI.ts b/packages/backend/src/api/endpoints/AccountlessApplicationsAPI.ts index 37b69809753..cdb34fddca3 100644 --- a/packages/backend/src/api/endpoints/AccountlessApplicationsAPI.ts +++ b/packages/backend/src/api/endpoints/AccountlessApplicationsAPI.ts @@ -10,18 +10,6 @@ type AccountlessApplicationParams = { }; export class AccountlessApplicationAPI extends AbstractAPI { - public async createAccountlessApplication(params?: AccountlessApplicationParams): Promise { - const headerParams = params?.requestHeaders ? Object.fromEntries(params.requestHeaders.entries()) : undefined; - return this.request({ - method: 'POST', - path: basePath, - headerParams, - queryParams: { - source: params?.source, - }, - }); - } - public async completeAccountlessApplicationOnboarding( params?: AccountlessApplicationParams, ): Promise { diff --git a/packages/nextjs/src/app-router/server/ClerkProvider.tsx b/packages/nextjs/src/app-router/server/ClerkProvider.tsx index b43d83306c8..6ab7fec7c5f 100644 --- a/packages/nextjs/src/app-router/server/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/server/ClerkProvider.tsx @@ -5,6 +5,7 @@ import React, { Suspense } from 'react'; import { getDynamicAuthData } from '../../server/buildClerkProps'; import { errorThrower } from '../../server/errorThrower'; import type { NextClerkProviderProps } from '../../types'; +import { canUseKeyless } from '../../utils/feature-flags'; import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv'; import { ClientClerkProvider } from '../client/ClerkProvider'; import { DynamicClerkScripts } from './DynamicClerkScripts'; @@ -32,7 +33,7 @@ export async function ClerkProvider( initialState: statePromiseOrValue as InitialState | undefined, }); - const { shouldRunAsKeyless, runningWithClaimedKeys } = await getKeylessStatus(propsWithEnvs); + const { runningWithClaimedKeys } = await getKeylessStatus(propsWithEnvs); // When dynamic mode is enabled, render scripts in a Suspense boundary to isolate // the nonce fetching (which calls headers()) from the rest of the page. @@ -52,14 +53,14 @@ export async function ClerkProvider( ) : undefined; - if (shouldRunAsKeyless) { - if (!propsWithEnvs.publishableKey) { - errorThrower.throwMissingPublishableKeyError(); - } + if (canUseKeyless && !propsWithEnvs.publishableKey) { + errorThrower.throwMissingPublishableKeyError(); + } + + if (runningWithClaimedKeys) { return ( {children} diff --git a/packages/nextjs/src/app-router/server/keyless-provider.tsx b/packages/nextjs/src/app-router/server/keyless-provider.tsx index b2956a2b1e6..e84135538f1 100644 --- a/packages/nextjs/src/app-router/server/keyless-provider.tsx +++ b/packages/nextjs/src/app-router/server/keyless-provider.tsx @@ -11,40 +11,42 @@ import { deleteKeylessAction } from '../keyless-actions'; export async function getKeylessStatus( params: Without, ) { - let [shouldRunAsKeyless, runningWithClaimedKeys, locallyStoredPublishableKey] = [false, false, '']; - if (canUseKeyless) { - locallyStoredPublishableKey = await import('../../server/keyless-node.js') - .then(mod => mod.keyless().readKeys()?.publishableKey || '') - .catch(() => ''); + if (!canUseKeyless) { + return { runningWithClaimedKeys: false }; + } - runningWithClaimedKeys = Boolean(params.publishableKey) && params.publishableKey === locallyStoredPublishableKey; - shouldRunAsKeyless = !params.publishableKey || runningWithClaimedKeys; + const storedKeys = await import('../../server/keyless-node.js') + .then(mod => mod.keyless().readKeys() ?? null) + .catch(() => null); + if (!storedKeys) { + return { runningWithClaimedKeys: false }; } - return { - shouldRunAsKeyless, - runningWithClaimedKeys, - }; + if (!params.publishableKey) { + const { clerkDevelopmentCache } = await import('../../server/keyless-log-cache.js'); + clerkDevelopmentCache?.log({ + cacheKey: `${storedKeys.publishableKey}_stored`, + msg: `[Clerk]: Found existing keyless-mode keys in .clerk/.tmp/keyless.json. Copy the publishableKey and secretKey into .env.local (NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY / CLERK_SECRET_KEY) to keep using that application, or claim it at ${storedKeys.claimUrl}`, + }); + return { runningWithClaimedKeys: false }; + } + + return { runningWithClaimedKeys: params.publishableKey === storedKeys.publishableKey }; } type KeylessProviderProps = PropsWithChildren<{ rest: Without; - runningWithClaimedKeys: boolean; __internal_scriptsSlot?: React.ReactNode; }>; export const KeylessProvider = async (props: KeylessProviderProps) => { - const { rest, runningWithClaimedKeys, __internal_scriptsSlot, children } = props; + const { rest, __internal_scriptsSlot, children } = props; - // Read-only: the SDK no longer mints keyless applications, it only reads claimed keys from disk. - const newOrReadKeys = await import('../../server/keyless-node.js') + const storedKeys = await import('../../server/keyless-node.js') .then(mod => mod.keyless().readKeys() ?? null) .catch(() => null); - const { clerkDevelopmentCache, createConfirmationMessage } = await import('../../server/keyless-log-cache.js'); - - if (!newOrReadKeys) { - // When case keyless should run, but keys are not available, then fallback to throwing for missing keys + if (!storedKeys) { return ( { ); } - const clientProvider = ( + try { + const keylessService = await import('../../server/keyless-node.js').then(mod => mod.keyless()); + const { completeClaimedOnboarding } = await import('@clerk/shared/keyless'); + await completeClaimedOnboarding(storedKeys.publishableKey, keylessService); + } catch { + // noop + } + + return ( {children} ); - - if (runningWithClaimedKeys) { - try { - const keylessService = await import('../../server/keyless-node.js').then(mod => mod.keyless()); - - /** - * 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(() => keylessService.completeOnboarding(), { - cacheKey: `${newOrReadKeys.publishableKey}_complete`, - onSuccessStale: 24 * 60 * 60 * 1000, // 24 hours - }); - } catch { - // noop - } - - /** - * Notify developers. - */ - clerkDevelopmentCache?.log({ - cacheKey: `${newOrReadKeys.publishableKey}_claimed`, - msg: createConfirmationMessage(), - }); - } - - return clientProvider; }; diff --git a/packages/nextjs/src/server/__tests__/clerkMiddlewareKeyless.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddlewareKeyless.test.ts index 8f4e8842cf4..95fec07a690 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddlewareKeyless.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddlewareKeyless.test.ts @@ -3,8 +3,7 @@ import type { NextFetchEvent } from 'next/server'; import { NextRequest } from 'next/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -// The mock SHOULD exist before the imports: unlike clerkMiddleware.test.ts, keys are empty so the -// missing-key error path is reachable. +// The mock SHOULD exist before the imports. Keys are intentionally empty so the missing-key error path is reachable. vi.mock(import('../constants.js'), async importOriginal => { const actual = await importOriginal(); return { diff --git a/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts b/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts index 1a49c6b6c13..a3836889c04 100644 --- a/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts +++ b/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts @@ -37,7 +37,6 @@ const mockRequest = (params: MockRequestParams) => { if (machineAuthObject) { const encryptedData = encryptClerkRequestData( {}, // requestData - {}, // keylessModeKeys // @ts-expect-error - mock machine auth object machineAuthObject, ); diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index 98d7edb26f4..9cbe161a9c5 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -389,7 +389,6 @@ async function runHandlerWithRequestState({ handlerResult, requestState, resolvedParams, - {}, authObject.tokenType === 'session_token' ? null : makeAuthObjectSerializable(authObject), ); diff --git a/packages/nextjs/src/server/keyless-log-cache.ts b/packages/nextjs/src/server/keyless-log-cache.ts index 7e0fdb90e34..905a0b4cf15 100644 --- a/packages/nextjs/src/server/keyless-log-cache.ts +++ b/packages/nextjs/src/server/keyless-log-cache.ts @@ -1,10 +1,5 @@ /** - * Re-export keyless development cache utilities from shared. + * Re-export the keyless development cache from shared. * This maintains backward compatibility with existing imports. */ -export { - clerkDevelopmentCache, - createClerkDevCache, - createConfirmationMessage, - createKeylessModeMessage, -} from '@clerk/shared/keyless'; +export { clerkDevelopmentCache } from '@clerk/shared/keyless'; diff --git a/packages/nextjs/src/server/keyless-node.ts b/packages/nextjs/src/server/keyless-node.ts index 5df56892ab9..320968125bb 100644 --- a/packages/nextjs/src/server/keyless-node.ts +++ b/packages/nextjs/src/server/keyless-node.ts @@ -24,16 +24,6 @@ export function keyless() { keylessServiceInstance = createKeylessService({ storage: createFileStorage(), api: { - async createAccountlessApplication(requestHeaders?: Headers, source?: string) { - try { - return await client.__experimental_accountlessApplications.createAccountlessApplication({ - requestHeaders, - source, - }); - } catch { - return null; - } - }, async completeOnboarding(requestHeaders?: Headers, source?: string) { try { return await client.__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({ diff --git a/packages/nextjs/src/server/keyless.ts b/packages/nextjs/src/server/keyless.ts deleted file mode 100644 index ca40e33ca7b..00000000000 --- a/packages/nextjs/src/server/keyless.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { AccountlessApplication } from '@clerk/backend'; - -import { canUseKeyless } from '../utils/feature-flags'; - -const keylessCookiePrefix = `__clerk_keys_`; - -async function hashString(str: string) { - const encoder = new TextEncoder(); - const data = encoder.encode(str); - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); - return hashHex.slice(0, 16); // Take only the first 16 characters -} - -async function getKeylessCookieName(): Promise { - // eslint-disable-next-line turbo/no-undeclared-env-vars - const PATH = process.env.PWD; - - // Handle gracefully missing PWD - if (!PATH) { - return `${keylessCookiePrefix}${0}`; - } - - const lastThreeDirs = PATH.split('/').filter(Boolean).slice(-3).reverse().join('/'); - - // Hash the resulting string - const hash = await hashString(lastThreeDirs); - - return `${keylessCookiePrefix}${hash}`; -} - -async function getKeylessCookieValue( - getter: (cookieName: string) => string | undefined, -): Promise { - if (!canUseKeyless) { - return undefined; - } - - const keylessCookieName = await getKeylessCookieName(); - let keyless; - - try { - if (keylessCookieName) { - keyless = JSON.parse(getter(keylessCookieName) || '{}'); - } - } catch { - keyless = undefined; - } - - return keyless; -} - -export { getKeylessCookieValue, getKeylessCookieName }; diff --git a/packages/nextjs/src/server/utils.ts b/packages/nextjs/src/server/utils.ts index e6504478823..b25c5a859d0 100644 --- a/packages/nextjs/src/server/utils.ts +++ b/packages/nextjs/src/server/utils.ts @@ -53,7 +53,6 @@ export function decorateRequest( res: Response, requestState: RequestState, requestData: AuthenticateRequestOptions, - keylessMode: Pick, machineAuthObject: AuthObject | null, ): Response { const { reason, message, status, token } = requestState; @@ -89,13 +88,13 @@ export function decorateRequest( } if (rewriteURL) { - const clerkRequestData = encryptClerkRequestData(requestData, keylessMode, machineAuthObject); + const clerkRequestData = encryptClerkRequestData(requestData, machineAuthObject); setRequestHeadersOnNextResponse(res, req, { [constants.Headers.AuthStatus]: status, [constants.Headers.AuthToken]: token || '', [constants.Headers.AuthSignature]: token - ? createTokenSignature(token, requestData?.secretKey || SECRET_KEY || keylessMode.secretKey || '') + ? createTokenSignature(token, requestData?.secretKey || SECRET_KEY || '') : '', [constants.Headers.AuthMessage]: message || '', [constants.Headers.AuthReason]: reason || '', @@ -208,7 +207,6 @@ const KEYLESS_ENCRYPTION_KEY = 'clerk_keyless_dummy_key'; **/ export function encryptClerkRequestData( requestData: Partial, - keylessModeKeys: Pick, machineAuthObject: AuthObject | null, ) { const isEmpty = (obj: Record | undefined) => { @@ -218,7 +216,7 @@ export function encryptClerkRequestData( return !Object.values(obj).some(v => v !== undefined); }; - if (isEmpty(requestData) && isEmpty(keylessModeKeys) && !machineAuthObject) { + if (isEmpty(requestData) && !machineAuthObject) { return; } @@ -231,7 +229,7 @@ export function encryptClerkRequestData( : ENCRYPTION_KEY || SECRET_KEY || KEYLESS_ENCRYPTION_KEY; return AES.encrypt( - JSON.stringify({ ...keylessModeKeys, ...requestData, machineAuthObject: machineAuthObject ?? undefined }), + JSON.stringify({ ...requestData, machineAuthObject: machineAuthObject ?? undefined }), maybeKeylessEncryptionKey, ).toString(); } diff --git a/packages/nextjs/src/utils/only-try.ts b/packages/nextjs/src/utils/only-try.ts deleted file mode 100644 index 5f8c62e87f8..00000000000 --- a/packages/nextjs/src/utils/only-try.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Discards errors thrown by attempted code - */ -const onlyTry = (cb: () => unknown) => { - try { - cb(); - } catch { - // ignore - } -}; - -export { onlyTry }; diff --git a/packages/nuxt/src/runtime/plugin.ts b/packages/nuxt/src/runtime/plugin.ts index 2ca118a99b4..650358ef02c 100644 --- a/packages/nuxt/src/runtime/plugin.ts +++ b/packages/nuxt/src/runtime/plugin.ts @@ -4,20 +4,16 @@ import { clerkPlugin } from '@clerk/vue'; import { setErrorThrowerOptions } from '@clerk/vue/internal'; import { defineNuxtPlugin, navigateTo, useRuntimeConfig, useState } from 'nuxt/app'; -import type { ClerkKeylessContext } from './server/types'; - setErrorThrowerOptions({ packageName: PACKAGE_NAME }); setClerkJSLoadingErrorPackageName(PACKAGE_NAME); export default defineNuxtPlugin(nuxtApp => { // SSR-friendly shared state const initialState = useState('clerk-initial-state', () => undefined); - const keylessContext = useState('clerk-keyless-context', () => undefined); if (import.meta.server) { // Save the initial state from server and pass it to the plugin initialState.value = nuxtApp.ssrContext?.event.context.__clerk_initial_state; - keylessContext.value = nuxtApp.ssrContext?.event.context.__clerk_keyless; } const runtimeConfig = useRuntimeConfig(); @@ -38,12 +34,5 @@ export default defineNuxtPlugin(nuxtApp => { routerPush: (to: string) => navigateTo(to), routerReplace: (to: string) => navigateTo(to, { replace: true }), initialState: initialState.value, - // Add keyless mode props if present - ...(keylessContext.value - ? { - __internal_keyless_claimKeylessApplicationUrl: keylessContext.value.claimUrl, - __internal_keyless_copyInstanceKeysUrl: keylessContext.value.apiKeysUrl, - } - : {}), }); }); diff --git a/packages/nuxt/src/runtime/server/clerkMiddleware.ts b/packages/nuxt/src/runtime/server/clerkMiddleware.ts index aae8e02305f..7b381788ab2 100644 --- a/packages/nuxt/src/runtime/server/clerkMiddleware.ts +++ b/packages/nuxt/src/runtime/server/clerkMiddleware.ts @@ -9,7 +9,7 @@ import { createError, eventHandler, setResponseHeader, useRuntimeConfig } from ' import { canUseKeyless } from '../utils/feature-flags'; import { clerkClient } from './clerkClient'; -import { resolveKeysWithKeylessFallback } from './keyless/utils'; +import { completeOnboardingIfClaimed } from './keyless/utils'; import type { AuthFn, AuthOptions } from './types'; import { createInitialState, toWebRequest } from './utils'; @@ -86,32 +86,12 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]) => { return eventHandler(async event => { const clerkRequest = toWebRequest(event); - // Resolve keyless in development if keys are missing - let keylessClaimUrl: string | undefined; - let keylessApiKeysUrl: string | undefined; - if (canUseKeyless) { try { const runtimeConfig = useRuntimeConfig(event); - - const { publishableKey, secretKey, claimUrl, apiKeysUrl } = await resolveKeysWithKeylessFallback( - runtimeConfig.public.clerk.publishableKey, - runtimeConfig.clerk.secretKey, - event, - ); - - keylessClaimUrl = claimUrl; - keylessApiKeysUrl = apiKeysUrl; - - // Override runtime config with keyless values if returned - if (publishableKey) { - runtimeConfig.public.clerk.publishableKey = publishableKey; - } - if (secretKey) { - runtimeConfig.clerk.secretKey = secretKey; - } + await completeOnboardingIfClaimed(runtimeConfig.public.clerk.publishableKey, event); } catch { - // Silently fail - continue without keyless + // Silently fail - claimed-keys onboarding must not break requests } } @@ -150,14 +130,6 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]) => { // Internal serializable state that will be passed to the client event.context.__clerk_initial_state = createInitialState(authObjectFn()); - // Store keyless mode URLs in separate context property - if (canUseKeyless && keylessClaimUrl) { - event.context.__clerk_keyless = { - claimUrl: keylessClaimUrl, - apiKeysUrl: keylessApiKeysUrl, - }; - } - try { await handler?.(event); } catch (e) { diff --git a/packages/nuxt/src/runtime/server/keyless/__tests__/utils.test.ts b/packages/nuxt/src/runtime/server/keyless/__tests__/utils.test.ts new file mode 100644 index 00000000000..6a182693e76 --- /dev/null +++ b/packages/nuxt/src/runtime/server/keyless/__tests__/utils.test.ts @@ -0,0 +1,78 @@ +import type { H3Event } from 'h3'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { completeOnboardingIfClaimed } from '../utils'; + +const { completeClaimedOnboarding, log, readKeys, completeOnboarding } = vi.hoisted(() => ({ + completeClaimedOnboarding: vi.fn(() => Promise.resolve()), + log: vi.fn(), + readKeys: vi.fn(), + completeOnboarding: vi.fn(), +})); + +vi.mock('@clerk/shared/keyless', () => ({ + completeClaimedOnboarding, + clerkDevelopmentCache: { log }, +})); + +vi.mock('../index', () => ({ + keyless: () => ({ readKeys, completeOnboarding }), +})); + +const event = {} as H3Event; + +const storedKeys = { + publishableKey: 'pk_test_stored', + secretKey: 'sk_test_stored', + claimUrl: 'https://dashboard.clerk.com/apps/claim', + apiKeysUrl: 'https://dashboard.clerk.com/last-active?path=api-keys', +}; + +describe('completeOnboardingIfClaimed', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('completes onboarding when the configured key matches the stored keyless keys', async () => { + readKeys.mockReturnValue(storedKeys); + + await completeOnboardingIfClaimed('pk_test_stored', event); + + expect(completeClaimedOnboarding).toHaveBeenCalledTimes(1); + expect(completeClaimedOnboarding).toHaveBeenCalledWith('pk_test_stored', expect.objectContaining({ readKeys })); + }); + + it('does nothing when the configured key does not match the stored keys', async () => { + readKeys.mockReturnValue(storedKeys); + + await completeOnboardingIfClaimed('pk_test_other', event); + + expect(completeClaimedOnboarding).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + }); + + it('does nothing when no keyless keys are stored', async () => { + readKeys.mockReturnValue(undefined); + + await completeOnboardingIfClaimed('pk_test_stored', event); + + expect(completeClaimedOnboarding).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + }); + + it('logs a pointer to the stored keys when no key is configured, without completing onboarding', async () => { + readKeys.mockReturnValue(storedKeys); + + await completeOnboardingIfClaimed(undefined, event); + + expect(completeClaimedOnboarding).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ + cacheKey: 'pk_test_stored_stored', + msg: expect.stringContaining('.clerk/.tmp/keyless.json'), + }), + ); + expect(log).toHaveBeenCalledWith(expect.objectContaining({ msg: expect.stringContaining(storedKeys.claimUrl) })); + }); +}); diff --git a/packages/nuxt/src/runtime/server/keyless/index.ts b/packages/nuxt/src/runtime/server/keyless/index.ts index 14072688105..7014f126110 100644 --- a/packages/nuxt/src/runtime/server/keyless/index.ts +++ b/packages/nuxt/src/runtime/server/keyless/index.ts @@ -12,16 +12,6 @@ export function keyless(event: H3Event) { keylessServiceInstance = createKeylessService({ storage: createFileStorage(), api: { - async createAccountlessApplication(requestHeaders?: Headers, source?: string) { - try { - return await clerkClient(event).__experimental_accountlessApplications.createAccountlessApplication({ - requestHeaders, - source, - }); - } catch { - return null; - } - }, async completeOnboarding(requestHeaders?: Headers, source?: string) { try { return await clerkClient( diff --git a/packages/nuxt/src/runtime/server/keyless/utils.ts b/packages/nuxt/src/runtime/server/keyless/utils.ts index 005f02242e2..a17c6976c1d 100644 --- a/packages/nuxt/src/runtime/server/keyless/utils.ts +++ b/packages/nuxt/src/runtime/server/keyless/utils.ts @@ -1,24 +1,34 @@ -import { resolveKeysWithKeylessFallback as sharedResolveKeysWithKeylessFallback } from '@clerk/shared/keyless'; +import { clerkDevelopmentCache, completeClaimedOnboarding } from '@clerk/shared/keyless'; import type { H3Event } from 'h3'; -import { canUseKeyless } from '../../utils/feature-flags'; import { keyless } from './index'; -export type { KeylessResult } from '@clerk/shared/keyless'; - /** - * Resolves Clerk keys, falling back to keyless mode in development if configured keys are missing. + * Notifies the dashboard that a claimed keyless application is now running with its + * keys configured. When no key is configured but stored keyless keys exist, logs a + * one-time pointer to them instead (the missing-key error throws downstream). */ -export async function resolveKeysWithKeylessFallback( +export async function completeOnboardingIfClaimed( configuredPublishableKey: string | undefined, - configuredSecretKey: string | undefined, event: H3Event, -) { +): Promise { const keylessService = keyless(event); - return sharedResolveKeysWithKeylessFallback( - configuredPublishableKey, - configuredSecretKey, - keylessService, - canUseKeyless, - ); + const locallyStoredKeys = keylessService.readKeys(); + if (!locallyStoredKeys) { + return; + } + + if (!configuredPublishableKey) { + clerkDevelopmentCache?.log({ + cacheKey: `${locallyStoredKeys.publishableKey}_stored`, + msg: `[Clerk]: Found existing keyless-mode keys in .clerk/.tmp/keyless.json. Copy the publishableKey and secretKey into .env (NUXT_PUBLIC_CLERK_PUBLISHABLE_KEY / NUXT_CLERK_SECRET_KEY) to keep using that application, or claim it at ${locallyStoredKeys.claimUrl}`, + }); + return; + } + + if (locallyStoredKeys.publishableKey !== configuredPublishableKey) { + return; + } + + await completeClaimedOnboarding(locallyStoredKeys.publishableKey, keylessService); } diff --git a/packages/nuxt/src/runtime/server/types.ts b/packages/nuxt/src/runtime/server/types.ts index 0700b3531f3..c4369d57e23 100644 --- a/packages/nuxt/src/runtime/server/types.ts +++ b/packages/nuxt/src/runtime/server/types.ts @@ -7,11 +7,3 @@ export type AuthOptions = PendingSessionOptions & Pick({ children, ...rest }: ClerkProv __telemetryDisabled, __telemetryDebug, __unsafeDisableDevelopmentModeConsoleWarning, - __keylessClaimUrl, - __keylessApiKeysUrl, } = clerkState?.__internal_clerk_state || {}; React.useEffect(() => { @@ -106,13 +104,6 @@ function ClerkProviderBase({ children, ...rest }: ClerkProv getPublicEnvVariables(undefined).unsafeDisableDevelopmentModeConsoleWarning, }; - const keylessProps = __keylessClaimUrl - ? { - __internal_keyless_claimKeylessApplicationUrl: __keylessClaimUrl, - __internal_keyless_copyInstanceKeysUrl: __keylessApiKeysUrl, - } - : {}; - return ( ({ children, ...rest }: ClerkProv initialState={__clerk_ssr_state} sdkMetadata={SDK_METADATA} {...mergedProps} - {...keylessProps} {...restProps} > {children} diff --git a/packages/react-router/src/client/types.ts b/packages/react-router/src/client/types.ts index c6d402f92a9..21dfbab6716 100644 --- a/packages/react-router/src/client/types.ts +++ b/packages/react-router/src/client/types.ts @@ -26,8 +26,6 @@ export type ClerkState = { __telemetryDisabled: boolean | undefined; __telemetryDebug: boolean | undefined; __unsafeDisableDevelopmentModeConsoleWarning: boolean | undefined; - __keylessClaimUrl?: string; - __keylessApiKeysUrl?: string; }; }; diff --git a/packages/react-router/src/server/clerkMiddleware.ts b/packages/react-router/src/server/clerkMiddleware.ts index 7b6e9a81413..c94d10e06ab 100644 --- a/packages/react-router/src/server/clerkMiddleware.ts +++ b/packages/react-router/src/server/clerkMiddleware.ts @@ -7,8 +7,9 @@ import type { PendingSessionOptions } from '@clerk/shared/types'; import type { MiddlewareFunction } from 'react-router'; import { createContext } from 'react-router'; +import { canUseKeyless } from '../utils/feature-flags'; import { clerkClient } from './clerkClient'; -import { resolveKeysWithKeylessFallback } from './keyless/utils'; +import { completeOnboardingIfClaimed } from './keyless/utils'; import { loadOptions } from './loadOptions'; import type { AdditionalStateOptions, ClerkMiddlewareOptions } from './types'; @@ -36,18 +37,12 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun const clerkRequest = createClerkRequest(patchRequest(args.request)); const loadedOptions = loadOptions(args, options); - const { - publishableKey, - secretKey, - claimUrl: __keylessClaimUrl, - apiKeysUrl: __keylessApiKeysUrl, - } = await resolveKeysWithKeylessFallback(loadedOptions.publishableKey, loadedOptions.secretKey, args, options); - - if (publishableKey) { - loadedOptions.publishableKey = publishableKey; - } - if (secretKey) { - loadedOptions.secretKey = secretKey; + if (canUseKeyless) { + try { + await completeOnboardingIfClaimed(loadedOptions.publishableKey, args, options); + } catch { + // Silently fail - claimed-keys onboarding must not break requests + } } // Pick only the properties needed by authenticateRequest. @@ -102,8 +97,6 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun args.context.set(requestStateContext, { requestState, additionalState: { - __keylessClaimUrl, - __keylessApiKeysUrl, signInForceRedirectUrl: loadedOptions.signInForceRedirectUrl, signUpForceRedirectUrl: loadedOptions.signUpForceRedirectUrl, signInFallbackRedirectUrl: loadedOptions.signInFallbackRedirectUrl, diff --git a/packages/react-router/src/server/keyless/__tests__/utils.test.ts b/packages/react-router/src/server/keyless/__tests__/utils.test.ts new file mode 100644 index 00000000000..be3f4034d71 --- /dev/null +++ b/packages/react-router/src/server/keyless/__tests__/utils.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { DataFunctionArgs } from '../../loadOptions'; +import { completeOnboardingIfClaimed } from '../utils'; + +const { completeClaimedOnboarding, log, readKeys, completeOnboarding } = vi.hoisted(() => ({ + completeClaimedOnboarding: vi.fn(() => Promise.resolve()), + log: vi.fn(), + readKeys: vi.fn(), + completeOnboarding: vi.fn(), +})); + +vi.mock('@clerk/shared/keyless', () => ({ + completeClaimedOnboarding, + clerkDevelopmentCache: { log }, +})); + +vi.mock('../index', () => ({ + keyless: () => ({ readKeys, completeOnboarding }), +})); + +const args = {} as DataFunctionArgs; + +const storedKeys = { + publishableKey: 'pk_test_stored', + secretKey: 'sk_test_stored', + claimUrl: 'https://dashboard.clerk.com/apps/claim', + apiKeysUrl: 'https://dashboard.clerk.com/last-active?path=api-keys', +}; + +describe('completeOnboardingIfClaimed', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('completes onboarding when the configured key matches the stored keyless keys', async () => { + readKeys.mockReturnValue(storedKeys); + + await completeOnboardingIfClaimed('pk_test_stored', args); + + expect(completeClaimedOnboarding).toHaveBeenCalledTimes(1); + expect(completeClaimedOnboarding).toHaveBeenCalledWith('pk_test_stored', expect.objectContaining({ readKeys })); + }); + + it('does nothing when the configured key does not match the stored keys', async () => { + readKeys.mockReturnValue(storedKeys); + + await completeOnboardingIfClaimed('pk_test_other', args); + + expect(completeClaimedOnboarding).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + }); + + it('does nothing when no keyless keys are stored', async () => { + readKeys.mockReturnValue(undefined); + + await completeOnboardingIfClaimed('pk_test_stored', args); + + expect(completeClaimedOnboarding).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + }); + + it('logs a pointer to the stored keys when no key is configured, without completing onboarding', async () => { + readKeys.mockReturnValue(storedKeys); + + await completeOnboardingIfClaimed(undefined, args); + + expect(completeClaimedOnboarding).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ + cacheKey: 'pk_test_stored_stored', + msg: expect.stringContaining('.clerk/.tmp/keyless.json'), + }), + ); + expect(log).toHaveBeenCalledWith(expect.objectContaining({ msg: expect.stringContaining(storedKeys.claimUrl) })); + }); +}); diff --git a/packages/react-router/src/server/keyless/index.ts b/packages/react-router/src/server/keyless/index.ts index b347fa888e1..6021e0d1eca 100644 --- a/packages/react-router/src/server/keyless/index.ts +++ b/packages/react-router/src/server/keyless/index.ts @@ -13,18 +13,6 @@ export function keyless(args: DataFunctionArgs, options?: ClerkMiddlewareOptions keylessServiceInstance = createKeylessService({ storage: createFileStorage(), api: { - async createAccountlessApplication(requestHeaders?: Headers, source?: string) { - try { - return await clerkClient(args, options).__experimental_accountlessApplications.createAccountlessApplication( - { - requestHeaders, - source, - }, - ); - } catch { - return null; - } - }, async completeOnboarding(requestHeaders?: Headers, source?: string) { try { return await clerkClient( diff --git a/packages/react-router/src/server/keyless/utils.ts b/packages/react-router/src/server/keyless/utils.ts index 138a30d22cd..c2effb6f877 100644 --- a/packages/react-router/src/server/keyless/utils.ts +++ b/packages/react-router/src/server/keyless/utils.ts @@ -1,25 +1,36 @@ -import { resolveKeysWithKeylessFallback as sharedResolveKeysWithKeylessFallback } from '@clerk/shared/keyless'; -export type { KeylessResult } from '@clerk/shared/keyless'; +import { clerkDevelopmentCache, completeClaimedOnboarding } from '@clerk/shared/keyless'; -import { canUseKeyless } from '../../utils/feature-flags'; import type { DataFunctionArgs } from '../loadOptions'; import type { ClerkMiddlewareOptions } from '../types'; import { keyless } from './index'; /** - * Resolves Clerk keys, falling back to keyless mode in development if configured keys are missing. + * Notifies the dashboard that a claimed keyless application is now running with its + * keys configured. When no key is configured but stored keyless keys exist, logs a + * one-time pointer to them instead (the missing-key error throws downstream). */ -export async function resolveKeysWithKeylessFallback( +export async function completeOnboardingIfClaimed( configuredPublishableKey: string | undefined, - configuredSecretKey: string | undefined, args: DataFunctionArgs, options?: ClerkMiddlewareOptions, -) { - const keylessService = await keyless(args, options); - return sharedResolveKeysWithKeylessFallback( - configuredPublishableKey, - configuredSecretKey, - keylessService, - canUseKeyless, - ); +): Promise { + const keylessService = keyless(args, options); + const locallyStoredKeys = keylessService.readKeys(); + if (!locallyStoredKeys) { + return; + } + + if (!configuredPublishableKey) { + clerkDevelopmentCache?.log({ + cacheKey: `${locallyStoredKeys.publishableKey}_stored`, + msg: `[Clerk]: Found existing keyless-mode keys in .clerk/.tmp/keyless.json. Copy the publishableKey and secretKey into .env (CLERK_PUBLISHABLE_KEY / CLERK_SECRET_KEY) to keep using that application, or claim it at ${locallyStoredKeys.claimUrl}`, + }); + return; + } + + if (locallyStoredKeys.publishableKey !== configuredPublishableKey) { + return; + } + + await completeClaimedOnboarding(locallyStoredKeys.publishableKey, keylessService); } diff --git a/packages/react-router/src/server/types.ts b/packages/react-router/src/server/types.ts index 9342f6b39af..b5008814e3d 100644 --- a/packages/react-router/src/server/types.ts +++ b/packages/react-router/src/server/types.ts @@ -63,16 +63,10 @@ export type RootAuthLoaderOptions = ClerkMiddlewareOptions & { loadOrganization?: boolean; }; -export interface KeylessUrls { - __keylessClaimUrl?: string; - __keylessApiKeysUrl?: string; -} - export type AdditionalStateOptions = SignInFallbackRedirectUrl & SignUpFallbackRedirectUrl & SignInForceRedirectUrl & - SignUpForceRedirectUrl & - KeylessUrls; + SignUpForceRedirectUrl; /** * @deprecated This type is no longer used internally. Use `AdditionalStateOptions` instead. @@ -81,8 +75,7 @@ export type RequestStateWithRedirectUrls = RequestState & SignInForceRedirectUrl & SignInFallbackRedirectUrl & SignUpForceRedirectUrl & - SignUpFallbackRedirectUrl & - KeylessUrls; + SignUpFallbackRedirectUrl; export type RootAuthLoaderCallback = ( args: LoaderFunctionArgsWithAuth, diff --git a/packages/react-router/src/server/utils.ts b/packages/react-router/src/server/utils.ts index 98904f11116..681b3abfa70 100644 --- a/packages/react-router/src/server/utils.ts +++ b/packages/react-router/src/server/utils.ts @@ -4,7 +4,6 @@ import { parse as parseCookie } from 'cookie'; import type { UNSAFE_DataWithResponseInit } from 'react-router'; import { getPublicEnvVariables } from '../utils/env'; -import { canUseKeyless } from '../utils/feature-flags'; import type { AdditionalStateOptions } from './types'; // AppLoadContext was removed from React Router v8. Keep a structural type for the context shape we use. @@ -94,7 +93,7 @@ export function getResponseClerkState( ) { const { reason, message, isSignedIn, ...rest } = requestState; const envVars = getPublicEnvVariables(context); - const { __keylessClaimUrl, __keylessApiKeysUrl, ...redirectUrlOptions } = additionalStateOptions; + const redirectUrlOptions = additionalStateOptions; const baseState: Record = { __clerk_ssr_state: rest.toAuth(), @@ -119,11 +118,6 @@ export function getResponseClerkState( __unsafeDisableDevelopmentModeConsoleWarning: envVars.unsafeDisableDevelopmentModeConsoleWarning, }; - if (canUseKeyless && __keylessClaimUrl) { - baseState.__keylessClaimUrl = __keylessClaimUrl; - baseState.__keylessApiKeysUrl = __keylessApiKeysUrl; - } - const clerkState = wrapWithClerkState(baseState); return { diff --git a/packages/shared/src/keyless/__tests__/service.spec.ts b/packages/shared/src/keyless/__tests__/service.spec.ts index 7a76b26cb3e..b2ef21d2ba6 100644 --- a/packages/shared/src/keyless/__tests__/service.spec.ts +++ b/packages/shared/src/keyless/__tests__/service.spec.ts @@ -25,30 +25,11 @@ const createStorage = (): KeylessStorage => { }; const createApi = (overrides: Partial = {}): KeylessAPI => ({ - createAccountlessApplication: vi.fn(() => Promise.resolve(accountlessApplication)), completeOnboarding: vi.fn(() => Promise.resolve(accountlessApplication)), ...overrides, }); describe('createKeylessService', () => { - it('passes the framework as the source when creating an accountless application', async () => { - const createAccountlessApplication = vi.fn(() => - Promise.resolve(accountlessApplication), - ); - - const service = createKeylessService({ - storage: createStorage(), - api: createApi({ createAccountlessApplication }), - framework: 'nextjs', - }); - - await service.getOrCreateKeys(); - - const [headers, source] = createAccountlessApplication.mock.calls[0]; - expect(headers).toBeInstanceOf(Headers); - expect(source).toBe('nextjs'); - }); - it('passes the framework as the source when completing accountless application onboarding', async () => { const completeOnboarding = vi.fn(() => Promise.resolve(accountlessApplication)); @@ -66,50 +47,73 @@ describe('createKeylessService', () => { }); it('sanitizes the framework before passing it as the source', async () => { - const createAccountlessApplication = vi.fn(() => - Promise.resolve(accountlessApplication), - ); + const completeOnboarding = vi.fn(() => Promise.resolve(accountlessApplication)); const service = createKeylessService({ storage: createStorage(), - api: createApi({ createAccountlessApplication }), + api: createApi({ completeOnboarding }), framework: 'Next.js @ Canary!', }); - await service.getOrCreateKeys(); + await service.completeOnboarding(); - expect(createAccountlessApplication.mock.calls[0][1]).toBe('next.js-canary'); + expect(completeOnboarding.mock.calls[0][1]).toBe('next.js-canary'); }); it('falls back to javascript when framework sanitization produces an empty source', async () => { - const createAccountlessApplication = vi.fn(() => - Promise.resolve(accountlessApplication), - ); + const completeOnboarding = vi.fn(() => Promise.resolve(accountlessApplication)); const service = createKeylessService({ storage: createStorage(), - api: createApi({ createAccountlessApplication }), + api: createApi({ completeOnboarding }), framework: '!!!', }); - await service.getOrCreateKeys(); + await service.completeOnboarding(); - expect(createAccountlessApplication.mock.calls[0][1]).toBe('javascript'); + expect(completeOnboarding.mock.calls[0][1]).toBe('javascript'); }); it('truncates the source before passing it to the accountless application API', async () => { - const createAccountlessApplication = vi.fn(() => - Promise.resolve(accountlessApplication), - ); + const completeOnboarding = vi.fn(() => Promise.resolve(accountlessApplication)); const service = createKeylessService({ storage: createStorage(), - api: createApi({ createAccountlessApplication }), + api: createApi({ completeOnboarding }), framework: 'a'.repeat(50), }); - await service.getOrCreateKeys(); + await service.completeOnboarding(); + + expect(completeOnboarding.mock.calls[0][1]).toBe('a'.repeat(36)); + }); + + it('readKeys returns the stored configuration', () => { + const storage = createStorage(); + storage.write(JSON.stringify(accountlessApplication)); + + const service = createKeylessService({ storage, api: createApi() }); + + expect(service.readKeys()).toEqual(accountlessApplication); + }); + + it('readKeys returns undefined when storage is empty or invalid', () => { + const emptyService = createKeylessService({ storage: createStorage(), api: createApi() }); + expect(emptyService.readKeys()).toBeUndefined(); + + const corruptStorage = createStorage(); + corruptStorage.write('not-json'); + const corruptService = createKeylessService({ storage: corruptStorage, api: createApi() }); + expect(corruptService.readKeys()).toBeUndefined(); + }); + + it('removeKeys clears the stored configuration', () => { + const storage = createStorage(); + storage.write(JSON.stringify(accountlessApplication)); + + const service = createKeylessService({ storage, api: createApi() }); + service.removeKeys(); - expect(createAccountlessApplication.mock.calls[0][1]).toBe('a'.repeat(36)); + expect(service.readKeys()).toBeUndefined(); }); }); diff --git a/packages/shared/src/keyless/completeClaimedOnboarding.ts b/packages/shared/src/keyless/completeClaimedOnboarding.ts new file mode 100644 index 00000000000..949e2c41e28 --- /dev/null +++ b/packages/shared/src/keyless/completeClaimedOnboarding.ts @@ -0,0 +1,27 @@ +import { clerkDevelopmentCache, createConfirmationMessage } from './devCache'; +import type { AccountlessApplication } from './types'; + +interface CompletionService { + completeOnboarding: () => Promise; +} + +/** + * Notifies the backend that a claimed keyless application is running with its keys + * configured (cached to once per 24 hours) and logs the one-time claim confirmation. + * Resolves even when the completion request fails. + */ +export async function completeClaimedOnboarding(publishableKey: string, service: CompletionService): Promise { + try { + await clerkDevelopmentCache?.run(() => service.completeOnboarding(), { + cacheKey: `${publishableKey}_complete`, + onSuccessStale: 24 * 60 * 60 * 1000, // 24 hours + }); + } catch { + // noop + } + + clerkDevelopmentCache?.log({ + cacheKey: `${publishableKey}_claimed`, + msg: createConfirmationMessage(), + }); +} diff --git a/packages/shared/src/keyless/devCache.ts b/packages/shared/src/keyless/devCache.ts index 0fbdabd24e1..ebc060453af 100644 --- a/packages/shared/src/keyless/devCache.ts +++ b/packages/shared/src/keyless/devCache.ts @@ -1,5 +1,4 @@ import { isDevelopmentEnvironment } from '../utils/runtimeEnvironment'; -import type { AccountlessApplication, PublicKeylessApplication } from './types'; // 10 minutes in milliseconds const THROTTLE_DURATION_MS = 10 * 60 * 1000; @@ -84,16 +83,6 @@ export function createClerkDevCache(): ClerkDevCache | undefined { 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. * diff --git a/packages/shared/src/keyless/index.ts b/packages/shared/src/keyless/index.ts index 75e2cf16c91..ee2512336f7 100644 --- a/packages/shared/src/keyless/index.ts +++ b/packages/shared/src/keyless/index.ts @@ -1,18 +1,12 @@ -export { - clerkDevelopmentCache, - createClerkDevCache, - createConfirmationMessage, - createKeylessModeMessage, -} from './devCache'; +export { clerkDevelopmentCache, createConfirmationMessage } from './devCache'; export type { ClerkDevCache } from './devCache'; export { createNodeFileStorage } from './nodeFileStorage'; export type { FileSystemAdapter, NodeFileStorageOptions, PathAdapter } from './nodeFileStorage'; +export { completeClaimedOnboarding } from './completeClaimedOnboarding'; + export { createKeylessService } from './service'; export type { KeylessAPI, KeylessService, KeylessServiceOptions, KeylessStorage } from './service'; -export { resolveKeysWithKeylessFallback } from './resolveKeysWithKeylessFallback'; -export type { KeylessResult } from './resolveKeysWithKeylessFallback'; - -export type { AccountlessApplication, PublicKeylessApplication } from './types'; +export type { AccountlessApplication } from './types'; diff --git a/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts b/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts deleted file mode 100644 index fc923ae2a2f..00000000000 --- a/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } from './devCache'; -import type { KeylessService } from './service'; -import type { AccountlessApplication } from './types'; - -export interface KeylessResult { - publishableKey: string | undefined; - secretKey: string | undefined; - claimUrl: string | undefined; - apiKeysUrl: string | undefined; -} - -/** - * Resolves Clerk keys, falling back to keyless mode in development if configured keys are missing. - * - * @param configuredPublishableKey - The publishable key from options or environment - * @param configuredSecretKey - The secret key from options or environment - * @param keylessService - The keyless service instance (or null if unavailable) - * @param canUseKeyless - Whether keyless mode is enabled in the current environment - * @returns The resolved keys (either configured or from keyless mode) - */ -export async function resolveKeysWithKeylessFallback( - configuredPublishableKey: string | undefined, - configuredSecretKey: string | undefined, - keylessService: KeylessService | null, - canUseKeyless: boolean, -): Promise { - let publishableKey = configuredPublishableKey; - let secretKey = configuredSecretKey; - let claimUrl: string | undefined; - let apiKeysUrl: string | undefined; - - if (!canUseKeyless) { - return { publishableKey, secretKey, claimUrl, apiKeysUrl }; - } - - if (!keylessService) { - return { publishableKey, secretKey, claimUrl, apiKeysUrl }; - } - - try { - const locallyStoredKeys = keylessService.readKeys(); - - // Check if running with claimed keys (configured keys match locally stored keyless keys) - const runningWithClaimedKeys = - Boolean(configuredPublishableKey) && configuredPublishableKey === locallyStoredKeys?.publishableKey; - - if (runningWithClaimedKeys && locallyStoredKeys) { - // Complete onboarding when running with claimed keys - try { - await clerkDevelopmentCache?.run(() => keylessService.completeOnboarding(), { - cacheKey: `${locallyStoredKeys.publishableKey}_complete`, - onSuccessStale: 24 * 60 * 60 * 1000, // 24 hours - }); - } catch { - // noop - } - - clerkDevelopmentCache?.log({ - cacheKey: `${locallyStoredKeys.publishableKey}_claimed`, - msg: createConfirmationMessage(), - }); - - return { publishableKey, secretKey, claimUrl, apiKeysUrl }; - } - - // In keyless mode, try to read/create keys from the file system - if (!publishableKey && !secretKey) { - const keylessApp: AccountlessApplication | null = await keylessService.getOrCreateKeys(); - - if (keylessApp) { - publishableKey = keylessApp.publishableKey; - secretKey = keylessApp.secretKey; - claimUrl = keylessApp.claimUrl; - apiKeysUrl = keylessApp.apiKeysUrl; - - clerkDevelopmentCache?.log({ - cacheKey: keylessApp.publishableKey, - msg: createKeylessModeMessage(keylessApp), - }); - } - } - } catch { - // noop - fall through to return whatever keys we have - } - - return { publishableKey, secretKey, claimUrl, apiKeysUrl }; -} diff --git a/packages/shared/src/keyless/service.ts b/packages/shared/src/keyless/service.ts index 7e776a3e9ac..795812d8c59 100644 --- a/packages/shared/src/keyless/service.ts +++ b/packages/shared/src/keyless/service.ts @@ -1,4 +1,3 @@ -import { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } from './devCache'; import type { AccountlessApplication } from './types'; const KEYLESS_SOURCE_FALLBACK = 'javascript'; @@ -34,19 +33,10 @@ export interface KeylessStorage { } /** - * API adapter for keyless mode operations. + * API adapter for keyless mode operations on already-claimed applications. * 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. - * @param source - Optional source value to include with the request. - * @returns The created AccountlessApplication or null if failed. - */ - createAccountlessApplication(requestHeaders?: Headers, source?: string): Promise; - /** * Notifies the backend that onboarding is complete (instance has been claimed). * @@ -67,7 +57,7 @@ export interface KeylessServiceOptions { storage: KeylessStorage; /** - * API adapter for keyless operations (create application, complete onboarding). + * API adapter for keyless operations (complete onboarding). */ api: KeylessAPI; @@ -82,25 +72,10 @@ export interface KeylessServiceOptions { frameworkVersion?: string; } -/** - * Result type for key resolution. - */ -export interface KeylessResult { - publishableKey: string | undefined; - secretKey: string | undefined; - claimUrl: string | undefined; - apiKeysUrl: string | undefined; -} - /** * The keyless service interface. */ export interface KeylessService { - /** - * Gets existing keyless keys or creates new ones via the API. - */ - getOrCreateKeys: () => Promise; - /** * Reads existing keyless keys without creating new ones. */ @@ -116,23 +91,6 @@ export interface KeylessService { * 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; - - /** - * Resolves Clerk keys, falling back to keyless mode if configured keys are missing. - * - * @param configuredPublishableKey - The publishable key from options or environment - * @param configuredSecretKey - The secret key from options or environment - * @returns The resolved keys (either configured or from keyless mode) - */ - resolveKeysWithKeylessFallback: ( - configuredPublishableKey: string | undefined, - configuredSecretKey: string | undefined, - ) => Promise; } /** @@ -162,8 +120,8 @@ function createSource(framework?: string): string { } /** - * Creates a keyless service that handles accountless application creation and storage. - * This provides a simple API for frameworks to integrate keyless mode. + * Creates a keyless service that reads stored keyless keys and completes onboarding + * for claimed applications. * * @param options - Configuration for the service including storage and API adapters * @returns A keyless service instance @@ -174,20 +132,16 @@ function createSource(framework?: string): string { * * const keylessService = createKeylessService({ * storage: createFileStorage(), - * api: createKeylessAPI({ secretKey }), + * api: { completeOnboarding }, * framework: 'TanStack Start', * }); * - * const keys = await keylessService.getOrCreateKeys(request); - * if (keys) { - * console.log('Publishable Key:', keys.publishableKey); - * } + * const keys = keylessService.readKeys(); * ``` */ export function createKeylessService(options: KeylessServiceOptions): KeylessService { const { storage, api, framework, frameworkVersion } = options; - let hasLoggedKeylessMessage = false; const source = createSource(framework); const safeParseConfig = (): AccountlessApplication | undefined => { @@ -203,26 +157,6 @@ export function createKeylessService(options: KeylessServiceOptions): KeylessSer }; 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, source); - - if (accountlessApplication) { - storage.write(JSON.stringify(accountlessApplication)); - } - - return accountlessApplication; - }, - readKeys(): AccountlessApplication | undefined { return safeParseConfig(); }, @@ -235,70 +169,5 @@ export function createKeylessService(options: KeylessServiceOptions): KeylessSer const headers = createMetadataHeaders(framework, frameworkVersion); return api.completeOnboarding(headers, source); }, - - logKeylessMessage(claimUrl: string): void { - if (!hasLoggedKeylessMessage) { - hasLoggedKeylessMessage = true; - console.log(`[Clerk]: Running in keyless mode. Claim your keys at: ${claimUrl}`); - } - }, - - async resolveKeysWithKeylessFallback( - configuredPublishableKey: string | undefined, - configuredSecretKey: string | undefined, - ): Promise { - let publishableKey = configuredPublishableKey; - let secretKey = configuredSecretKey; - let claimUrl: string | undefined; - let apiKeysUrl: string | undefined; - - try { - const locallyStoredKeys = safeParseConfig(); - - // Check if running with claimed keys (configured keys match locally stored keyless keys) - const runningWithClaimedKeys = - Boolean(configuredPublishableKey) && configuredPublishableKey === locallyStoredKeys?.publishableKey; - - if (runningWithClaimedKeys && locallyStoredKeys) { - // Complete onboarding when running with claimed keys - try { - await clerkDevelopmentCache?.run(() => this.completeOnboarding(), { - cacheKey: `${locallyStoredKeys.publishableKey}_complete`, - onSuccessStale: 24 * 60 * 60 * 1000, // 24 hours - }); - } catch { - // noop - } - - clerkDevelopmentCache?.log({ - cacheKey: `${locallyStoredKeys.publishableKey}_claimed`, - msg: createConfirmationMessage(), - }); - - return { publishableKey, secretKey, claimUrl, apiKeysUrl }; - } - - // In keyless mode, try to read/create keys from the file system - if (!publishableKey && !secretKey) { - const keylessApp: AccountlessApplication | null = await this.getOrCreateKeys(); - - if (keylessApp) { - publishableKey = keylessApp.publishableKey; - secretKey = keylessApp.secretKey; - claimUrl = keylessApp.claimUrl; - apiKeysUrl = keylessApp.apiKeysUrl; - - clerkDevelopmentCache?.log({ - cacheKey: keylessApp.publishableKey, - msg: createKeylessModeMessage(keylessApp), - }); - } - } - } catch { - // noop - fall through to return whatever keys we have - } - - return { publishableKey, secretKey, claimUrl, apiKeysUrl }; - }, }; } diff --git a/packages/shared/src/keyless/types.ts b/packages/shared/src/keyless/types.ts index 18b13e1e57a..294bbc42926 100644 --- a/packages/shared/src/keyless/types.ts +++ b/packages/shared/src/keyless/types.ts @@ -12,8 +12,3 @@ export interface AccountlessApplication { readonly claimUrl: string; readonly apiKeysUrl: string; } - -/** - * Public-facing keyless application data (without secret key). - */ -export type PublicKeylessApplication = Omit; diff --git a/packages/tanstack-react-start/src/client/ClerkProvider.tsx b/packages/tanstack-react-start/src/client/ClerkProvider.tsx index 9231da2a554..f73a3310e1b 100644 --- a/packages/tanstack-react-start/src/client/ClerkProvider.tsx +++ b/packages/tanstack-react-start/src/client/ClerkProvider.tsx @@ -31,23 +31,13 @@ export function ClerkProvider({ const clerkInitState = isClient() ? (window as any).__clerk_init_state : clerkInitialState; - const { clerkSsrState, __keylessClaimUrl, __keylessApiKeysUrl, ...restInitState } = pickFromClerkInitState( - clerkInitState?.__internal_clerk_state, - ); + const { clerkSsrState, ...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 = ${htmlSafeJson(clerkInitialState)};`} @@ -74,7 +64,6 @@ 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 fd9bcea0afd..4198618b81e 100644 --- a/packages/tanstack-react-start/src/client/utils.ts +++ b/packages/tanstack-react-start/src/client/utils.ts @@ -10,8 +10,6 @@ export const pickFromClerkInitState = ( ): TanStackProviderAndInitialProps & InternalClerkScriptProps & { clerkSsrState: any; - __keylessClaimUrl?: string; - __keylessApiKeysUrl?: string; } => { const { __clerk_ssr_state, @@ -32,8 +30,6 @@ export const pickFromClerkInitState = ( __signUpForceRedirectUrl, __signInFallbackRedirectUrl, __signUpFallbackRedirectUrl, - __keylessClaimUrl, - __keylessApiKeysUrl, __prefetchUI, } = clerkInitState || {}; @@ -59,8 +55,6 @@ 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 5ba42112a7c..a3158abf6bc 100644 --- a/packages/tanstack-react-start/src/server/clerkMiddleware.ts +++ b/packages/tanstack-react-start/src/server/clerkMiddleware.ts @@ -8,7 +8,7 @@ import { createMiddleware } from '@tanstack/react-start'; import { canUseKeyless } from '../utils/feature-flags'; import { clerkClient } from './clerkClient'; -import { resolveKeysWithKeylessFallback } from './keyless/utils'; +import { completeOnboardingIfClaimed } from './keyless/utils'; import { loadOptions } from './loadOptions'; import type { ClerkMiddlewareOptions, ClerkMiddlewareOptionsCallback } from './types'; import { getResponseClerkState } from './utils'; @@ -29,19 +29,12 @@ export const clerkMiddleware = ( secretKey: resolvedOptions?.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; + if (canUseKeyless) { + try { + await completeOnboardingIfClaimed(loadedOptions.publishableKey); + } catch { + // Silently fail - claimed-keys onboarding must not break requests + } } const requestState = await clerkClient().authenticateRequest(clerkRequest, { @@ -67,15 +60,6 @@ export const clerkMiddleware = ( const clerkInitialState = getResponseClerkState(requestState as RequestState, loadedOptions); - // 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, diff --git a/packages/tanstack-react-start/src/server/keyless/__tests__/utils.test.ts b/packages/tanstack-react-start/src/server/keyless/__tests__/utils.test.ts new file mode 100644 index 00000000000..bdc7d39165d --- /dev/null +++ b/packages/tanstack-react-start/src/server/keyless/__tests__/utils.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { completeOnboardingIfClaimed } from '../utils'; + +const { completeClaimedOnboarding, log, readKeys, completeOnboarding } = vi.hoisted(() => ({ + completeClaimedOnboarding: vi.fn(() => Promise.resolve()), + log: vi.fn(), + readKeys: vi.fn(), + completeOnboarding: vi.fn(), +})); + +vi.mock('@clerk/shared/keyless', () => ({ + completeClaimedOnboarding, + clerkDevelopmentCache: { log }, +})); + +vi.mock('../index', () => ({ + keyless: () => ({ readKeys, completeOnboarding }), +})); + +const storedKeys = { + publishableKey: 'pk_test_stored', + secretKey: 'sk_test_stored', + claimUrl: 'https://dashboard.clerk.com/apps/claim', + apiKeysUrl: 'https://dashboard.clerk.com/last-active?path=api-keys', +}; + +describe('completeOnboardingIfClaimed', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('completes onboarding when the configured key matches the stored keyless keys', async () => { + readKeys.mockReturnValue(storedKeys); + + await completeOnboardingIfClaimed('pk_test_stored'); + + expect(completeClaimedOnboarding).toHaveBeenCalledTimes(1); + expect(completeClaimedOnboarding).toHaveBeenCalledWith('pk_test_stored', expect.objectContaining({ readKeys })); + }); + + it('does nothing when the configured key does not match the stored keys', async () => { + readKeys.mockReturnValue(storedKeys); + + await completeOnboardingIfClaimed('pk_test_other'); + + expect(completeClaimedOnboarding).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + }); + + it('does nothing when no keyless keys are stored', async () => { + readKeys.mockReturnValue(undefined); + + await completeOnboardingIfClaimed('pk_test_stored'); + + expect(completeClaimedOnboarding).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + }); + + it('logs a pointer to the stored keys when no key is configured, without completing onboarding', async () => { + readKeys.mockReturnValue(storedKeys); + + await completeOnboardingIfClaimed(undefined); + + expect(completeClaimedOnboarding).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ + cacheKey: 'pk_test_stored_stored', + msg: expect.stringContaining('.clerk/.tmp/keyless.json'), + }), + ); + expect(log).toHaveBeenCalledWith(expect.objectContaining({ msg: expect.stringContaining(storedKeys.claimUrl) })); + }); +}); diff --git a/packages/tanstack-react-start/src/server/keyless/index.ts b/packages/tanstack-react-start/src/server/keyless/index.ts index bb91fbedfa9..e2a5d74fdb9 100644 --- a/packages/tanstack-react-start/src/server/keyless/index.ts +++ b/packages/tanstack-react-start/src/server/keyless/index.ts @@ -11,16 +11,6 @@ export function keyless() { keylessServiceInstance = createKeylessService({ storage: createFileStorage(), api: { - async createAccountlessApplication(requestHeaders?: Headers, source?: string) { - try { - return await clerkClient().__experimental_accountlessApplications.createAccountlessApplication({ - requestHeaders, - source, - }); - } catch { - return null; - } - }, async completeOnboarding(requestHeaders?: Headers, source?: string) { try { return await clerkClient().__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({ diff --git a/packages/tanstack-react-start/src/server/keyless/utils.ts b/packages/tanstack-react-start/src/server/keyless/utils.ts index ab4896cadb5..9bce40385e0 100644 --- a/packages/tanstack-react-start/src/server/keyless/utils.ts +++ b/packages/tanstack-react-start/src/server/keyless/utils.ts @@ -1,19 +1,30 @@ -import { resolveKeysWithKeylessFallback as sharedResolveKeysWithKeylessFallback } from '@clerk/shared/keyless'; -export type { KeylessResult } from '@clerk/shared/keyless'; +import { clerkDevelopmentCache, completeClaimedOnboarding } from '@clerk/shared/keyless'; -import { canUseKeyless } from '../../utils/feature-flags'; import { keyless } from './index'; /** - * 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) + * Notifies the dashboard that a claimed keyless application is now running with its + * keys configured. When no key is configured but stored keyless keys exist, logs a + * one-time pointer to them instead (the missing-key error throws downstream). */ -export function resolveKeysWithKeylessFallback( - configuredPublishableKey: string | undefined, - configuredSecretKey: string | undefined, -) { - return sharedResolveKeysWithKeylessFallback(configuredPublishableKey, configuredSecretKey, keyless(), canUseKeyless); +export async function completeOnboardingIfClaimed(configuredPublishableKey: string | undefined): Promise { + const keylessService = keyless(); + const locallyStoredKeys = keylessService.readKeys(); + if (!locallyStoredKeys) { + return; + } + + if (!configuredPublishableKey) { + clerkDevelopmentCache?.log({ + cacheKey: `${locallyStoredKeys.publishableKey}_stored`, + msg: `[Clerk]: Found existing keyless-mode keys in .clerk/.tmp/keyless.json. Copy the publishableKey and secretKey into .env (CLERK_PUBLISHABLE_KEY / CLERK_SECRET_KEY) to keep using that application, or claim it at ${locallyStoredKeys.claimUrl}`, + }); + return; + } + + if (locallyStoredKeys.publishableKey !== configuredPublishableKey) { + return; + } + + await completeClaimedOnboarding(locallyStoredKeys.publishableKey, keylessService); } diff --git a/packages/tanstack-react-start/src/server/loadOptions.ts b/packages/tanstack-react-start/src/server/loadOptions.ts index 694494a8c6d..3af9632686b 100644 --- a/packages/tanstack-react-start/src/server/loadOptions.ts +++ b/packages/tanstack-react-start/src/server/loadOptions.ts @@ -30,7 +30,7 @@ export const loadOptions = (request: ClerkRequest, overrides: LoaderOptions = {} proxyUrl = relativeOrAbsoluteProxyUrl; } - // In keyless mode, don't throw if secretKey is missing - ClerkProvider will handle it + // In development, defer missing-key errors to authenticateRequest so the CLI-pointing message surfaces if (!secretKey && !canUseKeyless) { // eslint-disable-next-line @typescript-eslint/only-throw-error throw errorThrower.throw('Clerk: no secret key provided');