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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ All services start with sensible defaults. No config file needed:
- **PostHog** on `http://localhost:4016`
- **MCP** on `http://localhost:4017`
- **GitLab** on `http://localhost:4018` (full real GraphQL schema)
- **Context** on `http://localhost:4019` (company lookup by work email domain)

Every running service also exposes a public control plane under `/_emulate`:

Expand Down Expand Up @@ -174,7 +175,7 @@ github:

## Deployed Instances

All services are available on host-based routing when deployed: `github`, `gitlab`, `mcp`, `vercel`, `google`, `okta`, `microsoft`, `spotify`, `slack`, `apple`, `aws`, `resend`, `stripe`, `mongoatlas`, `clerk`, `x`, `workos`, `autumn`, and `posthog`. Each one supports three addressing forms:
All services are available on host-based routing when deployed: `github`, `gitlab`, `mcp`, `vercel`, `google`, `okta`, `microsoft`, `spotify`, `slack`, `apple`, `aws`, `resend`, `stripe`, `mongoatlas`, `clerk`, `x`, `workos`, `autumn`, `context`, and `posthog`. Each one supports three addressing forms:

```text
https://github.emulators.dev # service host (control plane only)
Expand Down Expand Up @@ -264,7 +265,7 @@ afterAll(() => Promise.all([github.close(), vercel.close()]));

| Option | Default | Description |
| --------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `service` | _(required)_ | Service name: `'vercel'`, `'github'`, `'gitlab'`, `'google'`, `'slack'`, `'apple'`, `'microsoft'`, `'okta'`, `'aws'`, `'resend'`, `'stripe'`, `'mongoatlas'`, `'clerk'`, `'spotify'`, `'x'`, `'workos'`, `'autumn'`, or `'posthog'` |
| `service` | _(required)_ | Service name: `'vercel'`, `'github'`, `'gitlab'`, `'google'`, `'slack'`, `'apple'`, `'microsoft'`, `'okta'`, `'aws'`, `'resend'`, `'stripe'`, `'mongoatlas'`, `'clerk'`, `'spotify'`, `'x'`, `'workos'`, `'autumn'`, `'context'`, or `'posthog'` |
| `port` | `4000` | Port for the HTTP server |
| `seed` | none | Inline seed data (same shape as YAML config) |
| `baseUrl` | none | Override advertised base URL. Per-service `baseUrl` in seed config takes highest priority, then this option, then `EMULATE_BASE_URL` env var (supports `{service}`), then `PORTLESS_URL` (supports `{service}`, automatically set by the `portless` CLI wrapper), then `http://localhost:<port>`. |
Expand Down
3 changes: 2 additions & 1 deletion packages/@emulators/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@
"@emulators/posthog": "workspace:*",
"@emulators/x": "workspace:*",
"@emulators/workos": "workspace:*",
"@emulators/autumn": "workspace:*"
"@emulators/autumn": "workspace:*",
"@emulators/context": "workspace:*"
},
"devDependencies": {
"tsup": "^8",
Expand Down
54 changes: 54 additions & 0 deletions packages/@emulators/cloudflare/src/__tests__/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ describe("cloudflare worker routing", () => {
expect(ids).toContain("github");
expect(ids).toContain("mcp");
expect(ids).toContain("stripe");
expect(ids).toContain("context");
});

it("keeps path routing available for local and shared-domain URLs", async () => {
Expand Down Expand Up @@ -349,6 +350,59 @@ describe("cloudflare durable object control plane", () => {
...extra,
});

// Executor's cloud onboarding e2e provisions this service exactly this way:
// mint an api-key, seed a brand, then resolve the company from a work email.
// A missing registration only shows up here, as a 404 from the control plane.
it("provisions the context company lookup and resolves a seeded brand", async () => {
const { state } = makeState();
const durableObject = new EmulatorDurableObject(state, {});
const headers = {
"content-type": "application/json",
"x-emulator-service": "context",
"x-emulator-base-url": "https://context.instance.emulators.dev",
};

const credentialRes = await durableObject.fetch(
new Request("https://context.instance.emulators.dev/_emulate/credentials", {
method: "POST",
headers,
body: JSON.stringify({ type: "api-key" }),
}),
);
expect(credentialRes.status).toBe(200);
const { credential } = (await credentialRes.json()) as { credential: { token: string } };
expect(credential.token).toMatch(/^emu_context_/);

const seedRes = await durableObject.fetch(
new Request("https://context.instance.emulators.dev/_emulate/seed", {
method: "POST",
headers,
body: JSON.stringify({ brands: [{ domain: "acme.example", title: "Example Company" }] }),
}),
);
expect(seedRes.status).toBe(200);

const hit = await durableObject.fetch(
new Request("https://context.instance.emulators.dev/v1/brand/retrieve", {
method: "POST",
headers: { ...headers, authorization: `Bearer ${credential.token}` },
body: JSON.stringify({ type: "by_email", email: "workspace@acme.example" }),
}),
);
expect(hit.status).toBe(200);
const body = (await hit.json()) as { brand: { title: string } };
expect(body.brand.title).toBe("Example Company");

const miss = await durableObject.fetch(
new Request("https://context.instance.emulators.dev/v1/brand/retrieve", {
method: "POST",
headers: { ...headers, authorization: `Bearer ${credential.token}` },
body: JSON.stringify({ type: "by_email", email: "workspace@example.test" }),
}),
);
expect(miss.status).toBe(404);
});

it("reports the real instance id in the manifest", async () => {
const { state } = makeState();
const durableObject = new EmulatorDurableObject(state, {});
Expand Down
9 changes: 9 additions & 0 deletions packages/@emulators/cloudflare/src/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
workosPlugin,
} from "@emulators/workos";
import { autumnPlugin, manifest as autumnManifest, seedFromConfig as autumnSeed } from "@emulators/autumn";
import { contextPlugin, manifest as contextManifest, seedFromConfig as contextSeed } from "@emulators/context";

// GitHub exposes three surfaces over ONE store: REST + GraphQL (githubPlugin) and
// an MCP server (mcpPlugin's transport + OAuth/DCR routes). They compose cleanly —
Expand Down Expand Up @@ -349,6 +350,14 @@ export const SERVICES: Record<string, ServiceEntry> = {
seedFromConfig: autumnSeed,
defaultFallback: () => ({ login: "am_emulate_admin", id: 1, scopes: [] }),
},
// Context company lookup: resolve a brand from a work email domain. Read-only
// over seeded brands, so no ensureUser; the api-key path mints the bearer.
context: {
plugin: contextPlugin,
manifest: contextManifest,
seedFromConfig: contextSeed,
defaultFallback: () => ({ login: "ctx_emulate_admin", id: 1, scopes: [] }),
},
};

export type ServiceName = keyof typeof SERVICES;
Expand Down
43 changes: 43 additions & 0 deletions packages/@emulators/context/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"name": "@emulators/context",
"version": "0.14.2",
"private": true,
"license": "Apache-2.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"homepage": "https://emulate.dev",
"repository": {
"type": "git",
"url": "https://github.com/UsefulSoftwareCo/emulate.git",
"directory": "packages/@emulators/context"
},
"bugs": {
"url": "https://github.com/UsefulSoftwareCo/emulate/issues"
},
"files": [
"dist"
],
"scripts": {
"build": "tsup --clean",
"dev": "tsup --watch",
"test": "vitest run",
"clean": "rm -rf dist .turbo",
"type-check": "tsc --noEmit",
"lint": "eslint src"
},
"dependencies": {
"@emulators/core": "workspace:*"
},
"devDependencies": {
"tsup": "^8",
"typescript": "^5.7",
"vitest": "^4.1.0"
}
}
97 changes: 97 additions & 0 deletions packages/@emulators/context/src/__tests__/context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createServer, serve } from "@emulators/core";

import { contextPlugin, seedFromConfig } from "../index.js";
import { manifest } from "../manifest.js";

const PORT = 41893;
const BASE = `http://localhost:${PORT}`;

let httpServer: ReturnType<typeof serve>;

interface RetrieveResponse {
partial: boolean;
brand: { domain: string; title?: string; description?: string; logos?: unknown; colors?: unknown };
}

const retrieve = (body: unknown) =>
fetch(`${BASE}/v1/brand/retrieve`, {
method: "POST",
headers: { authorization: "Bearer ctx_test_emulate", "content-type": "application/json" },
body: JSON.stringify(body),
});

const retrieveJson = async (body: unknown): Promise<RetrieveResponse> =>
(await (await retrieve(body)).json()) as RetrieveResponse;

beforeAll(() => {
const { app, store } = createServer(contextPlugin, {
port: PORT,
baseUrl: BASE,
manifest,
fallbackUser: { login: "ctx_emulate_admin", id: 1, scopes: [] },
});
seedFromConfig(store, BASE, {
brands: [
{
domain: "acme.example",
title: "Acme",
description: "An example company.",
logos: [{ url: "https://cdn.example/acme.png", type: "icon" }],
colors: [{ hex: "#101010" }],
},
{ domain: "Enriching.Example", title: "Enriching", partial: true },
],
});
httpServer = serve({ fetch: app.fetch, port: PORT });
});

afterAll(() => {
httpServer.close();
});

describe("brand/retrieve", () => {
it("resolves a seeded company from a work email address", async () => {
const response = await retrieve({ type: "by_email", email: "workspace@acme.example" });
expect(response.status).toBe(200);
const body = (await response.json()) as RetrieveResponse;
expect(body.partial).toBe(false);
expect(body.brand).toMatchObject({
domain: "acme.example",
title: "Acme",
description: "An example company.",
logos: [{ url: "https://cdn.example/acme.png", type: "icon" }],
colors: [{ hex: "#101010" }],
});
});

it("resolves the same company by domain", async () => {
// A pasted website URL normalizes to the same domain key as the seed.
const body = await retrieveJson({ type: "by_domain", domain: "https://www.Acme.example/pricing" });
expect(body.brand.domain).toBe("acme.example");
expect(body.brand.title).toBe("Acme");
});

it("returns 404 for a domain that was never seeded", async () => {
const response = await retrieve({ type: "by_email", email: "someone@unknown.example" });
expect(response.status).toBe(404);
});

it("rejects free and disposable mailbox domains without looking them up", async () => {
for (const email of ["someone@gmail.com", "someone@outlook.com", "someone@mailinator.com"]) {
const response = await retrieve({ type: "by_email", email });
expect(response.status).toBe(404);
}
});

it("flags a still-enriching profile as partial", async () => {
const response = await retrieve({ type: "by_email", email: "workspace@enriching.example" });
expect(response.status).toBe(200);
expect(((await response.json()) as RetrieveResponse).partial).toBe(true);
});

it("rejects a malformed address and an unsupported lookup type", async () => {
expect((await retrieve({ type: "by_email", email: "not-an-address" })).status).toBe(422);
expect((await retrieve({ type: "by_phone", phone: "555" })).status).toBe(422);
});
});
22 changes: 22 additions & 0 deletions packages/@emulators/context/src/entities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Entity } from "@emulators/core";

