diff --git a/packages/@emulators/clerk/package.json b/packages/@emulators/clerk/package.json index 7894962f..6861c5ce 100644 --- a/packages/@emulators/clerk/package.json +++ b/packages/@emulators/clerk/package.json @@ -37,6 +37,7 @@ "jose": "^6" }, "devDependencies": { + "@clerk/backend": "^3.4.13", "tsup": "^8", "typescript": "^5.7", "vitest": "^4.1.0" diff --git a/packages/@emulators/clerk/src/__tests__/clerk-fapi.test.ts b/packages/@emulators/clerk/src/__tests__/clerk-fapi.test.ts new file mode 100644 index 00000000..c90d11c2 --- /dev/null +++ b/packages/@emulators/clerk/src/__tests__/clerk-fapi.test.ts @@ -0,0 +1,311 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { getClerkStore } from "../index.js"; +import { clerkTestSecretKey, startClerkTestEmulator, type ClerkTestEmulator } from "./helpers.js"; + +describe("Clerk plugin - FAPI (Frontend API)", () => { + let emulator: ClerkTestEmulator; + + beforeAll(async () => { + emulator = await startClerkTestEmulator(); + }); + + afterAll(async () => { + await emulator?.close(); + }); + + it("POST /v1/dev_browser returns a dev browser JWT", async () => { + const res = await fetch(`${emulator.url}/v1/dev_browser`, { method: "POST" }); + expect(res.ok).toBe(true); + const body = (await res.json()) as Record; + expect(body.id).toBeTruthy(); + expect(res.headers.get("clerk-db-jwt")).toBeTruthy(); + }); + + it("GET /v1/environment returns environment config", async () => { + const res = await fetch(`${emulator.url}/v1/environment`); + expect(res.ok).toBe(true); + const body = (await res.json()) as Record; + expect(body.response).toBeDefined(); + expect(body.client).toBeDefined(); + + const env = body.response as Record; + expect(env.display_config).toBeDefined(); + expect(env.user_settings).toBeDefined(); + expect(env.organization_settings).toBeDefined(); + expect((env.display_config as any).instance_environment_type).toBe("development"); + }); + + it("GET /v1/client returns client state", async () => { + const res = await fetch(`${emulator.url}/v1/client`); + expect(res.ok).toBe(true); + const body = (await res.json()) as Record; + expect(body.response).toBeDefined(); + + const client = body.response as Record; + expect(client.object).toBe("client"); + expect(client.id).toBe("client_emulate"); + expect(Array.isArray(client.sessions)).toBe(true); + }); + + it("sign-in flow: identifier → password → session", async () => { + // Step 1: Create sign-in with identifier + const createRes = await fetch(`${emulator.url}/v1/client/sign_ins`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identifier: "alice@example.com" }), + }); + expect(createRes.ok).toBe(true); + const createBody = (await createRes.json()) as { response: Record; client: Record }; + const signIn = createBody.response; + expect(signIn.object).toBe("sign_in"); + expect(signIn.status).toBe("needs_first_factor"); + expect((signIn.supported_first_factors as any[])[0].strategy).toBe("password"); + + // Step 2: Attempt first factor with password + const attemptRes = await fetch(`${emulator.url}/v1/client/sign_ins/${signIn.id}/attempt_first_factor`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ strategy: "password", password: "alice123" }), + }); + expect(attemptRes.ok).toBe(true); + const attemptBody = (await attemptRes.json()) as { response: Record; client: Record }; + const completed = attemptBody.response; + expect(completed.status).toBe("complete"); + expect(completed.created_session_id).toMatch(/^sess_/); + + // Step 3: Verify client now has the session + const client = attemptBody.client as { sessions: Array>; last_active_session_id: string }; + expect(client.sessions.length).toBeGreaterThanOrEqual(1); + expect(client.last_active_session_id).toBe(completed.created_session_id); + }); + + it("sign-in rejects wrong password", async () => { + const createRes = await fetch(`${emulator.url}/v1/client/sign_ins`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identifier: "alice@example.com" }), + }); + const createBody = (await createRes.json()) as { response: Record }; + const signIn = createBody.response; + + const attemptRes = await fetch(`${emulator.url}/v1/client/sign_ins/${signIn.id}/attempt_first_factor`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ strategy: "password", password: "wrong" }), + }); + expect(attemptRes.status).toBe(422); + const body = (await attemptRes.json()) as { errors: Array<{ code: string }> }; + expect(body.errors[0].code).toBe("form_password_incorrect"); + }); + + it("sign-in rejects unknown identifier", async () => { + const res = await fetch(`${emulator.url}/v1/client/sign_ins`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identifier: "nobody@example.com" }), + }); + expect(res.status).toBe(422); + const body = (await res.json()) as { errors: Array<{ code: string }> }; + expect(body.errors[0].code).toBe("form_identifier_not_found"); + }); + + it("FAPI session token creation", async () => { + // Sign in first + const createRes = await fetch(`${emulator.url}/v1/client/sign_ins`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identifier: "alice@example.com" }), + }); + const { response: signIn } = (await createRes.json()) as { response: Record }; + + const attemptRes = await fetch(`${emulator.url}/v1/client/sign_ins/${signIn.id}/attempt_first_factor`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ strategy: "password", password: "alice123" }), + }); + const { response: completed } = (await attemptRes.json()) as { response: Record }; + const sessionId = completed.created_session_id as string; + + // Get token via FAPI path + const tokenRes = await fetch(`${emulator.url}/v1/client/sessions/${sessionId}/tokens`, { + method: "POST", + }); + expect(tokenRes.ok).toBe(true); + const tokenBody = (await tokenRes.json()) as { object: string; jwt: string }; + expect(tokenBody.object).toBe("token"); + expect(tokenBody.jwt).toBeTruthy(); + }); + + it("session touch", async () => { + const cs = getClerkStore(emulator.store); + const session = cs.sessions.all().find((s) => s.status === "active"); + if (!session) return; + + const res = await fetch(`${emulator.url}/v1/client/sessions/${session.clerk_id}/touch`, { + method: "POST", + }); + expect(res.ok).toBe(true); + const body = (await res.json()) as { response: Record; client: Record }; + expect(body.client).toBeDefined(); + }); + + async function form(url: string, fields: Record) { + return fetch(url, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(fields).toString(), + }); + } + + describe("email-code sign-in", () => { + it("create exposes an email_code first factor with email_address_id", async () => { + const res = await form(`${emulator.url}/v1/client/sign_ins`, { identifier: "alice@example.com" }); + const { response } = (await res.json()) as { response: any }; + expect(response.status).toBe("needs_first_factor"); + const emailFactor = response.supported_first_factors.find((f: any) => f.strategy === "email_code"); + expect(emailFactor).toBeDefined(); + expect(emailFactor.email_address_id).toMatch(/^idn_/); + }); + + it("prepare then attempt with correct code completes", async () => { + const createRes = await form(`${emulator.url}/v1/client/sign_ins`, { identifier: "alice@example.com" }); + const { response: signIn } = (await createRes.json()) as { response: any }; + + const prepRes = await form(`${emulator.url}/v1/client/sign_ins/${signIn.id}/prepare_first_factor`, { + strategy: "email_code", + email_address_id: signIn.supported_first_factors.find((f: any) => f.strategy === "email_code").email_address_id, + }); + const { response: prepared } = (await prepRes.json()) as { response: any }; + expect(prepared.first_factor_verification.status).toBe("unverified"); + + const attemptRes = await form(`${emulator.url}/v1/client/sign_ins/${signIn.id}/attempt_first_factor`, { + strategy: "email_code", + code: "424242", + }); + const { response: done } = (await attemptRes.json()) as { response: any }; + expect(done.status).toBe("complete"); + expect(done.created_session_id).toMatch(/^sess_/); + }); + + it("rejects an incorrect email code", async () => { + const createRes = await form(`${emulator.url}/v1/client/sign_ins`, { identifier: "alice@example.com" }); + const { response: signIn } = (await createRes.json()) as { response: any }; + + const res = await form(`${emulator.url}/v1/client/sign_ins/${signIn.id}/attempt_first_factor`, { + strategy: "email_code", + code: "000000", + }); + expect(res.status).toBe(422); + const body = (await res.json()) as { errors: Array<{ code: string }> }; + expect(body.errors[0].code).toBe("form_code_incorrect"); + }); + }); + + describe("MFA (second factor)", () => { + it("password sign-in for a TOTP user returns needs_second_factor, then completes", async () => { + const createRes = await form(`${emulator.url}/v1/client/sign_ins`, { + identifier: "mfa@example.com", + password: "mfa12345", + }); + const { response: signIn } = (await createRes.json()) as { response: any }; + expect(signIn.status).toBe("needs_second_factor"); + expect(signIn.supported_second_factors.some((f: any) => f.strategy === "totp")).toBe(true); + expect(signIn.created_session_id).toBeNull(); + + const mfaRes = await form(`${emulator.url}/v1/client/sign_ins/${signIn.id}/attempt_second_factor`, { + strategy: "totp", + code: "424242", + }); + const { response: done } = (await mfaRes.json()) as { response: any }; + expect(done.status).toBe("complete"); + expect(done.created_session_id).toMatch(/^sess_/); + }); + + it("rejects an incorrect TOTP code", async () => { + const createRes = await form(`${emulator.url}/v1/client/sign_ins`, { + identifier: "mfa@example.com", + password: "mfa12345", + }); + const { response: signIn } = (await createRes.json()) as { response: any }; + + const res = await form(`${emulator.url}/v1/client/sign_ins/${signIn.id}/attempt_second_factor`, { + strategy: "totp", + code: "999999", + }); + expect(res.status).toBe(422); + }); + }); + + describe("sign-up", () => { + it("create → prepare → attempt creates a user and session", async () => { + const email = `signup-${Date.now()}@example.com`; + const createRes = await form(`${emulator.url}/v1/client/sign_ups`, { + email_address: email, + password: "newpass123", + first_name: "New", + last_name: "User", + }); + const { response: signUp } = (await createRes.json()) as { response: any }; + expect(signUp.status).toBe("missing_requirements"); + expect(signUp.unverified_fields).toContain("email_address"); + + await form(`${emulator.url}/v1/client/sign_ups/${signUp.id}/prepare_verification`, { strategy: "email_code" }); + + const attemptRes = await form(`${emulator.url}/v1/client/sign_ups/${signUp.id}/attempt_verification`, { + strategy: "email_code", + code: "424242", + }); + const { response: done } = (await attemptRes.json()) as { response: any }; + expect(done.status).toBe("complete"); + expect(done.created_session_id).toMatch(/^sess_/); + + const cs = getClerkStore(emulator.store); + const created = cs.emailAddresses.findOneBy("email_address", email); + expect(created).toBeDefined(); + expect(created!.verification_status).toBe("verified"); + }); + + it("rejects a duplicate email", async () => { + const res = await form(`${emulator.url}/v1/client/sign_ups`, { + email_address: "alice@example.com", + password: "whatever1", + }); + expect(res.status).toBe(422); + const body = (await res.json()) as { errors: Array<{ code: string }> }; + expect(body.errors[0].code).toBe("form_identifier_exists"); + }); + + it("rejects an incorrect verification code", async () => { + const createRes = await form(`${emulator.url}/v1/client/sign_ups`, { + email_address: `bad-${Date.now()}@example.com`, + password: "newpass123", + }); + const { response: signUp } = (await createRes.json()) as { response: any }; + const res = await form(`${emulator.url}/v1/client/sign_ups/${signUp.id}/attempt_verification`, { + strategy: "email_code", + code: "111111", + }); + expect(res.status).toBe(422); + }); + }); + + describe("organization memberships", () => { + it("returns paginated {data,total_count} for the session user", async () => { + const cs = getClerkStore(emulator.store); + const alice = cs.users.all().find((u) => u.first_name === "Alice")!; + const sessionRes = await fetch(`${emulator.url}/v1/sessions`, { + method: "POST", + headers: { Authorization: `Bearer ${clerkTestSecretKey}`, "Content-Type": "application/json" }, + body: JSON.stringify({ user_id: alice.clerk_id }), + }); + const session = (await sessionRes.json()) as { id: string }; + + const res = await fetch(`${emulator.url}/v1/me/organization_memberships?_clerk_session_id=${session.id}`); + expect(res.ok).toBe(true); + const { response } = (await res.json()) as { response: { data: any[]; total_count: number } }; + expect(Array.isArray(response.data)).toBe(true); + expect(response.total_count).toBe(response.data.length); + expect(response.data[0].organization.slug).toBe("acme"); + }); + }); +}); diff --git a/packages/@emulators/clerk/src/__tests__/clerk-m2m.test.ts b/packages/@emulators/clerk/src/__tests__/clerk-m2m.test.ts new file mode 100644 index 00000000..ad1957e6 --- /dev/null +++ b/packages/@emulators/clerk/src/__tests__/clerk-m2m.test.ts @@ -0,0 +1,194 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createClerkClient } from "@clerk/backend"; +import { decodeJwt } from "jose"; +import { getClerkStore } from "../index.js"; +import { + clerkTestSecretKey, + clerkTestMachineKey, + startClerkTestEmulator, + type ClerkTestEmulator, +} from "./helpers.js"; + +describe("Clerk plugin - M2M tokens", () => { + let emulator: ClerkTestEmulator; + + beforeAll(async () => { + emulator = await startClerkTestEmulator(); + }); + + afterAll(async () => { + await emulator?.close(); + }); + + function authHeaders(key = clerkTestSecretKey): Record { + return { + Authorization: `Bearer ${key}`, + "Content-Type": "application/json", + }; + } + + describe("HTTP API", () => { + it("creates a JWT-format M2M token", async () => { + const res = await fetch(`${emulator.url}/m2m_tokens`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ token_format: "jwt" }), + }); + expect(res.ok).toBe(true); + + const body = (await res.json()) as Record; + expect(body.object).toBe("machine_to_machine_token"); + expect(body.id).toMatch(/^mt_/); + expect(body.subject).toMatch(/^mch_/); + expect(body.token).toBeTruthy(); + expect(body.revoked).toBe(false); + + const claims = decodeJwt(body.token as string); + expect(claims.sub).toMatch(/^mch_/); + expect(claims.jti).toBe(body.id); + expect(claims.iss).toBe(emulator.url); + }); + + it("creates an opaque-format M2M token", async () => { + const res = await fetch(`${emulator.url}/m2m_tokens`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ token_format: "opaque" }), + }); + expect(res.ok).toBe(true); + + const body = (await res.json()) as Record; + expect(body.object).toBe("machine_to_machine_token"); + expect(body.token).toMatch(/^mt_/); + }); + + it("creates a token with custom expiration", async () => { + const res = await fetch(`${emulator.url}/m2m_tokens`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ token_format: "jwt", seconds_until_expiration: 3600 }), + }); + expect(res.ok).toBe(true); + + const body = (await res.json()) as Record; + expect(body.expiration).toBeTruthy(); + + const claims = decodeJwt(body.token as string); + expect(claims.exp).toBeDefined(); + }); + + it("creates a token with custom claims", async () => { + const res = await fetch(`${emulator.url}/m2m_tokens`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ token_format: "jwt", claims: { service: "billing" } }), + }); + expect(res.ok).toBe(true); + + const body = (await res.json()) as Record; + expect(body.claims).toEqual({ service: "billing" }); + + const claims = decodeJwt(body.token as string); + expect(claims.service).toBe("billing"); + }); + + it("verifies an opaque token", async () => { + const createRes = await fetch(`${emulator.url}/m2m_tokens`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ token_format: "opaque" }), + }); + const created = (await createRes.json()) as Record; + + const verifyRes = await fetch(`${emulator.url}/m2m_tokens/verify`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ token: created.token }), + }); + expect(verifyRes.ok).toBe(true); + + const body = (await verifyRes.json()) as Record; + expect(body.id).toBe(created.id); + expect(body.subject).toBe(created.subject); + }); + + it("rejects verification of unknown token", async () => { + const res = await fetch(`${emulator.url}/m2m_tokens/verify`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ token: "mt_nonexistent" }), + }); + expect(res.status).toBe(404); + }); + + it("lists M2M tokens", async () => { + const res = await fetch(`${emulator.url}/m2m_tokens`, { + headers: authHeaders(), + }); + expect(res.ok).toBe(true); + + const body = (await res.json()) as { m2m_tokens: unknown[]; total_count: number }; + expect(body.m2m_tokens.length).toBeGreaterThan(0); + expect(body.total_count).toBe(body.m2m_tokens.length); + }); + + it("revokes a token", async () => { + const createRes = await fetch(`${emulator.url}/m2m_tokens`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ token_format: "opaque" }), + }); + const created = (await createRes.json()) as Record; + + const revokeRes = await fetch(`${emulator.url}/m2m_tokens/${created.id}/revoke`, { + method: "POST", + headers: authHeaders(), + }); + expect(revokeRes.ok).toBe(true); + + const body = (await revokeRes.json()) as Record; + expect(body.revoked).toBe(true); + expect(body.revocation_reason).toBe("manually_revoked"); + + // Verify a revoked token fails + const verifyRes = await fetch(`${emulator.url}/m2m_tokens/verify`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ token: created.token }), + }); + expect(verifyRes.status).toBe(401); + }); + + it("authenticates with ak_ machine secret key", async () => { + const res = await fetch(`${emulator.url}/m2m_tokens`, { + method: "POST", + headers: authHeaders(clerkTestMachineKey), + body: JSON.stringify({ token_format: "jwt" }), + }); + expect(res.ok).toBe(true); + + const body = (await res.json()) as Record; + expect(body.object).toBe("machine_to_machine_token"); + }); + }); + + describe("SDK integration", () => { + it("creates and verifies M2M JWT tokens through the SDK", async () => { + const clerk = createClerkClient({ + secretKey: clerkTestSecretKey, + publishableKey: "pk_test_ZW11bGF0ZS5leGFtcGxlLmNvbSQ", + apiUrl: emulator.url, + }); + + const token = await clerk.m2m.createToken({ tokenFormat: "jwt" }); + expect(token.id).toMatch(/^mt_/); + expect(token.subject).toMatch(/^mch_/); + expect(token.token).toBeTruthy(); + + // SDK verifies JWT locally via JWKS + const verified = await clerk.m2m.verify({ token: token.token! }); + expect(verified.id).toBe(token.id); + expect(verified.subject).toBe(token.subject); + }); + }); +}); diff --git a/packages/@emulators/clerk/src/__tests__/clerk-org-domains.test.ts b/packages/@emulators/clerk/src/__tests__/clerk-org-domains.test.ts new file mode 100644 index 00000000..885ab907 --- /dev/null +++ b/packages/@emulators/clerk/src/__tests__/clerk-org-domains.test.ts @@ -0,0 +1,141 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { getClerkStore } from "../index.js"; +import { clerkTestSecretKey, startClerkTestEmulator, type ClerkTestEmulator } from "./helpers.js"; + +describe("Clerk plugin - organization domains", () => { + let emulator: ClerkTestEmulator; + let orgId: string; + + beforeAll(async () => { + emulator = await startClerkTestEmulator(); + const cs = getClerkStore(emulator.store); + orgId = cs.organizations.findOneBy("slug", "acme")!.clerk_id; + }); + + afterAll(async () => { + await emulator?.close(); + }); + + function authHeaders(): Record { + return { + Authorization: `Bearer ${clerkTestSecretKey}`, + "Content-Type": "application/json", + }; + } + + it("creates a domain", async () => { + const res = await fetch(`${emulator.url}/v1/organizations/${orgId}/domains`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ name: "example.com", enrollment_mode: "manual_invitation", verified: false }), + }); + expect(res.ok).toBe(true); + + const body = (await res.json()) as Record; + expect(body.object).toBe("organization_domain"); + expect(body.id).toMatch(/^orgdom_/); + expect(body.name).toBe("example.com"); + expect(body.organization_id).toBe(orgId); + expect(body.enrollment_mode).toBe("manual_invitation"); + expect((body.verification as any).status).toBe("unverified"); + }); + + it("creates a verified domain by default", async () => { + const res = await fetch(`${emulator.url}/v1/organizations/${orgId}/domains`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ name: "verified.com", enrollment_mode: "automatic_invitation" }), + }); + expect(res.ok).toBe(true); + + const body = (await res.json()) as Record; + expect((body.verification as any).status).toBe("verified"); + expect(body.enrollment_mode).toBe("automatic_invitation"); + }); + + it("lists domains for an organization", async () => { + const res = await fetch(`${emulator.url}/v1/organizations/${orgId}/domains`, { + headers: authHeaders(), + }); + expect(res.ok).toBe(true); + + const body = (await res.json()) as Array>; + expect(body.length).toBeGreaterThanOrEqual(2); + expect(body.every((d) => d.organization_id === orgId)).toBe(true); + }); + + it("updates a domain enrollment mode", async () => { + const createRes = await fetch(`${emulator.url}/v1/organizations/${orgId}/domains`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ name: "update-test.com", enrollment_mode: "manual_invitation" }), + }); + const created = (await createRes.json()) as Record; + + const res = await fetch(`${emulator.url}/v1/organizations/${orgId}/domains/${created.id}`, { + method: "PATCH", + headers: authHeaders(), + body: JSON.stringify({ enrollment_mode: "automatic_suggestion" }), + }); + expect(res.ok).toBe(true); + + const body = (await res.json()) as Record; + expect(body.enrollment_mode).toBe("automatic_suggestion"); + expect(body.name).toBe("update-test.com"); + }); + + it("updates domain verification status", async () => { + const createRes = await fetch(`${emulator.url}/v1/organizations/${orgId}/domains`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ name: "verify-test.com", verified: false }), + }); + const created = (await createRes.json()) as Record; + expect((created.verification as any).status).toBe("unverified"); + + const res = await fetch(`${emulator.url}/v1/organizations/${orgId}/domains/${created.id}`, { + method: "PATCH", + headers: authHeaders(), + body: JSON.stringify({ verified: true }), + }); + expect(res.ok).toBe(true); + + const body = (await res.json()) as Record; + expect((body.verification as any).status).toBe("verified"); + }); + + it("deletes a domain", async () => { + const createRes = await fetch(`${emulator.url}/v1/organizations/${orgId}/domains`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ name: "delete-test.com" }), + }); + const created = (await createRes.json()) as Record; + + const res = await fetch(`${emulator.url}/v1/organizations/${orgId}/domains/${created.id}`, { + method: "DELETE", + headers: authHeaders(), + }); + expect(res.ok).toBe(true); + + const body = (await res.json()) as Record; + expect(body.deleted).toBe(true); + expect(body.id).toBe(created.id); + }); + + it("returns 404 for domain in wrong org", async () => { + const createRes = await fetch(`${emulator.url}/v1/organizations/${orgId}/domains`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ name: "wrong-org.com" }), + }); + const created = (await createRes.json()) as Record; + + const res = await fetch(`${emulator.url}/v1/organizations/org_nonexistent/domains/${created.id}`, { + method: "PATCH", + headers: authHeaders(), + body: JSON.stringify({ enrollment_mode: "automatic_invitation" }), + }); + expect(res.status).toBe(404); + }); +}); diff --git a/packages/@emulators/clerk/src/__tests__/clerk-sdk.test.ts b/packages/@emulators/clerk/src/__tests__/clerk-sdk.test.ts new file mode 100644 index 00000000..dfc14bef --- /dev/null +++ b/packages/@emulators/clerk/src/__tests__/clerk-sdk.test.ts @@ -0,0 +1,213 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createClerkClient } from "@clerk/backend"; +import { decodeJwt } from "jose"; +import { getClerkStore } from "../index.js"; +import { clerkTestSecretKey, startClerkTestEmulator, type ClerkTestEmulator } from "./helpers.js"; + +// pk_test_ + base64("emulate.example.com$") with trailing = stripped +const TEST_PUBLISHABLE_KEY = "pk_test_ZW11bGF0ZS5leGFtcGxlLmNvbSQ"; + +describe("Clerk plugin - real @clerk/backend SDK", () => { + let emulator: ClerkTestEmulator; + let clerk: ReturnType; + + beforeAll(async () => { + emulator = await startClerkTestEmulator(); + clerk = createClerkClient({ + secretKey: clerkTestSecretKey, + publishableKey: TEST_PUBLISHABLE_KEY, + apiUrl: emulator.url, + }); + }); + + afterAll(async () => { + await emulator?.close(); + }); + + async function createSessionJwt(userId: string): Promise<{ jwt: string; sessionId: string }> { + const headers = { + Authorization: `Bearer ${clerkTestSecretKey}`, + "Content-Type": "application/json", + }; + + const sessionRes = await fetch(`${emulator.url}/v1/sessions`, { + method: "POST", + headers, + body: JSON.stringify({ user_id: userId }), + }); + const session = (await sessionRes.json()) as { id: string }; + + const tokenRes = await fetch(`${emulator.url}/v1/sessions/${session.id}/tokens`, { + method: "POST", + headers: { Authorization: `Bearer ${clerkTestSecretKey}` }, + }); + const { jwt } = (await tokenRes.json()) as { jwt: string }; + + return { jwt, sessionId: session.id }; + } + + it("lists users through the SDK", async () => { + const { data: users } = await clerk.users.getUserList(); + expect(users.length).toBeGreaterThanOrEqual(2); + const alice = users.find((u) => u.firstName === "Alice"); + expect(alice).toBeDefined(); + expect(alice!.lastName).toBe("Smith"); + }); + + it("gets a single user through the SDK", async () => { + const cs = getClerkStore(emulator.store); + const aliceUser = cs.users.all().find((u) => u.first_name === "Alice")!; + + const user = await clerk.users.getUser(aliceUser.clerk_id); + expect(user.id).toBe(aliceUser.clerk_id); + expect(user.firstName).toBe("Alice"); + }); + + it("lists organizations through the SDK", async () => { + const orgs = await clerk.organizations.getOrganizationList(); + const orgList = Array.isArray(orgs) ? orgs : (orgs as any).data; + expect(orgList.length).toBeGreaterThanOrEqual(1); + const acme = orgList.find((o: any) => o.slug === "acme"); + expect(acme).toBeDefined(); + expect(acme!.name).toBe("Acme Corp"); + }); + + it("authenticateRequest verifies emulator-issued JWTs", async () => { + const cs = getClerkStore(emulator.store); + const aliceUser = cs.users.all().find((u) => u.first_name === "Alice")!; + + const { jwt, sessionId } = await createSessionJwt(aliceUser.clerk_id); + + const pemRes = await fetch(`${emulator.url}/_emulate/jwt-public-key`); + const jwtKey = await pemRes.text(); + + const request = new Request("https://example.com/api/test", { + headers: { Authorization: `Bearer ${jwt}` }, + }); + + const result = await clerk.authenticateRequest(request, { jwtKey }); + expect(result.isSignedIn).toBe(true); + + const auth = result.toAuth(); + expect(auth.userId).toBe(aliceUser.clerk_id); + expect(auth.sessionId).toBe(sessionId); + }); + + it("authenticateRequest includes org claims when user has membership", async () => { + const cs = getClerkStore(emulator.store); + const aliceUser = cs.users.all().find((u) => u.first_name === "Alice")!; + + const { jwt } = await createSessionJwt(aliceUser.clerk_id); + + const pemRes = await fetch(`${emulator.url}/_emulate/jwt-public-key`); + const jwtKey = await pemRes.text(); + + const request = new Request("https://example.com/api/test", { + headers: { Authorization: `Bearer ${jwt}` }, + }); + + const result = await clerk.authenticateRequest(request, { jwtKey }); + expect(result.isSignedIn).toBe(true); + + const auth = result.toAuth(); + expect(auth.orgId).toMatch(/^org_/); + expect(auth.orgRole).toBe("org:admin"); + expect(auth.orgSlug).toBe("acme"); + }); + + it("authenticateRequest works with acceptsToken and jwtKey", async () => { + const cs = getClerkStore(emulator.store); + const aliceUser = cs.users.all().find((u) => u.first_name === "Alice")!; + + const { jwt, sessionId } = await createSessionJwt(aliceUser.clerk_id); + + const pemRes = await fetch(`${emulator.url}/_emulate/jwt-public-key`); + const jwtKey = await pemRes.text(); + + // Typical backend pattern: strip the body, pass jwtKey + acceptsToken + const incoming = new Request("https://app.example.com/api/some-endpoint", { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ some: "payload" }), + }); + + const stripped = new Request(incoming.url, { + headers: incoming.headers, + method: incoming.method, + }); + + const result = await clerk.authenticateRequest(stripped, { + jwtKey, + acceptsToken: "session_token", + }); + + expect(result.isSignedIn).toBe(true); + + const auth = result.toAuth(); + expect(auth.userId).toBe(aliceUser.clerk_id); + expect(auth.sessionId).toBe(sessionId); + expect(auth.orgId).toMatch(/^org_/); + expect(auth.orgRole).toBe("org:admin"); + expect(auth.orgSlug).toBe("acme"); + expect(auth.orgPermissions).toContain("org:sys_memberships:read"); + + // sessionClaims should include metadata if user has public_metadata + expect(auth.sessionClaims).toBeDefined(); + expect(auth.sessionClaims.sub).toBe(aliceUser.clerk_id); + expect(auth.sessionClaims.sid).toBe(sessionId); + }); + + it("authenticateRequest works without jwtKey (auto-fetches JWKS)", async () => { + const cs = getClerkStore(emulator.store); + const aliceUser = cs.users.all().find((u) => u.first_name === "Alice")!; + + const { jwt, sessionId } = await createSessionJwt(aliceUser.clerk_id); + + const request = new Request("https://example.com/api/test", { + headers: { Authorization: `Bearer ${jwt}` }, + }); + + // No jwtKey — SDK should fetch /v1/jwks from the emulator automatically + const result = await clerk.authenticateRequest(request, { + acceptsToken: "session_token", + }); + + expect(result.isSignedIn).toBe(true); + + const auth = result.toAuth(); + expect(auth.userId).toBe(aliceUser.clerk_id); + expect(auth.sessionId).toBe(sessionId); + }); + + it("authenticateRequest rejects unsigned/invalid tokens", async () => { + const pemRes = await fetch(`${emulator.url}/_emulate/jwt-public-key`); + const jwtKey = await pemRes.text(); + + const request = new Request("https://example.com/api/test", { + headers: { Authorization: "Bearer invalid.token.here" }, + }); + + const result = await clerk.authenticateRequest(request, { jwtKey }); + expect(result.isSignedIn).toBe(false); + }); + + it("session JWT includes sts claim", async () => { + const cs = getClerkStore(emulator.store); + const user = cs.users.all()[0]; + + const { jwt } = await createSessionJwt(user.clerk_id); + const claims = decodeJwt(jwt); + expect(claims.sts).toBe("active"); + }); + + it("serves the public key PEM at /v1/jwt-public-key", async () => { + const res = await fetch(`${emulator.url}/_emulate/jwt-public-key`); + expect(res.ok).toBe(true); + const pem = await res.text(); + expect(pem).toContain("-----BEGIN PUBLIC KEY-----"); + expect(pem).toContain("-----END PUBLIC KEY-----"); + }); +}); diff --git a/packages/@emulators/clerk/src/__tests__/clerk-webhooks.test.ts b/packages/@emulators/clerk/src/__tests__/clerk-webhooks.test.ts new file mode 100644 index 00000000..1c463643 --- /dev/null +++ b/packages/@emulators/clerk/src/__tests__/clerk-webhooks.test.ts @@ -0,0 +1,241 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { getClerkStore } from "../index.js"; +import { clerkTestSecretKey, startClerkTestEmulator, type ClerkTestEmulator } from "./helpers.js"; + +interface CapturedRequest { + url: string; + body: { type: string; data: Record }; +} + +function captureWebhookRequests(): { requests: CapturedRequest[] } { + const requests: CapturedRequest[] = []; + const originalFetch = globalThis.fetch; + + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.startsWith("https://hooks.test/")) { + const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body; + requests.push({ url, body }); + return new Response("ok", { status: 200 }); + } + return originalFetch(input, init); + }), + ); + + return { requests }; +} + +describe("Clerk plugin - webhook emission", () => { + let emulator: ClerkTestEmulator; + let capture: { requests: CapturedRequest[] }; + + beforeAll(async () => { + capture = captureWebhookRequests(); + emulator = await startClerkTestEmulator(); + emulator.webhooks.register({ + url: "https://hooks.test/clerk", + events: ["*"], + active: true, + owner: "clerk", + }); + }); + + afterEach(() => { + capture.requests.length = 0; + }); + + afterAll(async () => { + vi.restoreAllMocks(); + await emulator?.close(); + }); + + function authHeaders(): Record { + return { + Authorization: `Bearer ${clerkTestSecretKey}`, + "Content-Type": "application/json", + }; + } + + it("emits user.created on POST /v1/users", async () => { + const res = await fetch(`${emulator.url}/v1/users`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + email_address: ["webhook-user@example.com"], + first_name: "Webhook", + last_name: "User", + }), + }); + expect(res.ok).toBe(true); + + // Wait for async dispatch + await new Promise((r) => setTimeout(r, 50)); + + expect(capture.requests).toHaveLength(1); + expect(capture.requests[0].body.type).toBe("user.created"); + expect(capture.requests[0].body.data.first_name).toBe("Webhook"); + }); + + it("emits user.updated on PATCH /v1/users/:id", async () => { + const cs = getClerkStore(emulator.store); + const user = cs.users.all().find((u) => u.first_name === "Alice")!; + + const res = await fetch(`${emulator.url}/v1/users/${user.clerk_id}`, { + method: "PATCH", + headers: authHeaders(), + body: JSON.stringify({ first_name: "Alicia" }), + }); + expect(res.ok).toBe(true); + + await new Promise((r) => setTimeout(r, 50)); + + expect(capture.requests).toHaveLength(1); + expect(capture.requests[0].body.type).toBe("user.updated"); + expect(capture.requests[0].body.data.first_name).toBe("Alicia"); + }); + + it("emits user.deleted on DELETE /v1/users/:id", async () => { + const createRes = await fetch(`${emulator.url}/v1/users`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ email_address: ["delete-me@example.com"], first_name: "Delete" }), + }); + const created = (await createRes.json()) as Record; + capture.requests.length = 0; + + const res = await fetch(`${emulator.url}/v1/users/${created.id}`, { + method: "DELETE", + headers: authHeaders(), + }); + expect(res.ok).toBe(true); + + await new Promise((r) => setTimeout(r, 50)); + + expect(capture.requests).toHaveLength(1); + expect(capture.requests[0].body.type).toBe("user.deleted"); + expect(capture.requests[0].body.data.id).toBe(created.id); + }); + + it("emits organization.created on POST /v1/organizations", async () => { + const res = await fetch(`${emulator.url}/v1/organizations`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ name: "Webhook Org" }), + }); + expect(res.ok).toBe(true); + + await new Promise((r) => setTimeout(r, 50)); + + expect(capture.requests).toHaveLength(1); + expect(capture.requests[0].body.type).toBe("organization.created"); + expect(capture.requests[0].body.data.name).toBe("Webhook Org"); + }); + + it("emits org_membership.created on POST memberships", async () => { + const cs = getClerkStore(emulator.store); + const org = cs.organizations.findOneBy("slug", "acme")!; + + const userRes = await fetch(`${emulator.url}/v1/users`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ email_address: ["member-hook@example.com"], first_name: "Member" }), + }); + const user = (await userRes.json()) as Record; + capture.requests.length = 0; + + const res = await fetch(`${emulator.url}/v1/organizations/${org.clerk_id}/memberships`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ user_id: user.id, role: "org:member" }), + }); + expect(res.ok).toBe(true); + + await new Promise((r) => setTimeout(r, 50)); + + expect(capture.requests).toHaveLength(1); + expect(capture.requests[0].body.type).toBe("org_membership.created"); + expect(capture.requests[0].body.data.role).toBe("org:member"); + }); + + it("emits org_invitation.created on POST invitations", async () => { + const cs = getClerkStore(emulator.store); + const org = cs.organizations.findOneBy("slug", "acme")!; + + const res = await fetch(`${emulator.url}/v1/organizations/${org.clerk_id}/invitations`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ email_address: "invite-hook@example.com", role: "org:member" }), + }); + expect(res.ok).toBe(true); + + await new Promise((r) => setTimeout(r, 50)); + + expect(capture.requests).toHaveLength(1); + expect(capture.requests[0].body.type).toBe("org_invitation.created"); + expect(capture.requests[0].body.data.email_address).toBe("invite-hook@example.com"); + }); + + it("emits org_domain.created and org_domain.deleted", async () => { + const cs = getClerkStore(emulator.store); + const org = cs.organizations.findOneBy("slug", "acme")!; + + const createRes = await fetch(`${emulator.url}/v1/organizations/${org.clerk_id}/domains`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ name: "webhook-domain.com", enrollment_mode: "manual_invitation" }), + }); + expect(createRes.ok).toBe(true); + const domain = (await createRes.json()) as Record; + + await new Promise((r) => setTimeout(r, 50)); + + expect(capture.requests).toHaveLength(1); + expect(capture.requests[0].body.type).toBe("org_domain.created"); + expect(capture.requests[0].body.data.name).toBe("webhook-domain.com"); + + capture.requests.length = 0; + + const deleteRes = await fetch(`${emulator.url}/v1/organizations/${org.clerk_id}/domains/${domain.id}`, { + method: "DELETE", + headers: authHeaders(), + }); + expect(deleteRes.ok).toBe(true); + + await new Promise((r) => setTimeout(r, 50)); + + expect(capture.requests).toHaveLength(1); + expect(capture.requests[0].body.type).toBe("org_domain.deleted"); + }); + + it("emits user.created when a user registers via FAPI sign-up", async () => { + const email = `fapi-signup-${Date.now()}@example.com`; + const form = (url: string, fields: Record) => + fetch(url, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(fields).toString(), + }); + + const createRes = await form(`${emulator.url}/v1/client/sign_ups`, { + email_address: email, + password: "newpass123", + }); + const { response: signUp } = (await createRes.json()) as { response: { id: string } }; + await form(`${emulator.url}/v1/client/sign_ups/${signUp.id}/prepare_verification`, { strategy: "email_code" }); + capture.requests.length = 0; + + const done = await form(`${emulator.url}/v1/client/sign_ups/${signUp.id}/attempt_verification`, { + strategy: "email_code", + code: "424242", + }); + expect(done.ok).toBe(true); + + await new Promise((r) => setTimeout(r, 50)); + + expect(capture.requests).toHaveLength(1); + expect(capture.requests[0].body.type).toBe("user.created"); + expect((capture.requests[0].body.data.email_addresses as any[])[0].email_address).toBe(email); + }); +}); diff --git a/packages/@emulators/clerk/src/__tests__/clerk.test.ts b/packages/@emulators/clerk/src/__tests__/clerk.test.ts index 38fe95cf..c135b364 100644 --- a/packages/@emulators/clerk/src/__tests__/clerk.test.ts +++ b/packages/@emulators/clerk/src/__tests__/clerk.test.ts @@ -279,37 +279,34 @@ describe("Clerk plugin integration", () => { }); describe("users API", () => { - it("lists users with pagination", async () => { + it("lists users", async () => { const res = await app.request(`${base}/v1/users`, { headers: authHeaders() }); expect(res.status).toBe(200); - const body = (await res.json()) as { data: unknown[]; total_count: number; has_more: boolean }; - expect(body.total_count).toBeGreaterThanOrEqual(3); - expect(body.has_more).toBe(false); - expect(body.data.length).toBeGreaterThanOrEqual(3); + const body = (await res.json()) as unknown[]; + expect(body.length).toBeGreaterThanOrEqual(3); }); it("lists users with limit and offset", async () => { const res = await app.request(`${base}/v1/users?limit=1&offset=0`, { headers: authHeaders() }); expect(res.status).toBe(200); - const body = (await res.json()) as { data: unknown[]; total_count: number; has_more: boolean }; - expect(body.data).toHaveLength(1); - expect(body.has_more).toBe(true); + const body = (await res.json()) as unknown[]; + expect(body).toHaveLength(1); }); it("filters users by query", async () => { const res = await app.request(`${base}/v1/users?query=alice`, { headers: authHeaders() }); expect(res.status).toBe(200); - const body = (await res.json()) as { data: Array<{ first_name: string }>; total_count: number }; - expect(body.total_count).toBe(1); - expect(body.data[0].first_name).toBe("Alice"); + const body = (await res.json()) as Array<{ first_name: string }>; + expect(body).toHaveLength(1); + expect(body[0].first_name).toBe("Alice"); }); it("filters users by email_address", async () => { const res = await app.request(`${base}/v1/users?email_address=bob@example.com`, { headers: authHeaders() }); expect(res.status).toBe(200); - const body = (await res.json()) as { data: Array<{ first_name: string }>; total_count: number }; - expect(body.total_count).toBe(1); - expect(body.data[0].first_name).toBe("Bob"); + const body = (await res.json()) as Array<{ first_name: string }>; + expect(body).toHaveLength(1); + expect(body[0].first_name).toBe("Bob"); }); it("creates a user", async () => { @@ -521,9 +518,9 @@ describe("Clerk plugin integration", () => { it("lists organizations", async () => { const res = await app.request(`${base}/v1/organizations`, { headers: authHeaders() }); expect(res.status).toBe(200); - const body = (await res.json()) as { data: Array>; total_count: number }; - expect(body.total_count).toBeGreaterThanOrEqual(1); - const acme = body.data.find((o) => o.slug === "acme"); + const body = (await res.json()) as Array>; + expect(body.length).toBeGreaterThanOrEqual(1); + const acme = body.find((o) => o.slug === "acme"); expect(acme).toBeDefined(); expect(acme!.name).toBe("Acme Corp"); }); @@ -597,8 +594,8 @@ describe("Clerk plugin integration", () => { const org = cs.organizations.findOneBy("slug", "acme")!; const res = await app.request(`${base}/v1/organizations/${org.clerk_id}/memberships`, { headers: authHeaders() }); expect(res.status).toBe(200); - const body = (await res.json()) as { data: Array>; total_count: number }; - expect(body.total_count).toBe(2); + const body = (await res.json()) as Array>; + expect(body).toHaveLength(2); }); it("adds a member to organization", async () => { @@ -698,8 +695,8 @@ describe("Clerk plugin integration", () => { headers: authHeaders(), }); expect(res.status).toBe(200); - const body = (await res.json()) as { data: unknown[]; total_count: number }; - expect(body.total_count).toBeGreaterThanOrEqual(1); + const body = (await res.json()) as unknown[]; + expect(body.length).toBeGreaterThanOrEqual(1); }); it("revokes an invitation", async () => { @@ -770,8 +767,8 @@ describe("Clerk plugin integration", () => { const res = await app.request(`${base}/v1/sessions`, { headers: authHeaders() }); expect(res.status).toBe(200); - const body = (await res.json()) as { data: unknown[]; total_count: number }; - expect(body.total_count).toBeGreaterThanOrEqual(1); + const body = (await res.json()) as unknown[]; + expect(body.length).toBeGreaterThanOrEqual(1); }); it("revokes a session", async () => { diff --git a/packages/@emulators/clerk/src/__tests__/helpers.ts b/packages/@emulators/clerk/src/__tests__/helpers.ts new file mode 100644 index 00000000..0603fc8c --- /dev/null +++ b/packages/@emulators/clerk/src/__tests__/helpers.ts @@ -0,0 +1,87 @@ +import type { AddressInfo } from "node:net"; +import { + Hono, + Store, + WebhookDispatcher, + authMiddleware, + createApiErrorHandler, + createErrorHandler, + serve, +} from "@emulators/core"; +import type { AppEnv, TokenMap } from "@emulators/core"; +import type { Server } from "node:http"; +import { clerkPlugin, getClerkStore, seedFromConfig } from "../index.js"; + +export const clerkTestSecretKey = "sk_test_emulate"; +export const clerkTestMachineKey = "ak_test_emulate"; + +export interface ClerkTestEmulator { + url: string; + store: Store; + webhooks: WebhookDispatcher; + tokenMap: TokenMap; + close: () => Promise; +} + +export async function startClerkTestEmulator( + customize?: (opts: { store: Store; tokenMap: TokenMap }) => void | Promise, +): Promise { + const store = new Store(); + const webhooks = new WebhookDispatcher(); + const tokenMap: TokenMap = new Map(); + tokenMap.set(clerkTestSecretKey, { login: "admin", id: 1, scopes: [] }); + tokenMap.set("ak_test_emulate", { login: "machine", id: 2, scopes: [] }); + + const app = new Hono(); + app.onError(createApiErrorHandler()); + app.use("*", createErrorHandler()); + app.use("*", (authMiddleware as (tokens: TokenMap) => ReturnType)(tokenMap)); + + const server = serve({ fetch: app.fetch, port: 0, hostname: "127.0.0.1" }) as unknown as Server; + await new Promise((resolve, reject) => { + server.once("listening", () => resolve()); + server.once("error", reject); + }); + + const { port } = server.address() as AddressInfo; + const url = `http://127.0.0.1:${port}`; + clerkPlugin.register(app as any, store, webhooks, url, tokenMap); + clerkPlugin.seed?.(store, url); + + seedFromConfig(store, url, { + users: [ + { email_addresses: ["alice@example.com"], first_name: "Alice", last_name: "Smith", password: "alice123" }, + { email_addresses: ["bob@example.com"], first_name: "Bob", last_name: "Jones" }, + { + email_addresses: ["mfa@example.com"], + first_name: "Mfa", + last_name: "User", + password: "mfa12345", + totp_enabled: true, + }, + ], + organizations: [ + { + name: "Acme Corp", + slug: "acme", + members: [ + { email: "alice@example.com", role: "admin" }, + { email: "bob@example.com", role: "member" }, + ], + }, + ], + }); + + await customize?.({ store, tokenMap }); + + return { + url, + store, + webhooks, + tokenMap, + close: () => + new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }), + }; +} diff --git a/packages/@emulators/clerk/src/entities.ts b/packages/@emulators/clerk/src/entities.ts index c59b124d..f508d68d 100644 --- a/packages/@emulators/clerk/src/entities.ts +++ b/packages/@emulators/clerk/src/entities.ts @@ -89,6 +89,36 @@ export interface ClerkSession extends Entity { updated_at_unix: number; } +export interface ClerkOrganizationDomain extends Entity { + domain_id: string; + name: string; + org_id: string; + enrollment_mode: "manual_invitation" | "automatic_invitation" | "automatic_suggestion"; + verification_status: "verified" | "unverified"; + verification_strategy: string; + verification_attempts: number; + verification_expires_at: number; + affiliation_email_address: string | null; + total_pending_invitations: number; + total_pending_suggestions: number; + created_at_unix: number; + updated_at_unix: number; +} + +export interface ClerkM2MToken extends Entity { + token_id: string; + token: string; + subject: string; + scopes: string[]; + claims: Record | null; + revoked: boolean; + revocation_reason: string | null; + expired: boolean; + expiration: number | null; + created_at_unix: number; + updated_at_unix: number; +} + export interface ClerkOAuthApplication extends Entity { app_id: string; name: string; diff --git a/packages/@emulators/clerk/src/helpers.ts b/packages/@emulators/clerk/src/helpers.ts index 1c7a587b..73a5a35c 100644 --- a/packages/@emulators/clerk/src/helpers.ts +++ b/packages/@emulators/clerk/src/helpers.ts @@ -10,6 +10,22 @@ export function nowUnix(): number { return Math.floor(Date.now() / 1000); } +// Canonical default system permissions for an organization role. Used by org +// creation, membership create/update, and seeding so the lists never drift. +export function defaultOrganizationPermissions(role: string): string[] { + if (role === "org:admin") { + return [ + "org:sys_profile:manage", + "org:sys_profile:delete", + "org:sys_memberships:read", + "org:sys_memberships:manage", + "org:sys_domains:read", + "org:sys_domains:manage", + ]; + } + return ["org:sys_memberships:read"]; +} + export function createDefaultUser(): Omit { const now = nowUnix(); return { diff --git a/packages/@emulators/clerk/src/index.ts b/packages/@emulators/clerk/src/index.ts index eb4b55d1..ca18f93d 100644 --- a/packages/@emulators/clerk/src/index.ts +++ b/packages/@emulators/clerk/src/index.ts @@ -1,6 +1,12 @@ import type { Hono } from "@emulators/core"; import type { AppEnv, RouteContext, ServicePlugin, Store, TokenMap, WebhookDispatcher } from "@emulators/core"; -import { generateClerkId, nowUnix, createDefaultUser, createDefaultEmailAddress } from "./helpers.js"; +import { + generateClerkId, + nowUnix, + createDefaultUser, + createDefaultEmailAddress, + defaultOrganizationPermissions, +} from "./helpers.js"; import { oauthRoutes } from "./routes/oauth.js"; import { userRoutes } from "./routes/users.js"; import { emailAddressRoutes } from "./routes/email-addresses.js"; @@ -8,6 +14,10 @@ import { organizationRoutes } from "./routes/organizations.js"; import { membershipRoutes } from "./routes/memberships.js"; import { invitationRoutes } from "./routes/invitations.js"; import { sessionRoutes } from "./routes/sessions.js"; +import { organizationDomainRoutes } from "./routes/organization-domains.js"; +import { m2mTokenRoutes } from "./routes/m2m-tokens.js"; +import { fapiRoutes } from "./routes/fapi.js"; +import { clerkJsProxyRoutes } from "./routes/clerk-js-proxy.js"; import { getClerkStore } from "./store.js"; export { getClerkStore, type ClerkStore } from "./store.js"; @@ -22,6 +32,7 @@ export interface ClerkSeedConfig { username?: string; password?: string; external_id?: string; + totp_enabled?: boolean; public_metadata?: Record; private_metadata?: Record; unsafe_metadata?: Record; @@ -46,6 +57,11 @@ export interface ClerkSeedConfig { scopes?: string[]; public?: boolean; }>; + webhooks?: Array<{ + url: string; + events?: string[]; + secret?: string; + }>; } function seedDefaults(store: Store, _baseUrl: string): void { @@ -74,7 +90,7 @@ function seedDefaults(store: Store, _baseUrl: string): void { }); } -export function seedFromConfig(store: Store, _baseUrl: string, config: ClerkSeedConfig): void { +export function seedFromConfig(store: Store, _baseUrl: string, config: ClerkSeedConfig, webhooks?: WebhookDispatcher): void { const cs = getClerkStore(store); const now = nowUnix(); @@ -99,9 +115,9 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: ClerkSeed primary_phone_number_id: null, password_enabled: typeof userCfg.password === "string" && userCfg.password.length > 0, password_hash: userCfg.password ?? null, - totp_enabled: false, + totp_enabled: userCfg.totp_enabled ?? false, backup_code_enabled: false, - two_factor_enabled: false, + two_factor_enabled: userCfg.totp_enabled ?? false, banned: false, locked: false, public_metadata: userCfg.public_metadata ?? {}, @@ -175,15 +191,7 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: ClerkSeed org_id: orgId, user_id: user.clerk_id, role, - permissions: - role === "org:admin" - ? [ - "org:sys_profile:manage", - "org:sys_profile:delete", - "org:sys_memberships:read", - "org:sys_memberships:manage", - ] - : ["org:sys_memberships:read"], + permissions: defaultOrganizationPermissions(role), public_metadata: {}, private_metadata: {}, created_at_unix: now, @@ -214,11 +222,34 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: ClerkSeed }); } } + + if (config.webhooks && webhooks) { + for (const hookCfg of config.webhooks) { + webhooks.register({ + url: hookCfg.url, + events: hookCfg.events ?? ["*"], + active: true, + owner: "clerk", + secret: hookCfg.secret, + }); + } + } } export const clerkPlugin: ServicePlugin = { name: "clerk", register(app: Hono, store: Store, webhooks: WebhookDispatcher, baseUrl: string, tokenMap?: TokenMap): void { + // Clerk SDK checks Content-Type === "application/json" (strict equality). + // The framework defaults to "application/json; charset=UTF-8" which fails. + // Pre-setting via c.header() overrides the default in c.json(). + app.use("*", async (c, next) => { + const path = c.req.path; + if (path.startsWith("/v1/") || path.startsWith("/m2m_tokens") || path.startsWith("/_emulate/")) { + c.header("Content-Type", "application/json"); + } + await next(); + }); + const ctx: RouteContext = { app, store, webhooks, baseUrl, tokenMap }; oauthRoutes(ctx); userRoutes(ctx); @@ -226,7 +257,11 @@ export const clerkPlugin: ServicePlugin = { organizationRoutes(ctx); membershipRoutes(ctx); invitationRoutes(ctx); + organizationDomainRoutes(ctx); sessionRoutes(ctx); + m2mTokenRoutes(ctx); + fapiRoutes(ctx); + clerkJsProxyRoutes(ctx); }, seed(store: Store, baseUrl: string): void { seedDefaults(store, baseUrl); diff --git a/packages/@emulators/clerk/src/route-helpers.ts b/packages/@emulators/clerk/src/route-helpers.ts index f2f834db..40cdb9b1 100644 --- a/packages/@emulators/clerk/src/route-helpers.ts +++ b/packages/@emulators/clerk/src/route-helpers.ts @@ -41,7 +41,7 @@ export function requireSecretKey(c: Context, tokenMap?: TokenMap): AuthU const authHeader = c.req.header("Authorization") ?? ""; if (authHeader.startsWith("Bearer ")) { const token = authHeader.slice(7).trim(); - if (token.startsWith("sk_test_") || token.startsWith("sk_live_")) { + if (token.startsWith("sk_test_") || token.startsWith("sk_live_") || token.startsWith("ak_")) { const mapped = tokenMap?.get(token); if (mapped) { c.set("authUser", mapped); @@ -68,19 +68,6 @@ export function deletedResponse(objectType: string, objectId: string): Record( - data: T[], - totalCount: number, - limit: number, - offset: number, -): Record { - return { - data, - total_count: totalCount, - has_more: offset + limit < totalCount, - }; -} - export function parsePagination(c: Context): { limit: number; offset: number } { const limit = Math.min(Math.max(Number.parseInt(c.req.query("limit") ?? "10", 10) || 10, 1), 500); const offset = Math.max(Number.parseInt(c.req.query("offset") ?? "0", 10) || 0, 0); @@ -225,6 +212,28 @@ export async function readJsonBody(c: Context): Promise { + const path = c.req.param("path"); + try { + const cdnRes = await fetch(`https://cdn.jsdelivr.net/npm/${path}`); + const body = await cdnRes.text(); + const contentType = cdnRes.headers.get("content-type") ?? "application/javascript"; + return c.body(body, 200, { "Content-Type": contentType, "Access-Control-Allow-Origin": "*" }); + } catch { + return c.text("Failed to load clerk-js", 502); + } + }); +} diff --git a/packages/@emulators/clerk/src/routes/fapi-environment.ts b/packages/@emulators/clerk/src/routes/fapi-environment.ts new file mode 100644 index 00000000..46fa0b0d --- /dev/null +++ b/packages/@emulators/clerk/src/routes/fapi-environment.ts @@ -0,0 +1,103 @@ +// Static FAPI environment config returned by GET/POST /v1/environment. +// clerk-js reads this on Clerk.load() to learn auth methods, display, and org settings. +export function buildEnvironmentJson(baseUrl: string): Record { + return { + api_keys_settings: { user_api_keys_enabled: false, orgs_api_keys_enabled: false }, + auth_config: { + object: "auth_config", + id: "aac_emulate", + single_session_mode: false, + claimed_at: null, + reverification: false, + }, + commerce_settings: { + billing: { + stripe_publishable_key: null, + organization: { enabled: false, has_paid_plans: false }, + user: { enabled: false, has_paid_plans: false }, + }, + }, + display_config: { + object: "display_config", + id: "display_emulate", + after_sign_in_url: "/", + after_sign_out_all_url: "/", + after_sign_out_one_url: "/", + after_sign_up_url: "/", + after_switch_session_url: "/", + application_name: "Emulate", + branded: false, + captcha_public_key: null, + captcha_widget_type: "invisible", + captcha_public_key_invisible: null, + captcha_provider: "turnstile", + captcha_oauth_bypass: [], + home_url: baseUrl, + instance_environment_type: "development", + logo_image_url: "", + favicon_image_url: "", + preferred_sign_in_strategy: "password", + sign_in_url: "/sign-in", + sign_up_url: "/sign-up", + support_email: "", + theme: {}, + user_profile_url: "/user", + clerk_js_version: "5", + organization_profile_url: "/organization", + create_organization_url: "/create-organization", + after_leave_organization_url: "/", + after_create_organization_url: "/", + show_devmode_warning: false, + terms_url: "", + privacy_policy_url: "", + waitlist_url: "", + after_join_waitlist_url: "", + }, + maintenance_mode: false, + organization_settings: { + enabled: true, + max_allowed_memberships: 100, + force_organization_selection: false, + actions: { admin_delete: true }, + domains: { + enabled: true, + enrollment_modes: ["manual_invitation", "automatic_invitation", "automatic_suggestion"], + default_role: "org:member", + }, + slug: { disabled: false }, + organization_creation_defaults: { enabled: true }, + }, + user_settings: { + attributes: { + email_address: { + enabled: true, + required: true, + verifications: ["email_code"], + used_for_first_factor: true, + first_factors: ["email_code"], + used_for_second_factor: false, + second_factors: [], + verify_at_sign_up: false, + }, + password: { + enabled: true, + required: true, + }, + first_name: { enabled: true, required: false }, + last_name: { enabled: true, required: false }, + username: { enabled: false, required: false }, + phone_number: { enabled: false, required: false }, + web3_wallet: { enabled: false, required: false }, + }, + actions: { delete_self: true, create_organization: true }, + social: {}, + enterprise_sso: { enabled: false, self_serve_sso: false }, + sign_in: { second_factor: { required: false, enabled: false } }, + sign_up: { allowlist_only: false, progressive: true, captcha_enabled: false, mode: "public", legal_consent_enabled: false }, + password_settings: { min_length: 8, max_length: 72, disable_hibp: true, allowed_special_characters: "" }, + passkey_settings: { allow_autofill: false, show_sign_in_button: false }, + username_settings: { min_length: 4, max_length: 64 }, + }, + protect_config: { object: "protect_config", id: "protect_emulate" }, + }; +} diff --git a/packages/@emulators/clerk/src/routes/fapi-serializers.ts b/packages/@emulators/clerk/src/routes/fapi-serializers.ts new file mode 100644 index 00000000..e5666b45 --- /dev/null +++ b/packages/@emulators/clerk/src/routes/fapi-serializers.ts @@ -0,0 +1,137 @@ +import type { Store } from "@emulators/core"; +import { userResponse, resolvePrimaryOrgClaims } from "../route-helpers.js"; +import { getClerkStore } from "../store.js"; +import { createSessionToken } from "./oauth.js"; +import type { ClerkUser, ClerkSession, ClerkOrganizationMembership } from "../entities.js"; + +// FAPI projection layer: builds the client/session/membership JSON shapes that +// clerk-js consumes. FAPI uses millisecond timestamps and embeds resources +// (vs the Backend API serializers in route-helpers.ts, which use seconds). + +// FAPI organization_membership JSON. Shared by the user-resource embed and the +// /v1/me/organization_memberships list. +export function fapiMembershipJson( + cs: ReturnType, + m: ClerkOrganizationMembership, +): Record | null { + const org = cs.organizations.findOneBy("clerk_id", m.org_id); + if (!org) return null; + return { + object: "organization_membership", + id: m.membership_id, + role: m.role, + permissions: m.permissions, + public_metadata: m.public_metadata, + organization: { + object: "organization", + id: org.clerk_id, + name: org.name, + slug: org.slug, + image_url: org.image_url, + has_image: org.image_url !== null, + members_count: org.members_count, + max_allowed_memberships: org.max_allowed_memberships, + admin_delete_enabled: org.admin_delete_enabled, + public_metadata: org.public_metadata, + created_at: org.created_at_unix * 1000, + updated_at: org.updated_at_unix * 1000, + }, + created_at: m.created_at_unix * 1000, + updated_at: m.updated_at_unix * 1000, + }; +} + +export function userOrgMemberships(store: Store, user: ClerkUser): Record[] { + const cs = getClerkStore(store); + return cs.memberships + .findBy("user_id", user.clerk_id) + .map((m) => fapiMembershipJson(cs, m)) + .filter((m): m is Record => m !== null); +} + +// Build a FAPI SessionJSON (with embedded user, org memberships, and a live token). +export async function buildSessionJson(store: Store, baseUrl: string, s: ClerkSession): Promise> { + const cs = getClerkStore(store); + const user = cs.users.findOneBy("clerk_id", s.user_id); + const emails = user ? cs.emailAddresses.findBy("user_id", user.clerk_id) : []; + const primaryEmail = emails.find((e) => e.is_primary) ?? emails[0]; + + // last_active_token is REQUIRED by clerk-js — must be a real token object. + let lastActiveToken: { object: string; jwt: string } | null = null; + let lastActiveOrgId: string | null = null; + let userJson: Record | null = null; + + if (user) { + const orgClaims = resolvePrimaryOrgClaims(cs, user); + lastActiveOrgId = orgClaims.orgId ?? null; + const jwt = await createSessionToken(store, user, s.clerk_id, baseUrl, orgClaims); + lastActiveToken = { object: "token", jwt }; + userJson = { ...userResponse(user, emails), organization_memberships: userOrgMemberships(store, user) }; + } + + return { + object: "session", + id: s.clerk_id, + status: s.status, + factor_verification_age: [0, 0], + expire_at: s.expire_at * 1000, + abandon_at: s.abandon_at * 1000, + last_active_at: (s.last_active_at ?? s.created_at_unix) * 1000, + last_active_token: lastActiveToken, + last_active_organization_id: lastActiveOrgId, + actor: null, + tasks: [], + user: userJson, + public_user_data: user + ? { + first_name: user.first_name, + last_name: user.last_name, + image_url: user.image_url ?? `https://img.clerk.com/preview?seed=${user.clerk_id}`, + has_image: user.image_url !== null, + identifier: primaryEmail?.email_address ?? user.username ?? user.clerk_id, + } + : null, + created_at: s.created_at_unix * 1000, + updated_at: s.updated_at_unix * 1000, + }; +} + +export async function buildClientJson(store: Store, baseUrl: string, activeSessionId?: string | null, now = Date.now()) { + const cs = getClerkStore(store); + const sessions = cs.sessions.all().filter((s) => s.status === "active"); + const sessionJsons = await Promise.all(sessions.map((s) => buildSessionJson(store, baseUrl, s))); + + return { + object: "client", + id: "client_emulate", + sessions: sessionJsons, + sign_up: null, + sign_in: null, + last_active_session_id: activeSessionId ?? sessionJsons[0]?.id ?? null, + last_authentication_strategy: null, + cookie_expires_at: null, + created_at: now, + updated_at: now, + }; +} + +// FAPI envelope: { response, client }. The optional embeds let clerk-js's +// useSignIn()/useSignUp() hooks (which proxy client.sign_in / client.sign_up) +// reflect an in-progress attempt. +export async function fapiResponse( + response: unknown, + store: Store, + baseUrl: string, + activeSessionId?: string | null, + embedSignIn?: Record | null, + embedSignUp?: Record | null, +) { + const client = await buildClientJson(store, baseUrl, activeSessionId); + if (embedSignIn !== undefined) { + (client as Record).sign_in = embedSignIn; + } + if (embedSignUp !== undefined) { + (client as Record).sign_up = embedSignUp; + } + return { response, client }; +} diff --git a/packages/@emulators/clerk/src/routes/fapi.ts b/packages/@emulators/clerk/src/routes/fapi.ts new file mode 100644 index 00000000..31594d2b --- /dev/null +++ b/packages/@emulators/clerk/src/routes/fapi.ts @@ -0,0 +1,470 @@ +import type { RouteContext, Store, Context, AppEnv } from "@emulators/core"; +import { generateClerkId, nowUnix } from "../helpers.js"; +import { clerkError, userResponse, resolvePrimaryOrgClaims } from "../route-helpers.js"; +import { getClerkStore } from "../store.js"; +import { createSessionToken } from "./oauth.js"; +import { buildEnvironmentJson } from "./fapi-environment.js"; +import { fapiResponse, buildClientJson, buildSessionJson, fapiMembershipJson } from "./fapi-serializers.js"; +import { dispatchClerkEvent } from "../webhook-events.js"; +import type { ClerkUser } from "../entities.js"; + +// clerk-js posts FAPI bodies as application/x-www-form-urlencoded (with a JSON fallback). +async function readFapiBody(c: Context): Promise> { + const contentType = c.req.header("Content-Type") ?? ""; + if (contentType.includes("application/json")) { + try { + const parsed = (await c.req.json()) as Record; + const out: Record = {}; + for (const [k, v] of Object.entries(parsed)) if (typeof v === "string") out[k] = v; + return out; + } catch { + return {}; + } + } + try { + const raw = await c.req.text(); + return Object.fromEntries(new URLSearchParams(raw)); + } catch { + return {}; + } +} + +// FAPI form-validation error envelope (HTTP 422), as clerk-js expects. +function fapiFormError(c: Context, code: string, message: string) { + return c.json({ errors: [{ code, message, long_message: message }], meta: {} }, 422); +} + +const PASSWORD_INCORRECT = "Password is incorrect. Try again, or use another method."; +const CODE_INCORRECT = "Incorrect code. Try again."; + +// Fixed verification codes for the emulator (mirrors Clerk's test-mode 424242 convention). +const EMULATE_EMAIL_CODE = "424242"; +const EMULATE_TOTP_CODE = "424242"; + +type Verification = { status: "verified" | "unverified"; strategy: string } | null; + +type PendingSignIn = { + id: string; + user: ClerkUser; + status: "needs_first_factor" | "needs_second_factor" | "complete"; + createdSessionId: string | null; + firstFactorVerification: Verification; + secondFactorVerification: Verification; +}; + +function getSignIns(store: Store): Map { + let map = store.getData>("clerk.fapi.signIns"); + if (!map) { + map = new Map(); + store.setData("clerk.fapi.signIns", map); + } + return map; +} + +// Create an active session and mark the sign-in complete. +function completeSignIn(cs: ReturnType, signIn: PendingSignIn) { + const now = nowUnix(); + const session = cs.sessions.insert({ + clerk_id: generateClerkId("sess_"), + user_id: signIn.user.clerk_id, + client_id: "client_emulate", + status: "active", + last_active_at: now, + expire_at: now + 86400, + abandon_at: now + 604800, + created_at_unix: now, + updated_at_unix: now, + }); + signIn.status = "complete"; + signIn.createdSessionId = session.clerk_id; + return session; +} + +// First factor verified with `strategy`: go to MFA if the user has TOTP enabled, else complete. +function advanceAfterFirstFactor(cs: ReturnType, signIn: PendingSignIn, strategy: string): void { + signIn.firstFactorVerification = { status: "verified", strategy }; + if (signIn.user.totp_enabled) { + signIn.status = "needs_second_factor"; + } else { + completeSignIn(cs, signIn); + } +} + +function signInJson(cs: ReturnType, signIn: PendingSignIn): Record { + const emails = cs.emailAddresses.findBy("user_id", signIn.user.clerk_id); + const primaryEmail = emails.find((e) => e.is_primary) ?? emails[0]; + const identifier = primaryEmail?.email_address ?? signIn.user.username ?? signIn.user.clerk_id; + + const firstFactors: Record[] = [{ strategy: "password", safe_identifier: identifier }]; + if (primaryEmail) { + firstFactors.push({ + strategy: "email_code", + safe_identifier: primaryEmail.email_address, + email_address_id: primaryEmail.email_id, + }); + } + + const secondFactors: Record[] = signIn.user.totp_enabled ? [{ strategy: "totp" }] : []; + + return { + object: "sign_in", + id: signIn.id, + status: signIn.status, + identifier, + user_data: { + first_name: signIn.user.first_name, + last_name: signIn.user.last_name, + image_url: signIn.user.image_url ?? `https://img.clerk.com/preview?seed=${signIn.user.clerk_id}`, + has_image: signIn.user.image_url !== null, + }, + supported_first_factors: firstFactors, + supported_second_factors: secondFactors, + first_factor_verification: signIn.firstFactorVerification, + second_factor_verification: signIn.secondFactorVerification, + created_session_id: signIn.createdSessionId, + }; +} + +type PendingSignUp = { + id: string; + email_address: string; + password: string | null; + first_name: string | null; + last_name: string | null; + emailVerification: Verification; + status: "missing_requirements" | "complete"; + createdSessionId: string | null; +}; + +function getSignUps(store: Store): Map { + let map = store.getData>("clerk.fapi.signUps"); + if (!map) { + map = new Map(); + store.setData("clerk.fapi.signUps", map); + } + return map; +} + +function signUpJson(signUp: PendingSignUp): Record { + const emailVerified = signUp.emailVerification?.status === "verified"; + return { + object: "sign_up", + id: signUp.id, + status: signUp.status, + required_fields: ["email_address", "password"], + optional_fields: ["first_name", "last_name"], + missing_fields: signUp.status === "complete" ? [] : signUp.password ? [] : ["password"], + unverified_fields: emailVerified ? [] : ["email_address"], + verifications: { + email_address: { + status: signUp.emailVerification?.status ?? "unverified", + strategy: signUp.emailVerification?.strategy ?? "email_code", + }, + phone_number: null, + web3_wallet: null, + external_account: null, + }, + username: null, + email_address: signUp.email_address, + phone_number: null, + web3_wallet: null, + first_name: signUp.first_name, + last_name: signUp.last_name, + has_password: signUp.password !== null, + created_session_id: signUp.createdSessionId, + created_user_id: null, + }; +} + +export function fapiRoutes({ app, store, webhooks, baseUrl }: RouteContext): void { + const cs = getClerkStore(store); + + // Dev browser init + app.post("/v1/dev_browser", (c) => { + const jwt = "emulate_dev_browser_jwt"; + c.header("Clerk-Db-Jwt", jwt); + return c.json({ id: jwt }); + }); + + // Environment (clerk-js sends GET or POST depending on version) + const environmentHandler = async (c: Context) => + c.json(await fapiResponse(buildEnvironmentJson(baseUrl), store, baseUrl)); + app.get("/v1/environment", environmentHandler); + app.post("/v1/environment", environmentHandler); + + // Client + const clientHandler = async (c: Context) => + c.json(await fapiResponse(await buildClientJson(store, baseUrl), store, baseUrl)); + app.get("/v1/client", clientHandler); + app.post("/v1/client", clientHandler); + + // Sign-in: create + app.post("/v1/client/sign_ins", async (c) => { + const body = await readFapiBody(c); + const identifier = (body.identifier as string) ?? ""; + + const email = cs.emailAddresses.findOneBy("email_address", identifier); + if (!email) return fapiFormError(c, "form_identifier_not_found", "Couldn't find your account."); + + const user = cs.users.findOneBy("clerk_id", email.user_id); + if (!user) return fapiFormError(c, "form_identifier_not_found", "Couldn't find your account."); + + const signInId = generateClerkId("sia_"); + const signIn: PendingSignIn = { + id: signInId, + user, + status: "needs_first_factor", + createdSessionId: null, + firstFactorVerification: null, + secondFactorVerification: null, + }; + getSignIns(store).set(signInId, signIn); + + // Combined flow: @clerk/react's future API (signIn.password()) sends the + // password in the create call and expects completion in one request. + const password = body.password as string | undefined; + if (password !== undefined) { + if (user.password_hash !== password) return fapiFormError(c, "form_password_incorrect", PASSWORD_INCORRECT); + advanceAfterFirstFactor(cs, signIn, "password"); + const json = signInJson(cs, signIn); + return c.json(await fapiResponse(json, store, baseUrl, signIn.createdSessionId, json)); + } + + const json = signInJson(cs, signIn); + return c.json(await fapiResponse(json, store, baseUrl, undefined, json)); + }); + + // Sign-in: prepare first factor (e.g. send email code) + app.post("/v1/client/sign_ins/:signInId/prepare_first_factor", async (c) => { + const signIn = getSignIns(store).get(c.req.param("signInId")); + if (!signIn) return clerkError(c, 404, "RESOURCE_NOT_FOUND", "Sign-in not found"); + + const body = await readFapiBody(c); + const strategy = (body.strategy as string) ?? "email_code"; + signIn.firstFactorVerification = { status: "unverified", strategy }; + + const json = signInJson(cs, signIn); + return c.json(await fapiResponse(json, store, baseUrl, undefined, json)); + }); + + // Sign-in: attempt first factor (password or email_code) + app.post("/v1/client/sign_ins/:signInId/attempt_first_factor", async (c) => { + const signIn = getSignIns(store).get(c.req.param("signInId")); + if (!signIn) return clerkError(c, 404, "RESOURCE_NOT_FOUND", "Sign-in not found"); + + const body = await readFapiBody(c); + const strategy = (body.strategy as string) ?? "password"; + + if (strategy === "password") { + if (signIn.user.password_hash !== (body.password as string)) return fapiFormError(c, "form_password_incorrect", PASSWORD_INCORRECT); + } else if (strategy === "email_code") { + if ((body.code as string) !== EMULATE_EMAIL_CODE) return fapiFormError(c, "form_code_incorrect", CODE_INCORRECT); + } + + advanceAfterFirstFactor(cs, signIn, strategy); + const json = signInJson(cs, signIn); + return c.json(await fapiResponse(json, store, baseUrl, signIn.createdSessionId, json)); + }); + + // Sign-in: attempt second factor (TOTP MFA) + app.post("/v1/client/sign_ins/:signInId/attempt_second_factor", async (c) => { + const signIn = getSignIns(store).get(c.req.param("signInId")); + if (!signIn) return clerkError(c, 404, "RESOURCE_NOT_FOUND", "Sign-in not found"); + + const body = await readFapiBody(c); + const strategy = (body.strategy as string) ?? "totp"; + if ((body.code as string) !== EMULATE_TOTP_CODE) return fapiFormError(c, "form_code_incorrect", CODE_INCORRECT); + + signIn.secondFactorVerification = { status: "verified", strategy }; + const session = completeSignIn(cs, signIn); + const json = signInJson(cs, signIn); + return c.json(await fapiResponse(json, store, baseUrl, session.clerk_id, json)); + }); + + // Sign-up: create + app.post("/v1/client/sign_ups", async (c) => { + const body = await readFapiBody(c); + const emailAddress = (body.email_address as string) ?? ""; + + if (!emailAddress) return fapiFormError(c, "form_param_missing", "email_address is required"); + if (cs.emailAddresses.findOneBy("email_address", emailAddress)) { + return fapiFormError(c, "form_identifier_exists", "That email address is taken."); + } + + const signUpId = generateClerkId("sua_"); + const signUp: PendingSignUp = { + id: signUpId, + email_address: emailAddress, + password: (body.password as string) ?? null, + first_name: (body.first_name as string) ?? null, + last_name: (body.last_name as string) ?? null, + emailVerification: null, + status: "missing_requirements", + createdSessionId: null, + }; + getSignUps(store).set(signUpId, signUp); + + const json = signUpJson(signUp); + return c.json(await fapiResponse(json, store, baseUrl, undefined, undefined, json)); + }); + + // Sign-up: prepare verification (send email code) + app.post("/v1/client/sign_ups/:signUpId/prepare_verification", async (c) => { + const signUp = getSignUps(store).get(c.req.param("signUpId")); + if (!signUp) return clerkError(c, 404, "RESOURCE_NOT_FOUND", "Sign-up not found"); + + const body = await readFapiBody(c); + signUp.emailVerification = { status: "unverified", strategy: (body.strategy as string) ?? "email_code" }; + + const json = signUpJson(signUp); + return c.json(await fapiResponse(json, store, baseUrl, undefined, undefined, json)); + }); + + // Sign-up: attempt verification (verify email code, create the user) + app.post("/v1/client/sign_ups/:signUpId/attempt_verification", async (c) => { + const signUp = getSignUps(store).get(c.req.param("signUpId")); + if (!signUp) return clerkError(c, 404, "RESOURCE_NOT_FOUND", "Sign-up not found"); + + const body = await readFapiBody(c); + if ((body.code as string) !== EMULATE_EMAIL_CODE) return fapiFormError(c, "form_code_incorrect", CODE_INCORRECT); + + signUp.emailVerification = { status: "verified", strategy: "email_code" }; + + // Create the user + verified email, then an active session. + const now = nowUnix(); + const clerkId = generateClerkId("user_"); + const user = cs.users.insert({ + clerk_id: clerkId, + username: null, + first_name: signUp.first_name, + last_name: signUp.last_name, + image_url: null, + profile_image_url: null, + external_id: null, + primary_email_address_id: null, + primary_phone_number_id: null, + password_enabled: signUp.password !== null, + password_hash: signUp.password, + totp_enabled: false, + backup_code_enabled: false, + two_factor_enabled: false, + banned: false, + locked: false, + public_metadata: {}, + private_metadata: {}, + unsafe_metadata: {}, + last_active_at: null, + last_sign_in_at: null, + created_at_unix: now, + updated_at_unix: now, + }); + const emailRec = cs.emailAddresses.insert({ + email_id: generateClerkId("idn_"), + email_address: signUp.email_address, + user_id: clerkId, + verification_status: "verified", + verification_strategy: "email_code", + is_primary: true, + reserved: false, + created_at_unix: now, + updated_at_unix: now, + }); + cs.users.update(user.id, { primary_email_address_id: emailRec.email_id }); + + const session = cs.sessions.insert({ + clerk_id: generateClerkId("sess_"), + user_id: clerkId, + client_id: "client_emulate", + status: "active", + last_active_at: now, + expire_at: now + 86400, + abandon_at: now + 604800, + created_at_unix: now, + updated_at_unix: now, + }); + + signUp.status = "complete"; + signUp.createdSessionId = session.clerk_id; + + // Real Clerk fires user.created when a user registers via the frontend (FAPI), + // not just via the Backend API. See clerk.com/docs/webhooks — user.created + // "triggers when a new user registers in the app". + const created = cs.users.findOneBy("clerk_id", clerkId)!; + dispatchClerkEvent(webhooks, "user.created", userResponse(created, cs.emailAddresses.findBy("user_id", clerkId))); + + const json = signUpJson(signUp); + return c.json(await fapiResponse(json, store, baseUrl, session.clerk_id, undefined, json)); + }); + + // FAPI session token creation + app.post("/v1/client/sessions/:sessionId/tokens", async (c) => { + const sessionId = c.req.param("sessionId"); + const session = cs.sessions.findOneBy("clerk_id", sessionId); + if (!session) return clerkError(c, 404, "RESOURCE_NOT_FOUND", "Session not found"); + + const user = cs.users.findOneBy("clerk_id", session.user_id); + if (!user) return clerkError(c, 404, "RESOURCE_NOT_FOUND", "User not found"); + + const jwt = await createSessionToken(store, user, sessionId, baseUrl, resolvePrimaryOrgClaims(cs, user)); + + cs.sessions.update(session.id, { last_active_at: nowUnix() }); + + return c.json({ object: "token", jwt }); + }); + + // Session touch + app.post("/v1/client/sessions/:sessionId/touch", async (c) => { + const sessionId = c.req.param("sessionId"); + const session = cs.sessions.findOneBy("clerk_id", sessionId); + if (!session) return clerkError(c, 404, "RESOURCE_NOT_FOUND", "Session not found"); + + cs.sessions.update(session.id, { last_active_at: nowUnix() }); + + // clerk-js's setActive reads the touched Session resource from `response`. + const updated = cs.sessions.findOneBy("clerk_id", sessionId)!; + const sessionJson = await buildSessionJson(store, baseUrl, updated); + return c.json(await fapiResponse(sessionJson, store, baseUrl, sessionId)); + }); + + // Sign out of all sessions — clerk-js sends DELETE /v1/client/sessions + // (delivered as POST with ?_method=DELETE due to a Safari CORS workaround). + const removeAllSessionsHandler = async (c: Context) => { + const now = nowUnix(); + for (const s of cs.sessions.all()) { + if (s.status === "active") { + cs.sessions.update(s.id, { status: "ended", updated_at_unix: now }); + } + } + return c.json(await fapiResponse(await buildClientJson(store, baseUrl), store, baseUrl)); + }; + app.post("/v1/client/sessions", removeAllSessionsHandler); + app.delete("/v1/client/sessions", removeAllSessionsHandler); + + // Single session end + app.delete("/v1/client/sessions/:sessionId", async (c) => { + const sessionId = c.req.param("sessionId"); + const session = cs.sessions.findOneBy("clerk_id", sessionId); + if (!session) return clerkError(c, 404, "RESOURCE_NOT_FOUND", "Session not found"); + + cs.sessions.update(session.id, { status: "ended", updated_at_unix: nowUnix() }); + + return c.json(await fapiResponse({ object: "session", id: sessionId, status: "ended" }, store, baseUrl)); + }); + + // User's organization memberships (FAPI path) + app.get("/v1/me/organization_memberships", async (c) => { + const empty = { data: [], total_count: 0 }; + const sessionId = c.req.query("_clerk_session_id"); + if (!sessionId) return c.json(await fapiResponse(empty, store, baseUrl)); + + const session = cs.sessions.findOneBy("clerk_id", sessionId); + if (!session) return c.json(await fapiResponse(empty, store, baseUrl)); + + const data = cs.memberships + .findBy("user_id", session.user_id) + .map((m) => fapiMembershipJson(cs, m)) + .filter((m): m is Record => m !== null); + + return c.json(await fapiResponse({ data, total_count: data.length }, store, baseUrl, sessionId)); + }); +} diff --git a/packages/@emulators/clerk/src/routes/invitations.ts b/packages/@emulators/clerk/src/routes/invitations.ts index e67f8185..9fb0beac 100644 --- a/packages/@emulators/clerk/src/routes/invitations.ts +++ b/packages/@emulators/clerk/src/routes/invitations.ts @@ -4,14 +4,14 @@ import { clerkError, requireSecretKey, isAuthResponse, - paginatedResponse, parsePagination, invitationResponse, readJsonBody, } from "../route-helpers.js"; import { getClerkStore } from "../store.js"; +import { dispatchClerkEvent } from "../webhook-events.js"; -export function invitationRoutes({ app, store, tokenMap }: RouteContext): void { +export function invitationRoutes({ app, store, webhooks, tokenMap }: RouteContext): void { const cs = getClerkStore(store); app.get("/v1/organizations/:orgId/invitations", (c) => { @@ -34,7 +34,7 @@ export function invitationRoutes({ app, store, tokenMap }: RouteContext): void { const totalCount = invitations.length; const paged = invitations.slice(offset, offset + limit); - return c.json(paginatedResponse(paged.map(invitationResponse), totalCount, limit, offset)); + return c.json(paged.map(invitationResponse)); }); app.get("/v1/organizations/:orgId/invitations/:invitationId", (c) => { @@ -86,7 +86,9 @@ export function invitationRoutes({ app, store, tokenMap }: RouteContext): void { updated_at_unix: now, }); - return c.json(invitationResponse(invitation), 200); + const response = invitationResponse(invitation); + dispatchClerkEvent(webhooks, "org_invitation.created", response); + return c.json(response, 200); }); app.post("/v1/organizations/:orgId/invitations/bulk", async (c) => { @@ -154,6 +156,8 @@ export function invitationRoutes({ app, store, tokenMap }: RouteContext): void { }); const updated = cs.invitations.findOneBy("invitation_id", invitationId)!; - return c.json(invitationResponse(updated)); + const response = invitationResponse(updated); + dispatchClerkEvent(webhooks, "org_invitation.revoked", response); + return c.json(response); }); } diff --git a/packages/@emulators/clerk/src/routes/m2m-tokens.ts b/packages/@emulators/clerk/src/routes/m2m-tokens.ts new file mode 100644 index 00000000..d8fd1bec --- /dev/null +++ b/packages/@emulators/clerk/src/routes/m2m-tokens.ts @@ -0,0 +1,157 @@ +import type { RouteContext } from "@emulators/core"; +import { generateClerkId, nowUnix } from "../helpers.js"; +import { + clerkError, + requireSecretKey, + isAuthResponse, + readJsonBody, +} from "../route-helpers.js"; +import { getClerkStore } from "../store.js"; +import { keyPairPromise, KID } from "./oauth.js"; +import { SignJWT } from "jose"; +import type { ClerkM2MToken } from "../entities.js"; + +function m2mTokenResponse(t: ClerkM2MToken): Record { + return { + object: "machine_to_machine_token", + id: t.token_id, + token: t.token, + subject: t.subject, + scopes: t.scopes, + claims: t.claims, + revoked: t.revoked, + revocation_reason: t.revocation_reason, + expired: t.expired, + expiration: t.expiration, + created_at: t.created_at_unix, + updated_at: t.updated_at_unix, + }; +} + +export function m2mTokenRoutes({ app, store, baseUrl, tokenMap }: RouteContext): void { + const cs = getClerkStore(store); + + app.post("/m2m_tokens", async (c) => { + const auth = requireSecretKey(c, tokenMap); + if (isAuthResponse(auth)) return auth; + + const body = await readJsonBody(c); + const tokenFormat = (body.token_format as string) ?? "opaque"; + const secondsUntilExpiration = body.seconds_until_expiration as number | null | undefined; + const claims = (body.claims as Record) ?? null; + + const tokenId = generateClerkId("mt_"); + const subject = generateClerkId("mch_"); + const now = nowUnix(); + const nowMs = now * 1000; + + const ttlSeconds = secondsUntilExpiration ?? 3600; + const expiration = nowMs + ttlSeconds * 1000; + + let tokenString: string; + + if (tokenFormat === "jwt") { + const { privateKey } = await keyPairPromise; + const jwtClaims: Record = { + scopes: subject, + }; + if (claims) { + Object.assign(jwtClaims, claims); + } + + const builder = new SignJWT(jwtClaims) + .setProtectedHeader({ alg: "RS256", kid: KID, typ: "JWT" }) + .setIssuer(baseUrl) + .setSubject(subject) + .setJti(tokenId) + .setIssuedAt(now) + .setExpirationTime(now + ttlSeconds); + + tokenString = await builder.sign(privateKey); + } else { + tokenString = `mt_${generateClerkId("")}`; + } + + const record = cs.m2mTokens.insert({ + token_id: tokenId, + token: tokenString, + subject, + scopes: [subject], + claims, + revoked: false, + revocation_reason: null, + expired: false, + expiration, + created_at_unix: nowMs, + updated_at_unix: nowMs, + }); + + return c.json(m2mTokenResponse(record)); + }); + + app.post("/m2m_tokens/verify", async (c) => { + const auth = requireSecretKey(c, tokenMap); + if (isAuthResponse(auth)) return auth; + + const body = await readJsonBody(c); + const token = body.token as string; + if (!token) { + return clerkError(c, 422, "INVALID_REQUEST_BODY", "token is required"); + } + + const record = cs.m2mTokens.all().find((t) => t.token === token); + if (!record) { + return clerkError(c, 404, "TOKEN_NOT_FOUND", "Token not found or invalid"); + } + + if (record.revoked) { + return clerkError(c, 401, "TOKEN_REVOKED", "Token has been revoked"); + } + + if (record.expiration != null && Date.now() > record.expiration) { + cs.m2mTokens.update(record.id, { expired: true }); + return clerkError(c, 401, "TOKEN_EXPIRED", "Token has expired"); + } + + return c.json(m2mTokenResponse(record)); + }); + + app.get("/m2m_tokens", (c) => { + const auth = requireSecretKey(c, tokenMap); + if (isAuthResponse(auth)) return auth; + + const subjectFilter = c.req.query("subject"); + let tokens = cs.m2mTokens.all(); + + if (subjectFilter) { + tokens = tokens.filter((t) => t.subject === subjectFilter); + } + + tokens.sort((a, b) => b.created_at_unix - a.created_at_unix); + + return c.json({ + m2m_tokens: tokens.map((t) => m2mTokenResponse(t)), + total_count: tokens.length, + }); + }); + + app.post("/m2m_tokens/:tokenId/revoke", (c) => { + const auth = requireSecretKey(c, tokenMap); + if (isAuthResponse(auth)) return auth; + + const tokenId = c.req.param("tokenId"); + const record = cs.m2mTokens.findOneBy("token_id", tokenId); + if (!record) { + return clerkError(c, 404, "RESOURCE_NOT_FOUND", "Token not found"); + } + + cs.m2mTokens.update(record.id, { + revoked: true, + revocation_reason: "manually_revoked", + updated_at_unix: Date.now(), + }); + + const updated = cs.m2mTokens.findOneBy("token_id", tokenId)!; + return c.json(m2mTokenResponse(updated)); + }); +} diff --git a/packages/@emulators/clerk/src/routes/memberships.ts b/packages/@emulators/clerk/src/routes/memberships.ts index 4256984e..55b1c7af 100644 --- a/packages/@emulators/clerk/src/routes/memberships.ts +++ b/packages/@emulators/clerk/src/routes/memberships.ts @@ -1,32 +1,18 @@ import type { RouteContext } from "@emulators/core"; -import { generateClerkId, nowUnix } from "../helpers.js"; +import { generateClerkId, nowUnix, defaultOrganizationPermissions } from "../helpers.js"; import { clerkError, requireSecretKey, isAuthResponse, deletedResponse, - paginatedResponse, parsePagination, membershipResponse, readJsonBody, } from "../route-helpers.js"; import { getClerkStore } from "../store.js"; +import { dispatchClerkEvent } from "../webhook-events.js"; -function defaultPermissions(role: string): string[] { - if (role === "org:admin") { - return [ - "org:sys_profile:manage", - "org:sys_profile:delete", - "org:sys_memberships:read", - "org:sys_memberships:manage", - "org:sys_domains:read", - "org:sys_domains:manage", - ]; - } - return ["org:sys_memberships:read"]; -} - -export function membershipRoutes({ app, store, tokenMap }: RouteContext): void { +export function membershipRoutes({ app, store, webhooks, tokenMap }: RouteContext): void { const cs = getClerkStore(store); app.get("/v1/organizations/:orgId/memberships", (c) => { @@ -55,7 +41,7 @@ export function membershipRoutes({ app, store, tokenMap }: RouteContext): void { return membershipResponse(m, org, user, emails); }); - return c.json(paginatedResponse(data, totalCount, limit, offset)); + return c.json(data); }); app.post("/v1/organizations/:orgId/memberships", async (c) => { @@ -84,7 +70,7 @@ export function membershipRoutes({ app, store, tokenMap }: RouteContext): void { org_id: orgId, user_id: userId, role, - permissions: defaultPermissions(role), + permissions: defaultOrganizationPermissions(role), public_metadata: {}, private_metadata: {}, created_at_unix: now, @@ -95,7 +81,9 @@ export function membershipRoutes({ app, store, tokenMap }: RouteContext): void { const emails = cs.emailAddresses.findBy("user_id", userId); const updatedOrg = cs.organizations.findOneBy("clerk_id", orgId)!; - return c.json(membershipResponse(membership, updatedOrg, user, emails), 200); + const response = membershipResponse(membership, updatedOrg, user, emails); + dispatchClerkEvent(webhooks, "org_membership.created", response); + return c.json(response, 200); }); app.patch("/v1/organizations/:orgId/memberships/:userId", async (c) => { @@ -116,14 +104,16 @@ export function membershipRoutes({ app, store, tokenMap }: RouteContext): void { if (body.role !== undefined) { patch.role = body.role; - patch.permissions = defaultPermissions(body.role as string); + patch.permissions = defaultOrganizationPermissions(body.role as string); } cs.memberships.update(membership.id, patch); const updated = cs.memberships.findBy("org_id", orgId).find((m) => m.user_id === userId)!; const user = cs.users.findOneBy("clerk_id", userId); const emails = user ? cs.emailAddresses.findBy("user_id", userId) : []; - return c.json(membershipResponse(updated, org, user, emails)); + const response = membershipResponse(updated, org, user, emails); + dispatchClerkEvent(webhooks, "org_membership.updated", response); + return c.json(response); }); app.delete("/v1/organizations/:orgId/memberships/:userId", (c) => { @@ -141,6 +131,7 @@ export function membershipRoutes({ app, store, tokenMap }: RouteContext): void { cs.memberships.delete(membership.id); cs.organizations.update(org.id, { members_count: Math.max(0, org.members_count - 1), updated_at_unix: nowUnix() }); + dispatchClerkEvent(webhooks, "org_membership.deleted", { id: membership.membership_id, deleted: true }); return c.json(deletedResponse("organization_membership", membership.membership_id)); }); @@ -174,6 +165,8 @@ export function membershipRoutes({ app, store, tokenMap }: RouteContext): void { const updated = cs.memberships.findBy("org_id", orgId).find((m) => m.user_id === userId)!; const user = cs.users.findOneBy("clerk_id", userId); const emails = user ? cs.emailAddresses.findBy("user_id", userId) : []; - return c.json(membershipResponse(updated, org, user, emails)); + const response = membershipResponse(updated, org, user, emails); + dispatchClerkEvent(webhooks, "org_membership.updated", response); + return c.json(response); }); } diff --git a/packages/@emulators/clerk/src/routes/oauth.ts b/packages/@emulators/clerk/src/routes/oauth.ts index 8e8e3736..dfaf3c8e 100644 --- a/packages/@emulators/clerk/src/routes/oauth.ts +++ b/packages/@emulators/clerk/src/routes/oauth.ts @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { SignJWT, exportJWK, generateKeyPair } from "jose"; +import { SignJWT, exportJWK, exportSPKI, generateKeyPair } from "jose"; import type { Context } from "@emulators/core"; import type { AppEnv, RouteContext, Store } from "@emulators/core"; import { @@ -14,7 +14,7 @@ import { } from "@emulators/core"; import type { ClerkUser } from "../entities.js"; import { userDisplayName } from "../helpers.js"; -import { clerkError } from "../route-helpers.js"; +import { clerkError, type PrimaryOrgClaims } from "../route-helpers.js"; import { getClerkStore } from "../store.js"; const keyPairPromise = generateKeyPair("RS256"); @@ -51,23 +51,21 @@ export async function createSessionToken( user: ClerkUser, sessionId: string, baseUrl: string, - orgId?: string, - orgRole?: string, - orgSlug?: string, - orgPermissions?: string[], + orgClaims: PrimaryOrgClaims, ): Promise { const { privateKey } = await keyPairPromise; const now = Math.floor(Date.now() / 1000); const claims: Record = { sid: sessionId, + sts: "active", }; - if (orgId) { - claims.org_id = orgId; - claims.org_role = orgRole ?? "org:member"; - claims.org_slug = orgSlug; - claims.org_permissions = orgPermissions ?? []; + if (orgClaims.orgId) { + claims.org_id = orgClaims.orgId; + claims.org_role = orgClaims.orgRole ?? "org:member"; + claims.org_slug = orgClaims.orgSlug; + claims.org_permissions = orgClaims.orgPermissions ?? []; } if (Object.keys(user.public_metadata).length > 0) { @@ -126,6 +124,12 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo }); }); + app.get("/_emulate/jwt-public-key", async (c) => { + const { publicKey } = await keyPairPromise; + const pem = await exportSPKI(publicKey); + return c.text(pem); + }); + app.get("/oauth/authorize", (c) => { const clientId = c.req.query("client_id") ?? ""; const redirectUri = c.req.query("redirect_uri") ?? ""; diff --git a/packages/@emulators/clerk/src/routes/organization-domains.ts b/packages/@emulators/clerk/src/routes/organization-domains.ts new file mode 100644 index 00000000..73108e05 --- /dev/null +++ b/packages/@emulators/clerk/src/routes/organization-domains.ts @@ -0,0 +1,141 @@ +import type { RouteContext } from "@emulators/core"; +import { generateClerkId, nowUnix } from "../helpers.js"; +import { + clerkError, + requireSecretKey, + isAuthResponse, + deletedResponse, + parsePagination, + readJsonBody, +} from "../route-helpers.js"; +import { getClerkStore } from "../store.js"; +import { dispatchClerkEvent } from "../webhook-events.js"; +import type { ClerkOrganizationDomain } from "../entities.js"; + +function domainResponse(domain: ClerkOrganizationDomain): Record { + return { + object: "organization_domain", + id: domain.domain_id, + name: domain.name, + organization_id: domain.org_id, + enrollment_mode: domain.enrollment_mode, + verification: { + status: domain.verification_status, + strategy: domain.verification_strategy, + attempts: domain.verification_attempts, + expires_at: domain.verification_expires_at, + }, + affiliation_email_address: domain.affiliation_email_address, + total_pending_invitations: domain.total_pending_invitations, + total_pending_suggestions: domain.total_pending_suggestions, + created_at: domain.created_at_unix, + updated_at: domain.updated_at_unix, + }; +} + +export function organizationDomainRoutes({ app, store, webhooks, tokenMap }: RouteContext): void { + const cs = getClerkStore(store); + + app.get("/v1/organizations/:orgId/domains", (c) => { + const auth = requireSecretKey(c, tokenMap); + if (isAuthResponse(auth)) return auth; + + const orgId = c.req.param("orgId"); + const org = cs.organizations.findOneBy("clerk_id", orgId); + if (!org) return clerkError(c, 404, "RESOURCE_NOT_FOUND", "Organization not found"); + + const { limit, offset } = parsePagination(c); + const domains = cs.organizationDomains.findBy("org_id", orgId); + const paged = domains.slice(offset, offset + limit); + + return c.json(paged.map(domainResponse)); + }); + + app.post("/v1/organizations/:orgId/domains", async (c) => { + const auth = requireSecretKey(c, tokenMap); + if (isAuthResponse(auth)) return auth; + + const orgId = c.req.param("orgId"); + const org = cs.organizations.findOneBy("clerk_id", orgId); + if (!org) return clerkError(c, 404, "RESOURCE_NOT_FOUND", "Organization not found"); + + const body = await readJsonBody(c); + const name = body.name as string; + if (!name) return clerkError(c, 422, "INVALID_REQUEST_BODY", "name is required"); + + const enrollmentMode = (body.enrollment_mode as string) ?? "manual_invitation"; + const verified = body.verified !== false; + const now = nowUnix(); + + const domain = cs.organizationDomains.insert({ + domain_id: generateClerkId("orgdom_"), + name, + org_id: orgId, + enrollment_mode: enrollmentMode as ClerkOrganizationDomain["enrollment_mode"], + verification_status: verified ? "verified" : "unverified", + verification_strategy: "email_code", + verification_attempts: 0, + verification_expires_at: now + 86400, + affiliation_email_address: null, + total_pending_invitations: 0, + total_pending_suggestions: 0, + created_at_unix: now, + updated_at_unix: now, + }); + + const response = domainResponse(domain); + dispatchClerkEvent(webhooks, "org_domain.created", response); + return c.json(response); + }); + + app.patch("/v1/organizations/:orgId/domains/:domainId", async (c) => { + const auth = requireSecretKey(c, tokenMap); + if (isAuthResponse(auth)) return auth; + + const orgId = c.req.param("orgId"); + const domainId = c.req.param("domainId"); + const org = cs.organizations.findOneBy("clerk_id", orgId); + if (!org) return clerkError(c, 404, "RESOURCE_NOT_FOUND", "Organization not found"); + + const domain = cs.organizationDomains.findOneBy("domain_id", domainId); + if (!domain || domain.org_id !== orgId) { + return clerkError(c, 404, "RESOURCE_NOT_FOUND", "Domain not found"); + } + + const body = await readJsonBody(c); + const updates: Partial = { updated_at_unix: nowUnix() }; + + if (body.name !== undefined) updates.name = body.name as string; + if (body.enrollment_mode !== undefined) { + updates.enrollment_mode = body.enrollment_mode as ClerkOrganizationDomain["enrollment_mode"]; + } + if (body.verified !== undefined) { + updates.verification_status = body.verified ? "verified" : "unverified"; + } + + cs.organizationDomains.update(domain.id, updates); + const updated = cs.organizationDomains.findOneBy("domain_id", domainId)!; + const response = domainResponse(updated); + dispatchClerkEvent(webhooks, "org_domain.updated", response); + return c.json(response); + }); + + app.delete("/v1/organizations/:orgId/domains/:domainId", (c) => { + const auth = requireSecretKey(c, tokenMap); + if (isAuthResponse(auth)) return auth; + + const orgId = c.req.param("orgId"); + const domainId = c.req.param("domainId"); + const org = cs.organizations.findOneBy("clerk_id", orgId); + if (!org) return clerkError(c, 404, "RESOURCE_NOT_FOUND", "Organization not found"); + + const domain = cs.organizationDomains.findOneBy("domain_id", domainId); + if (!domain || domain.org_id !== orgId) { + return clerkError(c, 404, "RESOURCE_NOT_FOUND", "Domain not found"); + } + + cs.organizationDomains.delete(domain.id); + dispatchClerkEvent(webhooks, "org_domain.deleted", { id: domainId, deleted: true }); + return c.json(deletedResponse("organization_domain", domainId)); + }); +} diff --git a/packages/@emulators/clerk/src/routes/organizations.ts b/packages/@emulators/clerk/src/routes/organizations.ts index e1e5cdf8..c093faec 100644 --- a/packages/@emulators/clerk/src/routes/organizations.ts +++ b/packages/@emulators/clerk/src/routes/organizations.ts @@ -1,18 +1,18 @@ import type { RouteContext } from "@emulators/core"; -import { generateClerkId, nowUnix } from "../helpers.js"; +import { generateClerkId, nowUnix, defaultOrganizationPermissions } from "../helpers.js"; import { clerkError, requireSecretKey, isAuthResponse, deletedResponse, - paginatedResponse, parsePagination, organizationResponse, readJsonBody, } from "../route-helpers.js"; import { getClerkStore } from "../store.js"; +import { dispatchClerkEvent } from "../webhook-events.js"; -export function organizationRoutes({ app, store, tokenMap }: RouteContext): void { +export function organizationRoutes({ app, store, webhooks, tokenMap }: RouteContext): void { const cs = getClerkStore(store); app.get("/v1/organizations", (c) => { @@ -33,7 +33,7 @@ export function organizationRoutes({ app, store, tokenMap }: RouteContext): void const totalCount = orgs.length; const paged = orgs.slice(offset, offset + limit); - return c.json(paginatedResponse(paged.map(organizationResponse), totalCount, limit, offset)); + return c.json(paged.map(organizationResponse)); }); app.get("/v1/organizations/:orgId", (c) => { @@ -88,14 +88,7 @@ export function organizationRoutes({ app, store, tokenMap }: RouteContext): void org_id: org.clerk_id, user_id: userId, role: "org:admin", - permissions: [ - "org:sys_profile:manage", - "org:sys_profile:delete", - "org:sys_memberships:read", - "org:sys_memberships:manage", - "org:sys_domains:read", - "org:sys_domains:manage", - ], + permissions: defaultOrganizationPermissions("org:admin"), public_metadata: {}, private_metadata: {}, created_at_unix: now, @@ -106,7 +99,9 @@ export function organizationRoutes({ app, store, tokenMap }: RouteContext): void } const updated = cs.organizations.findOneBy("clerk_id", org.clerk_id)!; - return c.json(organizationResponse(updated), 200); + const response = organizationResponse(updated); + dispatchClerkEvent(webhooks, "organization.created", response); + return c.json(response, 200); }); app.patch("/v1/organizations/:orgId", async (c) => { @@ -130,7 +125,9 @@ export function organizationRoutes({ app, store, tokenMap }: RouteContext): void cs.organizations.update(org.id, patch); const updated = cs.organizations.findOneBy("clerk_id", orgId)!; - return c.json(organizationResponse(updated)); + const response = organizationResponse(updated); + dispatchClerkEvent(webhooks, "organization.updated", response); + return c.json(response); }); app.delete("/v1/organizations/:orgId", (c) => { @@ -145,6 +142,7 @@ export function organizationRoutes({ app, store, tokenMap }: RouteContext): void for (const inv of cs.invitations.findBy("org_id", orgId)) cs.invitations.delete(inv.id); cs.organizations.delete(org.id); + dispatchClerkEvent(webhooks, "organization.deleted", { id: orgId, deleted: true }); return c.json(deletedResponse("organization", orgId)); }); @@ -169,6 +167,8 @@ export function organizationRoutes({ app, store, tokenMap }: RouteContext): void cs.organizations.update(org.id, patch); const updated = cs.organizations.findOneBy("clerk_id", orgId)!; - return c.json(organizationResponse(updated)); + const response = organizationResponse(updated); + dispatchClerkEvent(webhooks, "organization.updated", response); + return c.json(response); }); } diff --git a/packages/@emulators/clerk/src/routes/sessions.ts b/packages/@emulators/clerk/src/routes/sessions.ts index 5a54ed40..6ea9b819 100644 --- a/packages/@emulators/clerk/src/routes/sessions.ts +++ b/packages/@emulators/clerk/src/routes/sessions.ts @@ -4,10 +4,10 @@ import { clerkError, requireSecretKey, isAuthResponse, - paginatedResponse, parsePagination, sessionResponse, readJsonBody, + resolvePrimaryOrgClaims, } from "../route-helpers.js"; import { getClerkStore } from "../store.js"; import { createSessionToken } from "./oauth.js"; @@ -32,7 +32,7 @@ export function sessionRoutes({ app, store, baseUrl, tokenMap }: RouteContext): const totalCount = sessions.length; const paged = sessions.slice(offset, offset + limit); - return c.json(paginatedResponse(paged.map(sessionResponse), totalCount, limit, offset)); + return c.json(paged.map(sessionResponse)); }); app.get("/v1/sessions/:sessionId", (c) => { @@ -103,24 +103,7 @@ export function sessionRoutes({ app, store, baseUrl, tokenMap }: RouteContext): const user = cs.users.findOneBy("clerk_id", session.user_id); if (!user) return clerkError(c, 404, "RESOURCE_NOT_FOUND", "User not found"); - const memberships = cs.memberships.findBy("user_id", user.clerk_id); - const firstMembership = memberships[0]; - let orgId: string | undefined; - let orgRole: string | undefined; - let orgSlug: string | undefined; - let orgPermissions: string[] | undefined; - - if (firstMembership) { - const org = cs.organizations.findOneBy("clerk_id", firstMembership.org_id); - if (org) { - orgId = org.clerk_id; - orgRole = firstMembership.role; - orgSlug = org.slug; - orgPermissions = firstMembership.permissions; - } - } - - const jwt = await createSessionToken(store, user, sessionId, baseUrl, orgId, orgRole, orgSlug, orgPermissions); + const jwt = await createSessionToken(store, user, sessionId, baseUrl, resolvePrimaryOrgClaims(cs, user)); cs.sessions.update(session.id, { last_active_at: nowUnix() }); @@ -142,7 +125,7 @@ export function sessionRoutes({ app, store, baseUrl, tokenMap }: RouteContext): const user = cs.users.findOneBy("clerk_id", session.user_id); if (!user) return clerkError(c, 404, "RESOURCE_NOT_FOUND", "User not found"); - const jwt = await createSessionToken(store, user, sessionId, baseUrl); + const jwt = await createSessionToken(store, user, sessionId, baseUrl, resolvePrimaryOrgClaims(cs, user)); cs.sessions.update(session.id, { last_active_at: nowUnix() }); diff --git a/packages/@emulators/clerk/src/routes/users.ts b/packages/@emulators/clerk/src/routes/users.ts index b84b973f..e3dada44 100644 --- a/packages/@emulators/clerk/src/routes/users.ts +++ b/packages/@emulators/clerk/src/routes/users.ts @@ -5,14 +5,14 @@ import { requireSecretKey, isAuthResponse, deletedResponse, - paginatedResponse, parsePagination, userResponse, readJsonBody, } from "../route-helpers.js"; import { getClerkStore } from "../store.js"; +import { dispatchClerkEvent } from "../webhook-events.js"; -export function userRoutes({ app, store, tokenMap }: RouteContext): void { +export function userRoutes({ app, store, webhooks, tokenMap }: RouteContext): void { const cs = getClerkStore(store); app.get("/v1/users", (c) => { @@ -63,7 +63,7 @@ export function userRoutes({ app, store, tokenMap }: RouteContext): void { return userResponse(u, emails); }); - return c.json(paginatedResponse(data, totalCount, limit, offset)); + return c.json(data); }); app.get("/v1/users/count", (c) => { @@ -144,7 +144,9 @@ export function userRoutes({ app, store, tokenMap }: RouteContext): void { const emails = cs.emailAddresses.findBy("user_id", clerkId); const updatedUser = cs.users.findOneBy("clerk_id", clerkId)!; - return c.json(userResponse(updatedUser, emails), 200); + const response = userResponse(updatedUser, emails); + dispatchClerkEvent(webhooks, "user.created", response); + return c.json(response, 200); }); app.patch("/v1/users/:userId", async (c) => { @@ -178,7 +180,9 @@ export function userRoutes({ app, store, tokenMap }: RouteContext): void { cs.users.update(user.id, patch); const updated = cs.users.findOneBy("clerk_id", userId)!; const emails = cs.emailAddresses.findBy("user_id", userId); - return c.json(userResponse(updated, emails)); + const response = userResponse(updated, emails); + dispatchClerkEvent(webhooks, "user.updated", response); + return c.json(response); }); app.delete("/v1/users/:userId", (c) => { @@ -202,6 +206,7 @@ export function userRoutes({ app, store, tokenMap }: RouteContext): void { } cs.users.delete(user.id); + dispatchClerkEvent(webhooks, "user.deleted", { id: userId, deleted: true }); return c.json(deletedResponse("user", userId)); }); @@ -216,7 +221,9 @@ export function userRoutes({ app, store, tokenMap }: RouteContext): void { cs.users.update(user.id, { banned: true, updated_at_unix: nowUnix() }); const updated = cs.users.findOneBy("clerk_id", userId)!; const emails = cs.emailAddresses.findBy("user_id", userId); - return c.json(userResponse(updated, emails)); + const response = userResponse(updated, emails); + dispatchClerkEvent(webhooks, "user.updated", response); + return c.json(response); }); app.post("/v1/users/:userId/unban", (c) => { @@ -230,7 +237,9 @@ export function userRoutes({ app, store, tokenMap }: RouteContext): void { cs.users.update(user.id, { banned: false, updated_at_unix: nowUnix() }); const updated = cs.users.findOneBy("clerk_id", userId)!; const emails = cs.emailAddresses.findBy("user_id", userId); - return c.json(userResponse(updated, emails)); + const response = userResponse(updated, emails); + dispatchClerkEvent(webhooks, "user.updated", response); + return c.json(response); }); app.post("/v1/users/:userId/lock", (c) => { @@ -244,7 +253,9 @@ export function userRoutes({ app, store, tokenMap }: RouteContext): void { cs.users.update(user.id, { locked: true, updated_at_unix: nowUnix() }); const updated = cs.users.findOneBy("clerk_id", userId)!; const emails = cs.emailAddresses.findBy("user_id", userId); - return c.json(userResponse(updated, emails)); + const response = userResponse(updated, emails); + dispatchClerkEvent(webhooks, "user.updated", response); + return c.json(response); }); app.post("/v1/users/:userId/unlock", (c) => { @@ -258,7 +269,9 @@ export function userRoutes({ app, store, tokenMap }: RouteContext): void { cs.users.update(user.id, { locked: false, updated_at_unix: nowUnix() }); const updated = cs.users.findOneBy("clerk_id", userId)!; const emails = cs.emailAddresses.findBy("user_id", userId); - return c.json(userResponse(updated, emails)); + const response = userResponse(updated, emails); + dispatchClerkEvent(webhooks, "user.updated", response); + return c.json(response); }); app.patch("/v1/users/:userId/metadata", async (c) => { @@ -285,7 +298,9 @@ export function userRoutes({ app, store, tokenMap }: RouteContext): void { cs.users.update(user.id, patch); const updated = cs.users.findOneBy("clerk_id", userId)!; const emails = cs.emailAddresses.findBy("user_id", userId); - return c.json(userResponse(updated, emails)); + const response = userResponse(updated, emails); + dispatchClerkEvent(webhooks, "user.updated", response); + return c.json(response); }); app.post("/v1/users/:userId/verify_password", async (c) => { diff --git a/packages/@emulators/clerk/src/store.ts b/packages/@emulators/clerk/src/store.ts index 6b6e5849..c40edd07 100644 --- a/packages/@emulators/clerk/src/store.ts +++ b/packages/@emulators/clerk/src/store.ts @@ -7,6 +7,8 @@ import type { ClerkOrganizationInvitation, ClerkSession, ClerkOAuthApplication, + ClerkOrganizationDomain, + ClerkM2MToken, } from "./entities.js"; export interface ClerkStore { @@ -17,6 +19,8 @@ export interface ClerkStore { invitations: Collection; sessions: Collection; oauthApps: Collection; + organizationDomains: Collection; + m2mTokens: Collection; } export function getClerkStore(store: Store): ClerkStore { @@ -32,5 +36,7 @@ export function getClerkStore(store: Store): ClerkStore { invitations: store.collection("clerk.invitations", ["invitation_id", "org_id"]), sessions: store.collection("clerk.sessions", ["clerk_id", "user_id"]), oauthApps: store.collection("clerk.oauth_apps", ["app_id", "client_id"]), + organizationDomains: store.collection("clerk.org_domains", ["domain_id", "org_id"]), + m2mTokens: store.collection("clerk.m2m_tokens", ["token_id", "subject"]), }; } diff --git a/packages/@emulators/clerk/src/webhook-events.ts b/packages/@emulators/clerk/src/webhook-events.ts new file mode 100644 index 00000000..4b1d47f9 --- /dev/null +++ b/packages/@emulators/clerk/src/webhook-events.ts @@ -0,0 +1,26 @@ +import type { WebhookDispatcher } from "@emulators/core"; + +export type ClerkWebhookEventType = + | "user.created" + | "user.updated" + | "user.deleted" + | "organization.created" + | "organization.updated" + | "organization.deleted" + | "org_membership.created" + | "org_membership.updated" + | "org_membership.deleted" + | "org_invitation.created" + | "org_invitation.accepted" + | "org_invitation.revoked" + | "org_domain.created" + | "org_domain.updated" + | "org_domain.deleted"; + +export function dispatchClerkEvent( + webhooks: WebhookDispatcher, + type: ClerkWebhookEventType, + data: Record, +): void { + webhooks.dispatch(type, undefined, { type, data }, "clerk").catch(() => {}); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6abec2b3..1576d5d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -434,6 +434,9 @@ importers: specifier: ^6 version: 6.2.2 devDependencies: + '@clerk/backend': + specifier: ^3.4.13 + version: 3.4.13(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tsup: specifier: ^8 version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3) @@ -985,6 +988,22 @@ packages: '@chevrotain/utils@11.1.2': resolution: {integrity: sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==} + '@clerk/backend@3.4.13': + resolution: {integrity: sha512-3BABnKE1YZpQqJ/S8QD5FpzE7jq+mgaMrO7rLiWTI8Bfs/xk11XYSp1lMEf3BTo/rzEtaUsXaVDGvccYogKapg==} + engines: {node: '>=20.9.0'} + + '@clerk/shared@4.13.1': + resolution: {integrity: sha512-DyUtvNHgMmqjtTM0q285jKaAXUmCDSyItiGQTt1dNL0M6DZ3bxqsJz7wXPjh9zezmU4BAnLpwhj5gsM3OuNPzA==} + engines: {node: '>=20.9.0'} + peerDependencies: + react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 + react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + '@emnapi/core@1.9.1': resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} @@ -2822,6 +2841,9 @@ packages: '@tailwindcss/postcss@4.2.2': resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==} + '@tanstack/query-core@5.100.14': + resolution: {integrity: sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==} + '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} @@ -4176,6 +4198,9 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -4468,6 +4493,10 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-cookie@3.0.7: + resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==} + engines: {node: '>=20'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -5466,6 +5495,9 @@ packages: standardwebhooks@1.0.0: resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.0.0: resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} @@ -6651,6 +6683,26 @@ snapshots: '@chevrotain/utils@11.1.2': {} + '@clerk/backend@3.4.13(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@clerk/shared': 4.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + standardwebhooks: 1.0.0 + tslib: 2.8.1 + transitivePeerDependencies: + - react + - react-dom + + '@clerk/shared@4.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/query-core': 5.100.14 + dequal: 2.0.3 + glob-to-regexp: 0.4.1 + js-cookie: 3.0.7 + std-env: 3.10.0 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + '@emnapi/core@1.9.1': dependencies: '@emnapi/wasi-threads': 1.2.0 @@ -8435,6 +8487,8 @@ snapshots: postcss: 8.5.8 tailwindcss: 4.2.2 + '@tanstack/query-core@5.100.14': {} + '@tokenizer/inflate@0.4.1': dependencies: debug: 4.4.3 @@ -10035,6 +10089,8 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-to-regexp@0.4.1: {} + globals@14.0.0: {} globals@16.4.0: {} @@ -10390,6 +10446,8 @@ snapshots: joycon@3.1.1: {} + js-cookie@3.0.7: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -11864,6 +11922,8 @@ snapshots: '@stablelib/base64': 1.0.1 fast-sha256: 1.3.0 + std-env@3.10.0: {} + std-env@4.0.0: {} stop-iteration-iterator@1.1.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1f6daa36..b1cb3e9c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ minimumReleaseAge: 2880 allowBuilds: + '@clerk/shared': true "@mongodb-js/zstd@7.0.0": false "esbuild@0.27.4": false "msw@2.12.13": false