plugins
npx skills add https://github.com/crystallizeapi/ai --skill pluginsCrystallize Plugins
A Crystallize Plugin is a vendor-hosted application that extends the Crystallize App UI inside iframes at predefined placement points. Plugins do not run code in the App UI itself — they receive a scoped Backend Token to act on behalf of the signed-in user via Crystallize APIs, and any secrets they need are encrypted client-side with the vendor’s own public key.
The full normative contract — entities, fields, JWE/JWT specs, request shapes, sequence diagrams — lives in references/plugin-contract.md. This SKILL.md is the builder’s guide: how to ship a plugin end-to-end. Read the contract when a field, security guarantee, or wire format is in question.
Consultation Approach
Before writing code, get the lay of the land:
- What does the plugin do? Pure UI widget, server-side action on a Crystallize entity (order, customer…), or tenant-wide dashboard? This drives the entrypoint placements.
- Does it need to call Crystallize APIs? If yes, you’ll use the
backendTokenfrom the decrypted payload as aBearercredential. - Does it need secrets? API keys, webhook URLs, third-party credentials — these go in
secrets[]and are encrypted in the installer’s browser. Crystallize never sees plaintext. - Where does it run? Plugins are server-hosted (Cloudflare Worker, Vercel/Netlify edge, Lambda, plain Node/Bun server). Pure static hosting is not sufficient — the upstream must accept POST requests and decrypt JWE payloads.
- Which tenant(s)? Plugins are installed per-tenant. One plugin can be installed on many tenants, each with its own configuration.
Architecture in 30 Seconds
Three entities (full detail in the contract):
| Entity | Where it lives | Purpose |
|---|---|---|
| Plugin | /@me | The plugin definition (name, identifier, logo). Has a state: pending → active. |
| Plugin Revision | /@me | The locked contract surface: upstream, entrypoints, scopes, schema, secrets, key. |
| Plugin Installation | /@:tenant | A (tenant, plugin, revision) triple with the tenant-specific config + ciphertexts. |
Key invariants:
- A revision is immutable once submitted. Code at
upstreamcan change anytime; only contract changes require a new revision. - Installations pin a
revisionIdand never auto-migrate. Re-install to upgrade. - Crystallize never holds plaintext secrets. The installer’s browser encrypts them with the revision’s
publicKey; only the vendor can decrypt.
End-to-End Workflow
1. Generate the keypair
The vendor needs an RSA key (RSA-OAEP-256 / A256GCM, JWE compact). Use the Crystallize CLI:
crystallize plugin keygenThis emits a private.jwk.json and a public.jwk.json. Keep private.jwk.json on the server only; the public key goes into the revision.
2. Register the plugin
Run on the Me API (https://api.crystallize.com/@me):
mutation { createPlugin( input: { author: "Acme" description: "Order preview plugin" icon: "https://acme.com/icon.png" identifier: "com.acme.order-preview" # reverse-DNS, immutable logo: "https://acme.com/logo.png" name: "Acme Order Preview" } ) { ... on BasicError { message } ... on Plugin { identifier } }}The plugin starts in pending state — it can’t be installed until approved.
3. Create the first revision
The revision is the contract the installer will see and consent to. Locked once submitted.
mutation CREATE_PLUGIN_REVISION($input: CreatePluginRevisionInput!) { createPluginRevision(identifier: "com.acme.order-preview", input: $input) { ... on BasicError { message } ... on PluginRevision { id version } }}Variables (the $input):
{ "input": { "version": "1.0.0", "upstream": "https://plugin.acme.com", "postInstallationUri": "/post-install", "scopes": [], "entryPoints": [ { "placement": "order/view/toolbar-button", "target": "/order/preview", "label": "Preview Order", "icon": "https://acme.com/btn.png" } ], "configurationSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "additionalProperties": false, "required": ["apiKey"], "properties": { "apiKey": { "type": "string", "title": "Stripe API Key" }, "theme": { "type": "string", "enum": ["light", "dark"] } } }, "secrets": ["apiKey"], "publicKey": { "kty": "RSA", "kid": "public", "use": "enc", "alg": "RSA-OAEP-256", "enc": "A256GCM", "n": "<modulus from public.jwk.json>", "e": "AQAB" } }}Field cheat sheet
| Field | Notes |
|---|---|
upstream | Base HTTPS URL of your origin. Crystallize POSTs to $upstream/$tenantIdentifier/$path. |
entryPoints[] | Each: placement ($concern/$view/$placement(/$type?)), target (path under upstream), optional label + icon. |
scopes | Permissions the plugin needs. Installer must hold them; can grant a subset. (V1: not yet enforced on the token — declare honestly anyway.) |
configurationSchema | JSON Schema draft 2020-12. Crystallize renders this as the install form. Validates submissions (with secret fields excluded). |
secrets | Names of properties from the schema to treat as secrets. Each becomes <input type="password"> and is JWE-encrypted in the browser. |
publicKey | JWK from crystallize plugin keygen. Must be kty=RSA, use=enc, alg=RSA-OAEP-256, enc=A256GCM. |
postInstallationUri | Path Crystallize POSTs to on install / reinstall / uninstall events. Fire-and-forget, no retries — make your handler idempotent. |
version | Vendor-declared semver. Cosmetic — does not drive any Crystallize behavior. |
See the contract’s Entrypoints section for the full placement convention and the dashboard (non-entity-scoped) variant.
4. Get approval
Plugins must be approved before installation. Query state:
query { plugin(identifier: "com.acme.order-preview") { ... on Plugin { state approvedRevision { id } name } }}Contact Crystallize on Slack to move it from pending → active. Once approved, approvedRevision.id is populated and the plugin appears in the Plugin Store.
5. Install on a tenant
Either via the App UI (renders the schema, encrypts secrets in the browser) or via the API. For API installs, you must encrypt secrets yourself:
crystallize plugin encrypt-secret --public-key /path/to/public.jwk.json# paste the plaintext secret; outputs a JWE compact stringThen call (on https://api.crystallize.com/@<tenant>):
mutation { createPluginInstallation( input: { pluginIdentifier: "com.acme.order-preview" revisionId: "<revisionId>" grantedScopes: [] configuration: { theme: "dark" } # non-secret fields encryptedSecrets: { apiKey: "<JWE compact string>" } # one entry per secret } ) { ... on BasicError { message } ... on PluginInstallation { id } }}On install, Crystallize POSTs the post-install body to $upstream/$tenantIdentifier/$postInstallationUri. See Develop the upstream below.
Develop the Upstream
The upstream is your server. Two endpoint shapes to handle — both POST with the same wire format:
- Post-install webhook (
$postInstallationUri) — POST with form-encoded bodypayload=<JWE>. - Entrypoints (
$target) — POST with form-encoded bodypayload=<JWE>.
Both decrypt to the same plaintext shape (with different fields populated). Use createPluginPayloadDecrypter from @crystallize/js-api-client.
Decrypter setup
import { createPluginPayloadDecrypter } from "@crystallize/js-api-client";
const decrypter = createPluginPayloadDecrypter({ privateJwk: JSON.parse(process.env.PLUGIN_PRIVATE_JWK!), verify: { audience: process.env.PLUGIN_IDENTIFIER!, // matches `aud` in the Backend Token verifyBackendToken: true, // verifies RS256 via Crystallize JWKS },});Always read the private JWK from a secret env var — never commit it.
Post-install handler
app.post("/:tenantIdentifier/post-install", async (c) => { const tenantIdentifier = c.req.param("tenantIdentifier"); try { // Form-encoded. Crystallize may send the JWE under either field name — // accept both for forward compatibility. const body = await c.req.parseBody<{ payload?: string; encryptedPayload?: string }>(); const jwe = typeof body?.payload === "string" ? body.payload : body?.encryptedPayload; if (typeof jwe !== "string") return c.text("invalid body", 400);
const decoded = await decrypter(jwe); if (decoded.envelope?.tenantIdentifier !== tenantIdentifier) { return c.text("tenant mismatch", 403); // path tenant must match payload tenant } // decoded.envelope.event is "install" | "reinstall" | "uninstall" // decoded.envelope.configuration carries non-secret config; decoded.secrets // carries plaintext secrets (already decrypted by the decrypter). // Persist tenant-scoped state. Handler MUST be idempotent — no retries. return c.text("ok"); } catch { return c.text("bad payload", 400); }});Entrypoint handler
import { createClient } from "@crystallize/js-api-client";
app.post("/:tenantIdentifier/order/preview", async (c) => { const tenantIdentifier = c.req.param("tenantIdentifier"); try { const body = await c.req.parseBody(); // form-encoded if (typeof body?.payload !== "string") return c.text("invalid body", 400);
const decoded = await decrypter(body.payload); if (decoded.envelope?.tenantIdentifier !== tenantIdentifier) { return c.text("tenant mismatch", 403); }
// Plaintext secrets are at decoded.secrets[name] — no manual decrypt step. // Call Crystallize APIs on behalf of the viewer using the Backend Token. const api = createClient({ tenantIdentifier, bearerToken: decoded.envelope.backendToken, }); const data = await api.nextPimApi<{ tenant: { id: string; name: string }; }>("{ tenant { ... on Tenant { id name } } }");
return c.html(renderPage(decoded, data)); } catch (err) { console.error(err); return c.text("bad payload", 400); }});Decoded payload shape
The decrypter returns a structured DecryptedPluginPayload (see @crystallize/js-api-client):
| Field | Description |
|---|---|
envelope.tenantIdentifier | Always check against the path param. |
envelope.tenantId | Numeric tenant ID — needed by some Crystallize APIs. |
envelope.installationId | Stable across reinstalls of the same (tenant, plugin). |
envelope.pluginIdentifier | Sanity-check it’s yours. |
envelope.revisionId | Which revision this installation is pinned to. |
envelope.configuration | Non-secret configuration values (plaintext). |
envelope.encryptedSecrets | Per-field JWE ciphertext (raw). The decrypter already plaintexts these into secrets — only touch this for re-encryption. |
envelope.entityContext | e.g. { orderId: "..." } for entity-scoped placements; omitted for dashboard. |
envelope.event | Webhook variant only: "install" | "reinstall" | "uninstall". Absent on iframe payloads. |
envelope.backendToken | RS256 JWT (string) — pass as Authorization: Bearer … to Crystallize APIs. |
envelope.signatureSecret | (optional) Shared secret for verifying inbound Crystallize webhooks via createSignatureVerifier. Present when applicable. |
envelope.staticAuthToken | (optional) Long-lived bearer for Discovery / non-iframe contexts. Present when applicable. |
secrets | Plaintext map of secret values, already decrypted by the decrypter. Use this — don’t re-decrypt encryptedSecrets. |
signatureStatus.verified | true if the outer payload signature checks out. |
backendTokenStatus.verified | true if the JWKS verification of the Backend Token passed (when verifyBackendToken: true). |
backendTokenStatus.claims | Decoded claims (sub = userId, aud = your plugin identifier, act = { pluginId, installationId, revisionId }). |
Backend Token rules
- TTL: 1 hour. Never cache it across requests — every iframe load mints a fresh one.
- Send as
Authorization: Bearer <jwt>to any Crystallize API. The server verifies signature + plugin state on every call (theinactivekill-switch is live, not TTL-bounded). - The token impersonates the viewing user, bounded by their permissions. A plugin cannot escalate privileges.
Local Testing Without the App UI
You don’t need to click through the App UI to exercise an endpoint locally. Crystallize exposes the same payload-issuing mutation directly:
mutation { issuePluginPayload( installationId: "<installationId>" entryPointId: "<entryPointId>" entityContext: { orderId: 42 } # optional, depends on placement ) { ... on PluginPayload { encryptedPayload url } }}Then POST it yourself:
curl -X POST "$URL" \ --data-urlencode "payload=$ENCRYPTED_PAYLOAD"For the post-install webhook, the wire format is identical — curl --data-urlencode "payload=$ENCRYPTED_PAYLOAD" "$URL". To re-trigger an install event for testing, re-install the plugin (it’s atomic and keeps the same installationId).
For local development of the upstream, expose your dev server with a tunnel (e.g. ngrok) and set that URL as the revision’s upstream — code under upstream can change without a new revision.
Dev-payload Vite plugin (no Crystallize round-trip)
If you’re on Vite, the crystallize-buddy example ships a vite/dev-payload.ts plugin that intercepts GETs in dev and mints a fake JWE envelope (encrypted with your local public.jwk.json, then re-POSTs it to your handler). You declare the routes you want to mock in dev-payload.config.jsonc (entrypoints, post-install events, entity context, plaintext secrets that get JWE-wrapped). This decouples local development from the App UI entirely — open the route in a browser, your handler sees a payload identical to production.
The plugin uses a dev-payload:// prefix on the locally-minted backendToken as a sentinel; your decrypter middleware should let unsigned dev payloads through when it sees that prefix (production payloads always carry a real RS256 token and a verified signature). Lift this pattern wholesale if your stack permits.
Common Mistakes
| Mistake | What to do |
|---|---|
Storing PLUGIN_PRIVATE_JWK in source control | Treat it like any other private key. Env var only, secret manager in production. |
Forgetting to validate tenantIdentifier from the path vs payload | Always compare both. The contract requires the path to match the payload’s tenantIdentifier. |
Trusting pluginIdentifier from payload without checking | Compare it to your own PLUGIN_IDENTIFIER. The decrypter’s audience check covers the token; do this too. |
| Caching the Backend Token | Never. It’s per-load, 1h TTL, and live-revoked when the plugin goes inactive. |
| Designing the post-install handler with side effects that aren’t idempotent | Webhook is fire-and-forget, no retries. Make it idempotent or rely on the next iframe load’s payload. |
| Trying to mutate revision fields after creation | A revision is immutable. Create a new revision and re-install to migrate. |
Putting secrets in plaintext configurationSchema properties | List the property name in the top-level secrets[] array. The App UI then encrypts it client-side. |
| Auto-migrating users to a new revision | Installations pin a revisionId. Users must re-install to consent to the new contract. |
Working Examples
The Crystallize plugins monorepo ships two reference plugins:
plugins/hello-world— minimal Bun + Hono server. Two routes (post-install+ entrypoint) showing payload decryption, envelope/tenant validation, and a Backend-Token-authenticated Crystallize API call. Mirror this layout (src/index.tsfor routes,.envforPLUGIN_PRIVATE_JWK+PLUGIN_IDENTIFIER) for any new plugin.plugins/crystallize-buddy— full-featured Cloudflare Workers plugin (Hono + Vite + JSX SSR with React islands, Awilix DI, webhook receiver, SSE channel). Useful when you outgrow a single-file server: shows a reusablepayloadDecryptermiddleware, a per-tenant routing pattern (/:tenantIdentifier/...), and a local dev-payload Vite plugin that mints fake JWE payloads on GET so you can develop without round-tripping through Crystallize.
References
- Plugin Contract — normative spec: entities, fields, JWE/JWT formats, sequence diagrams, security model, V1 vs V2 behavior.
- Official documentation
- Companion skill: js-api-client —
createClient,createPluginPayloadDecrypter, all API callers. - CLI helpers:
crystallize plugin keygen,crystallize plugin encrypt-secret --public-key <path>.
Reference Details
Crystallize Plugin Contract
This document defines the technical and security conventions every Plugin (vendor-hosted application) must respect to integrate with Crystallize.
A Crystallize Plugin is a vendor-hosted application that extends the Crystallize App UI with custom functionality — rendered inside iframes at predefined placement points. Plugins don’t run code in the App UI itself; they receive a scoped Backend Token to act on behalf of the signed-in user via Crystallize APIs, and any secrets they need are encrypted client-side with the vendor’s own public key.
Plugins are server-hosted. The iframe is loaded via a form POST carrying an encrypted payload that contains the Backend Token, configuration, and secrets. Plugin vendors must operate a backend that can handle POST requests and decrypt the payload — a Cloudflare Worker, Vercel/Netlify edge function, Lambda, or any server-side runtime. Pure static file hosting is not sufficient.
The contract establishes the interface between three parties:
- Crystallize Core — manages identity, tenancy, the plugin collection, and the plugin registry
- Crystallize App UI — renders buttons, views, configuration forms, and plugin iframes
- Vendor Upstream — provides the actual business logic and secure endpoints
Core Concepts
Three entities manage the lifecycle of a plugin from creation to installation.
Plugin Collection
Where plugins are defined. A developer creates a plugin by calling a mutation on /@me, providing the plugin metadata (name, description, logo, etc.) and its first revision.
Every plugin has a state:
pending— submitted, awaiting validation by Crystallizeactive— validated, visible in the Plugin Store and installableinactive— disabled. The plugin is no longer installable and stops working immediately across all tenants (live auth rejects any request made with a token whoseact.pluginIdmatches an inactive plugin — the kill is not bounded by token TTL).
A plugin can’t be installed until it reaches active state.
Revisions
A plugin evolves through revisions. A revision locks the contract surface — the fields that define what the plugin is allowed to do and where it shows up:
upstream— the base URL of the plugin originentryPoints— where the plugin appears in the UIscopes— the permissions the plugin requiresconfigurationSchema— the JSON Schema defining the install-time configuration formsecrets— which configuration fields are secretspublicKey— the JWK public key for payload encryptionpostInstallationUri— path where Crystallize POSTs installation dataversion— vendor-declared semver for this revision
A revision is immutable once submitted. The code running at upstream is entirely under the vendor’s control — bug fixes, UI changes, and performance improvements can be deployed anytime without a new revision. A new revision is only needed when the contract itself changes.
Plugin-level fields (name, description, logo, etc.) live on the plugin and can be updated independently of revisions. They’re cosmetic — they don’t affect what the plugin is allowed to do.
Revision fields are locked per revision because they directly affect the Backend Token: when a user installs a plugin, they consent to a specific upstream, a specific set of entrypoints, and a specific set of scopes. The token issued at runtime is bounded by that consent. Locking the contract per revision ensures the token always reflects what was approved at installation.
Each plugin carries an approvedRevisionId pointer. This is the revision that the Plugin Store surfaces and that new installations default to. Installations pin a specific revisionId and never auto-migrate — upgrading an installation to a newer revision requires the user to re-install.
The full mutation input is detailed in the Revision Fields section.
Plugin Registry
Where installations live. When a user installs a plugin on their tenant, the Plugin Registry stores the result: which tenant, which plugin, which revision, the non-secret configuration, the granted scopes, and the encrypted secrets.
One plugin can be installed on many tenants, each with its own configuration and scopes. Each (tenant, plugin) pair maps to exactly one installation.
To change configuration, scopes, or revision, the plugin must be re-installed. Re-install is atomic and keeps the same installationId.
Plugin Store
The public-facing catalog of available plugins. Only plugins in active state appear, enriched with marketing content (descriptions, screenshots, vendor info, etc.).
Users browse the Plugin Store in the App UI to discover and install plugins.
Base Requirements
A valid Plugin must satisfy:
- Hosting: a publicly accessible HTTPS domain, operated by the vendor. The origin must accept POST requests and decrypt payloads with the vendor’s private key. Pure static hosting is not sufficient.
- Plugin registration: the developer creates the plugin and its first revision via a mutation on
/@mein Crystallize Core. - Post-installation endpoint: a path declared in the revision (
postInstallationUri) where Crystallize POSTs configuration and encrypted secrets at install / reinstall / uninstall time.
Vendor Endpoints
All URLs follow the same pattern:
$upstream/$tenantIdentifier/$path$upstreamis the plugin’s declaredupstreamURL (from the revision).$tenantIdentifieris the Crystallize tenant the request is scoped to.$pathis eitherpostInstallationUri(for install events) or an entrypoint’starget(for iframe loads).
Post-Installation Endpoint
When a user installs, re-installs, or uninstalls a plugin on their tenant, Crystallize POSTs a form-encoded body to:
$upstream/$tenantIdentifier/$postInstallationUriThe body is application/x-www-form-urlencoded with a single field payload=<JWE> — the same wire format used for entrypoints. The plaintext body (after decrypting the JWE) is:
type PostInstallBody = { event: "install" | "reinstall" | "uninstall"; tenantIdentifier: string; installationId: string; userId: string; // installer / actor config?: Record<string, JsonValue>; // omitted for uninstall encryptedSecrets?: Record<string, string>; // JWE-per-field, omitted for uninstall pluginIdentifier: string; revisionId: string; issuedAt: number; // epoch seconds};Delivery semantics: fire-and-forget, best-effort, no retries. A 2xx response counts as success; anything else is logged and ignored. The install/reinstall/uninstall mutation does not block on the vendor’s response — the user-visible operation completes regardless. Vendors should therefore design their post-install handler to be idempotent and to tolerate losing an occasional event.
Plugin UI Endpoints
All other endpoints (the ones loaded inside iframes) are addressed by an entrypoint’s target:
$upstream/$tenantIdentifier/$targetThese endpoints are called via POST. The body contains a single encrypted payload (see Plugin Loading Protocol).
The vendor chooses the target paths — the entrypoints registered in the revision are the source of truth.
Plugin Definition
The plugin definition is submitted via a mutation on /@me. It lives in the Plugin Collection.
The definition has two layers: plugin-level fields describing the plugin (updatable anytime) and revision-level fields defining the contract (locked per revision).
Plugin Fields
| Field | Type | Description |
|---|---|---|
name | string | Display name of the plugin |
identifier | string | Reverse-DNS identifier, globally unique, immutable (e.g. com.acme.invoice) |
author | string | (optional) Author / vendor name |
description | string | (optional) Short description |
logo | string | (optional) URL to a logo image |
icon | string | (optional) URL to an icon image |
Revision Fields
These define the contract surface and are locked per revision. Changing any of them requires a new revision via createPluginRevision.
| Field | Type | Description |
|---|---|---|
upstream | String! | Base HTTPS URL of the plugin origin |
entryPoints | [PluginEntryPointInput!]! | Where the plugin appears in the UI (see Entrypoints) |
scopes | [PluginScope!] | Permissions the plugin requires (see Scopes) |
configurationSchema | JSON | JSON Schema (draft 2020-12) defining the install-time configuration form (see Configuration) |
secrets | [String!] | Configuration property names to treat as secrets (see Secrets) |
publicKey | PluginPublicKeyInput! | JWK public key used to encrypt secrets at install time and the iframe payload at runtime (see Cryptography) |
postInstallationUri | String! | Path where Crystallize POSTs installation data (see Post-Installation Endpoint) |
version | String! | Semver string. The plugin’s “current version” is the version of approvedRevisionId |
input CreatePluginRevisionInput { upstream: String! entryPoints: [PluginEntryPointInput!]! scopes: [PluginScope!] configurationSchema: JSON secrets: [String!] publicKey: PluginPublicKeyInput! postInstallationUri: String! version: String!}Scopes
The scopes field declares the permissions the plugin needs. This is what the installer reviews before granting access.
{ "scopes": ["order:read", "order:write", "customer:read"]}At install time:
- The installer must hold all requested scopes — you can’t install a plugin that asks for permissions you don’t have.
- The installer can restrict the granted scopes to a subset. For example, if the plugin asks for
order:readandorder:write, the installer can grant onlyorder:read.
V1 behavior: granted scopes are recorded on the installation but are not enforced when minting the Backend Token. In V1, the Backend Token carries the current user’s full permissions (bounded by the user’s own role, as always). Scope intersection enforcement — narrowing the token to
grantedScopes ∩ userPermissions— is planned for V2. Declare scopes honestly now so that the installer’s consent is meaningful and so that V2 will “just work” for your plugin.
Cryptography
The revision must include a publicKey object in JWK format (RFC 7517). This key serves two purposes:
- At install time: encrypt each secret configuration value individually (client-side, in the App UI) before submitting to Crystallize.
- At runtime: encrypt the entire iframe payload on every load (server-side, in Crystallize Core).
Only the vendor holds the matching private key. Crystallize never holds plaintext secrets.
The key must be an RSA key with:
| JWK field | Required value |
|---|---|
kty | RSA |
use | enc |
alg | RSA-OAEP-256 |
enc | A256GCM |
JWE compact serialization is used on the wire.
{ "publicKey": { "kty": "RSA", "kid": "public", "use": "enc", "alg": "RSA-OAEP-256", "enc": "A256GCM", "n": "<modulus>", "e": "AQAB" }}Configuration
A plugin is a single application deployed once by the vendor, but it can be installed on many tenants, each with its own context. The configuration is what makes each installation unique.
The revision declares the shape of the configuration as a JSON Schema. When a user installs the plugin, the App UI renders that schema as a form. The values the user fills in become that installation’s specific configuration.
The same Invoice App can be installed on Tenant A with a purple theme pointing to Stripe instance X, and on Tenant B with a blue theme pointing to Stripe instance Y. The plugin code is identical — the configuration is what differentiates each installation.
Configuration is set at installation time and stored in the Plugin Registry. Non-secret values are stored in plaintext; secrets are stored as ciphertext (see below). To change any of it, the plugin must be re-installed.
The configurationSchema field must be a valid JSON Schema, draft 2020-12.
{ "configurationSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "additionalProperties": false, "required": ["organizations"], "properties": { "StripeApiKey": { "type": "string", "title": "Stripe API Key", "description": "API Key of the Stripe instance" }, "organizations": { "type": "array", "minItems": 1, "items": { "type": "object", "additionalProperties": false, "required": ["label", "email"], "properties": { "label": { "type": "string", "title": "Organization Label" }, "email": { "type": "string", "title": "Organization Email" }, "address": { "type": "string", "title": "Organization Address" } } } } } }}At install and re-install, Crystallize validates the submitted config against this schema (with secret-field nodes excluded from the validated subset).
Secrets
A top-level array of configuration property names to treat as secrets:
{ "secrets": ["StripeApiKey", "mySuperSecretPassword"]}Every name in secrets[] must resolve to a property in configuration.
When a property is listed in secrets, the App UI:
- Renders an
<input type="password">for that field. - Encrypts the value in the browser using the plugin’s
publicKey, producing a JWE compact string. - Submits
{ config, encryptedSecrets }to Crystallize. The plaintext secret value never leaves the user’s browser.
Crystallize stores the ciphertext verbatim. Each stored ciphertext is tagged with the revisionId it was encrypted against.
Re-install rules:
- If the user re-installs against the same revision and leaves a secret field blank, the existing ciphertext is preserved (passthrough).
- If the user re-installs against a new revision, every secret must be re-encrypted against the new revision’s
publicKey. Crystallize rejects stale-tagged ciphertexts to prevent silent leaks if a vendor rotates keys.
At runtime, Crystallize passes the stored ciphertexts through into the iframe payload as-is. The payload itself is then wrapped in an outer JWE using the same publicKey. The plugin’s backend:
- Decrypts the outer envelope to retrieve the payload plaintext.
- Reads
encryptedSecrets[field]— a per-field JWE — and decrypts it a second time with the same private key.
Only the vendor can decrypt secrets. Crystallize never holds plaintext.
Entrypoints
Entrypoints declare where the plugin appears in the Crystallize App UI. Each entrypoint defines a unique placement.
Placements follow the convention:
$CONCERN/$VIEW/$PLACEMENT(/$TYPE?)- concern — the domain entity:
order,customer,subscription-contract,dashboard, … - view — which view on that entity:
view,edit,nerdy,developer,create - placement — where on that page:
toolbar-button,main,side-widget,main-widget - type (optional) — how the UI renders it:
dialog,widget,link
Examples:
orders/nerdy/toolbar-button (dialog by design)orders/nerdy/toolbar-action/link (redirect by design)orders/nerdy/toolbar-action/button (dialog by design)orders/nerdy/main-widget (inline by design)order/view/main (widget by design)order/view/toolbar-button (dialog by design)order/view/sidebar (widget by design)In V1, placement is a free string (no enum enforcement). Stick to the convention so your plugin lines up with where the App UI actually renders entrypoints.
Each entrypoint has the following fields:
| Field | Type | Description |
|---|---|---|
id | string | Server-assigned at revision creation. Used by the App UI in issuePluginPayload(installationId, entryPointId). |
placement | string | The $CONCERN/$VIEW/$PLACEMENT(/$TYPE?) identifier |
target | string | URI path suffix appended to $upstream/$tenantIdentifier/. Must start with /, must not contain ://, must not contain .. segments. |
label | string | (optional) Display label |
icon | string | (optional) URL to an icon |
Entity Context
Most concerns (order, customer, subscription-contract, …) are entity-scoped — the plugin appears on a page tied to a specific resource. For entity-scoped entrypoints, the App UI passes the entity context (at minimum the entity ID) in the payload so the plugin knows which resource is currently on screen.
Some concerns, such as dashboard, are not entity-scoped — the plugin operates at the tenant level. For those, entityContext is omitted from the payload.
Examples
A plugin adding a “Preview Order” button on both the Order view and edit pages, plus a dashboard widget:
{ "entryPoints": [ { "placement": "order/view/toolbar-button", "target": "/order/preview", "label": "Preview Order", "icon": "https://example.com/icon.png" }, { "placement": "order/edit/toolbar-button", "target": "/order/preview", "label": "Preview Order", "icon": "https://example.com/icon.png" }, { "placement": "order/edit/main-widget", "target": "/order/edit-widget", "label": "Order Edition Customer documentation" }, { "placement": "dashboard/view/main", "target": "/dashboard/main", "label": "Tenant Overview" } ]}Note that order/edit/main-widget and order/edit/toolbar-button are two distinct positions on the same page. Without view in the placement, there would be no way to distinguish order/edit/main from order/view/main.
Installation Flow
End-to-end sequence when a user installs a plugin on their tenant. The same shape applies to re-install (event: "reinstall") and uninstall (event: "uninstall", no config / encryptedSecrets in the webhook body).
sequenceDiagram autonumber actor Installer participant AppUI as Crystallize App UI participant Core as Crystallize Core participant Vendor as Vendor Backend
Installer->>AppUI: Open Plugin Store, pick plugin AppUI->>Core: Fetch plugin + approved revision Core-->>AppUI: upstream, configuration schema,<br/>secrets[], scopes[], publicKey,<br/>revisionId, postInstallationUri AppUI->>Installer: Render form from JSON Schema<br/>(password inputs for secret fields) Installer->>AppUI: Fill config, choose granted scopes
Note over AppUI: For each field in secrets[]:<br/>encrypt its value with revision.publicKey<br/>(RSA-OAEP-256 + A256GCM, JWE compact).<br/>Plaintext secret never leaves the browser.
AppUI->>Core: createPluginInstallation(<br/>pluginId, revisionId, grantedScopes,<br/>config, encryptedSecrets)
Note over Core: Validate:<br/>• plugin.state = active<br/>• revisionId = approvedRevisionId<br/>• config vs schema (secret fields stripped)<br/>• encryptedSecrets well-formed JWE<br/> and tagged with this revisionId
Core->>Core: Persist installation<br/>(config plaintext, encryptedSecrets<br/>stored verbatim, tagged with revisionId)
Note over Core: Wrap post-install body in outer JWE<br/>using revision.publicKey.<br/>encryptedSecrets passed through<br/>(already per-field encrypted).
par Webhook (fire-and-forget) Core-)Vendor: POST $upstream/$tenantIdentifier/$postInstallationUri<br/>Content-Type: application/x-www-form-urlencoded<br/>body: payload=<outer JWE of><br/>{ event:"install", tenantIdentifier,<br/> installationId, userId, config,<br/> encryptedSecrets, pluginIdentifier,<br/> revisionId, issuedAt } and Mutation returns Core-->>AppUI: { installationId, ... } AppUI-->>Installer: Installation complete end
Note over Vendor: 1. Decrypt outer JWE → post-install body<br/>2. Decrypt each encryptedSecrets[field]<br/> a second time (same private key)<br/>3. Persist tenant-scoped state<br/>Handler MUST be idempotent — no retries. Vendor--)Core: 2xx (logged, not blocking)Notes on the flow
- Crystallize never sees plaintext secret values. The only party that can decrypt them is the vendor, who holds the private key matching
revision.publicKey. - The webhook is delivered once, best-effort. If the vendor is down, the install still completes — the authoritative state is always re-delivered on the next iframe load (see below).
- Re-install with a new
revisionIdrequires the browser to re-encrypt all secret fields against the new revision’spublicKey. Core rejects stale-tagged ciphertexts. - Re-install against the same
revisionIdmay leave secret fields blank; existing ciphertexts are preserved (passthrough).
Plugin Loading Protocol
The App UI uses a form-submit-to-named-iframe pattern to load each entrypoint:
- The App UI calls
issuePluginPayload(installationId, entryPointId, entityContext?)on Crystallize Core. The server returns{ url, encryptedPayload }, whereurlis$upstream/$tenantIdentifier/$targetandencryptedPayloadis a JWE compact string. - An empty iframe is rendered with a
nameattribute. - A hidden
<form>targets that iframe by name and submits viaPOST.
<iframe name="plugin-frame-<entryPointId>"></iframe><form method="POST" action="<url>" target="plugin-frame-<entryPointId>"> <input type="hidden" name="payload" value="<encryptedPayload>" /></form>Encrypted Payload
The POST body contains a single encrypted payload, built by Crystallize Core and encrypted with the revision’s publicKey. After decryption, the plaintext shape is:
type PluginPayloadPlaintext = { backendToken: string; // RS256 JWT — see next section configuration: Record<string, JsonValue>; // plaintext non-secret settings encryptedSecrets: Record<string, string>; // per-field JWE compact strings entityContext?: Record<string, JsonValue>; // e.g. { orderId: "..." }, omitted for non-entity concerns installationId: string; tenantIdentifier: string; pluginIdentifier: string; // reverse-DNS; sanity-check that this matches you revisionId: string; userId: string; // the viewing user; redundant with backendToken.sub issuedAt: number; // epoch seconds expiresAt: number; // epoch seconds, matches backendToken.exp};This pattern provides:
- No sensitive data in the URL or query string
- Nothing in server/CDN access logs
- No browser history pollution
- No URL length limits
- A single round-trip on load (POST, not GET)
- End-to-end encryption between Crystallize Core and the plugin’s backend
The plugin origin must accept the POST, decrypt the payload, and return HTML.
Loading Flow
End-to-end sequence for rendering one entrypoint in the App UI.
sequenceDiagram autonumber actor Viewer participant AppUI as Crystallize App UI participant Core as Crystallize Core participant Frame as Browser iframe participant Vendor as Vendor Backend participant API as Crystallize API
Viewer->>AppUI: Navigate to page<br/>(e.g. order/view/123) AppUI->>Core: List installed entrypoints<br/>matching placement for this page Core-->>AppUI: [{ installationId, entryPointId,<br/> label, icon, ... }]
loop For each matching entrypoint AppUI->>Core: issuePluginPayload(<br/>installationId, entryPointId,<br/>entityContext?)
Note over Core: 1. Check plugin.state = active<br/>2. Mint Backend Token (RS256 JWT):<br/> iss = Core base URL<br/> sub = viewing userId<br/> aud = pluginIdentifier<br/> exp = iat + 3600<br/> act = { pluginId, installationId, revisionId }<br/>3. Assemble plaintext payload:<br/> { backendToken, config,<br/> encryptedSecrets (passthrough),<br/> entityContext, installationId,<br/> tenantIdentifier, pluginIdentifier,<br/> revisionId, userId,<br/> issuedAt, expiresAt }<br/>4. Wrap plaintext in outer JWE<br/> using revision.publicKey
Core-->>AppUI: { url: $upstream/$tenantIdentifier/$target,<br/> encryptedPayload }
AppUI->>AppUI: Render empty <iframe name=...><br/>+ hidden <form method=POST action=url<br/> target=iframeName><br/> <input name="payload"<br/> value="<encryptedPayload>">
AppUI->>Frame: Auto-submit form Frame->>Vendor: POST $upstream/$tenantIdentifier/$target<br/>body: payload=<encryptedPayload>
Note over Vendor: 1. Decrypt outer JWE → plaintext payload<br/>2. Verify backendToken via JWKS<br/> ($CORE/.well-known/jwks.json):<br/> iss, aud, exp, act<br/>3. Decrypt encryptedSecrets[field]<br/> on demand (same private key)
opt Vendor calls Crystallize APIs on behalf of the viewer Vendor->>API: Request<br/>Authorization: Bearer <backendToken>
Note over API: • Verify RS256 signature via JWKS<br/>• Load real user by sub = userId<br/>• Live plugin-state check:<br/> reject if plugin not active<br/> (not bounded by token TTL)<br/>• Apply user's permissions
API-->>Vendor: Response end
Vendor-->>Frame: HTML Frame-->>Viewer: Rendered plugin UI endNotes on the flow
- The URL is assembled server-side — the App UI treats
urlas opaque and never composes it from parts. - Nothing sensitive appears in the iframe’s
srcor in any URL. The encrypted payload rides in the POST body. - The
inactivekill-switch is enforced at the API boundary on every call, not just at token issue time — a token minted just before a plugin is disabled stops working immediately. - Vendors who skip token verification can still read
userId/pluginIdentifier/revisionIdfrom the plaintext payload for display purposes, but any call back to Crystallize APIs requires the Bearer token — which Crystallize verifies end-to-end.
Backend Token
The Backend Token is generated by Crystallize Core on every plugin load and delivered inside the encrypted payload. No exchange step is required — the plugin uses it immediately to call Crystallize APIs server-side.
The token impersonates the visitor (the user currently viewing the page).
Format
RS256 JWT, signed by Crystallize Core.
Claims
| Claim | Value |
|---|---|
iss | Crystallize Core base URL |
sub | The viewing user’s userId |
aud | Your plugin’s identifier (reverse-DNS) |
exp | iat + 3600 (1 hour TTL) |
iat | Issue time (epoch seconds) |
jti | Random UUID |
act | { pluginId, installationId, revisionId } — RFC 8693 “actor” claim, identifies the plugin acting on behalf of the user |
Verification
Verify every token you receive:
- Fetch Crystallize’s public JWKS at
$CORE_BASE_URL/.well-known/jwks.json. - Verify the RS256 signature.
- Check
issmatches the Crystallize Core base URL you expect. - Check
audmatches your plugin’sidentifier. - Check
expis in the future. - Check
act.pluginId/act.installationId/act.revisionIdmatch the installation you’re handling.
Send the token as a Bearer credential:
Authorization: Bearer <jwt>Crystallize’s incoming auth pipeline verifies the token against the JWKS, rejects it if the plugin is not currently in active state (live check, not TTL-bounded), and produces a normal user context with act surfaced as the via field.
Permissions Model
Plugin permissions follow least privilege:
- The plugin revision declares the permissions the plugin requires (
scopes). - At install time, the installer (who must hold all requested permissions) can restrict the granted scopes to a subset.
- At runtime, the effective permissions are — conceptually — the intersection of:
- The scopes granted to the plugin at installation
- The current viewing user’s own permissions
This means a plugin can never escalate a user’s privileges. If a user has read-only access to orders, the plugin cannot write orders on their behalf — regardless of what the plugin requested.
V1 behavior: the Backend Token currently carries the viewing user’s full permissions.
grantedScopesis stored on the installation but not yet enforced when minting the token. The user’s own permissions still bound the token in the usual way. Scope-intersection enforcement is planned for V2; declaring accurate scopes now is still the right thing to do — the installer’s consent is recorded and becomes binding in V2 without any vendor change.
Security Considerations
- Plugin validation. Plugins must be approved by Crystallize before they become installable. The state model (
pending→active) ensures nothing runs without review.inactiveis a live kill-switch that invalidates every outstanding Backend Token for the plugin regardless of TTL. - Revision locking. Each revision locks the contract surface (upstream URL, entrypoints, scopes, configuration, secrets, public key, post-installation URI, version). The contract the user approved at install time is the one that runs. Code changes at
upstreamdon’t require a new revision; changes to the contract itself do. - Pinned installations. Installations pin a specific
revisionIdand never auto-migrate. The user has to re-install to move to a newer revision — at which point they re-consent to the new contract and re-encrypt all secrets against the new public key. - End-to-end payload encryption. Every iframe load delivers a JWE-encrypted payload that only the plugin’s backend can decrypt. The Backend Token, configuration, entity context, and secrets are never readable in the DOM or in any intermediate layer.
- Secrets isolation. Secret configuration values are encrypted in the installer’s browser using the revision’s
publicKeyand are stored as ciphertext only. The outer payload envelope is then encrypted again with the same public key. Crystallize never sees plaintext secrets — not during install, not during storage, not during runtime delivery, not during the post-install webhook. - No code execution in the UI. The App UI only renders iframes, links, and forms. No vendor code runs in the App UI’s JavaScript context.
- Plugin-to-App UI communication. A defined protocol (postMessage-based) governs iframe ↔ parent communication, with a shared library for common operations (open, close, select).
- Vendor trust. Installing a plugin means trusting the vendor with any secrets passed. The Plugin Store surfaces vendor identity clearly.
- Webhook idempotency. The post-install webhook is fire-and-forget with no retries. Design your handler to be idempotent and to tolerate occasional lost events — the authoritative state always lives on the next iframe load’s payload, which carries full
configandencryptedSecrets.
Crystallize AI