Skip to content

Commit b0ade20

Browse files
djgouldclaude
andauthored
feat(nextjs): throw missing-env error instead of keyless bootstrap
Squashed from the original four commits of this PR (bootstrap removal, import sort, adversarial-review findings, non-interactive CLI wording) during the simple-first stack reorder. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0cddd6d commit b0ade20

13 files changed

Lines changed: 128 additions & 366 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/backend': patch
3+
---
4+
5+
Mark the internal `createBootstrapSignedOutState` as deprecated. It is no longer used by `@clerk/nextjs` and is kept only for older published SDK versions.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/nextjs': minor
3+
---
4+
5+
In development, missing Clerk keys no longer activate keyless mode. When `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY` are not set, the SDK now throws an error directing you to run `npx clerk@latest init`, which provisions a Clerk application and writes the keys to `.env.local`. Keyless credentials stored in the development keyless cookie are no longer read. Existing apps with configured or claimed keys are unaffected.

integration/tests/next-middleware-keyless.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@ test.describe('Keyless mode | middleware authorization @nextjs', () => {
2727
await app.teardown();
2828
});
2929

30-
test('auth.protect() in middleware redirects to sign-in during keyless bootstrap', async ({ page }) => {
31-
await page.goto(`${app.serverUrl}/protected`);
32-
await page.waitForURL(/\/sign-in/);
33-
await expect(page.getByTestId('protected')).not.toBeVisible();
30+
test('requests without keys fail with the missing env vars error instead of keyless bootstrap', async ({ page }) => {
31+
const response = await page.goto(`${app.serverUrl}/protected`);
32+
expect(response?.status()).toBe(500);
33+
const content = await page.content();
34+
expect(content).toContain('Missing environment variables');
35+
expect(content).toContain('npx clerk@latest init');
3436
});
3537
});

integration/tests/next-quickstart-keyless.test.ts

Lines changed: 27 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import * as path from 'node:path';
2+
13
import { expect, test } from '@playwright/test';
24

35
import type { Application } from '../models/application';
46
import { appConfigs } from '../presets';
7+
import { fs } from '../scripts';
58
import { createTestUtils } from '../testUtils';
6-
import { mockClaimedInstanceEnvironmentCall, testToggleCollapsePopoverAndClaim } from '../testUtils/keylessHelpers';
9+
import { mockClaimedInstanceEnvironmentCall } from '../testUtils/keylessHelpers';
710

811
const commonSetup = appConfigs.next.appRouterQuickstart.clone();
912

@@ -17,87 +20,56 @@ test.describe('Keyless mode @quickstart', () => {
1720
});
1821

1922
let app: Application;
20-
let dashboardUrl = 'https://dashboard.clerk.com/';
2123

2224
test.beforeAll(async () => {
2325
app = await commonSetup.commit();
2426
await app.setup();
2527
await app.withEnv(appConfigs.envs.withKeyless);
26-
if (appConfigs.envs.withKeyless.privateVariables.get('CLERK_API_URL')?.includes('clerkstage')) {
27-
dashboardUrl = 'https://dashboard.clerkstage.dev/';
28-
}
2928
await app.dev();
3029
});
3130

3231
test.afterAll(async () => {
3332
await app.teardown();
3433
});
3534

