diff --git a/README.md b/README.md index 13ea45db..e681da12 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,9 @@ emulate init --service vercel # List available services emulate list + +# Record traffic from a real API and generate a seed config +emulate record --service github --upstream https://api.github.com ``` ### Options @@ -53,6 +56,30 @@ emulate list The port can also be set via `EMULATE_PORT` or `PORT` environment variables. +### Record mode + +Record real API traffic and generate seed configs automatically: + +```bash +# Start a proxy that records GitHub API traffic +emulate record --service github --upstream https://api.github.com --port 4000 + +# Point your app at localhost:4000 instead of api.github.com +# Make requests as usual -- emulate forwards them and records responses + +# Press Ctrl+C to stop -- emulate generates emulate.config.yaml +# from the recorded traffic with extracted users, repos, etc. +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `-s, --service` | *(required)* | Service to record (github, stripe, etc.) | +| `-u, --upstream` | *(required)* | Real API URL to proxy to | +| `-p, --port` | `4000` | Local proxy port | +| `-o, --output` | `emulate.config.yaml` | Output config file | + +Currently supports entity extraction for **GitHub** (users, repos) and **Stripe** (customers, products). + ## Programmatic API ```bash diff --git a/examples/resend-magic-link/src/app/actions.ts b/examples/resend-magic-link/src/app/actions.ts index c5d2c985..49d08aa4 100644 --- a/examples/resend-magic-link/src/app/actions.ts +++ b/examples/resend-magic-link/src/app/actions.ts @@ -55,11 +55,11 @@ export async function verifyCodeAction(_prev: { error: string } | null, formData const cookieStore = await cookies(); cookieStore.delete("pending_signin"); - cookieStore.set( - "session", - encodeSession({ email: pending.email, signedInAt: new Date().toISOString() }), - { httpOnly: true, path: "/", maxAge: 86400 }, - ); + cookieStore.set("session", encodeSession({ email: pending.email, signedInAt: new Date().toISOString() }), { + httpOnly: true, + path: "/", + maxAge: 86400, + }); redirect("/dashboard"); } diff --git a/examples/resend-magic-link/src/app/verify/page.tsx b/examples/resend-magic-link/src/app/verify/page.tsx index d4b066e2..ea403fae 100644 --- a/examples/resend-magic-link/src/app/verify/page.tsx +++ b/examples/resend-magic-link/src/app/verify/page.tsx @@ -22,9 +22,7 @@ export default async function VerifyPage() {
-

- Using the emulator? View the email in the inbox: -

+

Using the emulator? View the email in the inbox:

