diff --git a/README.md b/README.md index d6b2fb570..b410c940d 100644 --- a/README.md +++ b/README.md @@ -1170,3 +1170,10 @@ Tokens are configured in the seed config and map to users. Pass them as `Authori **Microsoft**: OIDC authorization code flow with PKCE support. Also supports client credentials grants and Microsoft Graph users, mail, calendar, and OneDrive routes. **AWS**: Bearer tokens or IAM access key credentials. Default key pair always seeded: `AKIAIOSFODNN7EXAMPLE` / `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`. + +## Context.dev company lookup + +The `context` service supports brand retrieval by email domain for onboarding tests. +Create an instance on `context.emulators.dev`, mint an API key and seed explicit +`brands` with `domain` and `title`. Unknown domains return 404; no external Context.dev +credentials or requests are used. Other enrichment modes are not implemented. diff --git a/apps/web/app/docs/context/page.mdx b/apps/web/app/docs/context/page.mdx new file mode 100644 index 000000000..3ecf85351 --- /dev/null +++ b/apps/web/app/docs/context/page.mdx @@ -0,0 +1,17 @@ +# Context.dev + +A curated emulator for `POST /v1/brand/retrieve` with `type: "by_email"`. +Seed explicit domain/title pairs through `/_emulate/seed`. Only seeded domains +match; unknown domains return 404. No Context.dev key or network call is needed. + +```json +{"brands":[{"domain":"company.example","title":"Example Company"}]} +``` + +Create an instance at `https://context.emulators.dev/_emulate/instances`, then +create an `api-key` credential through its `/_emulate/credentials` endpoint. +Use the returned provider base URL and bearer token for requests. The regular +ledger and fault controls support observing calls and injecting failures. + +This subset returns company names with empty logo/color arrays. Other Context +lookup modes, enrichment fields, OAuth, GraphQL and MCP are not implemented. 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/services.ts b/packages/@emulators/cloudflare/src/services.ts index 70cda98af..309c70df4 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 — @@ -100,6 +101,12 @@ export interface ServiceEntry { } export const SERVICES: Record = { + context: { + plugin: contextPlugin, + manifest: contextManifest, + seedFromConfig: contextSeed, + defaultFallback: () => ({ login: "context_test", id: 1, scopes: [] }), + }, github: { plugin: githubWithMcpPlugin, manifest: githubManifest, diff --git a/packages/@emulators/context/README.md b/packages/@emulators/context/README.md new file mode 100644 index 000000000..3ecf85351 --- /dev/null +++ b/packages/@emulators/context/README.md @@ -0,0 +1,17 @@ +# Context.dev + +A curated emulator for `POST /v1/brand/retrieve` with `type: "by_email"`. +Seed explicit domain/title pairs through `/_emulate/seed`. Only seeded domains +match; unknown domains return 404. No Context.dev key or network call is needed. + +```json +{"brands":[{"domain":"company.example","title":"Example Company"}]} +``` + +Create an instance at `https://context.emulators.dev/_emulate/instances`, then +create an `api-key` credential through its `/_emulate/credentials` endpoint. +Use the returned provider base URL and bearer token for requests. The regular +ledger and fault controls support observing calls and injecting failures. + +This subset returns company names with empty logo/color arrays. Other Context +lookup modes, enrichment fields, OAuth, GraphQL and MCP are not implemented. diff --git a/packages/@emulators/context/package.json b/packages/@emulators/context/package.json new file mode 100644 index 000000000..1ffbe8000 --- /dev/null +++ b/packages/@emulators/context/package.json @@ -0,0 +1,41 @@ +{ + "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", + "clean": "rm -rf dist .turbo", + "type-check": "tsc --noEmit", + "lint": "eslint src" + }, + "dependencies": { + "@emulators/core": "workspace:*" + }, + "devDependencies": { + "tsup": "^8", + "typescript": "^5.7" + } +} diff --git a/packages/@emulators/context/src/index.ts b/packages/@emulators/context/src/index.ts new file mode 100644 index 000000000..b69258b26 --- /dev/null +++ b/packages/@emulators/context/src/index.ts @@ -0,0 +1,94 @@ +import type { Entity, ServiceManifest, ServicePlugin, Store } from "@emulators/core"; + +interface Brand extends Entity { + domain: string; + title: string; +} +const brands = (store: Store) => store.collection("context.brands", ["domain"]); +const record = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); +const validDomain = (value: unknown): value is string => + typeof value === "string" && /^[a-z0-9.-]+\.[a-z]{2,}$/.test(value); + +/** Seed explicit company matches; absent domains deliberately return the provider's no-match response. */ +export function seedFromConfig(store: Store, _baseUrl: string, config: unknown): void { + if (!record(config)) throw new Error("Expected a Context seed object"); + if (config.brands === undefined) return; + if (!Array.isArray(config.brands)) throw new Error("Expected brands to be an array"); + const parsed = config.brands.map((brand: unknown) => { + if (!record(brand) || !validDomain(brand.domain) || typeof brand.title !== "string" || !brand.title.trim()) + throw new Error("Each brand requires a lowercase domain and a nonempty title"); + return { domain: brand.domain, title: brand.title.trim() }; + }); + const collection = brands(store); + for (const brand of parsed) { + const existing = collection.findOneBy("domain", brand.domain); + if (existing) collection.update(existing.id, brand); + else collection.insert(brand); + } +} + +/** Curated Context.dev by-email brand lookup over a real HTTP boundary. */ +export const contextPlugin: ServicePlugin = { + name: "context", + register(app, store) { + app.post("/v1/brand/retrieve", async (c) => { + const input: unknown = await c.req.json().catch(() => null); + if (!record(input) || input.type !== "by_email" || typeof input.email !== "string") + return c.json({ error: "invalid_request", message: "Use type by_email with an email address." }, 422); + const parts = input.email.toLowerCase().split("@"); + const domain = parts.length === 2 ? parts[1] : undefined; + if (!validDomain(domain)) return c.json({ error: "invalid_email" }, 422); + const brand = brands(store).findOneBy("domain", domain); + if (!brand) return c.json({ error: "brand_not_found" }, 404); + return c.json({ + partial: false, + brand: { + domain: brand.domain, + title: brand.title, + + logos: [], + colors: [], + }, + }); + }); + }, +}; + +/** Honest machine-readable coverage for the single supported Context endpoint. */ +export const manifest: ServiceManifest = { + id: "context", + name: "Context.dev", + description: "Seeded company matching by email domain for onboarding tests.", + docsUrl: "https://docs.emulators.dev/context", + surfaces: [{ id: "rest", kind: "rest", title: "Brand retrieval by email", status: "partial", basePath: "/v1" }], + auth: [{ id: "api-key", title: "Bearer API key", type: "api-key", status: "supported" }], + specs: [ + { + kind: "openapi", + title: "Curated brand lookup", + coverage: "hand-authored", + operations: [ + { operationId: "brand.retrieve", method: "POST", path: "/v1/brand/retrieve", status: "hand-authored" }, + ], + }, + ], + seedSchema: { + description: "Only seeded domains match. Unknown domains return 404; no external requests are made.", + fields: [ + { key: "brands", title: "Company brands", example: [{ domain: "company.example", title: "Example Company" }] }, + ], + example: { brands: [{ domain: "company.example", title: "Example Company" }] }, + }, + stateModel: { collections: [{ name: "context.brands", title: "Company matches" }] }, + connections: [ + { + id: "api", + title: "Brand lookup", + kind: "curl", + language: "shell", + template: + "curl -X POST '{{baseUrl}}/v1/brand/retrieve' -H 'Authorization: Bearer {{token}}' -H 'Content-Type: application/json' -d '{\"type\":\"by_email\",\"email\":\"person@company.example\"}'", + }, + ], +}; 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/emulate/package.json b/packages/emulate/package.json index 312d1c6fe..9d4f7c768 100644 --- a/packages/emulate/package.json +++ b/packages/emulate/package.json @@ -97,6 +97,7 @@ "tsup": "^8", "typescript": "^5.7", "@emulators/autumn": "workspace:*", - "@emulators/workos": "workspace:*" + "@emulators/workos": "workspace:*", + "@emulators/context": "workspace:*" } } diff --git a/packages/emulate/src/index.ts b/packages/emulate/src/index.ts index ae6aabc2b..768f873ef 100644 --- a/packages/emulate/src/index.ts +++ b/packages/emulate/src/index.ts @@ -66,7 +66,8 @@ 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, posthog, mcp, and context. + Context supports seeded brand lookup by email; unmatched domains return 404. 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..ea904f794 100644 --- a/packages/emulate/src/registry.ts +++ b/packages/emulate/src/registry.ts @@ -59,6 +59,7 @@ const SERVICE_NAME_LIST = [ // gitlab is appended last so adding it leaves every other service's default // multi-service port (basePort + index) unchanged. "gitlab", + "context", ] as const; export type ServiceName = (typeof SERVICE_NAME_LIST)[number]; export const SERVICE_NAMES: readonly ServiceName[] = SERVICE_NAME_LIST; @@ -273,6 +274,16 @@ function randomId(): string { } export const SERVICE_REGISTRY: Record = { + context: { + label: "Context.dev company lookup emulator", + endpoints: "brand retrieval by email domain", + async load() { + const mod = await import("@emulators/context"); + return { plugin: mod.contextPlugin, manifest: mod.manifest, seedFromConfig: mod.seedFromConfig }; + }, + defaultFallback: () => ({ login: "context_test", id: 1, scopes: [] }), + initConfig: { context: { brands: [{ domain: "company.example", title: "Example Company" }] } }, + }, vercel: { label: "Vercel REST API emulator", endpoints: "projects, deployments, domains, env vars, users, teams, file uploads, protection bypass", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80342cff4..492d55258 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,19 @@ 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 + packages/@emulators/core: dependencies: jose: @@ -937,6 +953,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..8ba823d9c --- /dev/null +++ b/skills/context/SKILL.md @@ -0,0 +1,16 @@ +--- +name: context +description: Use the Context.dev emulator to test company lookup by email domain through a running HTTP service. +--- + +# Context.dev company lookup + +Create an instance with `POST https://context.emulators.dev/_emulate/instances`. +Save its returned provider base URL privately. Create an `api-key` credential +at `/_emulate/credentials` and seed `{"brands":[{"domain":"company.example","title":"Example Company"}]}` +at `/_emulate/seed`. Call `POST /v1/brand/retrieve` with bearer auth and +`{"type":"by_email","email":"person@company.example"}`. + +Unknown domains return 404. Only company names are populated; logos/colors are +empty. Use the shared ledger and fault endpoints for inspection and failures. +For local use, run `npx emulate --service context`.