Crystallize Plugins
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.
- 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$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 JWE-encrypted body to:
$upstream/$tenantIdentifier/$postInstallationUri$upstream/$tenantIdentifier/$postInstallationUriThe plaintext body (after decryption) is:
type PostInstallBody = {
event: 'install' | 'reinstall' | 'uninstall'
tenantIdentifier: string
installationId: string
userId: string // installer / actor
configuration?: Record<string, JsonValue> // omitted for uninstall
encryptedSecrets?: Record<string, string> // JWE-per-field, omitted for uninstall
pluginIdentifier: string
revisionId: string
issuedAt: number // epoch seconds
}
type PostInstallBody = {
event: 'install' | 'reinstall' | 'uninstall'
tenantIdentifier: string
installationId: string
userId: string // installer / actor
configuration?: 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/$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
These describe the plugin itself. They can be updated independently of revisions.
Field | Type | Description |
|---|---|---|
|
| Display name of the plugin |
|
| Reverse-DNS identifier, globally unique, immutable (e.g. |
|
| (optional) Author / vendor name |
|
| (optional) Short description |
|
| (optional) URL to a logo image |
|
| (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 |
|---|---|---|
|
| Base HTTPS URL of the plugin origin |
|
| Where the plugin appears in the UI (see Entrypoints) |
|
| Permissions the plugin requires (see Scopes) |
|
| JSON Schema (draft 2020-12) defining the install-time configuration form (see Configuration) |
|
| Configuration property names to treat as secrets (see Secrets) |
|
| JWK public key used to encrypt secrets at install time and the iframe payload at runtime (see Cryptography) |
|
| Path where Crystallize POSTs installation data (see Post-Installation Endpoint) |
|
| Semver string. The plugin's "current version" is the |
input CreatePluginRevisionInput {
upstream: String!
entryPoints: [PluginEntryPointInput!]!
scopes: [PluginScope!]
configurationSchema: JSON
secrets: [String!]
publicKey: PluginPublicKeyInput!
postInstallationUri: String!
version: String!
}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"]
}{
"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.
Cryptographic Fields
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 |
|---|---|
|
|
|
|
|
|
|
|
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"
}
}{
"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" }
}
}
}
}
}
}
{
"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"]
}{
"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/$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)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 |
|---|---|---|
|
| Server-assigned at revision creation. Used by the App UI in |
|
| The |
|
| URI path suffix appended to |
|
| (optional) Display label |
|
| (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"
}
]
}
{
"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.
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><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
}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.
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). A Plugin Token gives access to Core Next only.
Format
RS256 JWT, signed by Crystallize Core.
Claims
Claim | Value |
|---|---|
| Crystallize Core base URL |
| The viewing user's |
| Your plugin's |
|
|
| Issue time (epoch seconds) |
| Random UUID |
|
|
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>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. grantedScopes is 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.
Plugin to Crystallize App UI (parent iFrame) communication Protocol
TDB