{ it("returns discovery document", async () => { const res = await app.request(`${base}/.well-known/openid-configuration`); expect(res.status).toBe(200); - const body = await res.json() as Record; + const body = (await res.json()) as Record; expect(body.issuer).toBe(base); expect(body.authorization_endpoint).toBe(`${base}/oauth/authorize`); expect(body.token_endpoint).toBe(`${base}/oauth/token`); expect(body.jwks_uri).toBe(`${base}/v1/jwks`); expect(body.userinfo_endpoint).toBe(`${base}/oauth/userinfo`); expect(body.code_challenge_methods_supported).toEqual(["plain", "S256"]); - expect((body.claims_supported as string[])).toContain("org_id"); - expect((body.claims_supported as string[])).toContain("sid"); + expect(body.claims_supported as string[]).toContain("org_id"); + expect(body.claims_supported as string[]).toContain("sid"); }); it("returns JWKS with RS256 key", async () => { const res = await app.request(`${base}/v1/jwks`); expect(res.status).toBe(200); - const body = await res.json() as { keys: Array> }; + const body = (await res.json()) as { keys: Array> }; expect(body.keys).toHaveLength(1); expect(body.keys[0].kty).toBe("RSA"); expect(body.keys[0].kid).toBe("emulate-clerk-1"); @@ -207,7 +207,7 @@ describe("Clerk plugin integration", () => { const tokenRes = await exchangeCode(app, code); expect(tokenRes.status).toBe(200); - const body = await tokenRes.json() as Record; + const body = (await tokenRes.json()) as Record; expect((body.access_token as string).startsWith("clerk_")).toBe(true); expect(body.token_type).toBe("Bearer"); expect(body.expires_in).toBe(3600); @@ -225,7 +225,7 @@ describe("Clerk plugin integration", () => { expect(first.status).toBe(200); const second = await exchangeCode(app, code); expect(second.status).toBe(400); - const body = await second.json() as Record; + const body = (await second.json()) as Record; expect(body.error).toBe("invalid_grant"); }); @@ -266,7 +266,7 @@ describe("Clerk plugin integration", () => { it("rejects requests without auth", async () => { const res = await app.request(`${base}/v1/users`); expect(res.status).toBe(401); - const body = await res.json() as { errors: Array<{ code: string }> }; + const body = (await res.json()) as { errors: Array<{ code: string }> }; expect(body.errors[0].code).toBe("UNAUTHORIZED"); }); @@ -282,7 +282,7 @@ describe("Clerk plugin integration", () => { it("lists users with pagination", 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 }; + 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); @@ -291,7 +291,7 @@ describe("Clerk plugin integration", () => { 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 }; + 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); }); @@ -299,7 +299,7 @@ describe("Clerk plugin integration", () => { 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 }; + 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"); }); @@ -307,7 +307,7 @@ describe("Clerk plugin integration", () => { 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 }; + 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"); }); @@ -324,9 +324,9 @@ describe("Clerk plugin integration", () => { }), }); expect(res.status).toBe(200); - const body = await res.json() as Record; + const body = (await res.json()) as Record; expect(body.object).toBe("user"); - expect((body.id as string)).toMatch(/^user_/); + expect(body.id as string).toMatch(/^user_/); expect(body.first_name).toBe("New"); expect(body.password_enabled).toBe(true); expect((body.email_addresses as Array<{ email_address: string }>)[0].email_address).toBe("new@example.com"); @@ -337,7 +337,7 @@ describe("Clerk plugin integration", () => { const user = cs.users.all().find((u) => u.first_name === "Alice")!; const res = await app.request(`${base}/v1/users/${user.clerk_id}`, { headers: authHeaders() }); expect(res.status).toBe(200); - const body = await res.json() as Record; + const body = (await res.json()) as Record; expect(body.first_name).toBe("Alice"); }); @@ -355,7 +355,7 @@ describe("Clerk plugin integration", () => { body: JSON.stringify({ first_name: "Alicia" }), }); expect(res.status).toBe(200); - const body = await res.json() as Record; + const body = (await res.json()) as Record; expect(body.first_name).toBe("Alicia"); }); @@ -367,7 +367,7 @@ describe("Clerk plugin integration", () => { headers: authHeaders(), }); expect(res.status).toBe(200); - const body = await res.json() as Record; + const body = (await res.json()) as Record; expect(body.object).toBe("deleted_object"); expect(body.deleted).toBe(true); }); @@ -381,14 +381,14 @@ describe("Clerk plugin integration", () => { headers: authHeaders(), }); expect(banRes.status).toBe(200); - expect((await banRes.json() as Record).banned).toBe(true); + expect(((await banRes.json()) as Record).banned).toBe(true); const unbanRes = await app.request(`${base}/v1/users/${user.clerk_id}/unban`, { method: "POST", headers: authHeaders(), }); expect(unbanRes.status).toBe(200); - expect((await unbanRes.json() as Record).banned).toBe(false); + expect(((await unbanRes.json()) as Record).banned).toBe(false); }); it("locks and unlocks a user", async () => { @@ -400,14 +400,14 @@ describe("Clerk plugin integration", () => { headers: authHeaders(), }); expect(lockRes.status).toBe(200); - expect((await lockRes.json() as Record).locked).toBe(true); + expect(((await lockRes.json()) as Record).locked).toBe(true); const unlockRes = await app.request(`${base}/v1/users/${user.clerk_id}/unlock`, { method: "POST", headers: authHeaders(), }); expect(unlockRes.status).toBe(200); - expect((await unlockRes.json() as Record).locked).toBe(false); + expect(((await unlockRes.json()) as Record).locked).toBe(false); }); it("updates metadata (merge)", async () => { @@ -426,7 +426,7 @@ describe("Clerk plugin integration", () => { body: JSON.stringify({ public_metadata: { role: "admin" } }), }); expect(res.status).toBe(200); - const body = await res.json() as Record; + const body = (await res.json()) as Record; const meta = body.public_metadata as Record; expect(meta.plan).toBe("pro"); expect(meta.role).toBe("admin"); @@ -435,7 +435,7 @@ describe("Clerk plugin integration", () => { it("returns user count", async () => { const res = await app.request(`${base}/v1/users/count`, { headers: authHeaders() }); expect(res.status).toBe(200); - const body = await res.json() as { total_count: number }; + const body = (await res.json()) as { total_count: number }; expect(body.total_count).toBeGreaterThanOrEqual(3); }); @@ -448,7 +448,7 @@ describe("Clerk plugin integration", () => { body: JSON.stringify({ password: "alice123" }), }); expect(res.status).toBe(200); - const body = await res.json() as { verified: boolean }; + const body = (await res.json()) as { verified: boolean }; expect(body.verified).toBe(true); }); @@ -461,7 +461,7 @@ describe("Clerk plugin integration", () => { body: JSON.stringify({ password: "wrong" }), }); expect(res.status).toBe(200); - const body = await res.json() as { verified: boolean }; + const body = (await res.json()) as { verified: boolean }; expect(body.verified).toBe(false); }); }); @@ -480,7 +480,7 @@ describe("Clerk plugin integration", () => { }), }); expect(res.status).toBe(200); - const body = await res.json() as Record; + const body = (await res.json()) as Record; expect(body.object).toBe("email_address"); expect(body.email_address).toBe("alice2@example.com"); expect((body.verification as Record).status).toBe("verified"); @@ -491,7 +491,7 @@ describe("Clerk plugin integration", () => { const email = cs.emailAddresses.all().find((e) => e.email_address === "alice@example.com")!; const res = await app.request(`${base}/v1/email_addresses/${email.email_id}`, { headers: authHeaders() }); expect(res.status).toBe(200); - const body = await res.json() as Record; + const body = (await res.json()) as Record; expect(body.email_address).toBe("alice@example.com"); }); @@ -504,14 +504,14 @@ describe("Clerk plugin integration", () => { headers: authHeaders(), body: JSON.stringify({ user_id: user.clerk_id, email_address: "delete-me@example.com" }), }); - const created = await createRes.json() as Record; + const created = (await createRes.json()) as Record; const res = await app.request(`${base}/v1/email_addresses/${created.id}`, { method: "DELETE", headers: authHeaders(), }); expect(res.status).toBe(200); - const body = await res.json() as Record; + const body = (await res.json()) as Record; expect(body.object).toBe("deleted_object"); expect(body.deleted).toBe(true); }); @@ -521,7 +521,7 @@ 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 }; + 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"); expect(acme).toBeDefined(); @@ -533,7 +533,7 @@ describe("Clerk plugin integration", () => { const org = cs.organizations.findOneBy("slug", "acme")!; const res = await app.request(`${base}/v1/organizations/${org.clerk_id}`, { headers: authHeaders() }); expect(res.status).toBe(200); - const body = await res.json() as Record; + const body = (await res.json()) as Record; expect(body.name).toBe("Acme Corp"); }); @@ -542,7 +542,7 @@ describe("Clerk plugin integration", () => { const org = cs.organizations.findOneBy("slug", "acme")!; const res = await app.request(`${base}/v1/organizations/acme`, { headers: authHeaders() }); expect(res.status).toBe(200); - const body = await res.json() as Record; + const body = (await res.json()) as Record; expect(body.id).toBe(org.clerk_id); }); @@ -553,11 +553,11 @@ describe("Clerk plugin integration", () => { body: JSON.stringify({ name: "New Org", slug: "new-org" }), }); expect(res.status).toBe(200); - const body = await res.json() as Record; + const body = (await res.json()) as Record; expect(body.object).toBe("organization"); expect(body.name).toBe("New Org"); expect(body.slug).toBe("new-org"); - expect((body.id as string)).toMatch(/^org_/); + expect(body.id as string).toMatch(/^org_/); }); it("updates an organization", async () => { @@ -569,7 +569,7 @@ describe("Clerk plugin integration", () => { body: JSON.stringify({ name: "Acme Inc" }), }); expect(res.status).toBe(200); - const body = await res.json() as Record; + const body = (await res.json()) as Record; expect(body.name).toBe("Acme Inc"); }); @@ -579,14 +579,14 @@ describe("Clerk plugin integration", () => { headers: authHeaders(), body: JSON.stringify({ name: "Delete Me" }), }); - const created = await createRes.json() as Record; + const created = (await createRes.json()) as Record; const res = await app.request(`${base}/v1/organizations/${created.id}`, { method: "DELETE", headers: authHeaders(), }); expect(res.status).toBe(200); - const body = await res.json() as Record; + const body = (await res.json()) as Record; expect(body.deleted).toBe(true); }); }); @@ -597,7 +597,7 @@ 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 }; + const body = (await res.json()) as { data: Array>; total_count: number }; expect(body.total_count).toBe(2); }); @@ -609,7 +609,7 @@ describe("Clerk plugin integration", () => { headers: authHeaders(), body: JSON.stringify({ name: "Membership Test", slug: "membership-test" }), }); - const org = await createRes.json() as Record; + const org = (await createRes.json()) as Record; const user = cs.users.all().find((u) => u.first_name === "Alice")!; const res = await app.request(`${base}/v1/organizations/${org.id}/memberships`, { @@ -618,7 +618,7 @@ describe("Clerk plugin integration", () => { body: JSON.stringify({ user_id: user.clerk_id, role: "org:admin" }), }); expect(res.status).toBe(200); - const body = await res.json() as Record; + const body = (await res.json()) as Record; expect(body.role).toBe("org:admin"); }); @@ -633,7 +633,7 @@ describe("Clerk plugin integration", () => { body: JSON.stringify({ role: "org:admin" }), }); expect(res.status).toBe(200); - const body = await res.json() as Record; + const body = (await res.json()) as Record; expect(body.role).toBe("org:admin"); }); @@ -647,7 +647,7 @@ describe("Clerk plugin integration", () => { headers: authHeaders(), }); expect(res.status).toBe(200); - const body = await res.json() as Record; + const body = (await res.json()) as Record; expect(body.deleted).toBe(true); }); @@ -662,7 +662,7 @@ describe("Clerk plugin integration", () => { body: JSON.stringify({ user_id: aliceUser.clerk_id, role: "org:member" }), }); expect(res.status).toBe(422); - const body = await res.json() as { errors: Array<{ code: string }> }; + const body = (await res.json()) as { errors: Array<{ code: string }> }; expect(body.errors[0].code).toBe("DUPLICATE_RECORD"); }); }); @@ -678,7 +678,7 @@ describe("Clerk plugin integration", () => { body: JSON.stringify({ email_address: "invite@example.com", role: "org:member" }), }); expect(res.status).toBe(200); - const body = await res.json() as Record; + const body = (await res.json()) as Record; expect(body.object).toBe("organization_invitation"); expect(body.email_address).toBe("invite@example.com"); expect(body.status).toBe("pending"); @@ -694,9 +694,11 @@ describe("Clerk plugin integration", () => { body: JSON.stringify({ email_address: "inv1@example.com" }), }); - const res = await app.request(`${base}/v1/organizations/${org.clerk_id}/invitations?status=pending`, { headers: authHeaders() }); + const res = await app.request(`${base}/v1/organizations/${org.clerk_id}/invitations?status=pending`, { + headers: authHeaders(), + }); expect(res.status).toBe(200); - const body = await res.json() as { data: unknown[]; total_count: number }; + const body = (await res.json()) as { data: unknown[]; total_count: number }; expect(body.total_count).toBeGreaterThanOrEqual(1); }); @@ -709,14 +711,14 @@ describe("Clerk plugin integration", () => { headers: authHeaders(), body: JSON.stringify({ email_address: "revoke@example.com" }), }); - const invitation = await createRes.json() as Record; + const invitation = (await createRes.json()) as Record; const res = await app.request(`${base}/v1/organizations/${org.clerk_id}/invitations/${invitation.id}/revoke`, { method: "POST", headers: authHeaders(), }); expect(res.status).toBe(200); - const body = await res.json() as Record; + const body = (await res.json()) as Record; expect(body.status).toBe("revoked"); }); @@ -733,7 +735,7 @@ describe("Clerk plugin integration", () => { }), }); expect(res.status).toBe(200); - const body = await res.json() as Array>; + const body = (await res.json()) as Array>; expect(body).toHaveLength(2); }); }); @@ -749,9 +751,9 @@ describe("Clerk plugin integration", () => { body: JSON.stringify({ user_id: user.clerk_id }), }); expect(res.status).toBe(200); - const body = await res.json() as Record; + const body = (await res.json()) as Record; expect(body.object).toBe("session"); - expect((body.id as string)).toMatch(/^sess_/); + expect(body.id as string).toMatch(/^sess_/); expect(body.status).toBe("active"); expect(body.user_id).toBe(user.clerk_id); }); @@ -768,7 +770,7 @@ 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 }; + const body = (await res.json()) as { data: unknown[]; total_count: number }; expect(body.total_count).toBeGreaterThanOrEqual(1); }); @@ -781,14 +783,14 @@ describe("Clerk plugin integration", () => { headers: authHeaders(), body: JSON.stringify({ user_id: user.clerk_id }), }); - const session = await createRes.json() as Record; + const session = (await createRes.json()) as Record; const res = await app.request(`${base}/v1/sessions/${session.id}/revoke`, { method: "POST", headers: authHeaders(), }); expect(res.status).toBe(200); - const body = await res.json() as Record; + const body = (await res.json()) as Record; expect(body.status).toBe("revoked"); }); @@ -801,14 +803,14 @@ describe("Clerk plugin integration", () => { headers: authHeaders(), body: JSON.stringify({ user_id: user.clerk_id }), }); - const session = await createRes.json() as Record; + const session = (await createRes.json()) as Record; const res = await app.request(`${base}/v1/sessions/${session.id}/tokens`, { method: "POST", headers: authHeaders(), }); expect(res.status).toBe(200); - const body = await res.json() as { object: string; jwt: string }; + const body = (await res.json()) as { object: string; jwt: string }; expect(body.object).toBe("token"); expect(body.jwt).toBeTruthy(); @@ -829,13 +831,13 @@ describe("Clerk plugin integration", () => { headers: authHeaders(), body: JSON.stringify({ user_id: aliceUser.clerk_id }), }); - const session = await createRes.json() as Record; + const session = (await createRes.json()) as Record; const res = await app.request(`${base}/v1/sessions/${session.id}/tokens`, { method: "POST", headers: authHeaders(), }); - const body = await res.json() as { jwt: string }; + const body = (await res.json()) as { jwt: string }; const claims = decodeJwt(body.jwt); expect(claims.org_id).toMatch(/^org_/); expect(claims.org_role).toBe("org:admin"); @@ -851,7 +853,7 @@ describe("Clerk plugin integration", () => { headers: authHeaders(), body: JSON.stringify({ user_id: user.clerk_id }), }); - const session = await createRes.json() as Record; + const session = (await createRes.json()) as Record; await app.request(`${base}/v1/sessions/${session.id}/revoke`, { method: "POST", @@ -911,7 +913,9 @@ describe("Clerk plugin integration", () => { it("returns Clerk error format", async () => { const res = await app.request(`${base}/v1/users/user_nonexistent`, { headers: authHeaders() }); expect(res.status).toBe(404); - const body = await res.json() as { errors: Array<{ code: string; message: string; long_message: string; meta: unknown }> }; + const body = (await res.json()) as { + errors: Array<{ code: string; message: string; long_message: string; meta: unknown }>; + }; expect(body.errors).toHaveLength(1); expect(body.errors[0].code).toBe("RESOURCE_NOT_FOUND"); expect(body.errors[0].message).toBeDefined(); diff --git a/packages/@emulators/clerk/src/index.ts b/packages/@emulators/clerk/src/index.ts index 2f1c8c3f..5a29ab4a 100644 --- a/packages/@emulators/clerk/src/index.ts +++ b/packages/@emulators/clerk/src/index.ts @@ -1,18 +1,6 @@ import type { Hono } from "hono"; -import type { - AppEnv, - RouteContext, - ServicePlugin, - Store, - TokenMap, - WebhookDispatcher, -} from "@emulators/core"; -import { - generateClerkId, - nowUnix, - createDefaultUser, - createDefaultEmailAddress, -} from "./helpers.js"; +import type { AppEnv, RouteContext, ServicePlugin, Store, TokenMap, WebhookDispatcher } from "@emulators/core"; +import { generateClerkId, nowUnix, createDefaultUser, createDefaultEmailAddress } from "./helpers.js"; import { oauthRoutes } from "./routes/oauth.js"; import { userRoutes } from "./routes/users.js"; import { emailAddressRoutes } from "./routes/email-addresses.js"; @@ -68,9 +56,7 @@ function seedDefaults(store: Store, _baseUrl: string): void { const userInput = createDefaultUser(); const user = cs.users.insert(userInput); - const email = cs.emailAddresses.insert( - createDefaultEmailAddress(user.clerk_id, "test@example.com", true), - ); + const email = cs.emailAddresses.insert(createDefaultEmailAddress(user.clerk_id, "test@example.com", true)); cs.users.update(user.id, { primary_email_address_id: email.email_id }); @@ -145,7 +131,12 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: ClerkSeed if (config.organizations) { for (const orgCfg of config.organizations) { - const existingSlug = orgCfg.slug ?? orgCfg.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + const existingSlug = + orgCfg.slug ?? + orgCfg.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); const existing = cs.organizations.findOneBy("slug", existingSlug); if (existing) continue; @@ -175,9 +166,7 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: ClerkSeed const user = cs.users.findOneBy("clerk_id", emailEntry.user_id); if (!user) continue; - const existingMembership = cs.memberships - .findBy("org_id", orgId) - .find((m) => m.user_id === user.clerk_id); + const existingMembership = cs.memberships.findBy("org_id", orgId).find((m) => m.user_id === user.clerk_id); if (existingMembership) continue; const role = memberCfg.role.startsWith("org:") ? memberCfg.role : `org:${memberCfg.role}`; @@ -186,9 +175,15 @@ 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: + role === "org:admin" + ? [ + "org:sys_profile:manage", + "org:sys_profile:delete", + "org:sys_memberships:read", + "org:sys_memberships:manage", + ] + : ["org:sys_memberships:read"], public_metadata: {}, private_metadata: {}, created_at_unix: now, @@ -223,13 +218,7 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: ClerkSeed export const clerkPlugin: ServicePlugin = { name: "clerk", - register( - app: Hono, - store: Store, - webhooks: WebhookDispatcher, - baseUrl: string, - tokenMap?: TokenMap, - ): void { + register(app: Hono, store: Store, webhooks: WebhookDispatcher, baseUrl: string, tokenMap?: TokenMap): void { const ctx: RouteContext = { app, store, webhooks, baseUrl, tokenMap }; oauthRoutes(ctx); userRoutes(ctx); diff --git a/packages/@emulators/clerk/src/route-helpers.ts b/packages/@emulators/clerk/src/route-helpers.ts index 139f2565..03419fe5 100644 --- a/packages/@emulators/clerk/src/route-helpers.ts +++ b/packages/@emulators/clerk/src/route-helpers.ts @@ -1,7 +1,14 @@ import type { Context } from "hono"; import type { ContentfulStatusCode } from "hono/utils/http-status"; import type { AuthUser, TokenMap, AppEnv } from "@emulators/core"; -import type { ClerkUser, ClerkEmailAddress, ClerkOrganization, ClerkOrganizationMembership, ClerkOrganizationInvitation, ClerkSession } from "./entities.js"; +import type { + ClerkUser, + ClerkEmailAddress, + ClerkOrganization, + ClerkOrganizationMembership, + ClerkOrganizationInvitation, + ClerkSession, +} from "./entities.js"; import type { ClerkStore } from "./store.js"; export function clerkError( @@ -61,7 +68,12 @@ export function deletedResponse(objectType: string, objectId: string): Record(data: T[], totalCount: number, limit: number, offset: number): Record { +export function paginatedResponse( + data: T[], + totalCount: number, + limit: number, + offset: number, +): Record { return { data, total_count: totalCount, diff --git a/packages/@emulators/clerk/src/routes/email-addresses.ts b/packages/@emulators/clerk/src/routes/email-addresses.ts index 3c6f745b..4c520d01 100644 --- a/packages/@emulators/clerk/src/routes/email-addresses.ts +++ b/packages/@emulators/clerk/src/routes/email-addresses.ts @@ -31,8 +31,8 @@ export function emailAddressRoutes({ app, store, tokenMap }: RouteContext): void const body = await readJsonBody(c); const userId = body.user_id as string; const emailAddr = body.email_address as string; - const verified = body.verified as boolean ?? false; - const primary = body.primary as boolean ?? false; + const verified = (body.verified as boolean) ?? false; + const primary = (body.primary as boolean) ?? false; if (!userId || !emailAddr) { return clerkError(c, 422, "INVALID_REQUEST_BODY", "user_id and email_address are required"); diff --git a/packages/@emulators/clerk/src/routes/memberships.ts b/packages/@emulators/clerk/src/routes/memberships.ts index 3f0f15fd..4256984e 100644 --- a/packages/@emulators/clerk/src/routes/memberships.ts +++ b/packages/@emulators/clerk/src/routes/memberships.ts @@ -164,7 +164,10 @@ export function membershipRoutes({ app, store, tokenMap }: RouteContext): void { patch.public_metadata = { ...membership.public_metadata, ...(body.public_metadata as Record) }; } if (body.private_metadata !== undefined) { - patch.private_metadata = { ...membership.private_metadata, ...(body.private_metadata as Record) }; + patch.private_metadata = { + ...membership.private_metadata, + ...(body.private_metadata as Record), + }; } cs.memberships.update(membership.id, patch); diff --git a/packages/@emulators/clerk/src/routes/oauth.ts b/packages/@emulators/clerk/src/routes/oauth.ts index fd7f780f..91442c2d 100644 --- a/packages/@emulators/clerk/src/routes/oauth.ts +++ b/packages/@emulators/clerk/src/routes/oauth.ts @@ -162,7 +162,11 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo } if (!matchesRedirectUri(redirectUri, oauthApp.redirect_uris)) { return c.html( - renderErrorPage("Redirect URI mismatch", "The redirect_uri is not registered for this application.", SERVICE_LABEL), + renderErrorPage( + "Redirect URI mismatch", + "The redirect_uri is not registered for this application.", + SERVICE_LABEL, + ), 400, ); } @@ -228,10 +232,7 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo const user = clerkStore.users.findOneBy("clerk_id", userRef); if (!user) { - return c.html( - renderErrorPage("Unknown user", "The selected user is not available.", SERVICE_LABEL), - 400, - ); + return c.html(renderErrorPage("Unknown user", "The selected user is not available.", SERVICE_LABEL), 400); } const oauthApps = clerkStore.oauthApps.all(); @@ -245,7 +246,11 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo } if (!matchesRedirectUri(redirectUri, oauthApp.redirect_uris)) { return c.html( - renderErrorPage("Redirect URI mismatch", "The redirect_uri is not registered for this application.", SERVICE_LABEL), + renderErrorPage( + "Redirect URI mismatch", + "The redirect_uri is not registered for this application.", + SERVICE_LABEL, + ), 400, ); } @@ -275,7 +280,7 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo if (contentType.includes("application/json")) { try { - const parsed = await c.req.json() as Record; + const parsed = (await c.req.json()) as Record; for (const [key, value] of Object.entries(parsed)) { if (typeof value === "string") body[key] = value; } @@ -306,7 +311,10 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo } if (grantType !== "authorization_code") { - return c.json({ error: "unsupported_grant_type", error_description: "Only authorization_code is supported." }, 400); + return c.json( + { error: "unsupported_grant_type", error_description: "Only authorization_code is supported." }, + 400, + ); } const pending = getPendingCodes(store).get(code); diff --git a/packages/@emulators/clerk/src/routes/organizations.ts b/packages/@emulators/clerk/src/routes/organizations.ts index c6ccd164..e1e5cdf8 100644 --- a/packages/@emulators/clerk/src/routes/organizations.ts +++ b/packages/@emulators/clerk/src/routes/organizations.ts @@ -55,7 +55,12 @@ export function organizationRoutes({ app, store, tokenMap }: RouteContext): void const name = body.name as string; if (!name) return clerkError(c, 422, "INVALID_REQUEST_BODY", "name is required"); - const slug = (body.slug as string) ?? name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + const slug = + (body.slug as string) ?? + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); const now = nowUnix(); const org = cs.organizations.insert({ @@ -83,7 +88,14 @@ 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: [ + "org:sys_profile:manage", + "org:sys_profile:delete", + "org:sys_memberships:read", + "org:sys_memberships:manage", + "org:sys_domains:read", + "org:sys_domains:manage", + ], public_metadata: {}, private_metadata: {}, created_at_unix: now, diff --git a/packages/@emulators/clerk/src/routes/sessions.ts b/packages/@emulators/clerk/src/routes/sessions.ts index 78b378c7..5a54ed40 100644 --- a/packages/@emulators/clerk/src/routes/sessions.ts +++ b/packages/@emulators/clerk/src/routes/sessions.ts @@ -120,16 +120,7 @@ export function sessionRoutes({ app, store, baseUrl, tokenMap }: RouteContext): } } - const jwt = await createSessionToken( - store, - user, - sessionId, - baseUrl, - orgId, - orgRole, - orgSlug, - orgPermissions, - ); + const jwt = await createSessionToken(store, user, sessionId, baseUrl, orgId, orgRole, orgSlug, orgPermissions); 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 57961b48..b84b973f 100644 --- a/packages/@emulators/clerk/src/routes/users.ts +++ b/packages/@emulators/clerk/src/routes/users.ts @@ -163,8 +163,10 @@ export function userRoutes({ app, store, tokenMap }: RouteContext): void { if (body.last_name !== undefined) patch.last_name = body.last_name as string | null; if (body.username !== undefined) patch.username = body.username as string | null; if (body.external_id !== undefined) patch.external_id = body.external_id as string | null; - if (body.primary_email_address_id !== undefined) patch.primary_email_address_id = body.primary_email_address_id as string; - if (body.primary_phone_number_id !== undefined) patch.primary_phone_number_id = body.primary_phone_number_id as string; + if (body.primary_email_address_id !== undefined) + patch.primary_email_address_id = body.primary_email_address_id as string; + if (body.primary_phone_number_id !== undefined) + patch.primary_phone_number_id = body.primary_phone_number_id as string; if (body.public_metadata !== undefined) patch.public_metadata = body.public_metadata as Record; if (body.private_metadata !== undefined) patch.private_metadata = body.private_metadata as Record; if (body.unsafe_metadata !== undefined) patch.unsafe_metadata = body.unsafe_metadata as Record; diff --git a/packages/@emulators/clerk/src/store.ts b/packages/@emulators/clerk/src/store.ts index 5939c32a..6b6e5849 100644 --- a/packages/@emulators/clerk/src/store.ts +++ b/packages/@emulators/clerk/src/store.ts @@ -24,7 +24,11 @@ export function getClerkStore(store: Store): ClerkStore { users: store.collection("clerk.users", ["clerk_id", "username"]), emailAddresses: store.collection("clerk.emails", ["email_id", "user_id", "email_address"]), organizations: store.collection("clerk.orgs", ["clerk_id", "slug"]), - memberships: store.collection("clerk.memberships", ["membership_id", "org_id", "user_id"]), + memberships: store.collection("clerk.memberships", [ + "membership_id", + "org_id", + "user_id", + ]), 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"]), diff --git a/packages/@emulators/stripe/src/__tests__/stripe.test.ts b/packages/@emulators/stripe/src/__tests__/stripe.test.ts index d4d52d0d..79e06c30 100644 --- a/packages/@emulators/stripe/src/__tests__/stripe.test.ts +++ b/packages/@emulators/stripe/src/__tests__/stripe.test.ts @@ -379,7 +379,14 @@ describe("Stripe plugin", () => { }), }); expect(res.status).toBe(200); - const session = (await res.json()) as { object: string; client_secret: string; customer: string; components: Record; created: number; expires_at: number }; + const session = (await res.json()) as { + object: string; + client_secret: string; + customer: string; + components: Record; + created: number; + expires_at: number; + }; expect(session.object).toBe("customer_session"); expect(session.client_secret).toBeTruthy(); expect(session.customer).toBe(cust.id); diff --git a/packages/@emulators/stripe/src/routes/customer-sessions.ts b/packages/@emulators/stripe/src/routes/customer-sessions.ts index ccabc87e..8a9be661 100644 --- a/packages/@emulators/stripe/src/routes/customer-sessions.ts +++ b/packages/@emulators/stripe/src/routes/customer-sessions.ts @@ -12,16 +12,26 @@ export function customerSessionRoutes({ app, store }: RouteContext): void { const customer = ss.customers.findOneBy("stripe_id", body.customer as string); if (!customer) - return stripeError(c, 400, "invalid_request_error", `No such customer: '${body.customer}'`, "resource_missing", "customer"); + return stripeError( + c, + 400, + "invalid_request_error", + `No such customer: '${body.customer}'`, + "resource_missing", + "customer", + ); - return c.json({ - object: "customer_session" as const, - client_secret: stripeId("cuss_secret"), - components: (body.components as Record) ?? {}, - created: Math.floor(Date.now() / 1000), - customer: customer.stripe_id, - expires_at: Math.floor(Date.now() / 1000) + 1800, - livemode: false, - }, 200); + return c.json( + { + object: "customer_session" as const, + client_secret: stripeId("cuss_secret"), + components: (body.components as Record) ?? {}, + created: Math.floor(Date.now() / 1000), + customer: customer.stripe_id, + expires_at: Math.floor(Date.now() / 1000) + 1800, + livemode: false, + }, + 200, + ); }); } diff --git a/packages/@emulators/stripe/src/routes/payment-methods.ts b/packages/@emulators/stripe/src/routes/payment-methods.ts index f49d83cc..c464f400 100644 --- a/packages/@emulators/stripe/src/routes/payment-methods.ts +++ b/packages/@emulators/stripe/src/routes/payment-methods.ts @@ -8,14 +8,24 @@ export function paymentMethodRoutes({ app, store }: RouteContext): void { app.get("/v1/payment_methods", (c) => { const customerId = c.req.query("customer"); if (customerId && !ss.customers.findOneBy("stripe_id", customerId)) { - return stripeError(c, 400, "invalid_request_error", `No such customer: '${customerId}'`, "resource_missing", "customer"); + return stripeError( + c, + 400, + "invalid_request_error", + `No such customer: '${customerId}'`, + "resource_missing", + "customer", + ); } - return c.json({ - object: "list" as const, - url: "/v1/payment_methods", - has_more: false, - data: [], - }, 200); + return c.json( + { + object: "list" as const, + url: "/v1/payment_methods", + has_more: false, + data: [], + }, + 200, + ); }); } diff --git a/packages/emulate/src/commands/record.ts b/packages/emulate/src/commands/record.ts new file mode 100644 index 00000000..ee9b8010 --- /dev/null +++ b/packages/emulate/src/commands/record.ts @@ -0,0 +1,255 @@ +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { serve } from "@hono/node-server"; +import { writeFileSync } from "fs"; +import { stringify as yamlStringify } from "yaml"; +import pc from "picocolors"; + +export interface RecordOptions { + port: number; + upstream: string; + service: string; + output: string; +} + +interface RecordedExchange { + method: string; + path: string; + requestBody: string | null; + responseStatus: number; + responseBody: string; +} + +function extractGitHubEntities(recordings: RecordedExchange[]): Record { + const users: Array> = []; + const repos: Array> = []; + const seenUsers = new Set(); + const seenRepos = new Set(); + + for (const rec of recordings) { + if (rec.responseStatus >= 400) continue; + + let body: Record; + try { + body = JSON.parse(rec.responseBody); + } catch { + continue; + } + + // Extract user from /user or /users/:login + if ((rec.path === "/user" || rec.path.match(/^\/users\/[^/]+$/)) && body.login) { + const login = String(body.login); + if (!seenUsers.has(login)) { + seenUsers.add(login); + users.push({ + login, + name: body.name ?? undefined, + email: body.email ?? undefined, + bio: body.bio ?? undefined, + }); + } + } + + // Extract repo from /repos/:owner/:name + if (rec.path.match(/^\/repos\/[^/]+\/[^/]+$/) && body.full_name) { + const fullName = String(body.full_name); + if (!seenRepos.has(fullName)) { + seenRepos.add(fullName); + const [owner, name] = fullName.split("/"); + repos.push({ + owner, + name, + description: body.description ?? undefined, + language: body.language ?? undefined, + private: body.private ?? false, + }); + } + } + + // Extract repos from array responses (e.g., /user/repos) + if (Array.isArray(body)) { + for (const item of body) { + if (item && typeof item === "object" && "full_name" in item) { + const fullName = String(item.full_name); + if (!seenRepos.has(fullName)) { + seenRepos.add(fullName); + const [owner, name] = fullName.split("/"); + repos.push({ + owner, + name, + description: item.description ?? undefined, + language: item.language ?? undefined, + private: item.private ?? false, + }); + } + } + } + } + } + + const config: Record = {}; + if (users.length > 0) config.users = users; + if (repos.length > 0) config.repos = repos; + return config; +} + +function extractStripeEntities(recordings: RecordedExchange[]): Record { + const customers: Array> = []; + const products: Array> = []; + const seenCustomers = new Set(); + const seenProducts = new Set(); + + for (const rec of recordings) { + if (rec.responseStatus >= 400) continue; + + let body: Record; + try { + body = JSON.parse(rec.responseBody); + } catch { + continue; + } + + if (String(body.object) === "customer" && body.email) { + const email = String(body.email); + if (!seenCustomers.has(email)) { + seenCustomers.add(email); + customers.push({ email, name: body.name ?? undefined }); + } + } + + if (String(body.object) === "product" && body.name) { + const name = String(body.name); + if (!seenProducts.has(name)) { + seenProducts.add(name); + products.push({ name, description: body.description ?? undefined }); + } + } + + // Handle list responses + if (body.data && Array.isArray(body.data)) { + for (const item of body.data as Array>) { + if (String(item.object) === "customer" && item.email) { + const email = String(item.email); + if (!seenCustomers.has(email)) { + seenCustomers.add(email); + customers.push({ email, name: item.name ?? undefined }); + } + } + if (String(item.object) === "product" && item.name) { + const name = String(item.name); + if (!seenProducts.has(name)) { + seenProducts.add(name); + products.push({ name, description: item.description ?? undefined }); + } + } + } + } + } + + const config: Record = {}; + if (customers.length > 0) config.customers = customers; + if (products.length > 0) config.products = products; + return config; +} + +const EXTRACTORS: Record Record> = { + github: extractGitHubEntities, + stripe: extractStripeEntities, +}; + +export async function recordCommand(options: RecordOptions): Promise { + const { port, upstream, service, output } = options; + const normalizedUpstream = upstream.replace(/\/+$/, ""); + const recordings: RecordedExchange[] = []; + + const app = new Hono(); + app.use("*", cors()); + + app.all("*", async (c) => { + const path = c.req.path; + const method = c.req.method; + + const upstreamUrl = `${normalizedUpstream}${path}${new URL(c.req.url).search}`; + + const headers = new Headers(c.req.raw.headers); + headers.delete("host"); + + let requestBody: string | null = null; + if (method !== "GET" && method !== "HEAD") { + requestBody = await c.req.text(); + } + + let upstreamRes: Response; + try { + upstreamRes = await fetch(upstreamUrl, { + method, + headers, + body: requestBody, + signal: AbortSignal.timeout(30000), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return c.text(`Upstream request failed: ${message}`, 502); + } + + const responseBody = await upstreamRes.text(); + + recordings.push({ + method, + path, + requestBody, + responseStatus: upstreamRes.status, + responseBody, + }); + + const resHeaders = new Headers(upstreamRes.headers); + resHeaders.delete("content-encoding"); + resHeaders.delete("transfer-encoding"); + + return new Response(responseBody, { + status: upstreamRes.status, + headers: resHeaders, + }); + }); + + const httpServer = serve({ fetch: app.fetch, port }); + + const lines: string[] = []; + lines.push(""); + lines.push(` ${pc.bold("emulate record")} ${pc.dim(`-> ${upstream}`)}`); + lines.push(""); + lines.push(` ${pc.cyan(service.padEnd(12))}${pc.bold(`http://localhost:${port}`)}`); + lines.push(` ${pc.dim("upstream")} ${upstream}`); + lines.push(""); + lines.push(` ${pc.dim("Press Ctrl+C to stop recording and generate config")}`); + lines.push(""); + console.log(lines.join("\n")); + + const shutdown = () => { + console.log(`\n${pc.dim(`Recorded ${recordings.length} exchanges`)}`); + + const extract = EXTRACTORS[service]; + const config: Record = {}; + + if (extract) { + const entities = extract(recordings); + if (Object.keys(entities).length > 0) { + config[service] = entities; + } + } + + if (Object.keys(config).length > 0) { + const yaml = yamlStringify(config); + writeFileSync(output, yaml, "utf-8"); + console.log(`${pc.green("Config written to")} ${output}`); + } else { + console.log(pc.yellow("No entities extracted from recorded traffic")); + } + + httpServer.close(); + process.exit(0); + }; + + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); +} diff --git a/packages/emulate/src/index.ts b/packages/emulate/src/index.ts index 090017a8..94156853 100644 --- a/packages/emulate/src/index.ts +++ b/packages/emulate/src/index.ts @@ -2,6 +2,7 @@ import { Command } from "commander"; import { startCommand } from "./commands/start.js"; import { initCommand } from "./commands/init.js"; import { listCommand } from "./commands/list.js"; +import { recordCommand } from "./commands/record.js"; declare const PKG_VERSION: string; const pkg = { version: PKG_VERSION }; @@ -50,4 +51,25 @@ program listCommand(); }); +program + .command("record") + .description("Record API traffic from a real service and generate a seed config") + .requiredOption("-s, --service ", "Service to record (e.g., github, stripe)") + .requiredOption("-u, --upstream ", "Upstream API URL to proxy to") + .option("-p, --port ", "Local proxy port", defaultPort) + .option("-o, --output ", "Output config file path", "emulate.config.yaml") + .action(async (opts) => { + const port = parseInt(opts.port, 10); + if (Number.isNaN(port) || port < 1 || port > 65535) { + console.error(`Invalid port: ${opts.port}`); + process.exit(1); + } + await recordCommand({ + port, + upstream: opts.upstream, + service: opts.service, + output: opts.output, + }); + }); + program.parse(); diff --git a/packages/emulate/src/registry.ts b/packages/emulate/src/registry.ts index a8eda1a3..a102b955 100644 --- a/packages/emulate/src/registry.ts +++ b/packages/emulate/src/registry.ts @@ -371,7 +371,8 @@ export const SERVICE_REGISTRY: Record = { }, stripe: { label: "Stripe payments emulator", - endpoints: "customers, payment methods, customer sessions, payment intents, charges, products, prices, checkout sessions, webhooks", + endpoints: + "customers, payment methods, customer sessions, payment intents, charges, products, prices, checkout sessions, webhooks", async load() { const mod = await import("@emulators/stripe"); return { plugin: mod.stripePlugin, seedFromConfig: mod.seedFromConfig }; @@ -409,34 +410,43 @@ export const SERVICE_REGISTRY: Record = { }, clerk: { label: "Clerk authentication and user management emulator", - endpoints: "OIDC discovery, JWKS, OAuth authorize/token/userinfo, users, email addresses, organizations, memberships, invitations, sessions", + endpoints: + "OIDC discovery, JWKS, OAuth authorize/token/userinfo, users, email addresses, organizations, memberships, invitations, sessions", async load() { const mod = await import("@emulators/clerk"); return { plugin: mod.clerkPlugin, seedFromConfig: mod.seedFromConfig }; }, defaultFallback(cfg) { - const firstEmail = (cfg?.users as Array<{ email_addresses?: string[] }> | undefined)?.[0]?.email_addresses?.[0] ?? "test@example.com"; + const firstEmail = + (cfg?.users as Array<{ email_addresses?: string[] }> | undefined)?.[0]?.email_addresses?.[0] ?? + "test@example.com"; return { login: firstEmail, id: 1, scopes: [] }; }, initConfig: { clerk: { - users: [{ - first_name: "Test", - last_name: "User", - email_addresses: ["test@example.com"], - password: "clerk_test_password", - }], - organizations: [{ - name: "My Company", - slug: "my-company", - members: [{ email: "test@example.com", role: "admin" }], - }], - oauth_applications: [{ - client_id: "clerk_emulate_client", - client_secret: "clerk_emulate_secret", - name: "Emulate App", - redirect_uris: ["http://localhost:3000/api/auth/callback/clerk"], - }], + users: [ + { + first_name: "Test", + last_name: "User", + email_addresses: ["test@example.com"], + password: "clerk_test_password", + }, + ], + organizations: [ + { + name: "My Company", + slug: "my-company", + members: [{ email: "test@example.com", role: "admin" }], + }, + ], + oauth_applications: [ + { + client_id: "clerk_emulate_client", + client_secret: "clerk_emulate_secret", + name: "Emulate App", + redirect_uris: ["http://localhost:3000/api/auth/callback/clerk"], + }, + ], }, }, }, diff --git a/skills/emulate/SKILL.md b/skills/emulate/SKILL.md index a0fe3ca6..df975334 100644 --- a/skills/emulate/SKILL.md +++ b/skills/emulate/SKILL.md @@ -49,6 +49,9 @@ emulate init --service vercel # List available services emulate list + +# Record traffic from a real API and generate seed config +emulate record --service github --upstream https://api.github.com ``` ### Options