From 58df9c3f9aa52a2b7b7bba89f34b4995f59b7580 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 24 Jul 2026 04:32:31 -0700 Subject: [PATCH] Add WorkOS domain and Stripe catalog parity --- README.md | 4 +- apps/web/app/docs/page.mdx | 1 + apps/web/app/docs/stripe/page.mdx | 5 ++ apps/web/app/docs/workos/layout.tsx | 7 ++ apps/web/app/docs/workos/page.mdx | 47 +++++++++++ apps/web/lib/docs-navigation.ts | 1 + apps/web/lib/page-titles.ts | 1 + packages/@emulators/stripe/package.json | 3 +- .../stripe/src/__tests__/stripe.test.ts | 60 ++++++++++++-- .../@emulators/stripe/src/routes/openapi.ts | 16 +++- .../workos/src/__tests__/workos.test.ts | 42 ++++++++++ packages/@emulators/workos/src/entities.ts | 10 +++ packages/@emulators/workos/src/helpers.ts | 23 +++++- packages/@emulators/workos/src/manifest.ts | 27 ++++++- .../@emulators/workos/src/routes/openapi.ts | 42 ++++++++++ .../workos/src/routes/organizations.ts | 78 +++++++++++++++---- packages/@emulators/workos/src/store.ts | 7 ++ packages/emulate/src/index.ts | 3 + packages/emulate/src/registry.ts | 2 +- pnpm-lock.yaml | 10 +++ skills/stripe/SKILL.md | 2 +- skills/workos/SKILL.md | 5 +- 22 files changed, 364 insertions(+), 32 deletions(-) create mode 100644 apps/web/app/docs/workos/layout.tsx create mode 100644 apps/web/app/docs/workos/page.mdx diff --git a/README.md b/README.md index 7d443c5f..4e064eae 100644 --- a/README.md +++ b/README.md @@ -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` @@ -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 diff --git a/apps/web/app/docs/page.mdx b/apps/web/app/docs/page.mdx index 67b6a456..f2ce2aaa 100644 --- a/apps/web/app/docs/page.mdx +++ b/apps/web/app/docs/page.mdx @@ -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 diff --git a/apps/web/app/docs/stripe/page.mdx b/apps/web/app/docs/stripe/page.mdx index 4be923c1..7e7cf002 100644 --- a/apps/web/app/docs/stripe/page.mdx +++ b/apps/web/app/docs/stripe/page.mdx @@ -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 diff --git a/apps/web/app/docs/workos/layout.tsx b/apps/web/app/docs/workos/layout.tsx new file mode 100644 index 00000000..c1320b93 --- /dev/null +++ b/apps/web/app/docs/workos/layout.tsx @@ -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; +} diff --git a/apps/web/app/docs/workos/page.mdx b/apps/web/app/docs/workos/page.mdx new file mode 100644 index 00000000..3552dc9e --- /dev/null +++ b/apps/web/app/docs/workos/page.mdx @@ -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. diff --git a/apps/web/lib/docs-navigation.ts b/apps/web/lib/docs-navigation.ts index 8cb9494b..9e3e5f1c 100644 --- a/apps/web/lib/docs-navigation.ts +++ b/apps/web/lib/docs-navigation.ts @@ -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" }, diff --git a/apps/web/lib/page-titles.ts b/apps/web/lib/page-titles.ts index b6299459..bb6fa136 100644 --- a/apps/web/lib/page-titles.ts +++ b/apps/web/lib/page-titles.ts @@ -16,6 +16,7 @@ export const PAGE_TITLES: Record = { resend: "Resend", stripe: "Stripe", clerk: "Clerk", + workos: "WorkOS", spotify: "Spotify", posthog: "PostHog", authentication: "Authentication", diff --git a/packages/@emulators/stripe/package.json b/packages/@emulators/stripe/package.json index 667b7ade..750c9653 100644 --- a/packages/@emulators/stripe/package.json +++ b/packages/@emulators/stripe/package.json @@ -35,7 +35,8 @@ "lint": "eslint src" }, "dependencies": { - "@emulators/core": "workspace:*" + "@emulators/core": "workspace:*", + "yaml": "^2.9.0" }, "devDependencies": { "tsup": "^8", diff --git a/packages/@emulators/stripe/src/__tests__/stripe.test.ts b/packages/@emulators/stripe/src/__tests__/stripe.test.ts index c6e39888..f7f4743d 100644 --- a/packages/@emulators/stripe/src/__tests__/stripe.test.ts +++ b/packages/@emulators/stripe/src/__tests__/stripe.test.ts @@ -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, @@ -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", @@ -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 { @@ -41,12 +44,41 @@ function auth(): Record { 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", () => { @@ -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; + }; 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 () => { diff --git a/packages/@emulators/stripe/src/routes/openapi.ts b/packages/@emulators/stripe/src/routes/openapi.ts index 6b9a239c..b68befe8 100644 --- a/packages/@emulators/stripe/src/routes/openapi.ts +++ b/packages/@emulators/stripe/src/routes/openapi.ts @@ -1,4 +1,5 @@ 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. @@ -6,6 +7,9 @@ import type { RouteContext } from "@emulators/core"; // 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) => ({ @@ -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> => { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + return (value as Readonly>)["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, 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 }]), + ), }, }, }); diff --git a/packages/@emulators/workos/src/__tests__/workos.test.ts b/packages/@emulators/workos/src/__tests__/workos.test.ts index b32da0af..dcd65e3c 100644 --- a/packages/@emulators/workos/src/__tests__/workos.test.ts +++ b/packages/@emulators/workos/src/__tests__/workos.test.ts @@ -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({ diff --git a/packages/@emulators/workos/src/entities.ts b/packages/@emulators/workos/src/entities.ts index 54db053d..ba6df69c 100644 --- a/packages/@emulators/workos/src/entities.ts +++ b/packages/@emulators/workos/src/entities.ts @@ -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 diff --git a/packages/@emulators/workos/src/helpers.ts b/packages/@emulators/workos/src/helpers.ts index c5d98e04..ffd097c2 100644 --- a/packages/@emulators/workos/src/helpers.ts +++ b/packages/@emulators/workos/src/helpers.ts @@ -5,6 +5,7 @@ import type { WorkosInvitation, WorkosMembership, WorkosOrganization, + WorkosOrganizationDomain, WorkosUser, WorkosVaultObject, } from "./entities.js"; @@ -60,13 +61,16 @@ export function serializeUser(user: WorkosUser): Record { }; } -export function serializeOrganization(org: WorkosOrganization): Record { +export function serializeOrganization( + org: WorkosOrganization, + domains: readonly WorkosOrganizationDomain[] = [], +): Record { 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: {}, @@ -75,6 +79,21 @@ export function serializeOrganization(org: WorkosOrganization): Record { + 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 { return { object: "organization_membership", diff --git a/packages/@emulators/workos/src/manifest.ts b/packages/@emulators/workos/src/manifest.ts index 8368d251..fcdd5dd1 100644 --- a/packages/@emulators/workos/src/manifest.ts +++ b/packages/@emulators/workos/src/manifest.ts @@ -4,7 +4,7 @@ export const manifest: ServiceManifest = { id: "workos", name: "WorkOS", description: - "Stateful WorkOS emulator: AuthKit user management (hosted login, code + refresh grants, sealed-session JWKS), organizations, memberships, invitations, API keys, Vault KV, and an OAuth authorization server for MCP clients.", + "Stateful WorkOS emulator: AuthKit user management (hosted login, code + refresh grants, sealed-session JWKS), organizations and organization domains, memberships, invitations, API keys, Vault KV, and an OAuth authorization server for MCP clients.", docsUrl: "https://docs.emulators.dev/workos", surfaces: [ { id: "rest", kind: "rest", title: "WorkOS REST API", status: "partial", basePath: "/" }, @@ -103,6 +103,30 @@ export const manifest: ServiceManifest = { { operationId: "organizations.create", method: "POST", path: "/organizations", status: "hand-authored" }, { operationId: "organizations.get", method: "GET", path: "/organizations/:id", status: "hand-authored" }, { operationId: "organizations.update", method: "PUT", path: "/organizations/:id", status: "hand-authored" }, + { + operationId: "organizationDomains.create", + method: "POST", + path: "/organization_domains", + status: "hand-authored", + }, + { + operationId: "organizationDomains.get", + method: "GET", + path: "/organization_domains/:id", + status: "hand-authored", + }, + { + operationId: "organizationDomains.verify", + method: "POST", + path: "/organization_domains/:id/verify", + status: "hand-authored", + }, + { + operationId: "organizationDomains.delete", + method: "DELETE", + path: "/organization_domains/:id", + status: "hand-authored", + }, { operationId: "organizations.roles", method: "GET", @@ -153,6 +177,7 @@ export const manifest: ServiceManifest = { collections: [ { name: "workos.users" }, { name: "workos.organizations" }, + { name: "workos.organization_domains" }, { name: "workos.memberships" }, { name: "workos.invitations" }, { name: "workos.api_keys" }, diff --git a/packages/@emulators/workos/src/routes/openapi.ts b/packages/@emulators/workos/src/routes/openapi.ts index 26b73339..573d1a29 100644 --- a/packages/@emulators/workos/src/routes/openapi.ts +++ b/packages/@emulators/workos/src/routes/openapi.ts @@ -256,6 +256,48 @@ function buildSpec(baseUrl: string): Record { responses: { "200": ok("The updated organization."), "404": ok("Not found.") }, }, }, + "/organization_domains": { + post: { + operationId: "organizationDomains.create", + tags: ["organization-domains"], + summary: "Create an organization domain", + requestBody: jsonBody( + { organization_id: { type: "string" }, domain: { type: "string" } }, + ["organization_id", "domain"], + "The organization and domain to verify.", + ), + responses: { + "201": ok("The pending organization domain."), + "404": ok("Organization not found."), + "422": ok("Validation error."), + }, + }, + }, + "/organization_domains/{id}": { + get: { + operationId: "organizationDomains.get", + tags: ["organization-domains"], + summary: "Retrieve an organization domain", + parameters: [id], + responses: { "200": ok("The organization domain."), "404": ok("Not found.") }, + }, + delete: { + operationId: "organizationDomains.delete", + tags: ["organization-domains"], + summary: "Delete an organization domain", + parameters: [id], + responses: { "204": noContent("Deleted."), "404": ok("Not found.") }, + }, + }, + "/organization_domains/{id}/verify": { + post: { + operationId: "organizationDomains.verify", + tags: ["organization-domains"], + summary: "Verify an organization domain", + parameters: [id], + responses: { "200": ok("The verified organization domain."), "404": ok("Not found.") }, + }, + }, "/organizations/{id}/roles": { get: { operationId: "organizations.roles", diff --git a/packages/@emulators/workos/src/routes/organizations.ts b/packages/@emulators/workos/src/routes/organizations.ts index 003cce7e..002f9ea6 100644 --- a/packages/@emulators/workos/src/routes/organizations.ts +++ b/packages/@emulators/workos/src/routes/organizations.ts @@ -1,7 +1,14 @@ import type { Context, RouteContext } from "@emulators/core"; import { getWorkosStore } from "../store.js"; -import { listEnvelope, serializeOrganization, workosError, workosId } from "../helpers.js"; +import { + listEnvelope, + randomToken, + serializeOrganization, + serializeOrganizationDomain, + workosError, + workosId, +} from "../helpers.js"; export function organizationRoutes(ctx: RouteContext): void { const { app, store, baseUrl } = ctx; @@ -16,13 +23,18 @@ export function organizationRoutes(ctx: RouteContext): void { name, external_id: typeof body.external_id === "string" ? body.external_id : null, }); - return c.json(serializeOrganization(organization), 201); + return c.json( + serializeOrganization(organization, ws().organizationDomains.findBy("organization_id", organization.workos_id)), + 201, + ); }); app.get("/organizations/:id", (c) => { const organization = ws().organizations.findOneBy("workos_id", c.req.param("id")); if (!organization) return workosError(c, 404, "entity_not_found", "Organization not found."); - return c.json(serializeOrganization(organization)); + return c.json( + serializeOrganization(organization, ws().organizationDomains.findBy("organization_id", organization.workos_id)), + ); }); app.put("/organizations/:id", async (c) => { @@ -32,7 +44,9 @@ export function organizationRoutes(ctx: RouteContext): void { const updated = ws().organizations.update(organization.id, { name: typeof body.name === "string" && body.name ? body.name : organization.name, })!; - return c.json(serializeOrganization(updated)); + return c.json( + serializeOrganization(updated, ws().organizationDomains.findBy("organization_id", updated.workos_id)), + ); }); app.delete("/organizations/:id", (c) => { @@ -43,6 +57,9 @@ export function organizationRoutes(ctx: RouteContext): void { for (const membership of ws().memberships.findBy("organization_id", organization.workos_id)) { ws().memberships.delete(membership.id); } + for (const domain of ws().organizationDomains.findBy("organization_id", organization.workos_id)) { + ws().organizationDomains.delete(domain.id); + } ws().organizations.delete(organization.id); return c.body(null, 204); }); @@ -79,17 +96,48 @@ export function organizationRoutes(ctx: RouteContext): void { return c.json({ link: `${baseUrl}/_portal/${organization}?intent=${body.intent ?? ""}` }); }); - app.get("/organization_domains/:id", (c) => - c.json({ - object: "organization_domain", - id: c.req.param("id"), - organization_id: "org_unknown", - domain: "example.com", - state: "verified", + app.post("/organization_domains", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as Record; + const organizationId = typeof body.organization_id === "string" ? body.organization_id : ""; + const domainName = typeof body.domain === "string" ? body.domain.trim() : ""; + if (!organizationId) return workosError(c, 422, "invalid_request", "organization_id is required"); + if (!domainName) return workosError(c, 422, "invalid_request", "domain is required"); + if (!ws().organizations.findOneBy("workos_id", organizationId)) { + return workosError(c, 404, "entity_not_found", "Organization not found."); + } + + const domain = ws().organizationDomains.insert({ + workos_id: workosId("org_domain"), + organization_id: organizationId, + domain: domainName, + state: "pending", + verification_prefix: "workos-domain-verification", + verification_token: randomToken("verification"), verification_strategy: "dns", - verification_token: "token", - }), - ); + }); + return c.json(serializeOrganizationDomain(domain), 201); + }); - app.delete("/organization_domains/:id", (c) => c.body(null, 204)); + app.get("/organization_domains/:id", (c) => { + const domain = ws().organizationDomains.findOneBy("workos_id", c.req.param("id")); + if (!domain) return workosError(c, 404, "entity_not_found", "Organization domain not found."); + return c.json(serializeOrganizationDomain(domain)); + }); + + app.delete("/organization_domains/:id", (c) => { + const domain = ws().organizationDomains.findOneBy("workos_id", c.req.param("id")); + if (!domain) return workosError(c, 404, "entity_not_found", "Organization domain not found."); + ws().organizationDomains.delete(domain.id); + return c.body(null, 204); + }); + + const verifyDomain = (c: Context) => { + const domain = ws().organizationDomains.findOneBy("workos_id", c.req.param("id")); + if (!domain) return workosError(c, 404, "entity_not_found", "Organization domain not found."); + const verified = ws().organizationDomains.update(domain.id, { state: "verified" }); + if (!verified) throw new Error("Organization domain disappeared while verifying."); + return c.json(serializeOrganizationDomain(verified)); + }; + app.post("/organization_domains/:id/verify", verifyDomain); + app.post("/_emulate/organization_domains/:id/verify", verifyDomain); } diff --git a/packages/@emulators/workos/src/store.ts b/packages/@emulators/workos/src/store.ts index 4bf91085..c068db76 100644 --- a/packages/@emulators/workos/src/store.ts +++ b/packages/@emulators/workos/src/store.ts @@ -9,6 +9,7 @@ import type { WorkosOAuthCode, WorkosOAuthSettings, WorkosOrganization, + WorkosOrganizationDomain, WorkosSession, WorkosUser, WorkosVaultObject, @@ -17,6 +18,7 @@ import type { export interface WorkosStore { users: Collection; organizations: Collection; + organizationDomains: Collection; memberships: Collection; invitations: Collection; apiKeys: Collection; @@ -32,6 +34,11 @@ export function getWorkosStore(store: Store): WorkosStore { return { users: store.collection("workos.users", ["workos_id", "email"]), organizations: store.collection("workos.organizations", ["workos_id"]), + organizationDomains: store.collection("workos.organization_domains", [ + "workos_id", + "organization_id", + "domain", + ]), memberships: store.collection("workos.memberships", ["workos_id", "user_id", "organization_id"]), invitations: store.collection("workos.invitations", [ "workos_id", diff --git a/packages/emulate/src/index.ts b/packages/emulate/src/index.ts index 9a112f6b..eb7c84e5 100644 --- a/packages/emulate/src/index.ts +++ b/packages/emulate/src/index.ts @@ -35,6 +35,9 @@ Control plane (under /_emulate on each service): GET /_emulate/coverage per-operation coverage and summary GET /_emulate/connections copyable SDK, CLI, env, and curl snippets GET /_emulate/openapi OpenAPI document (when supported) + Stripe also serves its OpenAPI subset at /openapi.json and /openapi.yaml. + WorkOS domains support POST /organization_domains/:id/verify and the + test control route POST /_emulate/organization_domains/:id/verify. GET /_emulate/graphql GraphQL surface (when supported) GET /_emulate/mcp MCP surface (when supported) GET /_emulate/state current emulator state diff --git a/packages/emulate/src/registry.ts b/packages/emulate/src/registry.ts index 031b2dc3..c34bb0a7 100644 --- a/packages/emulate/src/registry.ts +++ b/packages/emulate/src/registry.ts @@ -951,7 +951,7 @@ export const SERVICE_REGISTRY: Record = { workos: { label: "WorkOS emulator", endpoints: - "AuthKit user management (hosted login, code/refresh grants, sealed-session JWKS), organizations, memberships, invitations, API keys, Vault KV, OAuth authorization server", + "AuthKit user management (hosted login, code/refresh grants, sealed-session JWKS), organizations and organization domains, memberships, invitations, API keys, Vault KV, OAuth authorization server", async load() { const mod = await import("@emulators/workos"); return { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e36ab706..628a6ad4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -785,6 +785,9 @@ importers: '@emulators/core': specifier: workspace:* version: link:../core + yaml: + specifier: ^2.9.0 + version: 2.9.0 devDependencies: tsup: specifier: ^8 @@ -8155,6 +8158,11 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@22.0.0: resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} @@ -16747,6 +16755,8 @@ snapshots: yaml@2.8.3: {} + yaml@2.9.0: {} + yargs-parser@22.0.0: {} yargs@18.0.0: diff --git a/skills/stripe/SKILL.md b/skills/stripe/SKILL.md index 231511fa..e59981e6 100644 --- a/skills/stripe/SKILL.md +++ b/skills/stripe/SKILL.md @@ -386,7 +386,7 @@ console.log(confirmed.status) // 'succeeded' ## Discovery -When using a running emulator URL, inspect `GET /_emulate/manifest` first to confirm supported surfaces (REST, hosted checkout, webhooks), auth capabilities, and per-operation spec coverage. Use `GET /_emulate/connections` for copyable SDK, CLI, env, and curl snippets and `GET /_emulate/quickstart` for setup notes. +When using a running emulator URL, inspect `GET /_emulate/manifest` first to confirm supported surfaces (REST, hosted checkout, webhooks), auth capabilities, and per-operation spec coverage. Use `GET /_emulate/connections` for copyable SDK, CLI, env, and curl snippets and `GET /_emulate/quickstart` for setup notes. The hand-authored OpenAPI subset is available as JSON at `GET /openapi.json` and as equivalent YAML at `GET /openapi.yaml`. Mint credentials with `POST /_emulate/credentials`, the canonical, uniform way to create a credential for any service (here a Stripe secret key): diff --git a/skills/workos/SKILL.md b/skills/workos/SKILL.md index 2bdbfd83..55772a6a 100644 --- a/skills/workos/SKILL.md +++ b/skills/workos/SKILL.md @@ -1,12 +1,12 @@ --- name: workos -description: Emulated WorkOS APIs (AuthKit user management, organizations, memberships, invitations, API keys, Vault KV, OAuth authorization server) for local development and testing. Use when the user needs WorkOS auth flows or sealed sessions without calling real WorkOS. +description: Emulated WorkOS APIs (AuthKit user management, organizations and organization domains, memberships, invitations, API keys, Vault KV, OAuth authorization server) for local development and testing. Use when the user needs WorkOS auth flows or sealed sessions without calling real WorkOS. allowed-tools: Bash(npx emulate:*), Bash(curl:*) --- # WorkOS Emulator -Stateful WorkOS emulation: AuthKit hosted login, authorization-code and refresh grants, sealed-session JWKS, organizations, memberships, invitations, user API keys, Vault KV, and an OAuth authorization server suitable for MCP clients. +Stateful WorkOS emulation: AuthKit hosted login, authorization-code and refresh grants, sealed-session JWKS, organizations and organization domains, memberships, invitations, user API keys, Vault KV, and an OAuth authorization server suitable for MCP clients. ## Start @@ -40,6 +40,7 @@ Sealed sessions are sealed and unsealed locally by the SDK with your cookie pass ## Other surfaces - OAuth authorization server for MCP clients: `/.well-known/oauth-authorization-server`, `/oauth2/register`, `/oauth2/authorize`, `/oauth2/token`, `/oauth2/jwks`. Set `EMULATE_WORKOS_AUDIENCE` to control the `aud` claim resource servers verify. AuthKit-faithful scope handling: the token grant carries exactly the scopes the client requested at `/oauth2/authorize`, a refresh token is issued only when `offline_access` is among them, and refresh tokens are single use (rotated on every redemption). Register with the emulate-only DCR field `access_token_ttl_seconds` to compress access-token expiry for lifecycle tests, or seed `{ "oauth": { "default_access_token_ttl_seconds": 15 } }` to compress it for every plain-DCR client (real MCP clients that cannot carry the extension); seed `null` to restore the default 3600. +- Organization domains: `POST /organization_domains`, `GET /organization_domains/:id`, `POST /organization_domains/:id/verify`, `DELETE /organization_domains/:id`. Domains begin pending with DNS verification fields. The provider verification route and `POST /_emulate/organization_domains/:id/verify` both mark one verified in a test. - Vault KV: `POST /vault/v1/kv`, `GET /vault/v1/kv/name/:name`, `PUT /vault/v1/kv/:id`, `DELETE /vault/v1/kv/:id`. ## Seed