From 3ae21ff52f1b946ab3fd188282001c519152fb98 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:02:56 -0700 Subject: [PATCH] Add the Context company lookup emulator Executor's cloud onboarding e2e provisions a "context" service for company lookup, but no such emulator existed, so the hosted control plane answered 404 unknown emulator service. Adds packages/@emulators/context with brand/retrieve by work email address or domain over seeded brands, rejecting free and disposable mailbox domains the way the real resolver does. Registers it in the Cloudflare hosts worker and the CLI registry, and covers the hosted provisioning path with a worker test. --- README.md | 5 +- packages/@emulators/cloudflare/package.json | 3 +- .../cloudflare/src/__tests__/worker.test.ts | 54 +++++++++++ .../@emulators/cloudflare/src/services.ts | 9 ++ packages/@emulators/context/package.json | 43 ++++++++ .../context/src/__tests__/context.test.ts | 97 +++++++++++++++++++ packages/@emulators/context/src/entities.ts | 22 +++++ packages/@emulators/context/src/helpers.ts | 72 ++++++++++++++ packages/@emulators/context/src/index.ts | 60 ++++++++++++ packages/@emulators/context/src/manifest.ts | 64 ++++++++++++ .../@emulators/context/src/routes/brand.ts | 81 ++++++++++++++++ packages/@emulators/context/src/store.ts | 12 +++ packages/@emulators/context/tsconfig.json | 8 ++ packages/@emulators/context/tsup.config.ts | 19 ++++ packages/@emulators/context/vitest.config.ts | 7 ++ packages/emulate/package.json | 1 + packages/emulate/src/index.ts | 2 +- packages/emulate/src/registry.ts | 24 ++++- pnpm-lock.yaml | 22 +++++ skills/context/SKILL.md | 91 +++++++++++++++++ 20 files changed, 691 insertions(+), 5 deletions(-) create mode 100644 packages/@emulators/context/package.json create mode 100644 packages/@emulators/context/src/__tests__/context.test.ts create mode 100644 packages/@emulators/context/src/entities.ts create mode 100644 packages/@emulators/context/src/helpers.ts create mode 100644 packages/@emulators/context/src/index.ts create mode 100644 packages/@emulators/context/src/manifest.ts create mode 100644 packages/@emulators/context/src/routes/brand.ts create mode 100644 packages/@emulators/context/src/store.ts create mode 100644 packages/@emulators/context/tsconfig.json create mode 100644 packages/@emulators/context/tsup.config.ts create mode 100644 packages/@emulators/context/vitest.config.ts create mode 100644 skills/context/SKILL.md 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.