Skip to content
Merged
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ All services start with sensible defaults. No config file needed:
- **Clerk** on `http://localhost:4011`
- **Spotify** on `http://localhost:4012`
- **X** on `http://localhost:4013`
- **WorkOS** on `http://localhost:4014`
- **WorkOS** on `http://localhost:4014` (AuthKit, organizations, organization domains, Vault, and OAuth)
- **Autumn** on `http://localhost:4015`
- **PostHog** on `http://localhost:4016`
- **MCP** on `http://localhost:4017`
Expand Down Expand Up @@ -60,6 +60,8 @@ Every running service also exposes a public control plane under `/_emulate`:
| `POST /_emulate/credentials` | Create bearer tokens, API keys, OAuth clients, or client-credentials apps where supported |
| `POST /_emulate/instances` | Return URLs for a lazily created hosted instance with a server-generated, unguessable name |

Stripe exposes the same hand-authored OpenAPI subset at `GET /openapi.json` and `GET /openapi.yaml`.

The manifest is the machine-readable single source of truth for a service. Each plugin package owns its manifest and serves it at `/_emulate/manifest`. It describes service identity, supported surfaces, auth capabilities, specs with per-operation coverage, scenarios, seed schema, state model, reset behavior, inspector tabs, request ledger capabilities, copyable connection snippets, and a docs link. OpenAPI, GraphQL, MCP, discovery documents, and OAuth metadata can inform those surfaces, but the emulator only advertises protocols that match the real service shape.

### Request ledger
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/docs/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ All services start with sensible defaults. No config file needed:
- **Clerk** on `http://localhost:4011`
- **Spotify** on `http://localhost:4012`
- **PostHog** on `http://localhost:4016`
- **WorkOS** on `http://localhost:4014`

## Control Plane

Expand Down
5 changes: 5 additions & 0 deletions apps/web/app/docs/stripe/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

Stripe API emulation with customers, payment methods, customer sessions, payment intents, charges, products, prices, and checkout sessions. Includes a hosted checkout page and webhook delivery.

## OpenAPI

- `GET /openapi.json` — hand-authored OpenAPI subset in JSON
- `GET /openapi.yaml` — equivalent OpenAPI subset in YAML

## Customers

- `POST /v1/customers` — create customer
Expand Down
7 changes: 7 additions & 0 deletions apps/web/app/docs/workos/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";

export const metadata = pageMetadata("workos");

export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}
47 changes: 47 additions & 0 deletions apps/web/app/docs/workos/page.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# WorkOS

WorkOS emulation for AuthKit user management, organizations, organization domains, memberships, invitations, user API keys, Vault KV, and OAuth authorization-server flows.

## Start

```bash
npx emulate --service workos
```

The local WorkOS emulator listens on `http://localhost:4014` when all services run together.

## Organizations

- `POST /organizations` — create an organization
- `GET /organizations/:id` — retrieve an organization, including its organization domains
- `PUT /organizations/:id` — update an organization
- `DELETE /organizations/:id` — remove an organization and its memberships and organization domains

## Organization domains

- `POST /organization_domains` — create a pending domain with `{ "organization_id", "domain" }`
- `GET /organization_domains/:id` — retrieve the domain, verification state, DNS prefix, and token
- `POST /organization_domains/:id/verify` — verify the domain through the WorkOS SDK contract
- `DELETE /organization_domains/:id` — remove the domain
- `POST /_emulate/organization_domains/:id/verify` — mark a pending domain verified through the test control plane

## SDK

```ts
import { WorkOS } from "@workos-inc/node";

const workos = new WorkOS("sk_test_anything", {
clientId: "client_emulate",
apiHostname: "localhost",
port: 4014,
https: false,
});

const organization = await workos.organizations.createOrganization({ name: "Acme" });
const domain = await workos.organizationDomains.create({
organizationId: organization.id,
domain: "acme.example.test",
});
```

