From 33c2c46adaa150852e210312a578b2d28ab75674 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Thu, 20 Aug 2026 14:34:15 -0700 Subject: [PATCH 1/3] feat(astro): throw missing-env error instead of keyless bootstrap Co-Authored-By: Claude Fable 5 --- .changeset/astro-keyless-cli-init-error.md | 6 ++ integration/tests/astro/keyless.test.ts | 58 ++++++++++++------- packages/astro/src/env.d.ts | 3 - .../src/internal/create-clerk-instance.ts | 8 +-- .../internal/merge-env-vars-with-params.ts | 5 -- .../src/server/__tests__/get-safe-env.test.ts | 14 ----- packages/astro/src/server/clerk-middleware.ts | 42 ++------------ packages/astro/src/server/get-safe-env.ts | 16 +---- packages/astro/src/server/keyless/index.ts | 10 ---- packages/astro/src/server/keyless/utils.ts | 42 +++++++++----- packages/astro/src/types.ts | 20 +------ .../src/keyless/__tests__/service.spec.ts | 8 +-- packages/shared/src/keyless/service.ts | 7 ++- 13 files changed, 92 insertions(+), 147 deletions(-) create mode 100644 .changeset/astro-keyless-cli-init-error.md 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/integration/tests/astro/keyless.test.ts b/integration/tests/astro/keyless.test.ts index c0808437173..fccf0f64cc1 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,53 @@ 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); + const content = await page.content(); + expect(content).toContain('Publishable key is missing'); + expect(content).toContain('npx clerk@latest init'); }); - 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 renders without the keyless popover.', 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 copy the matching keys into `.env`. + */ + 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.keylessToEnv(); + // 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(); + + // Claimed apps with configured keys run without any keyless UI + await u.po.keylessPopover.waitForUnmounted(); }); }); 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..db7ed797530 100644 --- a/packages/astro/src/internal/merge-env-vars-with-params.ts +++ b/packages/astro/src/internal/merge-env-vars-with-params.ts @@ -54,7 +54,6 @@ const mergeEnvVarsWithParams = ( 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 || '', __internal_clerkJSUrl: paramClerkJSUrl || import.meta.env.PUBLIC_CLERK_JS_URL, @@ -69,10 +68,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/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..11656cadfff 100644 --- a/packages/astro/src/server/keyless/utils.ts +++ b/packages/astro/src/server/keyless/utils.ts @@ -1,23 +1,39 @@ -import { resolveKeysWithKeylessFallback as sharedResolveKeysWithKeylessFallback } from '@clerk/shared/keyless'; +import { clerkDevelopmentCache, createConfirmationMessage } 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, and logs a one-time confirmation. No-ops unless the configured + * publishable key matches the locally stored keyless keys. */ -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 { + if (!canUseKeyless || !configuredPublishableKey) { + return; + } + + const keylessService = keyless(context); + const locallyStoredKeys = keylessService.readKeys(); + if (locallyStoredKeys?.publishableKey !== configuredPublishableKey) { + return; + } + + 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(), + }); } diff --git a/packages/astro/src/types.ts b/packages/astro/src/types.ts index c6c563a68da..18751b4c57e 100644 --- a/packages/astro/src/types.ts +++ b/packages/astro/src/types.ts @@ -45,29 +45,13 @@ type AstroClerkCreateInstanceParams = AstroClerkIntegration /** * @internal - * Internal runtime options injected by the server for keyless mode support. + * Internal runtime options injected by the server. */ export type InternalRuntimeOptions = { /** - * Server-injected publishable key from keyless mode or context.locals + * Server-injected publishable key from the request context environment */ 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` diff --git a/packages/shared/src/keyless/__tests__/service.spec.ts b/packages/shared/src/keyless/__tests__/service.spec.ts index 7a76b26cb3e..ae686208c18 100644 --- a/packages/shared/src/keyless/__tests__/service.spec.ts +++ b/packages/shared/src/keyless/__tests__/service.spec.ts @@ -32,7 +32,7 @@ const createApi = (overrides: Partial = {}): KeylessAPI => ({ describe('createKeylessService', () => { it('passes the framework as the source when creating an accountless application', async () => { - const createAccountlessApplication = vi.fn(() => + const createAccountlessApplication = vi.fn>(() => Promise.resolve(accountlessApplication), ); @@ -66,7 +66,7 @@ describe('createKeylessService', () => { }); it('sanitizes the framework before passing it as the source', async () => { - const createAccountlessApplication = vi.fn(() => + const createAccountlessApplication = vi.fn>(() => Promise.resolve(accountlessApplication), ); @@ -82,7 +82,7 @@ describe('createKeylessService', () => { }); it('falls back to javascript when framework sanitization produces an empty source', async () => { - const createAccountlessApplication = vi.fn(() => + const createAccountlessApplication = vi.fn>(() => Promise.resolve(accountlessApplication), ); @@ -98,7 +98,7 @@ describe('createKeylessService', () => { }); it('truncates the source before passing it to the accountless application API', async () => { - const createAccountlessApplication = vi.fn(() => + const createAccountlessApplication = vi.fn>(() => Promise.resolve(accountlessApplication), ); diff --git a/packages/shared/src/keyless/service.ts b/packages/shared/src/keyless/service.ts index 7e776a3e9ac..d14113b8e41 100644 --- a/packages/shared/src/keyless/service.ts +++ b/packages/shared/src/keyless/service.ts @@ -40,12 +40,13 @@ export interface KeylessStorage { export interface KeylessAPI { /** * Creates a new accountless application. + * Optional: SDKs that no longer mint keyless applications omit it. * * @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; + createAccountlessApplication?(requestHeaders?: Headers, source?: string): Promise; /** * Notifies the backend that onboarding is complete (instance has been claimed). @@ -210,6 +211,10 @@ export function createKeylessService(options: KeylessServiceOptions): KeylessSer return existingConfig; } + if (!api.createAccountlessApplication) { + return null; + } + // Create metadata headers const headers = createMetadataHeaders(framework, frameworkVersion); From b6acf0d0046f25626255fdc27d8b3c5656c27d56 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Tue, 25 Aug 2026 17:45:47 -0400 Subject: [PATCH 2/3] fix(astro): assert dev-overlay error via shadow-piercing locators Co-Authored-By: Claude Fable 5 --- integration/tests/astro/keyless.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/integration/tests/astro/keyless.test.ts b/integration/tests/astro/keyless.test.ts index fccf0f64cc1..de35ced4ca0 100644 --- a/integration/tests/astro/keyless.test.ts +++ b/integration/tests/astro/keyless.test.ts @@ -38,9 +38,10 @@ test.describe('Keyless mode @astro', () => { }) => { const response = await page.goto(`${app.serverUrl}/`); expect(response?.status()).toBe(500); - const content = await page.content(); - expect(content).toContain('Publishable key is missing'); - expect(content).toContain('npx clerk@latest init'); + // 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('Claimed application with keys inside .env renders without the keyless popover.', async ({ page, context }) => { From b88467e6cc5ec69d5f235da1d5d6bae743689f83 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Wed, 26 Aug 2026 12:40:09 -0400 Subject: [PATCH 3/3] refactor(astro,shared): extract shared claimed-onboarding helper, tighten keyless types and tests Co-Authored-By: Claude Fable 5 --- integration/tests/astro/keyless.test.ts | 12 +-- .../internal/merge-env-vars-with-params.ts | 9 +-- .../server/keyless/__tests__/utils.test.ts | 78 +++++++++++++++++++ packages/astro/src/server/keyless/utils.ts | 33 ++++---- packages/astro/src/types.ts | 11 --- .../src/keyless/__tests__/service.spec.ts | 31 +++++++- .../src/keyless/completeClaimedOnboarding.ts | 27 +++++++ packages/shared/src/keyless/index.ts | 10 ++- .../keyless/resolveKeysWithKeylessFallback.ts | 19 +---- packages/shared/src/keyless/service.ts | 37 ++++----- 10 files changed, 183 insertions(+), 84 deletions(-) create mode 100644 packages/astro/src/server/keyless/__tests__/utils.test.ts create mode 100644 packages/shared/src/keyless/completeClaimedOnboarding.ts diff --git a/integration/tests/astro/keyless.test.ts b/integration/tests/astro/keyless.test.ts index de35ced4ca0..1a336b6e122 100644 --- a/integration/tests/astro/keyless.test.ts +++ b/integration/tests/astro/keyless.test.ts @@ -44,10 +44,13 @@ test.describe('Keyless mode @astro', () => { await expect(page.getByText('npx clerk@latest init').first()).toBeVisible(); }); - test('Claimed application with keys inside .env renders without the keyless popover.', async ({ page, context }) => { + 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 copy the matching keys into `.env`. + * 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'); @@ -58,7 +61,7 @@ test.describe('Keyless mode @astro', () => { claimUrl: 'https://dashboard.clerk.com/apps/claim', apiKeysUrl: 'https://dashboard.clerk.com/last-active?path=api-keys', }); - await app.keylessToEnv(); + 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(); @@ -66,8 +69,5 @@ test.describe('Keyless mode @astro', () => { await u.page.goToAppHome(); await u.page.waitForClerkJsLoaded(); await u.po.expect.toBeSignedOut(); - - // Claimed apps with configured keys run without any keyless UI - await u.po.keylessPopover.waitForUnmounted(); }); }); 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 db7ed797530..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,16 +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, - 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, 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/utils.ts b/packages/astro/src/server/keyless/utils.ts index 11656cadfff..3dd2f27e99a 100644 --- a/packages/astro/src/server/keyless/utils.ts +++ b/packages/astro/src/server/keyless/utils.ts @@ -1,39 +1,34 @@ -import { clerkDevelopmentCache, createConfirmationMessage } from '@clerk/shared/keyless'; +import { clerkDevelopmentCache, completeClaimedOnboarding } from '@clerk/shared/keyless'; import type { APIContext } from 'astro'; -import { canUseKeyless } from '../../utils/feature-flags'; import { keyless } from './index'; /** * Notifies the dashboard that a claimed keyless application is now running with its - * keys configured, and logs a one-time confirmation. No-ops unless the configured - * publishable key matches the locally stored keyless keys. + * 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 completeOnboardingIfClaimed( configuredPublishableKey: string | undefined, context: APIContext, ): Promise { - if (!canUseKeyless || !configuredPublishableKey) { - return; - } - const keylessService = keyless(context); const locallyStoredKeys = keylessService.readKeys(); - if (locallyStoredKeys?.publishableKey !== configuredPublishableKey) { + if (!locallyStoredKeys) { return; } - try { - await clerkDevelopmentCache?.run(() => keylessService.completeOnboarding(), { - cacheKey: `${locallyStoredKeys.publishableKey}_complete`, - onSuccessStale: 24 * 60 * 60 * 1000, // 24 hours + 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}`, }); - } catch { - // noop + return; + } + + if (locallyStoredKeys.publishableKey !== configuredPublishableKey) { + return; } - clerkDevelopmentCache?.log({ - cacheKey: `${locallyStoredKeys.publishableKey}_claimed`, - msg: createConfirmationMessage(), - }); + await completeClaimedOnboarding(locallyStoredKeys.publishableKey, keylessService); } diff --git a/packages/astro/src/types.ts b/packages/astro/src/types.ts index 18751b4c57e..702bd23beea 100644 --- a/packages/astro/src/types.ts +++ b/packages/astro/src/types.ts @@ -43,17 +43,6 @@ type AstroClerkCreateInstanceParams = AstroClerkIntegration publishableKey: string; }; -/** - * @internal - * Internal runtime options injected by the server. - */ -export type InternalRuntimeOptions = { - /** - * Server-injected publishable key from the request context environment - */ - publishableKey?: string; -}; - // Copied from `@clerk/react` export interface HeadlessBrowserClerk extends Clerk { load: (opts?: ClerkOptions) => Promise; diff --git a/packages/shared/src/keyless/__tests__/service.spec.ts b/packages/shared/src/keyless/__tests__/service.spec.ts index ae686208c18..dca8716d5b2 100644 --- a/packages/shared/src/keyless/__tests__/service.spec.ts +++ b/packages/shared/src/keyless/__tests__/service.spec.ts @@ -32,7 +32,7 @@ const createApi = (overrides: Partial = {}): KeylessAPI => ({ describe('createKeylessService', () => { it('passes the framework as the source when creating an accountless application', async () => { - const createAccountlessApplication = vi.fn>(() => + const createAccountlessApplication = vi.fn(() => Promise.resolve(accountlessApplication), ); @@ -49,6 +49,29 @@ describe('createKeylessService', () => { expect(source).toBe('nextjs'); }); + it('getOrCreateKeys resolves to null when the API cannot create applications and storage is empty', async () => { + const service = createKeylessService({ + storage: createStorage(), + api: { completeOnboarding: vi.fn(() => Promise.resolve(accountlessApplication)) }, + framework: 'astro', + }); + + await expect(service.getOrCreateKeys()).resolves.toBeNull(); + }); + + it('getOrCreateKeys returns stored keys even when the API cannot create applications', async () => { + const storage = createStorage(); + storage.write(JSON.stringify(accountlessApplication)); + + const service = createKeylessService({ + storage, + api: { completeOnboarding: vi.fn(() => Promise.resolve(accountlessApplication)) }, + framework: 'astro', + }); + + await expect(service.getOrCreateKeys()).resolves.toEqual(accountlessApplication); + }); + it('passes the framework as the source when completing accountless application onboarding', async () => { const completeOnboarding = vi.fn(() => Promise.resolve(accountlessApplication)); @@ -66,7 +89,7 @@ describe('createKeylessService', () => { }); it('sanitizes the framework before passing it as the source', async () => { - const createAccountlessApplication = vi.fn>(() => + const createAccountlessApplication = vi.fn(() => Promise.resolve(accountlessApplication), ); @@ -82,7 +105,7 @@ describe('createKeylessService', () => { }); it('falls back to javascript when framework sanitization produces an empty source', async () => { - const createAccountlessApplication = vi.fn>(() => + const createAccountlessApplication = vi.fn(() => Promise.resolve(accountlessApplication), ); @@ -98,7 +121,7 @@ describe('createKeylessService', () => { }); it('truncates the source before passing it to the accountless application API', async () => { - const createAccountlessApplication = vi.fn>(() => + const createAccountlessApplication = vi.fn(() => Promise.resolve(accountlessApplication), ); 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/index.ts b/packages/shared/src/keyless/index.ts index 75e2cf16c91..92c5175ea45 100644 --- a/packages/shared/src/keyless/index.ts +++ b/packages/shared/src/keyless/index.ts @@ -9,8 +9,16 @@ 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 type { + KeylessAPI, + KeylessCompletionAPI, + KeylessService, + KeylessServiceOptions, + KeylessStorage, +} from './service'; export { resolveKeysWithKeylessFallback } from './resolveKeysWithKeylessFallback'; export type { KeylessResult } from './resolveKeysWithKeylessFallback'; diff --git a/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts b/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts index fc923ae2a2f..8d54a315d43 100644 --- a/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts +++ b/packages/shared/src/keyless/resolveKeysWithKeylessFallback.ts @@ -1,4 +1,5 @@ -import { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } from './devCache'; +import { completeClaimedOnboarding } from './completeClaimedOnboarding'; +import { clerkDevelopmentCache, createKeylessModeMessage } from './devCache'; import type { KeylessService } from './service'; import type { AccountlessApplication } from './types'; @@ -45,21 +46,7 @@ export async function resolveKeysWithKeylessFallback( 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(), - }); - + await completeClaimedOnboarding(locallyStoredKeys.publishableKey, keylessService); return { publishableKey, secretKey, claimUrl, apiKeysUrl }; } diff --git a/packages/shared/src/keyless/service.ts b/packages/shared/src/keyless/service.ts index d14113b8e41..760f5cb136d 100644 --- a/packages/shared/src/keyless/service.ts +++ b/packages/shared/src/keyless/service.ts @@ -1,4 +1,5 @@ -import { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } from './devCache'; +import { completeClaimedOnboarding } from './completeClaimedOnboarding'; +import { clerkDevelopmentCache, createKeylessModeMessage } from './devCache'; import type { AccountlessApplication } from './types'; const KEYLESS_SOURCE_FALLBACK = 'javascript'; @@ -34,13 +35,14 @@ export interface KeylessStorage { } /** - * API adapter for keyless mode operations. + * API adapter for SDKs that only complete onboarding for already-claimed keyless + * applications and no longer mint new ones. * This abstraction allows the service to work without depending on @clerk/backend. */ -export interface KeylessAPI { +export interface KeylessCompletionAPI { /** * Creates a new accountless application. - * Optional: SDKs that no longer mint keyless applications omit it. + * Omitted by SDKs that no longer mint keyless applications. * * @param requestHeaders - Optional headers to include with the request. * @param source - Optional source value to include with the request. @@ -58,6 +60,13 @@ export interface KeylessAPI { completeOnboarding(requestHeaders?: Headers, source?: string): Promise; } +/** + * API adapter for keyless mode operations, for SDKs that can still mint keyless applications. + */ +export interface KeylessAPI extends KeylessCompletionAPI { + createAccountlessApplication(requestHeaders?: Headers, source?: string): Promise; +} + /** * Options for creating a keyless service. */ @@ -68,9 +77,9 @@ export interface KeylessServiceOptions { storage: KeylessStorage; /** - * API adapter for keyless operations (create application, complete onboarding). + * API adapter for keyless operations (complete onboarding, optionally create application). */ - api: KeylessAPI; + api: KeylessCompletionAPI; /** * Optional: Framework name for metadata (e.g., 'Next.js', 'TanStack Start'). @@ -265,21 +274,7 @@ export function createKeylessService(options: KeylessServiceOptions): KeylessSer 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(), - }); - + await completeClaimedOnboarding(locallyStoredKeys.publishableKey, this); return { publishableKey, secretKey, claimUrl, apiKeysUrl }; }