diff --git a/.changeset/nextjs-dev-key-init-notice.md b/.changeset/nextjs-dev-key-init-notice.md new file mode 100644 index 00000000000..2098e3d4adb --- /dev/null +++ b/.changeset/nextjs-dev-key-init-notice.md @@ -0,0 +1,10 @@ +--- +'@clerk/nextjs': minor +'@clerk/shared': minor +--- + +Print a one-time notice in the server terminal when `` renders with a development publishable key, naming `npx clerk@latest init` as the way to get working keys without a Clerk account. The notice appears once per process, so once per build worker during `next build`, and on the first server render under `next dev`. It never prints in the browser, in deployed runtimes, or when the keys came from keyless mode. It is silenced by the existing `unsafe_disableDevelopmentModeConsoleWarning` prop or `NEXT_PUBLIC_CLERK_UNSAFE_DISABLE_DEVELOPMENT_MODE_CONSOLE_WARNING` env var. + +Fix `unsafe_disableDevelopmentModeConsoleWarning` being ignored when passed as a prop to the Next.js ``; previously only the env var took effect, so the prop did not silence the browser development-keys warning either. + +`@clerk/shared/keys` now exports `accountlessInitGuidance`, the sentence used by this notice and by the existing missing-key errors. diff --git a/integration/tests/next-build.test.ts b/integration/tests/next-build.test.ts index 697384b9922..7822c293be6 100644 --- a/integration/tests/next-build.test.ts +++ b/integration/tests/next-build.test.ts @@ -131,11 +131,33 @@ export default function RootLayout({ children }: { children: React.ReactNode }) ); } `, + ) + .addFile( + 'src/app/dev-key-notice/node/page.tsx', + () => `export const dynamic = 'force-dynamic'; + +export default function Page() { + console.log('dev-key-notice-sentinel:node'); + return

dev-key-notice-marker:node

; +} +`, + ) + .addFile( + 'src/app/dev-key-notice/edge/page.tsx', + () => `export const runtime = 'edge'; +export const dynamic = 'force-dynamic'; + +export default function Page() { + console.log('dev-key-notice-sentinel:edge'); + return

dev-key-notice-marker:edge

; +} +`, ) .commit(); await app.setup(); await app.withEnv(appConfigs.envs.withEmailCodes); await app.build(); + await app.serve(); }); test.afterAll(async () => { @@ -155,6 +177,27 @@ export default function RootLayout({ children }: { children: React.ReactNode }) expect(notFoundPageLine).toContain(staticIndicator); }); + test('Prints the clerk init hint for development keys when is a client component', () => { + expect(app.buildOutput).toContain('Development keys in use'); + }); + + test('Does not print the clerk init hint when the built app is served', async () => { + // Both pages render the provider at request time, one on Node and one on Edge, and log a sentinel so + // the negative assertion below only runs once their server output has been captured. + for (const target of ['node', 'edge']) { + const res = await fetch(`${app.serverUrl}/dev-key-notice/${target}`); + expect(res.status).toBe(200); + expect(await res.text()).toContain(`dev-key-notice-marker:${target}`); + } + await expect + .poll(() => app.serveOutput, { timeout: 15_000 }) + .toMatch( + /dev-key-notice-sentinel:node[\s\S]*dev-key-notice-sentinel:edge|dev-key-notice-sentinel:edge[\s\S]*dev-key-notice-sentinel:node/, + ); + + expect(app.serveOutput).not.toContain('Development keys in use'); + }); + /** * Sometimes utilities from `/server` may use Node APIs even if `clerkMiddleware` does not consumes them. * This happens because of code for node runtime and edge runtime is bundled together in the `/server/index.ts` barrel file. diff --git a/packages/nextjs/src/app-router/client/ClerkProvider.tsx b/packages/nextjs/src/app-router/client/ClerkProvider.tsx index fb6834585a4..ebac0d70189 100644 --- a/packages/nextjs/src/app-router/client/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/client/ClerkProvider.tsx @@ -8,6 +8,7 @@ import { useSafeLayoutEffect } from '../../client-boundary/hooks/useSafeLayoutEf import { ClerkNextOptionsProvider, useClerkNextOptions } from '../../client-boundary/NextOptionsContext'; import { errorThrower } from '../../server/errorThrower'; import type { NextClerkProviderProps } from '../../types'; +import { maybeShowDevelopmentKeyNotice } from '../../utils/devKeyNotice'; import { canUseKeyless } from '../../utils/feature-flags'; import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv'; import { RouterTelemetry } from '../../utils/router-telemetry'; @@ -76,6 +77,12 @@ const NextClientClerkProvider = (props: NextClerkProviderPr routerReplace: replace, }); + maybeShowDevelopmentKeyNotice({ + publishableKey: mergedProps.publishableKey, + disabled: mergedProps.unsafe_disableDevelopmentModeConsoleWarning, + keyless: Boolean(mergedProps.__internal_keyless_claimKeylessApplicationUrl), + }); + return ( diff --git a/packages/nextjs/src/app-router/client/__tests__/ClerkProvider.test.tsx b/packages/nextjs/src/app-router/client/__tests__/ClerkProvider.test.tsx new file mode 100644 index 00000000000..047dfffa5fe --- /dev/null +++ b/packages/nextjs/src/app-router/client/__tests__/ClerkProvider.test.tsx @@ -0,0 +1,79 @@ +/** + * @vitest-environment node + */ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { maybeShowDevelopmentKeyNotice } from '../../../utils/devKeyNotice'; +import { ClientClerkProvider } from '../ClerkProvider'; + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ refresh: vi.fn(), push: vi.fn(), replace: vi.fn() }), +})); +vi.mock('../useAwaitablePush', () => ({ useAwaitablePush: () => vi.fn() })); +vi.mock('../useAwaitableReplace', () => ({ useAwaitableReplace: () => vi.fn() })); +vi.mock('../../server-actions', () => ({ invalidateCacheAction: vi.fn() })); +vi.mock('../ClerkScripts', () => ({ ClerkScripts: () => null })); +vi.mock('../../../utils/router-telemetry', () => ({ RouterTelemetry: () => null })); +vi.mock('@clerk/react/internal', () => ({ + InternalClerkProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); +vi.mock('../../../utils/devKeyNotice', () => ({ maybeShowDevelopmentKeyNotice: vi.fn() })); + +const notice = maybeShowDevelopmentKeyNotice as unknown as ReturnType; +const DEV_KEY = 'pk_test_ZmFrZS1jbGVyay5hY2NvdW50cy5kZXYk'; +const ORIGINAL_ENV = { ...process.env }; + +describe('ClientClerkProvider (server render)', () => { + beforeEach(() => { + delete process.env.NEXT_PUBLIC_CLERK_UNSAFE_DISABLE_DEVELOPMENT_MODE_CONSOLE_WARNING; + notice.mockClear(); + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it('asks for the development key notice with the resolved key', () => { + const html = renderToStaticMarkup(child); + + expect(html).toContain('child'); + expect(notice).toHaveBeenCalledTimes(1); + expect(notice).toHaveBeenCalledWith({ publishableKey: DEV_KEY, disabled: false, keyless: false }); + }); + + it('passes the opt-out through when set as a prop', () => { + renderToStaticMarkup( + + child + , + ); + + expect(notice).toHaveBeenCalledWith(expect.objectContaining({ disabled: true })); + }); + + it('passes the opt-out through when set by env var', () => { + process.env.NEXT_PUBLIC_CLERK_UNSAFE_DISABLE_DEVELOPMENT_MODE_CONSOLE_WARNING = 'true'; + + renderToStaticMarkup(child); + + expect(notice).toHaveBeenCalledWith(expect.objectContaining({ disabled: true })); + }); + + it('flags keys that came from keyless mode', () => { + renderToStaticMarkup( + + child + , + ); + + expect(notice).toHaveBeenCalledWith(expect.objectContaining({ keyless: true })); + }); +}); diff --git a/packages/nextjs/src/pages/ClerkProvider.tsx b/packages/nextjs/src/pages/ClerkProvider.tsx index 6d47886a470..2cecd97b4dc 100644 --- a/packages/nextjs/src/pages/ClerkProvider.tsx +++ b/packages/nextjs/src/pages/ClerkProvider.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { useSafeLayoutEffect } from '../client-boundary/hooks/useSafeLayoutEffect'; import { ClerkNextOptionsProvider } from '../client-boundary/NextOptionsContext'; import type { NextClerkProviderProps } from '../types'; +import { maybeShowDevelopmentKeyNotice } from '../utils/devKeyNotice'; import { invalidateNextRouterCache } from '../utils/invalidateNextRouterCache'; import { mergeNextClerkPropsWithEnv } from '../utils/mergeNextClerkPropsWithEnv'; import { removeBasePath } from '../utils/removeBasePath'; @@ -46,6 +47,11 @@ export function ClerkProvider({ children, ...props }: NextC routerPush: navigate, routerReplace: replaceNavigate, }); + maybeShowDevelopmentKeyNotice({ + publishableKey: mergedProps.publishableKey, + disabled: mergedProps.unsafe_disableDevelopmentModeConsoleWarning, + keyless: Boolean(mergedProps.__internal_keyless_claimKeylessApplicationUrl), + }); // ClerkProvider automatically injects __clerk_ssr_state // getAuth returns a user-facing authServerSideProps that hides __clerk_ssr_state // @ts-expect-error initialState is hidden from the types as it's a private prop diff --git a/packages/nextjs/src/pages/__tests__/ClerkProvider.test.tsx b/packages/nextjs/src/pages/__tests__/ClerkProvider.test.tsx new file mode 100644 index 00000000000..14bb5c185d0 --- /dev/null +++ b/packages/nextjs/src/pages/__tests__/ClerkProvider.test.tsx @@ -0,0 +1,70 @@ +/** + * @vitest-environment node + */ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { maybeShowDevelopmentKeyNotice } from '../../utils/devKeyNotice'; +import { ClerkProvider } from '../ClerkProvider'; + +vi.mock('next/router', () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); +vi.mock('../ClerkScripts', () => ({ ClerkScripts: () => null })); +vi.mock('../../utils/router-telemetry', () => ({ RouterTelemetry: () => null })); +vi.mock('@clerk/react/internal', () => ({ + InternalClerkProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + setClerkJSLoadingErrorPackageName: vi.fn(), + setErrorThrowerOptions: vi.fn(), +})); +vi.mock('../../utils/devKeyNotice', () => ({ maybeShowDevelopmentKeyNotice: vi.fn() })); + +const notice = maybeShowDevelopmentKeyNotice as unknown as ReturnType; +const DEV_KEY = 'pk_test_ZmFrZS1jbGVyay5hY2NvdW50cy5kZXYk'; +const ORIGINAL_ENV = { ...process.env }; + +describe('Pages Router ClerkProvider (server render)', () => { + beforeEach(() => { + delete process.env.NEXT_PUBLIC_CLERK_UNSAFE_DISABLE_DEVELOPMENT_MODE_CONSOLE_WARNING; + notice.mockClear(); + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it('asks for the development key notice with the resolved key and opt-out', () => { + const html = renderToStaticMarkup(child); + + expect(html).toContain('child'); + expect(notice).toHaveBeenCalledTimes(1); + expect(notice).toHaveBeenCalledWith({ publishableKey: DEV_KEY, disabled: false, keyless: false }); + }); + + it('flags keys that came from keyless mode', () => { + renderToStaticMarkup( + + child + , + ); + + expect(notice).toHaveBeenCalledWith(expect.objectContaining({ keyless: true })); + }); + + it('passes the opt-out through when set as a prop', () => { + renderToStaticMarkup( + + child + , + ); + + expect(notice).toHaveBeenCalledWith(expect.objectContaining({ disabled: true })); + }); +}); diff --git a/packages/nextjs/src/utils/__tests__/devKeyNotice.test.ts b/packages/nextjs/src/utils/__tests__/devKeyNotice.test.ts new file mode 100644 index 00000000000..e8583f46a8c --- /dev/null +++ b/packages/nextjs/src/utils/__tests__/devKeyNotice.test.ts @@ -0,0 +1,146 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { __resetDevelopmentKeyNoticeForTests, maybeShowDevelopmentKeyNotice } from '../devKeyNotice'; + +// pk_test_ + base64('fake-clerk.accounts.dev$') +const DEV_KEY = 'pk_test_ZmFrZS1jbGVyay5hY2NvdW50cy5kZXYk'; +const LIVE_KEY = 'pk_live_Zm9vLmNsZXJrLmNvbSQ='; +// pk_test_ + base64('evil.dev\nforged line$') +const DEV_KEY_WITH_NEWLINE = `pk_test_${Buffer.from('evil.dev\nforged line$').toString('base64')}`; +const ORIGINAL_ENV = { ...process.env }; + +describe('maybeShowDevelopmentKeyNotice', () => { + let logSpy: ReturnType; + + beforeEach(() => { + __resetDevelopmentKeyNoticeForTests(); + // Default to the `next build` environment; individual tests override it. + process.env.NEXT_PHASE = 'phase-production-build'; + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + vi.unstubAllEnvs(); + process.env = { ...ORIGINAL_ENV }; + }); + + const printed = () => logSpy.mock.calls.map((call: unknown[]) => String(call[0])).join('\n'); + + it('prints once for a development key, naming clerk init and the instance', () => { + maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY }); + maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY }); + maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY }); + + expect(logSpy).toHaveBeenCalledTimes(1); + expect(printed()).toContain('npx clerk@latest init'); + expect(printed()).toContain('No Clerk account or login required'); + expect(printed()).toContain('(fake-clerk.accounts.dev)'); + }); + + it('prints under next dev without a build phase', () => { + delete process.env.NEXT_PHASE; + vi.stubEnv('NODE_ENV', 'development'); + + maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY }); + + expect(logSpy).toHaveBeenCalledTimes(1); + }); + + it('prints nothing in a deployed production runtime', () => { + delete process.env.NEXT_PHASE; + vi.stubEnv('NODE_ENV', 'production'); + + maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY }); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('prints nothing in a deployed Edge Runtime', () => { + delete process.env.NEXT_PHASE; + vi.stubEnv('NODE_ENV', 'production'); + (globalThis as { EdgeRuntime?: string }).EdgeRuntime = 'edge-runtime'; + + try { + maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY }); + expect(logSpy).not.toHaveBeenCalled(); + } finally { + delete (globalThis as { EdgeRuntime?: string }).EdgeRuntime; + } + }); + + it('prints nothing for a production key', () => { + maybeShowDevelopmentKeyNotice({ publishableKey: LIVE_KEY }); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('prints nothing for a missing or malformed key', () => { + maybeShowDevelopmentKeyNotice({ publishableKey: undefined }); + maybeShowDevelopmentKeyNotice({ publishableKey: '' }); + maybeShowDevelopmentKeyNotice({ publishableKey: 'pk_test_not-base64!' }); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('prints nothing when disabled', () => { + maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY, disabled: true }); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('prints nothing when the keys came from keyless mode', () => { + maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY, keyless: true }); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('omits the instance when the decoded key is not safe to print', () => { + maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY_WITH_NEWLINE }); + + expect(logSpy).toHaveBeenCalledTimes(1); + expect(printed()).not.toContain('forged'); + expect(printed()).toContain('Development keys in use.'); + expect(printed()).toContain('npx clerk@latest init'); + }); + + it('prints nothing in a browser-like environment', () => { + (globalThis as { window?: unknown }).window = {}; + + try { + maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY }); + expect(logSpy).not.toHaveBeenCalled(); + } finally { + delete (globalThis as { window?: unknown }).window; + } + }); + + it('prints in Next.js Edge Runtime under next dev', () => { + delete process.env.NEXT_PHASE; + vi.stubEnv('NODE_ENV', 'development'); + (globalThis as { EdgeRuntime?: string }).EdgeRuntime = 'edge-runtime'; + + try { + maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY }); + expect(logSpy).toHaveBeenCalledTimes(1); + } finally { + delete (globalThis as { EdgeRuntime?: string }).EdgeRuntime; + } + }); + + it('does not throw if console.log fails, and retries on the next call', () => { + logSpy.mockImplementationOnce(() => { + throw new Error('console broken'); + }); + + expect(() => maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY })).not.toThrow(); + + maybeShowDevelopmentKeyNotice({ publishableKey: DEV_KEY }); + + expect(logSpy).toHaveBeenCalledTimes(2); + expect(printed()).toContain('npx clerk@latest init'); + }); +}); diff --git a/packages/nextjs/src/utils/__tests__/mergeNextClerkPropsWithEnv.test.ts b/packages/nextjs/src/utils/__tests__/mergeNextClerkPropsWithEnv.test.ts index 18bab0aa982..fa8965441ce 100644 --- a/packages/nextjs/src/utils/__tests__/mergeNextClerkPropsWithEnv.test.ts +++ b/packages/nextjs/src/utils/__tests__/mergeNextClerkPropsWithEnv.test.ts @@ -9,6 +9,34 @@ describe('mergeNextClerkPropsWithEnv', () => { process.env = { ...ORIGINAL_ENV }; }); + describe('unsafe_disableDevelopmentModeConsoleWarning', () => { + it('is false when neither the prop nor the env var is set', () => { + expect(mergeNextClerkPropsWithEnv({}).unsafe_disableDevelopmentModeConsoleWarning).toBe(false); + }); + + it('is true when set as a prop', () => { + expect( + mergeNextClerkPropsWithEnv({ unsafe_disableDevelopmentModeConsoleWarning: true }) + .unsafe_disableDevelopmentModeConsoleWarning, + ).toBe(true); + }); + + it('is true when set by env var', () => { + process.env.NEXT_PUBLIC_CLERK_UNSAFE_DISABLE_DEVELOPMENT_MODE_CONSOLE_WARNING = 'true'; + + expect(mergeNextClerkPropsWithEnv({}).unsafe_disableDevelopmentModeConsoleWarning).toBe(true); + }); + + it('is true when the env var is set even if the prop is explicitly false', () => { + process.env.NEXT_PUBLIC_CLERK_UNSAFE_DISABLE_DEVELOPMENT_MODE_CONSOLE_WARNING = '1'; + + expect( + mergeNextClerkPropsWithEnv({ unsafe_disableDevelopmentModeConsoleWarning: false }) + .unsafe_disableDevelopmentModeConsoleWarning, + ).toBe(true); + }); + }); + it('auto-derives a relative proxyUrl for Vercel production static generation', () => { process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = 'pk_live_Zm9vLmNsZXJrLmNvbSQ='; process.env.VERCEL_TARGET_ENV = 'production'; diff --git a/packages/nextjs/src/utils/devKeyNotice.ts b/packages/nextjs/src/utils/devKeyNotice.ts new file mode 100644 index 00000000000..d9274c534b0 --- /dev/null +++ b/packages/nextjs/src/utils/devKeyNotice.ts @@ -0,0 +1,75 @@ +import { accountlessInitGuidance, parsePublishableKey } from '@clerk/shared/keys'; + +const PROCESS_FLAG = Symbol.for('@clerk/nextjs.developmentKeyNoticeShown'); + +function hasSeen(): boolean { + return Boolean((globalThis as Record)[PROCESS_FLAG]); +} + +function markSeen(): void { + (globalThis as Record)[PROCESS_FLAG] = true; +} + +// Keeps a forged key from injecting escape sequences or extra lines into the terminal. +function isTerminalSafeInstance(value: string): boolean { + return /^[a-z0-9.-]+$/i.test(value); +} + +// PHASE_PRODUCTION_BUILD is hardcoded rather than imported from next/constants to keep that module out of client bundles. +function isBuildOrDevServer(): boolean { + if (typeof process === 'undefined' || !process.env) { + return false; + } + return process.env.NEXT_PHASE === 'phase-production-build' || process.env.NODE_ENV === 'development'; +} + +export type DevelopmentKeyNoticeOptions = { + publishableKey?: string; + /** + * The resolved `unsafe_disableDevelopmentModeConsoleWarning` option (prop or env var). + */ + disabled?: boolean; + /** + * Keys came from keyless mode, which prints its own guidance. + */ + keyless?: boolean; +}; + +/** + * Print a one-time terminal notice, per process, when `` renders on the server with a + * development publishable key. The notice names `npx clerk@latest init` so that a developer, or a + * coding agent reading build output, learns that working keys need no Clerk account. Prints only + * during `next build` and under `next dev`; browsers and deployed runtimes are skipped. Never throws. + */ +export function maybeShowDevelopmentKeyNotice(options: DevelopmentKeyNoticeOptions): void { + try { + if (typeof window !== 'undefined' || options.disabled === true || options.keyless === true || hasSeen()) { + return; + } + if (!isBuildOrDevServer()) { + return; + } + const parsed = parsePublishableKey(options.publishableKey); + if (parsed?.instanceType !== 'development') { + return; + } + if (typeof console === 'undefined' || typeof console.log !== 'function') { + return; + } + const instance = isTerminalSafeInstance(parsed.frontendApi) ? ` (${parsed.frontendApi})` : ''; + // Unconditional for development keys: the SDK cannot tell a real instance from a fabricated key of the same shape without a network call, so no reachability check belongs here. + console.log(`\n\x1b[35m[Clerk]:\x1b[0m Development keys in use${instance}. ${accountlessInitGuidance}\n`); + markSeen(); + } catch { + // never let the notice break rendering + } +} + +/** + * Test-only: clear the in-process flag so the next call re-runs the gating logic. + * + * @internal + */ +export function __resetDevelopmentKeyNoticeForTests(): void { + delete (globalThis as Record)[PROCESS_FLAG]; +} diff --git a/packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts b/packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts index 491e6cf810d..80ef00943bd 100644 --- a/packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts +++ b/packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts @@ -61,8 +61,8 @@ export const mergeNextClerkPropsWithEnv = ( debug: isTruthy(process.env.NEXT_PUBLIC_CLERK_TELEMETRY_DEBUG), }, sdkMetadata: SDK_METADATA, - unsafe_disableDevelopmentModeConsoleWarning: isTruthy( - process.env.NEXT_PUBLIC_CLERK_UNSAFE_DISABLE_DEVELOPMENT_MODE_CONSOLE_WARNING, - ), + unsafe_disableDevelopmentModeConsoleWarning: + props.unsafe_disableDevelopmentModeConsoleWarning === true || + isTruthy(process.env.NEXT_PUBLIC_CLERK_UNSAFE_DISABLE_DEVELOPMENT_MODE_CONSOLE_WARNING), }; }; diff --git a/packages/shared/src/__tests__/keys.spec.ts b/packages/shared/src/__tests__/keys.spec.ts index c9ec4d42acc..b0d09b00d1f 100644 --- a/packages/shared/src/__tests__/keys.spec.ts +++ b/packages/shared/src/__tests__/keys.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it, test } from 'vitest'; import { + accountlessInitGuidance, buildPublishableKey, createDevOrStagingUrlCache, getCookieSuffix, @@ -85,6 +86,26 @@ describe('parsePublishableKey(key)', () => { ); }); + it('appends the same guidance to every fatal error, and that guidance contains the init sentence', () => { + const messageFor = (key: string | undefined) => { + try { + parsePublishableKey(key, { fatal: true }); + } catch (error) { + return (error as Error).message; + } + throw new Error('expected parsePublishableKey to throw'); + }; + + const missingKeyMessage = messageFor(undefined); + const invalidKeyMessage = messageFor('fake_pk'); + + const guidance = missingKeyMessage.slice('Publishable key is missing. '.length); + expect(guidance).toContain(accountlessInitGuidance); + expect(invalidKeyMessage).toBe( + `Publishable key not valid (expected format: pk_test_... or pk_live_...). ${guidance}`, + ); + }); + it('applies the proxyUrl if provided', () => { expect( parsePublishableKey('pk_live_ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ=', { diff --git a/packages/shared/src/keys.ts b/packages/shared/src/keys.ts index 9949277f0da..14444a9a7f7 100644 --- a/packages/shared/src/keys.ts +++ b/packages/shared/src/keys.ts @@ -98,11 +98,18 @@ function isValidDecodedPublishableKey(decoded: string): boolean { return withoutTrailing.includes('.'); } +/** + * The one sentence that explains how to get working Clerk keys without a Clerk account. + * Shared by every message that mentions `clerk init` so there is a single sentence to keep true. + */ +export const accountlessInitGuidance = + '`npx clerk@latest init` creates a Clerk application and writes keys to your .env file. No Clerk account or login required and the command is non-interactive.'; + const fatalKeyGuidance = `To create a Clerk application with valid keys, in your terminal run: npx clerk@latest init -\`npx clerk@latest init\` creates a Clerk application and writes keys to your .env file. No Clerk account or login required and the command is non-interactive. +${accountlessInitGuidance} If you have a Clerk application, run \`npx clerk@latest env pull\` to write the keys (\`--instance prod\` for production keys). Or copy them from https://dashboard.clerk.com/~/api-keys.`;