Use `GET /_emulate/ledger` to inspect calls with credentials and tokens redacted.
1 change: 1 addition & 0 deletions apps/web/lib/docs-navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const allDocsPages: NavItem[] = [
{ name: "Resend", href: "/docs/resend" },
{ name: "Stripe", href: "/docs/stripe" },
{ name: "Clerk", href: "/docs/clerk" },
{ name: "WorkOS", href: "/docs/workos" },
{ name: "Spotify", href: "/docs/spotify" },
{ name: "PostHog", href: "/docs/posthog" },
{ name: "Authentication", href: "/docs/authentication" },
Expand Down
1 change: 1 addition & 0 deletions apps/web/lib/page-titles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const PAGE_TITLES: Record<string, string> = {
resend: "Resend",
stripe: "Stripe",
clerk: "Clerk",
workos: "WorkOS",
spotify: "Spotify",
posthog: "PostHog",
authentication: "Authentication",
Expand Down
3 changes: 2 additions & 1 deletion packages/@emulators/stripe/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@
"lint": "eslint src"
},
"dependencies": {
"@emulators/core": "workspace:*"
"@emulators/core": "workspace:*",
"yaml": "^2.9.0"
},
"devDependencies": {
"tsup": "^8",
Expand Down
60 changes: 53 additions & 7 deletions packages/@emulators/stripe/src/__tests__/stripe.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach } from "vitest";
import { Hono } from "@emulators/core";
import { Hono, RequestLedger, createLedgerMiddleware } from "@emulators/core";
import { parse as parseYaml } from "yaml";
import {
Store,
WebhookDispatcher,
Expand All @@ -15,6 +16,7 @@ const base = "http://localhost:14000";
function createTestApp() {
const store = new Store();
const webhooks = new WebhookDispatcher();
const ledger = new RequestLedger();
const tokenMap: TokenMap = new Map();
tokenMap.set("sk_test_abc123", {
login: "test-account",
Expand All @@ -26,10 +28,11 @@ function createTestApp() {
app.onError(createApiErrorHandler());
app.use("*", createErrorHandler());
app.use("*", authMiddleware(tokenMap));
app.use("*", createLedgerMiddleware(ledger, { webhooks }));
stripePlugin.register(app as any, store, webhooks, base, tokenMap);
stripePlugin.seed?.(store, base);

return { app, store, webhooks, tokenMap };
return { app, store, webhooks, ledger, tokenMap };
}

function auth(): Record<string, string> {
Expand All @@ -41,12 +44,41 @@ function auth(): Record<string, string> {

describe("Stripe plugin", () => {
let app: Hono;
let webhooks: WebhookDispatcher;
let ledger: RequestLedger;

beforeEach(() => {
const ctx = createTestApp();
app = ctx.app;
webhooks = ctx.webhooks;
ledger = ctx.ledger;
});

describe("OpenAPI", () => {
it("serves an equivalent YAML document", async () => {
const jsonResponse = await app.request(`${base}/openapi.json`);
const yamlResponse = await app.request(`${base}/openapi.yaml`);

expect(yamlResponse.status).toBe(200);
expect(yamlResponse.headers.get("content-type")).toMatch(/^application\/yaml(?:;|$)/);
const specification = await jsonResponse.json();
expect(specification).toMatchObject({
paths: {
"/v1/customers": {
post: {
requestBody: {
content: {
"application/x-www-form-urlencoded": {
encoding: {
metadata: { style: "deepObject", explode: true },
},
},
},
},
},
},
},
});
expect(parseYaml(await yamlResponse.text())).toEqual(specification);
});
});

describe("customers", () => {
Expand All @@ -68,19 +100,33 @@ describe("Stripe plugin", () => {
expect(fetched.id).toBe(customer.id);
});

it("creates a customer from form-urlencoded body", async () => {
it("projects form-urlencoded metadata while retaining flattened ledger fields", async () => {
const createRes = await app.request(`${base}/v1/customers`, {
method: "POST",
headers: {
Authorization: "Bearer sk_test_abc123",
"Content-Type": "application/x-www-form-urlencoded",
},
body: "email=form%40test.com&name=Form+User",
body: "email=form%40test.com&name=Form+User&metadata%5Bfoo%5D=bar&metadata%5Border_id%5D=order_123",
});
expect(createRes.status).toBe(200);
const customer = (await createRes.json()) as { id: string; email: string; name: string };
const customer = (await createRes.json()) as {
id: string;
email: string;
name: string;
metadata: Record<string, string>;
};
expect(customer.email).toBe("form@test.com");
expect(customer.name).toBe("Form User");
expect(customer.metadata).toEqual({ foo: "bar", order_id: "order_123" });

const entry = ledger.list().find((item) => item.path === "/v1/customers");
expect(entry?.request.body).toEqual({
email: "form@test.com",
name: "Form User",
"metadata[foo]": "bar",
"metadata[order_id]": "order_123",
});
});

it("returns Stripe-format error for missing customer", async () => {
Expand Down
16 changes: 15 additions & 1 deletion packages/@emulators/stripe/src/routes/openapi.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import type { RouteContext } from "@emulators/core";
import { stringify as stringifyYaml } from "yaml";

// OpenAPI 3.1 document for this Stripe emulator instance, pointed at itself,
// with the bearer-token security scheme real Stripe uses for secret keys.
// Covers the hand-authored surface (see manifest.ts); unsupported operations
// are omitted so OpenAPI-aware clients only see what actually works.
export function openapiRoutes({ app, baseUrl }: RouteContext): void {
app.get("/openapi.json", (c) => c.json(buildSpec(baseUrl)));
app.get("/openapi.yaml", (c) =>
c.body(stringifyYaml(buildSpec(baseUrl)), 200, { "Content-Type": "application/yaml; charset=UTF-8" }),
);
}

const ok = (description: string) => ({
Expand All @@ -14,14 +18,24 @@ const ok = (description: string) => ({
});
const id = { name: "id", in: "path", required: true, schema: { type: "string" } };
const metadata = { type: "object", additionalProperties: { type: "string" } };
const isObjectSchema = (value: unknown): value is Readonly<Record<string, unknown>> => {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
return (value as Readonly<Record<string, unknown>>)["type"] === "object";
};
// Stripe request bodies are form-encoded (the emulator also accepts JSON with
// the same field names). The body is only required when a field is required.
// the same field names). Object fields use bracket notation, which OpenAPI
// expresses with deepObject encoding. The body is only required when a field is required.
const formBody = (properties: Record<string, unknown>, required: readonly string[], description: string) => ({
required: required.length > 0,
description,
content: {
"application/x-www-form-urlencoded": {
schema: { type: "object", properties, required: [...required] },
encoding: Object.fromEntries(
Object.entries(properties)
.filter(([, schema]) => isObjectSchema(schema))
.map(([name]) => [name, { style: "deepObject", explode: true }]),
),
},
},
});
Expand Down
42 changes: 42 additions & 0 deletions packages/@emulators/workos/src/__tests__/workos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,48 @@ describe("workos emulator with the real @workos-inc/node SDK", () => {
expect(roles.data.map((role) => role.slug).sort()).toEqual(["admin", "member"]);
});

it("round-trips stateful organization domains through the SDK and control plane", async () => {
const organization = await workos.organizations.createOrganization({ name: "Executor E2E Org" });
const domain = await workos.organizationDomains.create({
organizationId: organization.id,
domain: "executor-e2e.example.test",
});
expect(domain.id).toMatch(/^org_domain_/);
expect(domain.organizationId).toBe(organization.id);
expect(domain.domain).toBe("executor-e2e.example.test");
expect(domain.state).toBe("pending");
expect(domain.verificationStrategy).toBe("dns");
expect(domain.verificationPrefix).toBe("workos-domain-verification");
expect(domain.verificationToken).toBeTruthy();

const fetched = await workos.organizationDomains.get(domain.id);
expect(fetched).toMatchObject({
id: domain.id,
organizationId: organization.id,
domain: "executor-e2e.example.test",
state: "pending",
verificationStrategy: "dns",
verificationPrefix: domain.verificationPrefix,
verificationToken: domain.verificationToken,
});
const organizationWithDomain = await workos.organizations.getOrganization(organization.id);
expect(organizationWithDomain.domains).toMatchObject([{ id: domain.id, state: "pending" }]);

const verification = await fetch(`${BASE}/_emulate/organization_domains/${domain.id}/verify`, { method: "POST" });
expect(verification.status).toBe(200);
expect((await verification.json()) as { state: string }).toMatchObject({ state: "verified" });

const verified = await workos.organizationDomains.verify(domain.id);
expect(verified.state).toBe("verified");
const organizationWithVerifiedDomain = await workos.organizations.getOrganization(organization.id);
expect(organizationWithVerifiedDomain.domains).toMatchObject([{ id: domain.id, state: "verified" }]);

await expect(workos.organizationDomains.delete(domain.id)).resolves.toBeUndefined();
await expect(workos.organizationDomains.get(domain.id)).rejects.toThrow();
const organizationWithoutDomain = await workos.organizations.getOrganization(organization.id);
expect(organizationWithoutDomain.domains).toEqual([]);
});

it("deletes an organization and cascades its memberships", async () => {
const code = await signInAndGetCode("dana@example.com");
const auth = await workos.userManagement.authenticateWithCode({
Expand Down
10 changes: 10 additions & 0 deletions packages/@emulators/workos/src/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ export interface WorkosOrganization extends Entity {
external_id: string | null;
}

export interface WorkosOrganizationDomain extends Entity {
workos_id: string; // org_domain_...
organization_id: string; // org workos_id
domain: string;
state: "pending" | "verified" | "failed";
verification_prefix: string;
verification_token: string;
verification_strategy: "dns" | "manual";
}

export interface WorkosMembership extends Entity {
workos_id: string; // om_...
user_id: string; // user workos_id
Expand Down
23 changes: 21 additions & 2 deletions packages/@emulators/workos/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
WorkosInvitation,
WorkosMembership,
WorkosOrganization,
WorkosOrganizationDomain,
WorkosUser,
WorkosVaultObject,
} from "./entities.js";
Expand Down Expand Up @@ -60,13 +61,16 @@ export function serializeUser(user: WorkosUser): Record<string, unknown> {
};
}

export function serializeOrganization(org: WorkosOrganization): Record<string, unknown> {
export function serializeOrganization(
org: WorkosOrganization,
domains: readonly WorkosOrganizationDomain[] = [],
): Record<string, unknown> {
return {
object: "organization",
id: org.workos_id,
name: org.name,
allow_profiles_outside_organization: false,
domains: [],
domains: domains.map(serializeOrganizationDomain),
stripe_customer_id: null,
external_id: org.external_id,
metadata: {},
Expand All @@ -75,6 +79,21 @@ export function serializeOrganization(org: WorkosOrganization): Record<string, u
};
}

export function serializeOrganizationDomain(domain: WorkosOrganizationDomain): Record<string, unknown> {
return {
object: "organization_domain",
id: domain.workos_id,
organization_id: domain.organization_id,
domain: domain.domain,
state: domain.state,
verification_prefix: domain.verification_prefix,
verification_token: domain.verification_token,
verification_strategy: domain.verification_strategy,
created_at: domain.created_at,
updated_at: domain.updated_at,
};
}

export function serializeMembership(membership: WorkosMembership, organizationName: string): Record<string, unknown> {
return {
object: "organization_membership",
Expand Down
Loading
Loading