36-
test('Navigates to non-existent page (/_not-found) without a infinite redirect loop.', async ({ page, context }) => {
37-
const u = createTestUtils({ app, page, context });
38-
await u.page.goToAppHome();
39-
await u.page.waitForClerkJsLoaded();
40-
await u.po.expect.toBeSignedOut();
41-
42-
await u.po.keylessPopover.waitForMounted();
43-
44-
const redirectMap = new Map<string, number>();
45-
page.on('request', request => {
46-
// Only count GET requests since Next.js server actions are sent with POST requests.
47-
if (request.method() === 'GET') {
48-
const url = request.url();
49-
redirectMap.set(url, (redirectMap.get(url) || 0) + 1);
50-
expect(redirectMap.get(url)).toBeLessThanOrEqual(1);
51-
}
52-
});
53-
54-
await u.page.goToRelative('/something');
55-
await u.page.waitForAppUrl('/something');
56-
});
57-
58-
test('Toggle collapse popover and claim.', async ({ page, context }) => {
59-
await testToggleCollapsePopoverAndClaim({ page, context, app, dashboardUrl, framework: 'nextjs' });
60-
});
61-
62-
test('Lands on claimed application with missing explicit keys, expanded by default, click to get keys from dashboard.', async ({
35+
test('Without keys, the app fails with the missing env vars error instead of keyless bootstrap.', async ({
6336
page,
64-
context,
6537
}) => {
66-
await mockClaimedInstanceEnvironmentCall(page);
67-
const u = createTestUtils({ app, page, context });
68-
await u.page.goToAppHome();
69-
await u.page.waitForClerkJsLoaded();
70-
71-
await u.po.keylessPopover.waitForMounted();
72-
expect(await u.po.keylessPopover.isExpanded()).toBe(true);
73-
await expect(u.po.keylessPopover.promptToUseClaimedKeys()).toBeVisible();
74-
75-
const href = await u.po.keylessPopover.promptToUseClaimedKeys().getAttribute('href');
76-
expect(href).toBeTruthy();
77-
expect(href).toContain(dashboardUrl);
38+
const response = await page.goto(`${app.serverUrl}/`);
39+
expect(response?.status()).toBe(500);
40+
const content = await page.content();
41+
expect(content).toContain('Missing environment variables');
42+
expect(content).toContain('npx clerk@latest init');
7843
});
7944

80-
test('Claimed application with keys inside .env, on dismiss, keyless prompt is removed.', async ({
45+
test('Claimed application with keys inside .env mounts the keyless prompt; on dismiss, it is removed.', async ({
8146
page,
8247
context,
8348
}) => {
84-
await mockClaimedInstanceEnvironmentCall(page);
85-
const u = createTestUtils({ app, page, context });
86-
await u.page.goToAppHome();
87-
88-
await u.po.keylessPopover.waitForMounted();
89-
await expect(await u.po.keylessPopover.promptToUseClaimedKeys()).toBeVisible();
90-
9149
/**
92-
* Copy keys from `.clerk/.tmp/keyless.json to `.env`
50+
* Seed claimed keyless state directly: the SDK no longer mints keys, so write the
51+
* keys fixture to `.clerk/.tmp/keyless.json` and copy the matching keys into `.env`.
9352
*/
53+
const publishableKey = appConfigs.envs.withEmailCodes.publicVariables.get('CLERK_PUBLISHABLE_KEY');
54+
const secretKey = appConfigs.envs.withEmailCodes.privateVariables.get('CLERK_SECRET_KEY');
55+
await fs.ensureDir(path.join(app.appDir, '.clerk', '.tmp'));
56+
await fs.writeJSON(path.join(app.appDir, '.clerk', '.tmp', 'keyless.json'), {
57+
publishableKey,
58+
secretKey,
59+
claimUrl: 'https://dashboard.clerk.com/apps/claim',
60+
apiKeysUrl: 'https://dashboard.clerk.com/last-active?path=api-keys',
61+
});
9462
await app.keylessToEnv();
9563
/**
9664
* wait a bit for the server to load the new env file
9765
*/
9866
await page.waitForTimeout(5_000);
9967

100-
await page.reload();
68+
await mockClaimedInstanceEnvironmentCall(page);
69+
const u = createTestUtils({ app, page, context });
70+
await u.page.goToAppHome();
71+
await u.page.waitForClerkJsLoaded();
72+
10173
await u.po.keylessPopover.waitForMounted();
10274
await u.po.keylessPopover.promptToDismiss().click();
10375

packages/backend/src/tokens/authStatus.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,8 @@ type BootstrapSignedOutParams = {
291291
* `isSatellite` / `domain` / `proxyUrl` are carried through so that cross-origin
292292
* satellite redirects produced by `createRedirect` include the `__clerk_status=needs-sync`
293293
* marker required for the return-trip handshake.
294+
*
295+
* @deprecated No longer used by `@clerk/nextjs`; kept for older published SDK versions. Remove in the next major.
294296
*/
295297
export function createBootstrapSignedOutState({
296298
signInUrl = '',

packages/nextjs/src/app-router/client/ClerkProvider.tsx

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
'use client';
22
import { InternalClerkProvider as ReactClerkProvider, type Ui } from '@clerk/react/internal';
33
import { InitialStateProvider } from '@clerk/shared/react';
4-
import dynamic from 'next/dynamic';
54
import { useRouter } from 'next/navigation';
65
import React from 'react';
76

87
import { useSafeLayoutEffect } from '../../client-boundary/hooks/useSafeLayoutEffect';
98
import { ClerkNextOptionsProvider, useClerkNextOptions } from '../../client-boundary/NextOptionsContext';
9+
import { missingEnvVars } from '../../server/errors';
1010
import type { NextClerkProviderProps } from '../../types';
1111
import { canUseKeyless } from '../../utils/feature-flags';
1212
import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv';
@@ -16,14 +16,6 @@ import { ClerkScripts } from './ClerkScripts';
1616
import { useAwaitablePush } from './useAwaitablePush';
1717
import { useAwaitableReplace } from './useAwaitableReplace';
1818

19-
/**
20-
* LazyCreateKeylessApplication should only be loaded if the conditions below are met.
21-
* Note: Using lazy() with Suspense instead of dynamic is not possible as React will throw a hydration error when `ClerkProvider` wraps `<html><body>...`
22-
*/
23-
const LazyCreateKeylessApplication = dynamic(() =>
24-
import('./keyless-creator-reader.js').then(m => m.KeylessCreatorOrReader),
25-
);
26-
2719
const NextClientClerkProvider = <TUi extends Ui = Ui>(props: NextClerkProviderProps<TUi>) => {
2820
const { __internal_invokeMiddlewareOnAuthStateChange = true, __internal_scriptsSlot, children } = props;
2921
const router = useRouter();
@@ -115,9 +107,5 @@ export const ClientClerkProvider = <TUi extends Ui = Ui>(
115107
return <NextClientClerkProvider {...rest}>{children}</NextClientClerkProvider>;
116108
}
117109

118-
return (
119-
<LazyCreateKeylessApplication>
120-
<NextClientClerkProvider {...rest}>{children}</NextClientClerkProvider>
121-
</LazyCreateKeylessApplication>
122-
);
110+
throw new Error(missingEnvVars);
123111
};

packages/nextjs/src/app-router/client/keyless-cookie-sync.tsx

Lines changed: 0 additions & 27 deletions
This file was deleted.

packages/nextjs/src/app-router/client/keyless-creator-reader.tsx

Lines changed: 0 additions & 32 deletions
This file was deleted.

packages/nextjs/src/app-router/keyless-actions.ts

Lines changed: 0 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,8 @@
11
'use server';
2-
import type { AccountlessApplication } from '@clerk/backend';
3-
import { cookies, headers } from 'next/headers';
4-
import { redirect, RedirectType } from 'next/navigation';
52

6-
import { errorThrower } from '../server/errorThrower';
7-
import { detectClerkMiddleware } from '../server/headers-utils';
8-
import { getKeylessCookieName, getKeylessCookieValue } from '../server/keyless';
9-
import { clerkDevelopmentCache, createKeylessModeMessage } from '../server/keyless-log-cache';
103
import { keyless } from '../server/keyless-node';
114
import { canUseKeyless } from '../utils/feature-flags';
125

13-
type SetCookieOptions = Parameters<Awaited<ReturnType<typeof cookies>>['set']>[2];
14-
15-
const keylessCookieConfig = {
16-
secure: false,
17-
httpOnly: false,
18-
sameSite: 'lax',
19-
} satisfies SetCookieOptions;
20-
21-
export async function syncKeylessConfigAction(args: AccountlessApplication & { returnUrl: string }): Promise<void> {
22-
const { claimUrl, publishableKey, secretKey, returnUrl } = args;
23-
const cookieStore = await cookies();
24-
const request = new Request('https://placeholder.com', { headers: await headers() });
25-
26-
const keylessCookie = await getKeylessCookieValue(name => cookieStore.get(name)?.value);
27-
const pksMatch = keylessCookie?.publishableKey === publishableKey;
28-
const sksMatch = keylessCookie?.secretKey === secretKey;
29-
if (pksMatch && sksMatch) {
30-
// Return early, syncing in not needed.
31-
return;
32-
}
33-
34-
// Set the new keys in the cookie.
35-
cookieStore.set(
36-
await getKeylessCookieName(),
37-
JSON.stringify({ claimUrl, publishableKey, secretKey }),
38-
keylessCookieConfig,
39-
);
40-
41-
// Request works at runtime since detectClerkMiddleware checks for Request via isRequestWebAPI
42-
if (detectClerkMiddleware(request as Parameters<typeof detectClerkMiddleware>[0])) {
43-
/**
44-
* Force middleware to execute to read the new keys from the cookies and populate the authentication state correctly.
45-
*/
46-
redirect(`/clerk-sync-keyless?returnUrl=${returnUrl}`, RedirectType.replace);
47-
}
48-
49-
return;
50-
}
51-
52-
export async function createOrReadKeylessAction(): Promise<null | Omit<AccountlessApplication, 'secretKey'>> {
53-
if (!canUseKeyless) {
54-
return null;
55-
}
56-
57-
let result;
58-
try {
59-
result = await keyless().getOrCreateKeys();
60-
} catch {
61-
result = null;
62-
}
63-
64-
if (!result) {
65-
errorThrower.throwMissingPublishableKeyError();
66-
return null;
67-
}
68-
69-
/**
70-
* Notify developers.
71-
*/
72-
clerkDevelopmentCache?.log({
73-
cacheKey: result.publishableKey,
74-
msg: createKeylessModeMessage(result),
75-
});
76-
77-
const { claimUrl, publishableKey, secretKey, apiKeysUrl } = result;
78-
void (await cookies()).set(
79-
await getKeylessCookieName(),
80-
JSON.stringify({ claimUrl, publishableKey, secretKey }),
81-
keylessCookieConfig,
82-
);
83-
84-
return {
85-
claimUrl,
86-
publishableKey,
87-
apiKeysUrl,
88-
};
89-
}
90-
916
export async function deleteKeylessAction() {
927
if (!canUseKeyless) {
938
return;

packages/nextjs/src/app-router/server/ClerkProvider.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { InitialState, Without } from '@clerk/shared/types';
33
import React, { Suspense } from 'react';
44

55
import { getDynamicAuthData } from '../../server/buildClerkProps';
6+
import { missingEnvVars } from '../../server/errors';
67
import type { NextClerkProviderProps } from '../../types';
78
import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv';
89
import { ClientClerkProvider } from '../client/ClerkProvider';
@@ -52,6 +53,9 @@ export async function ClerkProvider<TUi extends Ui = Ui>(
5253
) : undefined;
5354

5455
if (shouldRunAsKeyless) {
56+
if (!propsWithEnvs.publishableKey) {
57+
throw new Error(missingEnvVars);
58+
}
5559
return (
5660
<KeylessProvider
5761
rest={propsWithEnvs}

0 commit comments

Comments
 (0)