export interface ContextLogo {
url: string;
type?: string;
}

export interface ContextColor {
hex: string;
}

/** A company profile keyed by its primary web domain, as Context resolves it. */
export interface ContextBrand extends Entity {
domain: string;
title: string | null;
description: string | null;
logos: ContextLogo[];
colors: ContextColor[];
/** Context returns `partial: true` while enrichment is still running. The
* caller is expected to retry rather than cache the incomplete answer. */
partial: boolean;
}
72 changes: 72 additions & 0 deletions packages/@emulators/context/src/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/** Domains Context refuses to resolve: a person's mailbox is not a company.
* Real Context rejects free consumer and disposable mail providers before it
* ever looks for a brand, so the emulator has to reject them too. Otherwise a
* test seeded with a gmail.com brand would pass here and fail in production. */
const FREE_EMAIL_DOMAINS = new Set([
"gmail.com",
"googlemail.com",
"yahoo.com",
"yahoo.co.uk",
"hotmail.com",
"hotmail.co.uk",
"outlook.com",
"live.com",
"msn.com",
"icloud.com",
"me.com",
"mac.com",
"aol.com",
"gmx.com",
"gmx.net",
"mail.com",
"zoho.com",
"yandex.com",
"yandex.ru",
"protonmail.com",
"proton.me",
"pm.me",
"fastmail.com",
"hey.com",
"duck.com",
"qq.com",
"163.com",
"126.com",
"naver.com",
]);

const DISPOSABLE_EMAIL_DOMAINS = new Set([
"mailinator.com",
"guerrillamail.com",
"sharklasers.com",
"10minutemail.com",
"tempmail.com",
"temp-mail.org",
"throwaway.email",
"trashmail.com",
"yopmail.com",
"getnada.com",
"dispostable.com",
"maildrop.cc",
]);

export function normalizeDomain(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/^www\./, "")
.split("/")[0]!
.split("?")[0]!;
}

/** Returns the domain part of an email address, or null when it is not one. */
export function domainFromEmail(email: string): string | null {
const at = email.trim().lastIndexOf("@");
if (at <= 0 || at === email.trim().length - 1) return null;
const domain = normalizeDomain(email.trim().slice(at + 1));
return domain.includes(".") ? domain : null;
}

export function isPersonalDomain(domain: string): boolean {
return FREE_EMAIL_DOMAINS.has(domain) || DISPOSABLE_EMAIL_DOMAINS.has(domain);
}
Loading
Loading