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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 5 additions & 5 deletions examples/resend-magic-link/src/app/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down
4 changes: 1 addition & 3 deletions examples/resend-magic-link/src/app/verify/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@ export default async function VerifyPage() {
<CardContent className="flex flex-col gap-4">
<VerifyForm />
<div className="rounded-lg border border-dashed border-border bg-muted/50 p-3 text-center">
<p className="text-xs text-muted-foreground mb-2">
Using the emulator? View the email in the inbox:
</p>
<p className="text-xs text-muted-foreground mb-2">Using the emulator? View the email in the inbox:</p>
<a
href="/emulate/resend/inbox"
target="_blank"
Expand Down
122 changes: 63 additions & 59 deletions packages/@emulators/clerk/src/__tests__/clerk.test.ts

Large diffs are not rendered by default.

51 changes: 20 additions & 31 deletions packages/@emulators/clerk/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 });

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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}`;
Expand All @@ -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,
Expand Down Expand Up @@ -223,13 +218,7 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: ClerkSeed

export const clerkPlugin: ServicePlugin = {
name: "clerk",
register(
app: Hono<AppEnv>,
store: Store,
webhooks: WebhookDispatcher,
baseUrl: string,
tokenMap?: TokenMap,
): void {
register(app: Hono<AppEnv>, store: Store, webhooks: WebhookDispatcher, baseUrl: string, tokenMap?: TokenMap): void {
const ctx: RouteContext = { app, store, webhooks, baseUrl, tokenMap };
oauthRoutes(ctx);
userRoutes(ctx);
Expand Down
16 changes: 14 additions & 2 deletions packages/@emulators/clerk/src/route-helpers.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -61,7 +68,12 @@ export function deletedResponse(objectType: string, objectId: string): Record<st
};
}

export function paginatedResponse<T>(data: T[], totalCount: number, limit: number, offset: number): Record<string, unknown> {
export function paginatedResponse<T>(
data: T[],
totalCount: number,
limit: number,
offset: number,
): Record<string, unknown> {
return {
data,
total_count: totalCount,
Expand Down
4 changes: 2 additions & 2 deletions packages/@emulators/clerk/src/routes/email-addresses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
5 changes: 4 additions & 1 deletion packages/@emulators/clerk/src/routes/memberships.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,10 @@ export function membershipRoutes({ app, store, tokenMap }: RouteContext): void {
patch.public_metadata = { ...membership.public_metadata, ...(body.public_metadata as Record<string, unknown>) };
}
if (body.private_metadata !== undefined) {
patch.private_metadata = { ...membership.private_metadata, ...(body.private_metadata as Record<string, unknown>) };
patch.private_metadata = {
...membership.private_metadata,
...(body.private_metadata as Record<string, unknown>),
};
}

cs.memberships.update(membership.id, patch);
Expand Down
24 changes: 16 additions & 8 deletions packages/@emulators/clerk/src/routes/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
Expand Down Expand Up @@ -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();
Expand 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,
);
}
Expand Down Expand Up @@ -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<string, unknown>;
const parsed = (await c.req.json()) as Record<string, unknown>;
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "string") body[key] = value;
}
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 14 additions & 2 deletions packages/@emulators/clerk/src/routes/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 1 addition & 10 deletions packages/@emulators/clerk/src/routes/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() });

Expand Down
6 changes: 4 additions & 2 deletions packages/@emulators/clerk/src/routes/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
if (body.private_metadata !== undefined) patch.private_metadata = body.private_metadata as Record<string, unknown>;
if (body.unsafe_metadata !== undefined) patch.unsafe_metadata = body.unsafe_metadata as Record<string, unknown>;
Expand Down
6 changes: 5 additions & 1 deletion packages/@emulators/clerk/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ export function getClerkStore(store: Store): ClerkStore {
users: store.collection<ClerkUser>("clerk.users", ["clerk_id", "username"]),
emailAddresses: store.collection<ClerkEmailAddress>("clerk.emails", ["email_id", "user_id", "email_address"]),
organizations: store.collection<ClerkOrganization>("clerk.orgs", ["clerk_id", "slug"]),
memberships: store.collection<ClerkOrganizationMembership>("clerk.memberships", ["membership_id", "org_id", "user_id"]),
memberships: store.collection<ClerkOrganizationMembership>("clerk.memberships", [
"membership_id",
"org_id",
"user_id",
]),
invitations: store.collection<ClerkOrganizationInvitation>("clerk.invitations", ["invitation_id", "org_id"]),
sessions: store.collection<ClerkSession>("clerk.sessions", ["clerk_id", "user_id"]),
oauthApps: store.collection<ClerkOAuthApplication>("clerk.oauth_apps", ["app_id", "client_id"]),
Expand Down
9 changes: 8 additions & 1 deletion packages/@emulators/stripe/src/__tests__/stripe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>; created: number; expires_at: number };
const session = (await res.json()) as {
object: string;
client_secret: string;
customer: string;
components: Record<string, unknown>;
created: number;
expires_at: number;
};
expect(session.object).toBe("customer_session");
expect(session.client_secret).toBeTruthy();
expect(session.customer).toBe(cust.id);
Expand Down
30 changes: 20 additions & 10 deletions packages/@emulators/stripe/src/routes/customer-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) ?? {},
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<string, unknown>) ?? {},
created: Math.floor(Date.now() / 1000),
customer: customer.stripe_id,
expires_at: Math.floor(Date.now() / 1000) + 1800,
livemode: false,
},
200,
);
});
}
Loading
Loading