Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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" }] };
},
});
Expand Down Expand Up @@ -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://*.<parent domain of the suffix>` (`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`:
Expand All @@ -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 }
};
};
Expand Down Expand Up @@ -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.
9 changes: 7 additions & 2 deletions use-crystallize/mcp-servers/crystallize/bin/dev-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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).");
Expand All @@ -31,7 +33,8 @@ const corsHeaders = (origin: string | null): Record<string, string> => ({
"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",
});
Expand All @@ -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(), {
Expand All @@ -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.`);
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
46 changes: 46 additions & 0 deletions use-crystallize/mcp-servers/crystallize/src/core/api-origin.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, string> = {};
if (matchedTenant.staticAuthToken) {
headers["X-Crystallize-Static-Auth-Token"] = matchedTenant.staticAuthToken;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, string> = authContextResolver.getAuthHeaders(authContext);
const text = (value: string) => ({ content: [{ type: "text" as const, text: value }] });
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, string> = matchedTenant.staticAuthToken
? { "X-Crystallize-Static-Auth-Token": matchedTenant.staticAuthToken }
: {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -17,7 +18,7 @@ const fetchShopApiToken = async (
authContext: AuthContext,
authContextResolver: AuthContextResolver,
): Promise<string> => {
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",
Expand Down Expand Up @@ -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<string, string> = {
Authorization: `Bearer ${shopApiToken}`,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading