diff --git a/README.md b/README.md index f496d6bfa..6d0823925 100644 --- a/README.md +++ b/README.md @@ -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`: @@ -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) @@ -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:`. | diff --git a/packages/@emulators/cloudflare/package.json b/packages/@emulators/cloudflare/package.json index 9d586e450..2559d61cc 100644 --- a/packages/@emulators/cloudflare/package.json +++ b/packages/@emulators/cloudflare/package.json @@ -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", diff --git a/packages/@emulators/cloudflare/src/__tests__/worker.test.ts b/packages/@emulators/cloudflare/src/__tests__/worker.test.ts index b2e51484b..3266ec6e5 100644 --- a/packages/@emulators/cloudflare/src/__tests__/worker.test.ts +++ b/packages/@emulators/cloudflare/src/__tests__/worker.test.ts @@ -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 () => { @@ -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, {}); diff --git a/packages/@emulators/cloudflare/src/services.ts b/packages/@emulators/cloudflare/src/services.ts index 70cda98af..8e9068751 100644 --- a/packages/@emulators/cloudflare/src/services.ts +++ b/packages/@emulators/cloudflare/src/services.ts @@ -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 — @@ -349,6 +350,14 @@ export const SERVICES: Record = { 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; diff --git a/packages/@emulators/context/package.json b/packages/@emulators/context/package.json new file mode 100644 index 000000000..d69140fea --- /dev/null +++ b/packages/@emulators/context/package.json @@ -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" + } +} diff --git a/packages/@emulators/context/src/__tests__/context.test.ts b/packages/@emulators/context/src/__tests__/context.test.ts new file mode 100644 index 000000000..5476b2b2f --- /dev/null +++ b/packages/@emulators/context/src/__tests__/context.test.ts @@ -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; + +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 => + (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); + }); +}); diff --git a/packages/@emulators/context/src/entities.ts b/packages/@emulators/context/src/entities.ts new file mode 100644 index 000000000..1df026148 --- /dev/null +++ b/packages/@emulators/context/src/entities.ts @@ -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; +} diff --git a/packages/@emulators/context/src/helpers.ts b/packages/@emulators/context/src/helpers.ts new file mode 100644 index 000000000..c6d3aa4ec --- /dev/null +++ b/packages/@emulators/context/src/helpers.ts @@ -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); +} diff --git a/packages/@emulators/context/src/index.ts b/packages/@emulators/context/src/index.ts new file mode 100644 index 000000000..597651df6 --- /dev/null +++ b/packages/@emulators/context/src/index.ts @@ -0,0 +1,60 @@ +import type { Hono, Store, WebhookDispatcher, TokenMap, AppEnv, RouteContext, ServicePlugin } from "@emulators/core"; + +import { getContextStore, type ContextStore } from "./store.js"; +import { brandRoutes } from "./routes/brand.js"; +import { normalizeDomain } from "./helpers.js"; +import type { ContextColor, ContextLogo } from "./entities.js"; + +export { getContextStore, type ContextStore } from "./store.js"; +export * from "./entities.js"; +export { manifest } from "./manifest.js"; +export { domainFromEmail, isPersonalDomain, normalizeDomain } from "./helpers.js"; + +export interface ContextSeedBrand { + domain: string; + title?: string; + description?: string; + logos?: ContextLogo[]; + colors?: ContextColor[]; + /** Mark the profile as still enriching so the lookup answers `partial: true`. */ + partial?: boolean; +} + +export interface ContextSeedConfig { + brands?: ContextSeedBrand[]; +} + +export function seedFromConfig(store: Store, _baseUrl: string, config: ContextSeedConfig): void { + const cs: ContextStore = getContextStore(store); + for (const brand of config.brands ?? []) { + const domain = normalizeDomain(brand.domain); + const fields = { + domain, + title: brand.title ?? null, + description: brand.description ?? null, + logos: brand.logos ?? [], + colors: brand.colors ?? [], + partial: brand.partial ?? false, + }; + const existing = cs.brands.findOneBy("domain", domain); + if (existing) { + cs.brands.update(existing.id, fields); + continue; + } + cs.brands.insert(fields); + } +} + +export const contextPlugin: ServicePlugin = { + name: "context", + register(app: Hono, store: Store, webhooks: WebhookDispatcher, baseUrl: string, tokenMap?: TokenMap): void { + const ctx: RouteContext = { app, store, webhooks, baseUrl, tokenMap }; + brandRoutes(ctx); + }, + seed(_store: Store, _baseUrl: string): void { + // No default seed: an unseeded lookup must miss, which is what "we do not + // recognize this company" means for the application under test. + }, +}; + +export default contextPlugin; diff --git a/packages/@emulators/context/src/manifest.ts b/packages/@emulators/context/src/manifest.ts new file mode 100644 index 000000000..af44e1982 --- /dev/null +++ b/packages/@emulators/context/src/manifest.ts @@ -0,0 +1,64 @@ +import type { ServiceManifest } from "@emulators/core"; + +export const manifest: ServiceManifest = { + id: "context", + name: "Context", + description: + "Stateful Context company-lookup emulator: resolve a seeded company profile (title, description, logos, brand colors) from a work email address or a domain, with free and disposable mailbox domains rejected the way the real resolver rejects them.", + docsUrl: "https://docs.emulators.dev/context", + surfaces: [{ id: "rest", kind: "rest", title: "Context v1 API", status: "partial", basePath: "/v1" }], + auth: [{ id: "api-key", title: "Context API key", type: "api-key", status: "supported" }], + specs: [ + { + kind: "openapi", + title: "Context v1 subset", + coverage: "hand-authored", + operations: [ + { + operationId: "brand.retrieve", + method: "POST", + path: "/v1/brand/retrieve", + status: "hand-authored", + }, + ], + }, + ], + seedSchema: { + description: "Seed the company profiles the lookup can resolve.", + fields: [ + { + key: "brands", + title: "Brands", + description: + "Company profiles keyed by domain. A lookup for any other domain returns 404, which is how an unrecognized company is represented.", + example: [ + { + domain: "acme.example", + title: "Acme", + description: "An example company.", + logos: [{ url: "https://cdn.example/acme.png", type: "icon" }], + colors: [{ hex: "#101010" }], + }, + ], + }, + ], + example: { + brands: [{ domain: "acme.example", title: "Acme" }], + }, + }, + stateModel: { + description: "Entities read by Context provider calls.", + collections: [{ name: "context.brands" }], + }, + connections: [ + { + id: "fetch", + title: "Company lookup", + kind: "sdk", + language: "typescript", + description: "Resolve a company from a work email address.", + template: + 'const response = await fetch("{{baseUrl}}/v1/brand/retrieve", {\n method: "POST",\n headers: {\n authorization: "Bearer {{token}}",\n "content-type": "application/json",\n },\n body: JSON.stringify({ type: "by_email", email: "someone@acme.example" }),\n});\n\n// 404 means no company matched the domain.\nconst { brand } = await response.json();', + }, + ], +}; diff --git a/packages/@emulators/context/src/routes/brand.ts b/packages/@emulators/context/src/routes/brand.ts new file mode 100644 index 000000000..eda8ad016 --- /dev/null +++ b/packages/@emulators/context/src/routes/brand.ts @@ -0,0 +1,81 @@ +import type { RouteContext } from "@emulators/core"; +import type { ContextBrand } from "../entities.js"; +import { getContextStore } from "../store.js"; +import { domainFromEmail, isPersonalDomain, normalizeDomain } from "../helpers.js"; + +interface RetrieveBody { + type?: unknown; + email?: unknown; + domain?: unknown; +} + +function contextError(status: 400 | 404 | 422, code: string, message: string) { + return { body: { error: { type: code, message } }, status } as const; +} + +/** Serializes a stored brand into Context's `brand/retrieve` response shape. */ +function present(brand: ContextBrand) { + return { + domain: brand.domain, + title: brand.title ?? undefined, + description: brand.description ?? undefined, + logos: brand.logos, + colors: brand.colors, + }; +} + +export function brandRoutes(ctx: RouteContext): void { + const { app, store } = ctx; + const cs = () => getContextStore(store); + + app.post("/v1/brand/retrieve", async (c) => { + let body: RetrieveBody; + try { + body = (await c.req.json()) as RetrieveBody; + } catch { + const { body: payload, status } = contextError(422, "invalid_request", "Request body must be JSON"); + return c.json(payload, status); + } + + const type = typeof body.type === "string" ? body.type : "by_domain"; + let domain: string | null = null; + + if (type === "by_email") { + if (typeof body.email !== "string") { + const { body: payload, status } = contextError(422, "invalid_request", "email is required for by_email"); + return c.json(payload, status); + } + domain = domainFromEmail(body.email); + if (domain === null) { + const { body: payload, status } = contextError(422, "invalid_request", "email is not a valid address"); + return c.json(payload, status); + } + // Context resolves companies, not people: a free or disposable mailbox + // domain is never a brand, so it is rejected before any lookup. + if (isPersonalDomain(domain)) { + const { body: payload, status } = contextError(404, "not_found", "No brand for a personal email domain"); + return c.json(payload, status); + } + } else if (type === "by_domain") { + if (typeof body.domain !== "string") { + const { body: payload, status } = contextError(422, "invalid_request", "domain is required for by_domain"); + return c.json(payload, status); + } + domain = normalizeDomain(body.domain); + } else { + const { body: payload, status } = contextError(422, "invalid_request", `Unsupported type: ${type}`); + return c.json(payload, status); + } + + const brand = cs().brands.findOneBy("domain", domain); + if (!brand) { + const { body: payload, status } = contextError(404, "not_found", `No brand found for ${domain}`); + return c.json(payload, status); + } + + // A partial brand means enrichment has not finished. Context still answers + // 200 with the flag set so the caller retries instead of caching a miss. + if (brand.partial) return c.json({ partial: true, brand: present(brand) }); + return c.json({ partial: false, brand: present(brand) }); + }); +} diff --git a/packages/@emulators/context/src/store.ts b/packages/@emulators/context/src/store.ts new file mode 100644 index 000000000..b2f8dc9da --- /dev/null +++ b/packages/@emulators/context/src/store.ts @@ -0,0 +1,12 @@ +import { Store, type Collection } from "@emulators/core"; +import type { ContextBrand } from "./entities.js"; + +export interface ContextStore { + brands: Collection; +} + +export function getContextStore(store: Store): ContextStore { + return { + brands: store.collection("context.brands", ["domain"]), + }; +} diff --git a/packages/@emulators/context/tsconfig.json b/packages/@emulators/context/tsconfig.json new file mode 100644 index 000000000..c8c92cbd6 --- /dev/null +++ b/packages/@emulators/context/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/@emulators/context/tsup.config.ts b/packages/@emulators/context/tsup.config.ts new file mode 100644 index 000000000..59a7354cb --- /dev/null +++ b/packages/@emulators/context/tsup.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "tsup"; +import { cpSync, mkdirSync } from "node:fs"; +import { resolve } from "node:path"; + +const copyFonts = async () => { + const src = resolve(__dirname, "../core/src/fonts"); + const dest = resolve(__dirname, "dist/fonts"); + mkdirSync(dest, { recursive: true }); + cpSync(src, dest, { recursive: true }); +}; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + sourcemap: true, + noExternal: [/^@emulators\/core/], + onSuccess: copyFonts, +}); diff --git a/packages/@emulators/context/vitest.config.ts b/packages/@emulators/context/vitest.config.ts new file mode 100644 index 000000000..e2ec33294 --- /dev/null +++ b/packages/@emulators/context/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + }, +}); diff --git a/packages/emulate/package.json b/packages/emulate/package.json index 312d1c6fe..8f04d1180 100644 --- a/packages/emulate/package.json +++ b/packages/emulate/package.json @@ -97,6 +97,7 @@ "tsup": "^8", "typescript": "^5.7", "@emulators/autumn": "workspace:*", + "@emulators/context": "workspace:*", "@emulators/workos": "workspace:*" } } diff --git a/packages/emulate/src/index.ts b/packages/emulate/src/index.ts index f97c8b69b..841b31bae 100644 --- a/packages/emulate/src/index.ts +++ b/packages/emulate/src/index.ts @@ -63,7 +63,7 @@ Global catalog: Hosted services: Available services include vercel, github, gitlab, google, slack, apple, microsoft, okta, aws, resend, stripe, mongoatlas, clerk, spotify, x, workos, - autumn, posthog, and mcp. + autumn, context, posthog, and mcp. MCP OAuth compliance scenarios are configured under mcp.oauth in seed data; see the MCP manifest seed schema for issuer, resource, DCR, and token-auth knobs. Microsoft Graph includes OneDrive file content upload/download routes under diff --git a/packages/emulate/src/registry.ts b/packages/emulate/src/registry.ts index 25c38162c..6048c28e8 100644 --- a/packages/emulate/src/registry.ts +++ b/packages/emulate/src/registry.ts @@ -57,8 +57,10 @@ const SERVICE_NAME_LIST = [ "posthog", "mcp", // gitlab is appended last so adding it leaves every other service's default - // multi-service port (basePort + index) unchanged. + // multi-service port (basePort + index) unchanged. Append new services here + // for the same reason. "gitlab", + "context", ] as const; export type ServiceName = (typeof SERVICE_NAME_LIST)[number]; export const SERVICE_NAMES: readonly ServiceName[] = SERVICE_NAME_LIST; @@ -994,6 +996,26 @@ export const SERVICE_REGISTRY: Record = { }, }, }, + context: { + label: "Context company lookup emulator", + endpoints: "brand retrieval by work email address or domain", + async load() { + const mod = await import("@emulators/context"); + return { + plugin: mod.contextPlugin, + manifest: mod.manifest, + seedFromConfig: mod.seedFromConfig, + }; + }, + defaultFallback() { + return { login: "ctx_emulate_admin", id: 1, scopes: [] }; + }, + initConfig: { + context: { + brands: [{ domain: "acme.example", title: "Acme", description: "An example company." }], + }, + }, + }, }; export const DEFAULT_TOKENS = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80342cff4..5ded68033 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -508,6 +508,9 @@ importers: '@emulators/clerk': specifier: workspace:* version: link:../clerk + '@emulators/context': + specifier: workspace:* + version: link:../context '@emulators/core': specifier: workspace:* version: link:../core @@ -564,6 +567,22 @@ importers: specifier: ^5.7 version: 5.9.3 + packages/@emulators/context: + dependencies: + '@emulators/core': + specifier: workspace:* + version: link:../core + devDependencies: + tsup: + specifier: ^8 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: ^5.7 + version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.16.9)(yaml@2.9.0)) + packages/@emulators/core: dependencies: jose: @@ -937,6 +956,9 @@ importers: '@emulators/clerk': specifier: workspace:* version: link:../@emulators/clerk + '@emulators/context': + specifier: workspace:* + version: link:../@emulators/context '@emulators/core': specifier: workspace:* version: link:../@emulators/core diff --git a/skills/context/SKILL.md b/skills/context/SKILL.md new file mode 100644 index 000000000..64d9d57b4 --- /dev/null +++ b/skills/context/SKILL.md @@ -0,0 +1,91 @@ +--- +name: context +description: Emulated Context company-lookup API (resolve a company profile from a work email address or a domain, with free and disposable mailbox domains rejected) for local development and testing. Use when the user needs company enrichment behavior without calling real Context. +allowed-tools: Bash(npx emulate:*), Bash(curl:*) +--- + +# Context Emulator + +Stateful Context company-lookup emulation: `brand/retrieve` resolves a seeded company profile (title, description, logos, brand colors) from a work email address or a domain. Free consumer and disposable mailbox domains are rejected before any lookup, the way the real resolver rejects them, so a test cannot pass by seeding a `gmail.com` company. + +## Start + +```bash +npx emulate --service context +``` + +When all services run together, Context uses `http://localhost:4019`. + +## Seed the companies the lookup can resolve + +Only seeded domains resolve. Every other domain returns `404`, which is how "we do not recognize this company" is represented. + +```json +{ + "context": { + "brands": [ + { + "domain": "acme.example", + "title": "Acme", + "description": "An example company.", + "logos": [{ "url": "https://cdn.example/acme.png", "type": "icon" }], + "colors": [{ "hex": "#101010" }] + } + ] + } +} +``` + +Seed a running instance through the control plane: + +```bash +curl -X POST http://localhost:4019/_emulate/seed \ + -H 'content-type: application/json' \ + -d '{"brands":[{"domain":"acme.example","title":"Acme"}]}' +``` + +## Look a company up + +```bash +curl -X POST http://localhost:4019/v1/brand/retrieve \ + -H 'authorization: Bearer ctx_test_anything' \ + -H 'content-type: application/json' \ + -d '{"type":"by_email","email":"someone@acme.example"}' +``` + +```json +{ + "partial": false, + "brand": { + "domain": "acme.example", + "title": "Acme", + "description": "An example company.", + "logos": [], + "colors": [] + } +} +``` + +Use `{"type":"by_domain","domain":"acme.example"}` to look a domain up directly. A pasted website URL such as `https://www.acme.example/pricing` normalizes to the same domain. + +## Responses to expect + +| Case | Status | +| --- | --- | +| Seeded domain | `200` with `brand` | +| Domain that was never seeded | `404` | +| Free or disposable mailbox domain (`gmail.com`, `mailinator.com`, ...) | `404` | +| Malformed address or unsupported `type` | `422` | + +## Test the retry path + +Seed a brand with `"partial": true` to make the lookup answer `200` with `partial: true`. That models enrichment still running: the caller is expected to retry rather than cache the incomplete answer as a miss. + +## Hosted use + +```bash +curl -X POST https://context.emulators.dev/_emulate/instances \ + -H 'content-type: application/json' -d '{"instance":"my-run"}' +``` + +Mint an API key against the returned instance URL with `POST /_emulate/credentials` and `{"type":"api-key"}`, then seed and query it the same way.