Skip to content

plugins

Terminal window
npx skills add https://github.com/crystallizeapi/ai --skill plugins

Crystallize 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:

  1. 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.
  2. Does it need to call Crystallize APIs? If yes, you’ll use the backendToken from the decrypted payload as a Bearer credential.
  3. 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.
  4. 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.
  5. 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):

EntityWhere it livesPurpose
Plugin/@meThe plugin definition (name, identifier, logo). Has a state: pendingactive.
Plugin Revision/@meThe locked contract surface: upstream, entrypoints, scopes, schema, secrets, key.
Plugin Installation/@:tenantA (tenant, plugin, revision) triple with the tenant-specific config + ciphertexts.

Key invariants:

  • A revision is immutable once submitted. Code at upstream can change anytime; only contract changes require a new revision.
  • Installations pin a revisionId and 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:

Terminal window
crystallize plugin keygen

This 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

FieldNotes
upstreamBase 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.
scopesPermissions the plugin needs. Installer must hold them; can grant a subset. (V1: not yet enforced on the token — declare honestly anyway.)
configurationSchemaJSON Schema draft 2020-12. Crystallize renders this as the install form. Validates submissions (with secret fields excluded).
secretsNames of properties from the schema to treat as secrets. Each becomes <input type="password"> and is JWE-encrypted in the browser.
publicKeyJWK from crystallize plugin keygen. Must be kty=RSA, use=enc, alg=RSA-OAEP-256, enc=A256GCM.
postInstallationUriPath Crystallize POSTs to on install / reinstall / uninstall events. Fire-and-forget, no retries — make your handler idempotent.
versionVendor-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 pendingactive. 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:

Terminal window
crystallize plugin encrypt-secret --public-key /path/to/public.jwk.json
# paste the plaintext secret; outputs a JWE compact string

Then 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 body payload=<JWE>.
  • Entrypoints ($target) — POST with form-encoded body payload=<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):

