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/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/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