Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lazy-turkeys-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/nuxt": minor
---

Introduce Keyless quickstart for Nuxt. This allows the Clerk SDK to be used without having to sign up and paste your keys manually.
55 changes: 55 additions & 0 deletions integration/tests/nuxt/keyless.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { test } from '@playwright/test';

import type { Application } from '../../models/application';
import { appConfigs } from '../../presets';
import {
testClaimedAppWithMissingKeys,
testKeylessRemovedAfterEnvAndRestart,
testToggleCollapsePopoverAndClaim,
} from '../../testUtils/keylessHelpers';

const commonSetup = appConfigs.nuxt.node.clone();

test.describe('Keyless mode @nuxt', () => {
test.describe.configure({ mode: 'serial' });
test.setTimeout(90_000);

test.use({
extraHTTPHeaders: {
'x-vercel-protection-bypass': process.env.VERCEL_AUTOMATION_BYPASS_SECRET || '',
},
});

let app: Application;
let dashboardUrl = 'https://dashboard.clerk.com/';

test.beforeAll(async () => {
app = await commonSetup.commit();
await app.setup();
await app.withEnv(appConfigs.envs.withKeyless);
if (appConfigs.envs.withKeyless.privateVariables.get('CLERK_API_URL')?.includes('clerkstage')) {
dashboardUrl = 'https://dashboard.clerkstage.dev/';
}
await app.dev();
});

test.afterAll(async () => {
// Keep files for debugging
await app?.teardown();
});

test('Toggle collapse popover and claim.', async ({ page, context }) => {
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 ({
page,
context,
}) => {
await testClaimedAppWithMissingKeys({ page, context, app, dashboardUrl });
});

test('Keyless popover is removed after adding keys to .env and restarting.', async ({ page, context }) => {
await testKeylessRemovedAfterEnvAndRestart({ page, context, app });
});
});
106 changes: 30 additions & 76 deletions packages/astro/src/server/keyless/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,83 +4,37 @@ import type { APIContext } from 'astro';
import { clerkClient } from '../clerk-client';
import { createFileStorage } from './file-storage.js';

// Lazily initialized keyless service singleton
let keylessServiceInstance: ReturnType<typeof createKeylessService> | null = null;
let keylessInitPromise: Promise<ReturnType<typeof createKeylessService> | null> | null = null;

function canUseFileSystem(): boolean {
try {
return typeof process !== 'undefined' && typeof process.cwd === 'function';
} catch {
return false;
}
}

/**
* Gets or creates the keyless service singleton.
* Returns null for non-Node.js runtimes (e.g., Cloudflare Workers).
*/
export async function keyless(context: APIContext): Promise<ReturnType<typeof createKeylessService> | null> {
if (!canUseFileSystem()) {
return null;
}

if (keylessServiceInstance) {
return keylessServiceInstance;
}

if (keylessInitPromise) {
return keylessInitPromise;
}

keylessInitPromise = (async () => {
try {
const storage = await createFileStorage();

const service = createKeylessService({
storage,
api: {
async createAccountlessApplication(requestHeaders?: Headers) {
try {
return await clerkClient(context).__experimental_accountlessApplications.createAccountlessApplication({
requestHeaders,
});
} catch {
return null;
}
},
async completeOnboarding(requestHeaders?: Headers) {
try {
return await clerkClient(
context,
).__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({
requestHeaders,
});
} catch {
return null;
}
},
export function keyless(context: APIContext) {
if (!keylessServiceInstance) {
keylessServiceInstance = createKeylessService({
storage: createFileStorage(),
api: {
Comment on lines +10 to +14
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, check the structure and read the keyless index.ts file
cat -n packages/astro/src/server/keyless/index.ts

Repository: clerk/javascript

Length of output: 1606


🏁 Script executed:

# Check if file-storage.ts exists and what it imports
cat -n packages/astro/src/server/keyless/file-storage.ts

Repository: clerk/javascript

Length of output: 678


🏁 Script executed:

# Look for any existing runtime guards or environment checks
rg -n "process\.versions|typeof.*undefined|canUseFileSystem|runtime|edge" packages/astro/src/server/keyless/ -g '*.ts'

Repository: clerk/javascript

Length of output: 42


🏁 Script executed:

# Check if there are tests or usage examples of keyless in edge contexts
find packages/astro -name "*.test.ts" -o -name "*.spec.ts" -o -name "*.astro" | xargs rg -l "keyless" 2>/dev/null || echo "No test results"

Repository: clerk/javascript

Length of output: 75


🏁 Script executed:

# Check what adapters or runtimes Astro supports and if edge is documented
rg -n "edge|adapter|runtime" packages/astro/README.md packages/astro/package.json 2>/dev/null || echo "No matches found"

Repository: clerk/javascript

Length of output: 76


🏁 Script executed:

# Check package.json for runtime specifications and Astro version
cat packages/astro/package.json

Repository: clerk/javascript

Length of output: 3084


🏁 Script executed:

# Look for any mentions of edge, runtime, or alternative storage implementations
rg -n "edge|runtime|serverless|storage" packages/astro/package.json

Repository: clerk/javascript

Length of output: 328


🏁 Script executed:

# Check if there are any conditional imports or dynamic requires elsewhere in the codebase
rg -n "dynamic.*import|lazy.*load" packages/astro/src/ -g '*.ts'

Repository: clerk/javascript

Length of output: 233


🏁 Script executed:

# Look for environment-specific code in the broader astro package
fd . packages/astro/src --type f -name "*.ts" | head -20 | xargs rg -l "process\|globalThis\|typeof"

Repository: clerk/javascript

Length of output: 292


🏁 Script executed:

# Check if there's any reference to edge adapters in Astro documentation or config
find packages/astro -name "astro.config.*" -o -name "*.md" 2>/dev/null | xargs cat 2>/dev/null || echo "No config/docs found"

Repository: clerk/javascript

Length of output: 50375


🏁 Script executed:

# Check if keyless is documented as supporting edge or if it's marked Node-only
find packages/astro -name "README*" -o -name "*.md" | xargs rg -l "keyless" 2>/dev/null | head -3

Repository: clerk/javascript

Length of output: 42


🏁 Script executed:

# Look for any comments or TODOs about edge/runtime support in the keyless module
rg -n "TODO|FIXME|NOTE|edge|serverless|runtime|workerd" packages/astro/src/server/keyless/ -g '*.ts'

Repository: clerk/javascript

Length of output: 42


🏁 Script executed:

# Check if keyless is exported from the main server export
rg -n "keyless" packages/astro/src/server/index.ts

Repository: clerk/javascript

Length of output: 42


🏁 Script executed:

# Verify if there are any tests that use keyless in edge contexts
find packages/astro -type f \( -name "*.test.ts" -o -name "*.spec.ts" \) -exec grep -l "keyless" {} \;

Repository: clerk/javascript

Length of output: 42


🏁 Script executed:

# Check what is exported from packages/astro/src/server/index.ts
cat -n packages/astro/src/server/index.ts | head -30

Repository: clerk/javascript

Length of output: 875


🏁 Script executed:

# Look for any import of keyless in the broader codebase to see actual usage
rg -n "from.*keyless|import.*keyless" packages/astro -g '*.ts' -g '*.tsx'

Repository: clerk/javascript

Length of output: 863


🏁 Script executed:

# Verify if there's a guard pattern used elsewhere in the astro package for edge safety
rg -n "typeof.*undefined|process\.versions|globalThis" packages/astro/src -g '*.ts' -A 2

Repository: clerk/javascript

Length of output: 918


🏁 Script executed:

# Check how keyless is used in clerk-middleware.ts
cat -n packages/astro/src/server/clerk-middleware.ts | head -50

Repository: clerk/javascript

Length of output: 2054


🏁 Script executed:

# Look at the actual usage of resolveKeysWithKeylessFallback to understand when keyless() is called
rg -n "resolveKeysWithKeylessFallback" packages/astro/src/server/clerk-middleware.ts -B 5 -A 5

Repository: clerk/javascript

Length of output: 1008


🏁 Script executed:

# Check the keyless/utils.ts to see the wrapper
cat -n packages/astro/src/server/keyless/utils.ts

Repository: clerk/javascript

Length of output: 1006


🏁 Script executed:

# Check the feature flag that controls keyless
cat -n packages/astro/src/utils/feature-flags.ts

Repository: clerk/javascript

Length of output: 542


🏁 Script executed:

# Check if the middleware is executed for all environments including edge
rg -n "clerkMiddleware|onRequest" packages/astro/src/server/ -B 3 -A 3 | head -40

Repository: clerk/javascript

Length of output: 3108


Guard Node-only storage to prevent edge/serverless runtime crashes.

The keyless() function at line 12–14 calls createFileStorage(), which imports Node.js fs and path modules (from file-storage.ts). When resolveKeysWithKeylessFallback() is invoked in the middleware (clerk-middleware.ts line 95), it unconditionally calls keyless(context), causing the import to execute even in edge runtimes (Cloudflare, Vercel Edge, etc.) where these modules are unavailable. Although canUseKeyless prevents the service from being used in non-development environments, it does not prevent the module from being loaded—and the import fails at load time.

Add a runtime guard in the keyless() function to throw a clear error when Node.js is unavailable, or move the file-storage import behind a dynamic import so the failure only occurs if keyless is actually used.

Proposed fix
 export function keyless(context: APIContext) {
   if (!keylessServiceInstance) {
+    if (typeof process === 'undefined' || !process.versions?.node) {
+      throw new Error(
+        'Keyless mode for `@clerk/astro` requires a Node.js runtime with filesystem access. ' +
+          'Use a Node adapter or disable keyless.'
+      );
+    }
     keylessServiceInstance = createKeylessService({
       storage: createFileStorage(),
🤖 Prompt for AI Agents
In `@packages/astro/src/server/keyless/index.ts` around lines 10 - 14, The
keyless() factory unconditionally calls createFileStorage(), which imports
Node-only modules and crashes in edge runtimes; add a runtime Node check at the
start of keyless() (e.g., verify typeof process !== 'undefined' &&
process.versions?.node) and if Node is not available throw a clear error
explaining that keyless storage requires a Node runtime, so createFileStorage()
is never invoked in non-Node environments; keep references to keyless(),
keylessServiceInstance, createFileStorage(), resolveKeysWithKeylessFallback(),
and canUseKeyless when making this change.

async createAccountlessApplication(requestHeaders?: Headers) {
try {
return await clerkClient(context).__experimental_accountlessApplications.createAccountlessApplication({
requestHeaders,
});
} catch {
return null;
}
},
framework: 'astro',
frameworkVersion: PACKAGE_VERSION,
});

keylessServiceInstance = service;
return service;
} catch (error) {
console.warn('[Clerk] Failed to initialize keyless service:', error);
return null;
} finally {
keylessInitPromise = null;
}
})();

return keylessInitPromise;
}

/**
* @internal
*/
export function resetKeylessService(): void {
keylessServiceInstance = null;
keylessInitPromise = null;
async completeOnboarding(requestHeaders?: Headers) {
try {
return await clerkClient(
context,
).__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({
requestHeaders,
});
} catch {
return null;
}
},
},
framework: 'astro',
});
}
return keylessServiceInstance;
}
1 change: 1 addition & 0 deletions packages/nuxt/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export default defineNuxtModule<ModuleOptions>({
{
filename: 'types/clerk.d.ts',
getContents: () => `import type { AuthFn } from '@clerk/nuxt/server';
import type { InitialState } from '@clerk/shared/types';

declare module 'h3' {
interface H3EventContext {
Expand Down
12 changes: 12 additions & 0 deletions packages/nuxt/src/runtime/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,15 @@ setClerkJSLoadingErrorPackageName(PACKAGE_NAME);
export default defineNuxtPlugin(nuxtApp => {
// SSR-friendly shared state
const initialState = useState<InitialState | undefined>('clerk-initial-state', () => undefined);
const keylessContext = useState<{ claimUrl?: string; apiKeysUrl?: string } | undefined>(
'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();
Expand All @@ -32,5 +37,12 @@ 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,
}
: {}),
});
});
42 changes: 42 additions & 0 deletions packages/nuxt/src/runtime/server/clerkMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import type { PendingSessionOptions } from '@clerk/shared/types';
import type { EventHandler } from 'h3';
import { createError, eventHandler, setResponseHeader } from 'h3';

import { canUseKeyless } from '../utils/feature-flags';
import { clerkClient } from './clerkClient';
import { resolveKeysWithKeylessFallback } from './keyless/utils';
import type { AuthFn, AuthOptions } from './types';
import { createInitialState, toWebRequest } from './utils';

Expand Down Expand Up @@ -82,6 +84,38 @@ 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 {
// Get runtime config to access configured keys
// @ts-expect-error: Nitro import. Handled by Nuxt.
const { useRuntimeConfig } = await import('#imports');
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;
}
} catch {
// Silently fail - continue without keyless
}
}

const requestState = await clerkClient(event).authenticateRequest(clerkRequest, {
...options,
acceptsToken: 'any',
Expand Down Expand Up @@ -117,6 +151,14 @@ 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,
};
}

await handler?.(event);
});
};
19 changes: 19 additions & 0 deletions packages/nuxt/src/runtime/server/keyless/fileStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as fs from 'node:fs';
import * as path from 'node:path';

import { createNodeFileStorage, type KeylessStorage } from '@clerk/shared/keyless';

export type { KeylessStorage };

export interface FileStorageOptions {
cwd?: () => string;
}

export function createFileStorage(options: FileStorageOptions = {}): KeylessStorage {
const { cwd = () => process.cwd() } = options;

return createNodeFileStorage(fs, path, {
cwd,
frameworkPackageName: '@clerk/nuxt',
});
}
40 changes: 40 additions & 0 deletions packages/nuxt/src/runtime/server/keyless/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { createKeylessService } from '@clerk/shared/keyless';
import type { H3Event } from 'h3';

import { clerkClient } from '../clerkClient';
import { createFileStorage } from './fileStorage';

// Lazily initialized keyless service singleton
let keylessServiceInstance: ReturnType<typeof createKeylessService> | null = null;

export function keyless(event: H3Event) {
if (!keylessServiceInstance) {
keylessServiceInstance = createKeylessService({
storage: createFileStorage(),
api: {
async createAccountlessApplication(requestHeaders?: Headers) {
try {
return await clerkClient(event).__experimental_accountlessApplications.createAccountlessApplication({
requestHeaders,
});
} catch {
return null;
}
},
async completeOnboarding(requestHeaders?: Headers) {
try {
return await clerkClient(
event,
).__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({
requestHeaders,
});
} catch {
return null;
}
},
},
framework: 'nuxt',
});
}
return keylessServiceInstance;
Comment on lines +7 to +39
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Singleton captures the first request’s H3Event

Line 10-38 creates a module-scoped singleton whose API closures close over the first event. Subsequent requests will reuse that stale event when calling clerkClient, which can leak request-specific context (headers/cookies) across requests. Make the keyless service per-request, or keep only storage cached and rebind API functions per call.

🔧 One safe fix (cache storage only, re-create service per request)
-// Lazily initialized keyless service singleton
-let keylessServiceInstance: ReturnType<typeof createKeylessService> | null = null;
+// Cache storage only; service must be per-request to avoid leaking event context
+let storageInstance: ReturnType<typeof createFileStorage> | null = null;
+const getStorage = () => (storageInstance ??= createFileStorage());

 export function keyless(event: H3Event) {
-  if (!keylessServiceInstance) {
-    keylessServiceInstance = createKeylessService({
-      storage: createFileStorage(),
-      api: {
-        async createAccountlessApplication(requestHeaders?: Headers) {
-          try {
-            return await clerkClient(event).__experimental_accountlessApplications.createAccountlessApplication({
-              requestHeaders,
-            });
-          } catch {
-            return null;
-          }
-        },
-        async completeOnboarding(requestHeaders?: Headers) {
-          try {
-            return await clerkClient(
-              event,
-            ).__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({
-              requestHeaders,
-            });
-          } catch {
-            return null;
-          }
-        },
-      },
-      framework: 'nuxt',
-    });
-  }
-  return keylessServiceInstance;
+  return createKeylessService({
+    storage: getStorage(),
+    api: {
+      async createAccountlessApplication(requestHeaders?: Headers) {
+        try {
+          return await clerkClient(event).__experimental_accountlessApplications.createAccountlessApplication({
+            requestHeaders,
+          });
+        } catch {
+          return null;
+        }
+      },
+      async completeOnboarding(requestHeaders?: Headers) {
+        try {
+          return await clerkClient(
+            event,
+          ).__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({
+            requestHeaders,
+          });
+        } catch {
+          return null;
+        }
+      },
+    },
+    framework: 'nuxt',
+  });
 }
🤖 Prompt for AI Agents
In `@packages/nuxt/src/runtime/server/keyless/index.ts` around lines 7 - 39, The
current module-level singleton keylessServiceInstance closes over the first
request's event causing stale request context; change to cache only the storage
(e.g., createFileStorage) at module scope and instantiate createKeylessService
per request inside the keyless(event) function so that api methods call
clerkClient(event) with the current H3Event; specifically, keep a cached storage
variable (instead of keylessServiceInstance), and in keyless(event) call
createKeylessService({ storage: cachedStorage, api: {
createAccountlessApplication: async (headers?) => clerkClient(event)...,
completeOnboarding: async (headers?) => clerkClient(event)... }, framework:
'nuxt' }) so no module-level closure captures the first event.

}
24 changes: 24 additions & 0 deletions packages/nuxt/src/runtime/server/keyless/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { resolveKeysWithKeylessFallback as sharedResolveKeysWithKeylessFallback } 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.
*/
export async function resolveKeysWithKeylessFallback(
configuredPublishableKey: string | undefined,
configuredSecretKey: string | undefined,
event: H3Event,
) {
const keylessService = await keyless(event);
return sharedResolveKeysWithKeylessFallback(
configuredPublishableKey,
configuredSecretKey,
keylessService,
canUseKeyless,
);
}
8 changes: 8 additions & 0 deletions packages/nuxt/src/runtime/server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,11 @@ export type AuthOptions = PendingSessionOptions & Pick<AuthenticateRequestOption
* @internal This type is used to define the `auth` function in the event context.
*/
export type AuthFn = GetAuthFnNoRequest;

/**
* @internal Keyless mode data injected into event context
*/
export interface ClerkKeylessContext {
claimUrl?: string;
apiKeysUrl?: string;
}
10 changes: 10 additions & 0 deletions packages/nuxt/src/runtime/utils/feature-flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { getEnvVariable } from '@clerk/shared/getEnvVariable';
import { isTruthy } from '@clerk/shared/underscore';
import { isDevelopmentEnvironment } from '@clerk/shared/utils';

const KEYLESS_DISABLED =
isTruthy(getEnvVariable('NUXT_PUBLIC_CLERK_KEYLESS_DISABLED')) ||
isTruthy(getEnvVariable('CLERK_KEYLESS_DISABLED')) ||
false;

export const canUseKeyless = isDevelopmentEnvironment() && !KEYLESS_DISABLED;
Loading
Loading