FieldDescription
envelope.tenantIdentifierAlways check against the path param.
envelope.tenantIdNumeric tenant ID — needed by some Crystallize APIs.
envelope.installationIdStable across reinstalls of the same (tenant, plugin).
envelope.pluginIdentifierSanity-check it’s yours.
envelope.revisionIdWhich revision this installation is pinned to.
envelope.configurationNon-secret configuration values (plaintext).
envelope.encryptedSecretsPer-field JWE ciphertext (raw). The decrypter already plaintexts these into secrets — only touch this for re-encryption.
envelope.entityContexte.g. { orderId: "..." } for entity-scoped placements; omitted for dashboard.
envelope.eventWebhook variant only: "install" | "reinstall" | "uninstall". Absent on iframe payloads.
envelope.backendTokenRS256 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.
secretsPlaintext map of secret values, already decrypted by the decrypter. Use this — don’t re-decrypt encryptedSecrets.
signatureStatus.verifiedtrue if the outer payload signature checks out.
backendTokenStatus.verifiedtrue if the JWKS verification of the Backend Token passed (when verifyBackendToken: true).
backendTokenStatus.claimsDecoded 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 (the inactive kill-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:

Terminal window
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

MistakeWhat to do
Storing PLUGIN_PRIVATE_JWK in source controlTreat it like any other private key. Env var only, secret manager in production.
Forgetting to validate tenantIdentifier from the path vs payloadAlways compare both. The contract requires the path to match the payload’s tenantIdentifier.
Trusting pluginIdentifier from payload without checkingCompare it to your own PLUGIN_IDENTIFIER. The decrypter’s audience check covers the token; do this too.
Caching the Backend TokenNever. 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 idempotentWebhook is fire-and-forget, no retries. Make it idempotent or rely on the next iframe load’s payload.
Trying to mutate revision fields after creationA revision is immutable. Create a new revision and re-install to migrate.
Putting secrets in plaintext configurationSchema propertiesList the property name in the top-level secrets[] array. The App UI then encrypts it client-side.
Auto-migrating users to a new revisionInstallations 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.ts for routes, .env for PLUGIN_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 reusable payloadDecrypter middleware, 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-clientcreateClient, 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 Crystallize
  • active — validated, visible in the Plugin Store and installable
  • inactive — disabled. The plugin is no longer installable and stops working immediately across all tenants (live auth rejects any request made with a token whose act.pluginId matches 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 origin
  • entryPoints — where the plugin appears in the UI
  • scopes — the permissions the plugin requires
  • configurationSchema — the JSON Schema defining the install-time configuration form
  • secrets — which configuration fields are secrets
  • publicKey — the JWK public key for payload encryption
  • postInstallationUri — path where Crystallize POSTs installation data
  • version — 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:

  1. 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.
  2. Plugin registration: the developer creates the plugin and its first revision via a mutation on /@me in Crystallize Core.
  3. 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 is the plugin’s declared upstream URL (from the revision).
  • $tenantIdentifier is the Crystallize tenant the request is scoped to.
  • $path is either postInstallationUri (for install events) or an entrypoint’s target (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/$postInstallationUri

The 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/$target

These 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

FieldTypeDescription
namestringDisplay name of the plugin
identifierstringReverse-DNS identifier, globally unique, immutable (e.g. com.acme.invoice)
authorstring(optional) Author / vendor name
descriptionstring(optional) Short description
logostring(optional) URL to a logo image
iconstring(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.

FieldTypeDescription
upstreamString!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)
configurationSchemaJSONJSON Schema (draft 2020-12) defining the install-time configuration form (see Configuration)
secrets[String!]Configuration property names to treat as secrets (see Secrets)
publicKeyPluginPublicKeyInput!JWK public key used to encrypt secrets at install time and the iframe payload at runtime (see Cryptography)
postInstallationUriString!Path where Crystallize POSTs installation data (see Post-Installation Endpoint)
versionString!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:

  1. The installer must hold all requested scopes — you can’t install a plugin that asks for permissions you don’t have.
  2. The installer can restrict the granted scopes to a subset. For example, if the plugin asks for order:read and order:write, the installer can grant only order: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:

  1. At install time: encrypt each secret configuration value individually (client-side, in the App UI) before submitting to Crystallize.
  2. 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 fieldRequired value
ktyRSA
useenc
algRSA-OAEP-256
encA256GCM

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:

  1. Renders an <input type="password"> for that field.
  2. Encrypts the value in the browser using the plugin’s publicKey, producing a JWE compact string.
  3. 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:

  1. Decrypts the outer envelope to retrieve the payload plaintext.
  2. 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:

FieldTypeDescription
idstringServer-assigned at revision creation. Used by the App UI in issuePluginPayload(installationId, entryPointId).
placementstringThe $CONCERN/$VIEW/$PLACEMENT(/$TYPE?) identifier
targetstringURI path suffix appended to $upstream/$tenantIdentifier/. Must start with /, must not contain ://, must not contain .. segments.
labelstring(optional) Display label
iconstring(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=&lt;outer JWE of&gt;<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 revisionId requires the browser to re-encrypt all secret fields against the new revision’s publicKey. Core rejects stale-tagged ciphertexts.
  • Re-install against the same revisionId may 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:

  1. The App UI calls issuePluginPayload(installationId, entryPointId, entityContext?) on Crystallize Core. The server returns { url, encryptedPayload }, where url is $upstream/$tenantIdentifier/$target and encryptedPayload is a JWE compact string.
  2. An empty iframe is rendered with a name attribute.
  3. A hidden <form> targets that iframe by name and submits via POST.
<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 &lt;iframe name=...&gt;<br/>+ hidden &lt;form method=POST action=url<br/> target=iframeName&gt;<br/> &lt;input name="payload"<br/> value="&lt;encryptedPayload&gt;"&gt;
AppUI->>Frame: Auto-submit form
Frame->>Vendor: POST $upstream/$tenantIdentifier/$target<br/>body: payload=&lt;encryptedPayload&gt;
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 &lt;backendToken&gt;
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
end

Notes on the flow

  • The URL is assembled server-side — the App UI treats url as opaque and never composes it from parts.
  • Nothing sensitive appears in the iframe’s src or in any URL. The encrypted payload rides in the POST body.
  • The inactive kill-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 / revisionId from 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

ClaimValue
issCrystallize Core base URL
subThe viewing user’s userId
audYour plugin’s identifier (reverse-DNS)
expiat + 3600 (1 hour TTL)
iatIssue time (epoch seconds)
jtiRandom UUID
act{ pluginId, installationId, revisionId } — RFC 8693 “actor” claim, identifies the plugin acting on behalf of the user

Verification

Verify every token you receive:

  1. Fetch Crystallize’s public JWKS at $CORE_BASE_URL/.well-known/jwks.json.
  2. Verify the RS256 signature.
  3. Check iss matches the Crystallize Core base URL you expect.
  4. Check aud matches your plugin’s identifier.
  5. Check exp is in the future.
  6. Check act.pluginId / act.installationId / act.revisionId match 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:

  1. The plugin revision declares the permissions the plugin requires (scopes).
  2. At install time, the installer (who must hold all requested permissions) can restrict the granted scopes to a subset.
  3. 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 (pendingactive) ensures nothing runs without review. inactive is 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 upstream don’t require a new revision; changes to the contract itself do.
  • Pinned installations. Installations pin a specific revisionId and 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 publicKey and 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 config and encryptedSecrets.


Crystallize AI