diff --git a/use-crystallize/mcp-servers/crystallize/CLAUDE.md b/use-crystallize/mcp-servers/crystallize/AGENTS.md similarity index 90% rename from use-crystallize/mcp-servers/crystallize/CLAUDE.md rename to use-crystallize/mcp-servers/crystallize/AGENTS.md index 5ad1403..c7fad77 100644 --- a/use-crystallize/mcp-servers/crystallize/CLAUDE.md +++ b/use-crystallize/mcp-servers/crystallize/AGENTS.md @@ -42,11 +42,13 @@ src/ │ └── tool.ts # ToolWrapper type (incl. write marker) & defineToolWrapper helper ├── core/ │ ├── analytics.ts # Pure analytics event builders (tenant/tool + session paths) +│ ├── api-origin.ts # Production or dev: X-Crystallize-Environment → origin suffix │ ├── expose-flags.ts # Shared exposeWrite/exposeUi/exposeSkills query-param parsing │ ├── mcp-request.ts # Detect a session start: `initialize` (2025) / `server/discover` (2026) │ ├── container.ts # Awilix DI container + services + tool registration │ ├── mass-operation.ts # Shared mass-operation validation (validateMassOperations) │ ├── security.ts # Shared input schemas (tenant/query/variables) + error sanitization +│ ├── ui-resource-meta.ts # Add the session's environment to a UI panel's CSP domains │ ├── mcp/ │ │ └── tools/ # MCP tool wrappers (one file per tool) │ │ ├── build-mass-operation.ts # Validate a mass-operation file (read-only) @@ -163,7 +165,9 @@ export const createMyToolWrapper = ({}: Deps) => { param: z.string(), }), handler: async ({ param, authContext }) => { - // authContext: { accessTokenId, accessTokenSecret } + // authContext: credentials + tenants + `origin` (the environment's js-api-client suffix) + // Every createClient MUST pass `origin: authContext.origin`, and every hand-built URL MUST come from + // `apiBaseUrl("api" | "shop-api", authContext.origin)` — see "Environments" below. return { content: [{ type: "text", text: "result" }] }; }, }); @@ -459,6 +463,37 @@ retroactive, so create that goal before shipping. - `PLAUSIBLE_API_ENDPOINT` drives **both** sides: the server-side POST target, and the landing page's `data-api` plus its script origin. Point it at a self-hosted Plausible and the browser follows. +### Environments (production or dev) + +Production (`api.crystallize.com`, `shop-api.crystallize.com`, `pim.crystallize.com`) is the default and the only +environment named anywhere in the repo. A request can switch to dev the way the js-api-client's `shopApiStaging` +switch works: the caller flips a switch, never types a hostname. + +- **Header**: `X-Crystallize-Environment: dev` (case-insensitive). Absent, blank or `production` → production. Any + other value is a **400**. +- **Secret**: `CRYSTALLIZE_DEV_API_ORIGIN` holds the dev js-api-client `origin` *suffix*, e.g. `-dev.example.test`. + It is glued straight after `api` / `shop-api` / `pim` (`https://api${suffix}`), so it must start with `.` or `-` + and be a plain lowercase host fragment; anything else is a **500** when dev is requested. Unset means the server + has no dev environment and a `dev` request is a **400**. It is a **secret**, not a `var`: production + `wrangler secret put CRYSTALLIZE_DEV_API_ORIGIN`, locally a line in `.env` (gitignored; Wrangler loads it). The + dev hostname therefore never appears in the repo — keep it out of tests and docs too (use `-dev.example.test`). +- **Resolution** happens first thing in `authMiddleware` (`resolveApiOrigin` in `src/core/api-origin.ts`), before + any upstream call. The secret is only read when a request asks for dev, so a bad secret can never take + production traffic down. +- **Threading**: the resolved suffix is stored on the auth context as `origin`, because a credential is only valid + on its own environment. The middleware itself uses it for the bearer JWKS (one `createRemoteJWKSet` per + environment, cached for the isolate) and for the `me` call. Every tool passes `origin: authContext.origin` to + `createClient`, and the hand-built URLs (schema introspection, the Shop API `/auth/token` exchange) come from + `apiBaseUrl(prefix, authContext.origin)` in `src/core/api-origin.ts` — never a literal host. +- **UI panels**: a tool's `ui.meta` CSP lists production hosts. On dev the resource read adds + `https://*.` (`withEnvironmentResourceDomains`), so images still load in the panel. +- **Dev bridge**: `CRYSTALLIZE_ENVIRONMENT=dev` in `.env` makes `bun run dev:bridge` send the header. +- **Analytics** are unaffected: the tenant/tool paths carry no environment segment. + +Adding a tool means adding `origin: authContext.origin` to its `createClient` and using `apiBaseUrl` for any URL it +builds. `grep -n 'crystallize\.com' src` should only ever hit `api-origin.ts`, the landing page and the UI panels' +static CSP. + ### MCP protocol versions The endpoint speaks **both** protocol eras on the same `/mcp` URL, through `createMcpHandler` from `agents/mcp/server`: @@ -480,10 +515,10 @@ declares as its peer — bump them together with `agents`, never on their own. ` ```ts type AppContext = { - Bindings: CloudflareBindings; // from wrangler types + // CRYSTALLIZE_DEV_API_ORIGIN is a secret, so `bun cf-typegen` only emits it when `.env` has it — declared by hand. + Bindings: CloudflareBindings & { CRYSTALLIZE_DEV_API_ORIGIN?: string }; Variables: { - accessTokenId: string; - accessTokenSecret: string; + authContext: AuthContext; // { type: token|session|bearer, credentials, origin, tenants } services: Services; // { mcpServer, tenantMatcher, analyticsTracker } }; }; @@ -526,3 +561,6 @@ Use `c.set()` / `c.get()` in middleware/handlers. Extend `Variables` when adding events; unset to disable them), `PLAUSIBLE_SCRIPT_URL` (the landing page's `pa-XXXX.js`; unset to drop the script) and `PLAUSIBLE_API_ENDPOINT` (override for a self-hosted Plausible or a first-party proxy — it feeds both the server-side POST and the browser's `plausible.init({ endpoint })`) +- **Secrets** (`wrangler secret put`, or `.env` locally — never `vars`): `CRYSTALLIZE_DEV_API_ORIGIN`, the dev + origin suffix a request selects with `X-Crystallize-Environment: dev` (see "Environments" above). Unset means + production only. diff --git a/use-crystallize/mcp-servers/crystallize/bin/dev-bridge.ts b/use-crystallize/mcp-servers/crystallize/bin/dev-bridge.ts index 2d146b3..65a3f5a 100644 --- a/use-crystallize/mcp-servers/crystallize/bin/dev-bridge.ts +++ b/use-crystallize/mcp-servers/crystallize/bin/dev-bridge.ts @@ -7,6 +7,7 @@ * http://localhost:5173/mcp), injecting: * - X-Crystallize-Access-Token-Id from CRYSTALLIZE_TOKEN_ID * - X-Crystallize-Access-Token-Secret from CRYSTALLIZE_TOKEN_SECRET + * - X-Crystallize-Environment from CRYSTALLIZE_ENVIRONMENT (optional; `dev` to target the dev environment) * and adding permissive CORS headers for the loopback host. * * Usage (loads .env automatically): @@ -19,6 +20,7 @@ const TARGET = Bun.env.MCP_TARGET ?? "http://localhost:5173/mcp"; const PORT = Number(Bun.env.BRIDGE_PORT ?? 5174); const TOKEN_ID = Bun.env.CRYSTALLIZE_TOKEN_ID; const TOKEN_SECRET = Bun.env.CRYSTALLIZE_TOKEN_SECRET; +const ENVIRONMENT = Bun.env.CRYSTALLIZE_ENVIRONMENT; if (!TOKEN_ID || !TOKEN_SECRET) { console.error("Missing CRYSTALLIZE_TOKEN_ID / CRYSTALLIZE_TOKEN_SECRET in env (.env)."); @@ -31,7 +33,8 @@ const corsHeaders = (origin: string | null): Record => ({ "Access-Control-Allow-Methods": "GET,POST,DELETE,OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Accept, Authorization, MCP-Protocol-Version, MCP-Session-Id, " + - "X-Crystallize-Access-Token-Id, X-Crystallize-Access-Token-Secret, X-Crystallize-Session-Id", + "X-Crystallize-Access-Token-Id, X-Crystallize-Access-Token-Secret, X-Crystallize-Session-Id, " + + "X-Crystallize-Environment", "Access-Control-Expose-Headers": "MCP-Session-Id, MCP-Protocol-Version", "Access-Control-Max-Age": "86400", }); @@ -53,6 +56,7 @@ Bun.serve({ const fwdHeaders = new Headers(req.headers); fwdHeaders.set("X-Crystallize-Access-Token-Id", TOKEN_ID); fwdHeaders.set("X-Crystallize-Access-Token-Secret", TOKEN_SECRET); + if (ENVIRONMENT) fwdHeaders.set("X-Crystallize-Environment", ENVIRONMENT); fwdHeaders.delete("host"); const upstreamRes = await fetch(upstream.toString(), { @@ -75,4 +79,5 @@ Bun.serve({ }); console.log(`MCP dev-bridge: http://localhost:${PORT}/mcp -> ${TARGET}`); -console.log("Inject auth headers from env. CORS open for any origin."); +const environmentNote = ENVIRONMENT ? ` (environment: ${ENVIRONMENT})` : ""; +console.log(`Inject auth headers from env${environmentNote}. CORS open for any origin.`); diff --git a/use-crystallize/mcp-servers/crystallize/src/contracts/app-context.ts b/use-crystallize/mcp-servers/crystallize/src/contracts/app-context.ts index b70672c..a753baa 100644 --- a/use-crystallize/mcp-servers/crystallize/src/contracts/app-context.ts +++ b/use-crystallize/mcp-servers/crystallize/src/contracts/app-context.ts @@ -7,29 +7,38 @@ type Tenant = { staticAuthToken?: string; }; -type TokenAuthContext = { +type AuthContextBase = { + /** + * js-api-client `origin` suffix of the environment this credential belongs to (`.crystallize.com` in + * production). A credential is only valid on one environment, so it lives with the credential: every + * `createClient` and every hand-built API URL must take it from here. See `src/core/api-origin.ts`. + */ + origin: string; + tenants: Tenant[]; +}; + +type TokenAuthContext = AuthContextBase & { type: "token"; accessTokenId: string; accessTokenSecret: string; - tenants: Tenant[]; }; -type SessionAuthContext = { +type SessionAuthContext = AuthContextBase & { type: "session"; sessionId: string; - tenants: Tenant[]; }; -type BearerAuthContext = { +type BearerAuthContext = AuthContextBase & { type: "bearer"; bearerToken: string; - tenants: Tenant[]; }; export type AuthContext = TokenAuthContext | SessionAuthContext | BearerAuthContext; export type AppContext = { - Bindings: CloudflareBindings; + // CRYSTALLIZE_DEV_API_ORIGIN is a *secret* (`wrangler secret put` / `.env` locally), so `bun cf-typegen` only + // emits it when the local `.env` has it. Declared here so the type never depends on that. + Bindings: CloudflareBindings & { CRYSTALLIZE_DEV_API_ORIGIN?: string }; Variables: { authContext: AuthContext; services: Services; diff --git a/use-crystallize/mcp-servers/crystallize/src/core/api-origin.ts b/use-crystallize/mcp-servers/crystallize/src/core/api-origin.ts new file mode 100644 index 0000000..4b58ca7 --- /dev/null +++ b/use-crystallize/mcp-servers/crystallize/src/core/api-origin.ts @@ -0,0 +1,46 @@ +/** + * Which Crystallize environment a request talks to: production or dev. + * + * Works like the js-api-client's `shopApiStaging` switch: the caller flips a switch, never types a hostname. + * The switch is the `X-Crystallize-Environment` header (`dev`), and the dev hostname lives only in the + * `CRYSTALLIZE_DEV_API_ORIGIN` secret, so it never appears in the repo. + * + * The value is a js-api-client `origin` *suffix* (`https://${prefix}${origin}/...`), e.g. + * `.crystallize.com` → `api.crystallize.com`, `shop-api.crystallize.com`, `pim.crystallize.com`. + */ + +export const ENVIRONMENT_HEADER = "X-Crystallize-Environment"; +export const PRODUCTION_API_ORIGIN = ".crystallize.com"; +export const DEV_API_ORIGIN_SECRET = "CRYSTALLIZE_DEV_API_ORIGIN"; + +// A suffix is glued straight after `api` / `shop-api` / `pim`, so it must start with `.` or `-`, stay lowercase +// host characters, and end on one — a `/` or `?` would otherwise move the request to another path or host. +const SUFFIX_REGEX = /^[.-][a-z0-9.-]*[a-z0-9]$/; + +export type ResolvedApiOrigin = { ok: true; origin: string } | { ok: false; status: 400 | 500; error: string }; + +/** + * Turn the header value (if any) into an origin suffix. No header means production. + * `devOrigin` is the `CRYSTALLIZE_DEV_API_ORIGIN` secret, read only when the caller asks for dev. + */ +export function resolveApiOrigin(environment: string | undefined, devOrigin: string | undefined): ResolvedApiOrigin { + const name = environment?.trim().toLowerCase(); + if (!name || name === "production") { + return { ok: true, origin: PRODUCTION_API_ORIGIN }; + } + if (name !== "dev") { + return { ok: false, status: 400, error: `Unknown ${ENVIRONMENT_HEADER}: expected "production" or "dev"` }; + } + if (!devOrigin) { + return { ok: false, status: 400, error: "The dev environment is not configured on this server" }; + } + if (!SUFFIX_REGEX.test(devOrigin)) { + return { ok: false, status: 500, error: `${DEV_API_ORIGIN_SECRET} is not a valid origin suffix` }; + } + return { ok: true, origin: devOrigin }; +} + +/** `https://api.crystallize.com`, `https://shop-api-dev.example.test`, ... from a prefix and an origin suffix. */ +export function apiBaseUrl(prefix: "api" | "shop-api", origin: string): string { + return `https://${prefix}${origin}`; +} diff --git a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/fetch-catalog-graphql-schema.ts b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/fetch-catalog-graphql-schema.ts index c7f0905..0d2c534 100644 --- a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/fetch-catalog-graphql-schema.ts +++ b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/fetch-catalog-graphql-schema.ts @@ -3,6 +3,7 @@ import { defineToolWrapper } from "../../../contracts/tool"; import { GraphqlSchemaCompacter } from "../../../contracts/graphql-schema-compacter"; import { TenantMatcher } from "../../../contracts/tenant-matcher"; import { tenantSchema, sanitizeErrorMessage, buildApiUrl } from "../../security"; +import { apiBaseUrl } from "../../api-origin"; type Deps = { graphqlSchemaCompacter: GraphqlSchemaCompacter; @@ -36,7 +37,7 @@ export const createFetchCatalogGraphqlSchemaToolWrapper = ({ graphqlSchemaCompac ], }; } - const url = buildApiUrl("https://api.crystallize.com", matchedTenant.identifier, "/catalogue"); + const url = buildApiUrl(apiBaseUrl("api", authContext.origin), matchedTenant.identifier, "/catalogue"); const headers: Record = {}; if (matchedTenant.staticAuthToken) { headers["X-Crystallize-Static-Auth-Token"] = matchedTenant.staticAuthToken; diff --git a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/fetch-content-model.ts b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/fetch-content-model.ts index 9d259d5..f5ab2f2 100644 --- a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/fetch-content-model.ts +++ b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/fetch-content-model.ts @@ -27,6 +27,7 @@ export const createFetchContentModelToolWrapper = ({ tenantMatcher, authContextR handler: async ({ tenant, authContext }) => { const matchedTenant = tenantMatcher(authContext.tenants, { identifier: tenant }); const client = createClient({ + origin: authContext.origin, tenantIdentifier: matchedTenant.identifier, tenantId: matchedTenant.id, ...authContextResolver.getClientCredentials(authContext), diff --git a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/fetch-core-graphql-schema.ts b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/fetch-core-graphql-schema.ts index f6c422c..b52191e 100644 --- a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/fetch-core-graphql-schema.ts +++ b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/fetch-core-graphql-schema.ts @@ -8,6 +8,7 @@ import { fetchIntrospection } from "../../services/compact-schema-builder"; import { tenantSchema, sanitizeErrorMessage, buildAtApiUrl } from "../../security"; import { buildSelectionEvent, selectionOutcome } from "../../analytics"; import type { AnalyticsEvent } from "../../../contracts/analytics-tracker"; +import { apiBaseUrl } from "../../api-origin"; type Deps = { coreSchemaDomainSplitter: CoreSchemaDomainSplitter; @@ -95,7 +96,7 @@ export const createFetchCoreGraphqlSchemaToolWrapper = ({ }, handler: async ({ tenant, domain, intent, authContext }) => { const matchedTenant = tenantMatcher(authContext.tenants, { identifier: tenant }); - const url = buildAtApiUrl("https://api.crystallize.com", matchedTenant.identifier, ""); + const url = buildAtApiUrl(apiBaseUrl("api", authContext.origin), matchedTenant.identifier, ""); const headers: Record = authContextResolver.getAuthHeaders(authContext); const text = (value: string) => ({ content: [{ type: "text" as const, text: value }] }); try { diff --git a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/fetch-discovery-graphql-schema.ts b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/fetch-discovery-graphql-schema.ts index c3abbad..d7d7570 100644 --- a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/fetch-discovery-graphql-schema.ts +++ b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/fetch-discovery-graphql-schema.ts @@ -3,6 +3,7 @@ import { defineToolWrapper } from "../../../contracts/tool"; import { GraphqlSchemaCompacter } from "../../../contracts/graphql-schema-compacter"; import { TenantMatcher } from "../../../contracts/tenant-matcher"; import { tenantSchema, sanitizeErrorMessage, buildApiUrl } from "../../security"; +import { apiBaseUrl } from "../../api-origin"; type Deps = { graphqlSchemaCompacter: GraphqlSchemaCompacter; @@ -26,7 +27,7 @@ export const createFetchDiscoveryGraphqlSchemaToolWrapper = ({ graphqlSchemaComp }, handler: async ({ tenant, authContext }) => { const matchedTenant = tenantMatcher(authContext.tenants, { identifier: tenant }); - const url = buildApiUrl("https://api.crystallize.com", matchedTenant.identifier, "/discovery"); + const url = buildApiUrl(apiBaseUrl("api", authContext.origin), matchedTenant.identifier, "/discovery"); const headers: Record = matchedTenant.staticAuthToken ? { "X-Crystallize-Static-Auth-Token": matchedTenant.staticAuthToken } : {}; diff --git a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/fetch-shop-cart-graphql-schema.ts b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/fetch-shop-cart-graphql-schema.ts index 7ab268f..6f298a0 100644 --- a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/fetch-shop-cart-graphql-schema.ts +++ b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/fetch-shop-cart-graphql-schema.ts @@ -5,6 +5,7 @@ import { TenantMatcher } from "../../../contracts/tenant-matcher"; import { AuthContextResolver } from "../../../contracts/auth-context-resolver"; import { AuthContext } from "../../../contracts/app-context"; import { tenantSchema, sanitizeErrorMessage, buildAtApiUrl } from "../../security"; +import { apiBaseUrl } from "../../api-origin"; type Deps = { graphqlSchemaCompacter: GraphqlSchemaCompacter; @@ -17,7 +18,7 @@ const fetchShopApiToken = async ( authContext: AuthContext, authContextResolver: AuthContextResolver, ): Promise => { - const response = await fetch(buildAtApiUrl("https://shop-api.crystallize.com", tenant, "/auth/token"), { + const response = await fetch(buildAtApiUrl(apiBaseUrl("shop-api", authContext.origin), tenant, "/auth/token"), { method: "POST", headers: { "Content-Type": "application/json; charset=UTF-8", @@ -56,7 +57,7 @@ export const createFetchShopCartGraphqlSchemaToolWrapper = ({ handler: async ({ tenant, authContext }) => { const matchedTenant = tenantMatcher(authContext.tenants, { identifier: tenant }); const shopApiToken = await fetchShopApiToken(matchedTenant.identifier, authContext, authContextResolver); - const url = buildAtApiUrl("https://shop-api.crystallize.com", matchedTenant.identifier, "/cart"); + const url = buildAtApiUrl(apiBaseUrl("shop-api", authContext.origin), matchedTenant.identifier, "/cart"); const headers: Record = { Authorization: `Bearer ${shopApiToken}`, }; diff --git a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/get-mass-operation-status.ts b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/get-mass-operation-status.ts index a3c9930..7d4bd6d 100644 --- a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/get-mass-operation-status.ts +++ b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/get-mass-operation-status.ts @@ -31,6 +31,7 @@ export const createGetMassOperationStatusToolWrapper = ({ handler: async ({ tenant, taskId, authContext }) => { const matchedTenant = tenantMatcher(authContext.tenants, { identifier: tenant }); const client = createClient({ + origin: authContext.origin, tenantIdentifier: matchedTenant.identifier, tenantId: matchedTenant.id, ...authContextResolver.getClientCredentials(authContext), diff --git a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/mutate-core.ts b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/mutate-core.ts index d188885..4ea54fa 100644 --- a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/mutate-core.ts +++ b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/mutate-core.ts @@ -72,6 +72,7 @@ export const createMutateCoreToolWrapper = ({ tenantMatcher, mutationExecutor, a const matchedTenant = tenantMatcher(authContext.tenants, { identifier: tenant }); const client = createClient({ + origin: authContext.origin, tenantIdentifier: matchedTenant.identifier, tenantId: matchedTenant.id, ...authContextResolver.getClientCredentials(authContext), diff --git a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/mutate-shop-cart.ts b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/mutate-shop-cart.ts index 4241103..9b5c484 100644 --- a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/mutate-shop-cart.ts +++ b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/mutate-shop-cart.ts @@ -72,6 +72,7 @@ export const createMutateShopCartToolWrapper = ({ tenantMatcher, mutationExecuto const matchedTenant = tenantMatcher(authContext.tenants, { identifier: tenant }); const client = createClient({ + origin: authContext.origin, tenantId: matchedTenant.id, tenantIdentifier: matchedTenant.identifier, ...authContextResolver.getClientCredentials(authContext), diff --git a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/query-catalogue.ts b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/query-catalogue.ts index b94f22d..3be7e27 100644 --- a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/query-catalogue.ts +++ b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/query-catalogue.ts @@ -4,6 +4,7 @@ import { defineToolWrapper } from "../../../contracts/tool"; import { TenantMatcher } from "../../../contracts/tenant-matcher"; import { QueryExecutor } from "../../../contracts/query-executor"; import { tenantSchema, querySchema, variablesSchema, buildApiUrl } from "../../security"; +import { apiBaseUrl } from "../../api-origin"; type Deps = { tenantMatcher: TenantMatcher; @@ -41,6 +42,7 @@ export const createQueryCatalogueToolWrapper = ({ tenantMatcher, queryExecutor } } const client = createClient({ + origin: authContext.origin, tenantIdentifier: matchedTenant.identifier, tenantId: matchedTenant.id, ...(authContext.type === "token" @@ -59,7 +61,11 @@ export const createQueryCatalogueToolWrapper = ({ tenantMatcher, queryExecutor } return queryExecutor({ executor: (q, v) => client.catalogueApi(q, v), - introspectionUrl: buildApiUrl("https://api.crystallize.com", matchedTenant.identifier, "/catalogue"), + introspectionUrl: buildApiUrl( + apiBaseUrl("api", authContext.origin), + matchedTenant.identifier, + "/catalogue", + ), introspectionHeaders, query, variables, diff --git a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/query-core.ts b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/query-core.ts index 15904ca..e880203 100644 --- a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/query-core.ts +++ b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/query-core.ts @@ -6,6 +6,7 @@ import { QueryExecutor } from "../../../contracts/query-executor"; import { createClient } from "@crystallize/js-api-client"; import { AuthContextResolver } from "../../../contracts/auth-context-resolver"; import { tenantSchema, readOnlyQuerySchema, variablesSchema, buildAtApiUrl } from "../../security"; +import { apiBaseUrl } from "../../api-origin"; type Deps = { tenantMatcher: TenantMatcher; @@ -60,6 +61,7 @@ export const createQueryCoreToolWrapper = ({ tenantMatcher, queryExecutor, authC const matchedTenant = tenantMatcher(authContext.tenants, { identifier: tenant }); const client = createClient({ + origin: authContext.origin, tenantIdentifier: matchedTenant.identifier, tenantId: matchedTenant.id, ...authContextResolver.getClientCredentials(authContext), @@ -67,7 +69,7 @@ export const createQueryCoreToolWrapper = ({ tenantMatcher, queryExecutor, authC return queryExecutor({ executor: (q, v) => client.nextPimApi(q, v), - introspectionUrl: buildAtApiUrl("https://api.crystallize.com", matchedTenant.identifier, ""), + introspectionUrl: buildAtApiUrl(apiBaseUrl("api", authContext.origin), matchedTenant.identifier, ""), introspectionHeaders: authContextResolver.getAuthHeaders(authContext), query, variables, diff --git a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/query-discovery.ts b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/query-discovery.ts index fdc6fc7..279f06f 100644 --- a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/query-discovery.ts +++ b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/query-discovery.ts @@ -4,6 +4,7 @@ import { defineToolWrapper } from "../../../contracts/tool"; import { TenantMatcher } from "../../../contracts/tenant-matcher"; import { QueryExecutor } from "../../../contracts/query-executor"; import { tenantSchema, querySchema, variablesSchema, buildApiUrl } from "../../security"; +import { apiBaseUrl } from "../../api-origin"; type Deps = { tenantMatcher: TenantMatcher; @@ -29,6 +30,7 @@ export const createQueryDiscoveryToolWrapper = ({ tenantMatcher, queryExecutor } handler: async ({ tenant, query, variables, authContext }) => { const matchedTenant = tenantMatcher(authContext.tenants, { identifier: tenant }); const client = createClient({ + origin: authContext.origin, tenantIdentifier: matchedTenant.identifier, tenantId: matchedTenant.id, staticAuthToken: matchedTenant.staticAuthToken, @@ -40,7 +42,11 @@ export const createQueryDiscoveryToolWrapper = ({ tenantMatcher, queryExecutor } return queryExecutor({ executor: (q, v) => client.discoveryApi(q, v), - introspectionUrl: buildApiUrl("https://api.crystallize.com", matchedTenant.identifier, "/discovery"), + introspectionUrl: buildApiUrl( + apiBaseUrl("api", authContext.origin), + matchedTenant.identifier, + "/discovery", + ), introspectionHeaders, query, variables, diff --git a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/query-shop-cart.ts b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/query-shop-cart.ts index eb682d0..f6236c0 100644 --- a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/query-shop-cart.ts +++ b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/query-shop-cart.ts @@ -7,6 +7,7 @@ import { QueryExecutor } from "../../../contracts/query-executor"; import { AuthContextResolver } from "../../../contracts/auth-context-resolver"; import { AuthContext } from "../../../contracts/app-context"; import { tenantSchema, readOnlyQuerySchema, variablesSchema, buildAtApiUrl } from "../../security"; +import { apiBaseUrl } from "../../api-origin"; type Deps = { tenantMatcher: TenantMatcher; @@ -19,7 +20,7 @@ const fetchShopApiToken = async ( authContext: AuthContext, authContextResolver: AuthContextResolver, ): Promise => { - const response = await fetch(buildAtApiUrl("https://shop-api.crystallize.com", tenant, "/auth/token"), { + const response = await fetch(buildAtApiUrl(apiBaseUrl("shop-api", authContext.origin), tenant, "/auth/token"), { method: "POST", headers: { "Content-Type": "application/json; charset=UTF-8", @@ -82,6 +83,7 @@ export const createQueryShopCartToolWrapper = ({ tenantMatcher, queryExecutor, a const matchedTenant = tenantMatcher(authContext.tenants, { identifier: tenant }); const client = createClient({ + origin: authContext.origin, tenantId: matchedTenant.id, tenantIdentifier: matchedTenant.identifier, ...authContextResolver.getClientCredentials(authContext), @@ -89,7 +91,7 @@ export const createQueryShopCartToolWrapper = ({ tenantMatcher, queryExecutor, a const shopApiToken = await fetchShopApiToken(matchedTenant.identifier, authContext, authContextResolver); const introspectionUrl = buildAtApiUrl( - "https://shop-api.crystallize.com", + apiBaseUrl("shop-api", authContext.origin), matchedTenant.identifier, "/cart", ); diff --git a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/run-mass-operation.ts b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/run-mass-operation.ts index 8aedd3e..b70c7df 100644 --- a/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/run-mass-operation.ts +++ b/use-crystallize/mcp-servers/crystallize/src/core/mcp/tools/run-mass-operation.ts @@ -60,6 +60,7 @@ export const createRunMassOperationToolWrapper = ({ const matchedTenant = tenantMatcher(authContext.tenants, { identifier: tenant }); const client = createClient({ + origin: authContext.origin, tenantIdentifier: matchedTenant.identifier, tenantId: matchedTenant.id, ...authContextResolver.getClientCredentials(authContext), diff --git a/use-crystallize/mcp-servers/crystallize/src/core/ui-resource-meta.ts b/use-crystallize/mcp-servers/crystallize/src/core/ui-resource-meta.ts new file mode 100644 index 0000000..8fa6f82 --- /dev/null +++ b/use-crystallize/mcp-servers/crystallize/src/core/ui-resource-meta.ts @@ -0,0 +1,22 @@ +import type { McpUiResourceMeta } from "@modelcontextprotocol/ext-apps"; +import { PRODUCTION_API_ORIGIN } from "./api-origin"; + +/** + * Let a UI panel load images from the environment the request runs on. + * + * A tool's `ui.meta` is static and lists production hosts. When the session is on another environment, add that + * environment's wildcard domain so the panel's CSP admits its media host too. The wildcard is always `*.` + * because CSP only accepts `*` as a whole leftmost label: a domain suffix `.example.test` → `*.example.test`, and a + * host-prefix suffix such as `-dev.example.test` → `*.example.test`. + */ +export function withEnvironmentResourceDomains( + meta: McpUiResourceMeta | undefined, + origin: string, +): McpUiResourceMeta | undefined { + if (!meta || origin === PRODUCTION_API_ORIGIN) return meta; + const parent = origin.slice(origin.indexOf(".") + 1); + const domain = `https://*.${parent}`; + const existing = meta.csp?.resourceDomains ?? []; + if (existing.includes(domain)) return meta; + return { ...meta, csp: { ...meta.csp, resourceDomains: [...existing, domain] } }; +} diff --git a/use-crystallize/mcp-servers/crystallize/src/middlewares/auth.ts b/use-crystallize/mcp-servers/crystallize/src/middlewares/auth.ts index c4ef665..e80c06a 100644 --- a/use-crystallize/mcp-servers/crystallize/src/middlewares/auth.ts +++ b/use-crystallize/mcp-servers/crystallize/src/middlewares/auth.ts @@ -3,11 +3,22 @@ import type { AppContext, AuthContext } from "../contracts/app-context"; import { createClient } from "@crystallize/js-api-client"; import { getCookie } from "hono/cookie"; import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; +import { apiBaseUrl, ENVIRONMENT_HEADER, resolveApiOrigin } from "../core/api-origin"; const meQuery = `query { me { tenants { tenant { id name identifier staticAuthToken } } } }`; -const JWKS_URL = new URL("https://api.crystallize.com/.well-known/jwks.json"); -const JWKS = createRemoteJWKSet(JWKS_URL, { timeoutDuration: 15_000, cooldownDuration: 30_000 }); +// One remote JWKS per environment: a bearer token is signed by the environment that issued it. Built lazily and +// kept for the isolate's lifetime, like the single production JWKS used to be. +const jwksByOrigin = new Map>(); +const jwksFor = (origin: string) => { + let jwks = jwksByOrigin.get(origin); + if (!jwks) { + const url = new URL(`${apiBaseUrl("api", origin)}/.well-known/jwks.json`); + jwks = createRemoteJWKSet(url, { timeoutDuration: 15_000, cooldownDuration: 30_000 }); + jwksByOrigin.set(origin, jwks); + } + return jwks; +}; type BearerClaims = JWTPayload & { act?: { @@ -23,6 +34,16 @@ const extractBearerToken = (header: string | undefined): string | undefined => { }; export const authMiddleware = createMiddleware(async (c, next) => { + // Resolved before any credential check: the credential is only meaningful on its own environment, and both + // the JWKS lookup and the `me` call below have to target it. The secret is only read when a request asks for + // dev, so a bad secret can never take production traffic down. + const environment = c.req.header(ENVIRONMENT_HEADER); + const resolved = resolveApiOrigin(environment, environment ? c.env.CRYSTALLIZE_DEV_API_ORIGIN : undefined); + if (!resolved.ok) { + return c.json({ error: resolved.error }, resolved.status); + } + const origin = resolved.origin; + const accessTokenId = c.req.header("X-Crystallize-Access-Token-Id"); const accessTokenSecret = c.req.header("X-Crystallize-Access-Token-Secret"); const sessionId = getCookie(c, "connect.sid") ?? c.req.header("X-Crystallize-Session-Id"); @@ -48,7 +69,9 @@ export const authMiddleware = createMiddleware(async (c, next) => { let claims: BearerClaims; try { - const { payload } = await jwtVerify(bearerToken, JWKS, { algorithms: ["RS256"] }); + const { payload } = await jwtVerify(bearerToken, jwksFor(origin), { + algorithms: ["RS256"], + }); claims = payload; } catch { return c.json({ error: "Unauthorized: bearer token signature is invalid or expired" }, 403); @@ -62,6 +85,7 @@ export const authMiddleware = createMiddleware(async (c, next) => { c.set("authContext", { type: "bearer", bearerToken, + origin, tenants: [{ id: tenantId, identifier: tenantIdentifier, name: tenantIdentifier }], }); await next(); @@ -72,6 +96,7 @@ export const authMiddleware = createMiddleware(async (c, next) => { const client = createClient({ tenantIdentifier: "dummy", + origin, ...credentials, }); @@ -91,12 +116,13 @@ export const authMiddleware = createMiddleware(async (c, next) => { const tenants = response.me.tenants.map((t) => t.tenant); if (sessionId) { - c.set("authContext", { type: "session", sessionId, tenants }); + c.set("authContext", { type: "session", sessionId, origin, tenants }); } else { c.set("authContext", { type: "token", accessTokenId: accessTokenId!, accessTokenSecret: accessTokenSecret!, + origin, tenants, }); } diff --git a/use-crystallize/mcp-servers/crystallize/src/middlewares/services-provider.ts b/use-crystallize/mcp-servers/crystallize/src/middlewares/services-provider.ts index 845c4e7..5bf1707 100644 --- a/use-crystallize/mcp-servers/crystallize/src/middlewares/services-provider.ts +++ b/use-crystallize/mcp-servers/crystallize/src/middlewares/services-provider.ts @@ -8,6 +8,7 @@ import type { ToolWrapper } from "../contracts/tool"; import { buildContainer, toolRegistry } from "../core/container"; import { buildToolCallEvent } from "../core/analytics"; import { readExposeFlags } from "../core/expose-flags"; +import { withEnvironmentResourceDomains } from "../core/ui-resource-meta"; import { PLAUSIBLE_EVENTS_ENDPOINT } from "../core/services/plausible-analytics-tracker"; import { asValue } from "awilix"; @@ -89,16 +90,21 @@ export const servicesProvider = createMiddleware(async (c, next) => name, resourceUri, { description, _meta: meta ? { ui: meta } : undefined }, - async () => ({ - contents: [ - { - uri: resourceUri, - mimeType: RESOURCE_MIME_TYPE, - text: html, - ...(meta ? { _meta: { ui: meta } } : {}), - }, - ], - }), + async () => { + // Read inside the MCP request, so the panel's CSP can admit the session's environment. + const origin = (getMcpAuthContext()?.props as AuthContext | undefined)?.origin; + const resourceMeta = origin ? withEnvironmentResourceDomains(meta, origin) : meta; + return { + contents: [ + { + uri: resourceUri, + mimeType: RESOURCE_MIME_TYPE, + text: html, + ...(resourceMeta ? { _meta: { ui: resourceMeta } } : {}), + }, + ], + }; + }, ); } else { mcpServer.registerTool( diff --git a/use-crystallize/mcp-servers/crystallize/tests/core/analytics.test.ts b/use-crystallize/mcp-servers/crystallize/tests/core/analytics.test.ts index 494eb3c..62bf241 100644 --- a/use-crystallize/mcp-servers/crystallize/tests/core/analytics.test.ts +++ b/use-crystallize/mcp-servers/crystallize/tests/core/analytics.test.ts @@ -15,6 +15,7 @@ import { testAuthContext, testTenants } from "../utils/fixtures"; const singleTenantAuth: AuthContext = { type: "bearer", bearerToken: "jwt", + origin: ".crystallize.com", tenants: [testTenants[0]], }; @@ -22,6 +23,7 @@ const singleTenantAuth: AuthContext = { const bearerWithIdentifier = (identifier: string): AuthContext => ({ type: "bearer", bearerToken: "jwt", + origin: ".crystallize.com", tenants: [{ id: "t", identifier, name: "n" }], }); diff --git a/use-crystallize/mcp-servers/crystallize/tests/core/api-origin.test.ts b/use-crystallize/mcp-servers/crystallize/tests/core/api-origin.test.ts new file mode 100644 index 0000000..8cac8de --- /dev/null +++ b/use-crystallize/mcp-servers/crystallize/tests/core/api-origin.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "bun:test"; +import { apiBaseUrl, ENVIRONMENT_HEADER, PRODUCTION_API_ORIGIN, resolveApiOrigin } from "../../src/core/api-origin"; + +describe("resolveApiOrigin", () => { + const devOrigin = "-dev.example.test"; + + it("defaults to production when no environment is named", () => { + expect(resolveApiOrigin(undefined, devOrigin)).toEqual({ ok: true, origin: PRODUCTION_API_ORIGIN }); + expect(resolveApiOrigin("", devOrigin)).toEqual({ ok: true, origin: PRODUCTION_API_ORIGIN }); + }); + + it("accepts production by name without any secret", () => { + expect(resolveApiOrigin("production", undefined)).toEqual({ ok: true, origin: ".crystallize.com" }); + }); + + it("resolves dev to the configured suffix", () => { + expect(resolveApiOrigin("dev", devOrigin)).toEqual({ ok: true, origin: "-dev.example.test" }); + }); + + it("ignores case and surrounding whitespace in the header value", () => { + expect(resolveApiOrigin(" DEV ", devOrigin)).toEqual({ ok: true, origin: "-dev.example.test" }); + expect(resolveApiOrigin("Production", devOrigin)).toEqual({ ok: true, origin: ".crystallize.com" }); + }); + + it("rejects dev with 400 when the server has no dev suffix configured", () => { + const result = resolveApiOrigin("dev", undefined); + expect(result).toMatchObject({ ok: false, status: 400 }); + if (!result.ok) expect(result.error).toContain("not configured"); + }); + + it("rejects any other value with 400 and names the header and the choices", () => { + const result = resolveApiOrigin("staging", devOrigin); + expect(result).toMatchObject({ ok: false, status: 400 }); + if (!result.ok) { + expect(result.error).toContain(ENVIRONMENT_HEADER); + expect(result.error).toContain("dev"); + } + }); + + it("rejects a dev suffix that could change the URL path or host prefix with 500", () => { + for (const bad of ["-dev.example.test/evil", "dev.example.test", "-Dev.Example.Test", "-dev.example.test?x"]) { + const result = resolveApiOrigin("dev", bad); + expect(result).toMatchObject({ ok: false, status: 500 }); + if (!result.ok) expect(result.error).toContain("CRYSTALLIZE_DEV_API_ORIGIN"); + } + }); +}); + +describe("apiBaseUrl", () => { + it("builds the host from the prefix and the origin suffix", () => { + expect(apiBaseUrl("api", ".crystallize.com")).toBe("https://api.crystallize.com"); + expect(apiBaseUrl("shop-api", ".crystallize.com")).toBe("https://shop-api.crystallize.com"); + expect(apiBaseUrl("api", "-dev.example.test")).toBe("https://api-dev.example.test"); + }); +}); diff --git a/use-crystallize/mcp-servers/crystallize/tests/core/mcp/tools/fetch-core-graphql-schema.test.ts b/use-crystallize/mcp-servers/crystallize/tests/core/mcp/tools/fetch-core-graphql-schema.test.ts index 155ff21..9ff7966 100644 --- a/use-crystallize/mcp-servers/crystallize/tests/core/mcp/tools/fetch-core-graphql-schema.test.ts +++ b/use-crystallize/mcp-servers/crystallize/tests/core/mcp/tools/fetch-core-graphql-schema.test.ts @@ -41,6 +41,7 @@ describe("fetch-core-graphql-schema", () => { ((await call(input)).events ?? []).map((e) => e.path); beforeEach(() => { + mockFetchIntrospection.mockClear(); mockFetchIntrospection.mockImplementation(async () => buildIntrospectionFromSDL(SDL)); select = mock(async () => ({ picked: [], qualified: 0 })); tool = createFetchCoreGraphqlSchemaToolWrapper({ @@ -130,6 +131,11 @@ describe("fetch-core-graphql-schema", () => { }); }); + it("fetches the introspection from the auth context's environment", async () => { + await tool.handler({ tenant: "shop", authContext: { ...testAuthContext, origin: "-dev.example.test" } }); + expect(mockFetchIntrospection.mock.calls[0][0]).toBe("https://api-dev.example.test/@shop"); + }); + it("prefers an exact domain over an intent", async () => { const text = await run({ domain: "subscription", intent: "create an order" }); expect(text).toContain("Subscription"); diff --git a/use-crystallize/mcp-servers/crystallize/tests/core/mcp/tools/query-core.test.ts b/use-crystallize/mcp-servers/crystallize/tests/core/mcp/tools/query-core.test.ts new file mode 100644 index 0000000..860c1b4 --- /dev/null +++ b/use-crystallize/mcp-servers/crystallize/tests/core/mcp/tools/query-core.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, mock, beforeEach } from "bun:test"; + +const mockCreateClient = mock(); +mock.module("@crystallize/js-api-client", () => { + const actual = require("@crystallize/js-api-client"); + return { ...actual, createClient: mockCreateClient }; +}); + +import { createQueryCoreToolWrapper } from "../../../../src/core/mcp/tools/query-core"; +import { createTenantMatcher } from "../../../../src/core/services/tenant-matcher"; +import { createAuthContextResolver } from "../../../../src/core/services/auth-context-helpers"; +import type { QueryExecutor } from "../../../../src/contracts/query-executor"; +import { testAuthContext } from "../../../utils/fixtures"; + +describe("query-core", () => { + let queryExecutor: ReturnType; + let tool: ReturnType; + const dev = { ...testAuthContext, origin: "-dev.example.test" }; + + beforeEach(() => { + mockCreateClient.mockReset(); + mockCreateClient.mockReturnValue({ nextPimApi: mock() }); + queryExecutor = mock(async () => ({ content: [{ type: "text", text: "{}" }] })); + tool = createQueryCoreToolWrapper({ + tenantMatcher: createTenantMatcher(), + queryExecutor: queryExecutor as unknown as QueryExecutor, + authContextResolver: createAuthContextResolver(), + }); + }); + + it("creates the client on the auth context's environment", async () => { + await tool.handler({ tenant: "shop", query: "{ me { id } }", authContext: dev }); + expect(mockCreateClient).toHaveBeenCalledWith(expect.objectContaining({ origin: "-dev.example.test" })); + }); + + it("introspects the Core API of the auth context's environment", async () => { + await tool.handler({ tenant: "shop", query: "{ me { id } }", authContext: dev }); + expect(queryExecutor.mock.calls[0][0]).toMatchObject({ + introspectionUrl: "https://api-dev.example.test/@shop", + }); + }); +}); diff --git a/use-crystallize/mcp-servers/crystallize/tests/core/mcp/tools/query-shop-cart.test.ts b/use-crystallize/mcp-servers/crystallize/tests/core/mcp/tools/query-shop-cart.test.ts new file mode 100644 index 0000000..6af8dc3 --- /dev/null +++ b/use-crystallize/mcp-servers/crystallize/tests/core/mcp/tools/query-shop-cart.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test"; + +const mockCreateClient = mock(); +mock.module("@crystallize/js-api-client", () => { + const actual = require("@crystallize/js-api-client"); + return { ...actual, createClient: mockCreateClient }; +}); + +import { createQueryShopCartToolWrapper } from "../../../../src/core/mcp/tools/query-shop-cart"; +import { createTenantMatcher } from "../../../../src/core/services/tenant-matcher"; +import { createAuthContextResolver } from "../../../../src/core/services/auth-context-helpers"; +import type { QueryExecutor } from "../../../../src/contracts/query-executor"; +import { testAuthContext } from "../../../utils/fixtures"; + +describe("query-shop-cart", () => { + let fetchSpy: ReturnType; + let queryExecutor: ReturnType; + let tool: ReturnType; + const dev = { ...testAuthContext, origin: "-dev.example.test" }; + const run = () => tool.handler({ tenant: "shop", query: "{ cart(id: \"c1\") { id } }", authContext: dev }); + + beforeEach(() => { + fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ success: true, token: "shop-token" }), { status: 200 }), + ); + mockCreateClient.mockReset(); + mockCreateClient.mockReturnValue({ shopCartApi: mock() }); + queryExecutor = mock(async () => ({ content: [{ type: "text", text: "{}" }] })); + tool = createQueryShopCartToolWrapper({ + tenantMatcher: createTenantMatcher(), + queryExecutor: queryExecutor as unknown as QueryExecutor, + authContextResolver: createAuthContextResolver(), + }); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it("exchanges the shop token on the auth context's environment", async () => { + await run(); + expect(fetchSpy.mock.calls[0][0]).toBe("https://shop-api-dev.example.test/@shop/auth/token"); + }); + + it("creates the client on the auth context's environment", async () => { + await run(); + expect(mockCreateClient).toHaveBeenCalledWith(expect.objectContaining({ origin: "-dev.example.test" })); + }); + + it("introspects the Shop Cart API of the auth context's environment", async () => { + await run(); + expect(queryExecutor.mock.calls[0][0]).toMatchObject({ + introspectionUrl: "https://shop-api-dev.example.test/@shop/cart", + }); + }); +}); diff --git a/use-crystallize/mcp-servers/crystallize/tests/core/ui-resource-meta.test.ts b/use-crystallize/mcp-servers/crystallize/tests/core/ui-resource-meta.test.ts new file mode 100644 index 0000000..6e4ee5d --- /dev/null +++ b/use-crystallize/mcp-servers/crystallize/tests/core/ui-resource-meta.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "bun:test"; +import { withEnvironmentResourceDomains } from "../../src/core/ui-resource-meta"; + +describe("withEnvironmentResourceDomains", () => { + const meta = { csp: { resourceDomains: ["https://media.crystallize.com", "https://*.crystallize.com"] } }; + + it("leaves the meta untouched on production", () => { + expect(withEnvironmentResourceDomains(meta, ".crystallize.com")).toEqual(meta); + }); + + it("adds the dev environment's parent-domain wildcard", () => { + expect(withEnvironmentResourceDomains(meta, "-dev.example.test")).toEqual({ + csp: { + resourceDomains: [ + "https://media.crystallize.com", + "https://*.crystallize.com", + "https://*.example.test", + ], + }, + }); + }); + + it("wildcards the whole suffix when it is a domain rather than a host prefix", () => { + expect(withEnvironmentResourceDomains(meta, ".example.test")?.csp?.resourceDomains).toContain( + "https://*.example.test", + ); + }); + + it("passes an absent meta through", () => { + expect(withEnvironmentResourceDomains(undefined, "-dev.example.test")).toBeUndefined(); + }); + + it("does not mutate the tool's static meta", () => { + withEnvironmentResourceDomains(meta, "-dev.example.test"); + expect(meta.csp.resourceDomains).toHaveLength(2); + }); +}); diff --git a/use-crystallize/mcp-servers/crystallize/tests/middlewares/auth.test.ts b/use-crystallize/mcp-servers/crystallize/tests/middlewares/auth.test.ts index c71079e..b1b885b 100644 --- a/use-crystallize/mcp-servers/crystallize/tests/middlewares/auth.test.ts +++ b/use-crystallize/mcp-servers/crystallize/tests/middlewares/auth.test.ts @@ -296,4 +296,126 @@ describe("authMiddleware", () => { expect(body.authContext.bearerToken).toBe("my-jwt-token"); expect(mockCreateClient).not.toHaveBeenCalled(); }); + + describe("environment selection", () => { + const tokenHeaders = { + "X-Crystallize-Access-Token-Id": "test-id", + "X-Crystallize-Access-Token-Secret": "test-secret", + }; + const env = { CRYSTALLIZE_DEV_API_ORIGIN: "-dev.example.test" }; + const okMe = () => { + const tenantData = [{ tenant: { id: "t1", identifier: "shop", name: "Shop" } }]; + const pimApi = mock().mockResolvedValue({ me: { tenants: tenantData } }); + mockCreateClient.mockReturnValue({ pimApi } as never); + }; + const jwksHrefs = () => + mockCreateRemoteJWKSet.mock.calls.map((call) => (call as unknown as [URL])[0].href); + const bearerDev = () => + app.request( + "/test", + { + headers: { + Authorization: "Bearer my-jwt-token", + "X-Crystallize-Tenant-Identifier": "shop", + "X-Crystallize-Environment": "dev", + }, + }, + env, + ); + + it("resolves X-Crystallize-Environment: dev into authContext.origin and the me-call client", async () => { + okMe(); + const res = await app.request( + "/test", + { headers: { ...tokenHeaders, "X-Crystallize-Environment": "dev" } }, + env, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { authContext: { origin: string } }; + expect(body.authContext.origin).toBe("-dev.example.test"); + expect(mockCreateClient).toHaveBeenCalledWith(expect.objectContaining({ origin: "-dev.example.test" })); + }); + + it("defaults authContext.origin to production without the header", async () => { + okMe(); + const res = await app.request("/test", { headers: tokenHeaders }, env); + + expect(res.status).toBe(200); + const body = (await res.json()) as { authContext: { origin: string } }; + expect(body.authContext.origin).toBe(".crystallize.com"); + expect(mockCreateClient).toHaveBeenCalledWith(expect.objectContaining({ origin: ".crystallize.com" })); + }); + + it("rejects an unknown environment with 400 before contacting Crystallize", async () => { + okMe(); + const res = await app.request( + "/test", + { headers: { ...tokenHeaders, "X-Crystallize-Environment": "staging" } }, + env, + ); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("X-Crystallize-Environment"); + expect(mockCreateClient).not.toHaveBeenCalled(); + }); + + it("rejects dev with 400 when CRYSTALLIZE_DEV_API_ORIGIN is unset", async () => { + okMe(); + const res = await app.request( + "/test", + { headers: { ...tokenHeaders, "X-Crystallize-Environment": "dev" } }, + {}, + ); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("not configured"); + expect(mockCreateClient).not.toHaveBeenCalled(); + }); + + it("returns 500 when CRYSTALLIZE_DEV_API_ORIGIN is not a valid suffix", async () => { + okMe(); + const res = await app.request( + "/test", + { headers: { ...tokenHeaders, "X-Crystallize-Environment": "dev" } }, + { CRYSTALLIZE_DEV_API_ORIGIN: "dev.example.test/evil" }, + ); + + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("CRYSTALLIZE_DEV_API_ORIGIN"); + expect(mockCreateClient).not.toHaveBeenCalled(); + }); + + it("verifies dev bearer tokens against the dev environment's JWKS", async () => { + mockJwtVerify.mockResolvedValue({ payload: { act: { tenantId: "t42" } } } as never); + const res = await bearerDev(); + + expect(res.status).toBe(200); + const body = (await res.json()) as { authContext: { origin: string } }; + expect(body.authContext.origin).toBe("-dev.example.test"); + expect(jwksHrefs()).toContain("https://api-dev.example.test/.well-known/jwks.json"); + }); + + it("verifies production bearer tokens against api.crystallize.com", async () => { + mockJwtVerify.mockResolvedValue({ payload: { act: { tenantId: "t42" } } } as never); + const res = await app.request("/test", { + headers: { Authorization: "Bearer my-jwt-token", "X-Crystallize-Tenant-Identifier": "shop" }, + }); + + expect(res.status).toBe(200); + expect(jwksHrefs()).toContain("https://api.crystallize.com/.well-known/jwks.json"); + }); + + it("builds one JWKS per environment and reuses it", async () => { + mockJwtVerify.mockResolvedValue({ payload: { act: { tenantId: "t42" } } } as never); + await bearerDev(); + await bearerDev(); + + const devHref = "https://api-dev.example.test/.well-known/jwks.json"; + expect(jwksHrefs().filter((href) => href === devHref)).toHaveLength(1); + }); + }); }); diff --git a/use-crystallize/mcp-servers/crystallize/tests/utils/fixtures.ts b/use-crystallize/mcp-servers/crystallize/tests/utils/fixtures.ts index 9e8b32a..ac014f5 100644 --- a/use-crystallize/mcp-servers/crystallize/tests/utils/fixtures.ts +++ b/use-crystallize/mcp-servers/crystallize/tests/utils/fixtures.ts @@ -12,6 +12,7 @@ export const testAuthContext: AuthContext = { type: "token", accessTokenId: "test-id", accessTokenSecret: "test-secret", + origin: ".crystallize.com", tenants: testTenants, };