JustFlows

Документация пока доступна только на английском языке. Остальная часть сайта соответствует вашему языку.

Plugins

Write a plugin in plugins/<name>/, then install, activate, and manage .jfpkg packages. Manifest, permissions, and the SDK context.

8 минута чтения

Write a plugin

If you are developing against the CE source, create a folder under `plugins/` and start there. One folder per plugin. Copy plugins/hello-world (it listens to content.published). The pnpm workspace includes plugins/*, and the server scans that directory when you run from source. Import types from @justflows/sdk only. Build to dist/index.js — the runtime does not load TypeScript.

  1. 1

    Copy the example

    cp -R plugins/hello-world plugins/acme-seo

  2. 2

    Set the id

    Use an id of the form justflows.<name> in justflows.json and src/index.ts (justflows.acme-seo); the installer rejects any other id. Declare a GPL-compatible license.

  3. 3

    Write and build

    Edit src/. Then pnpm --filter justflows.acme-seo build so dist/index.js exists.

Note

Do not put plugin source under packages/. That tree is platform code. Site owners never copy folders into plugins/ — they install a .jfpkg in Admin.

Install a packaged plugin

Admin → Plugins. Drop a .jfpkg on the page or use justflows plugin install <path>. Then activate. Core is MIT; plugins keep the license in their manifest (example: GPL-2.0-or-later).

Updates and auto-update

Plugins, Themes, and Marketplace show Update available when the Marketplace has a newer build that works with this version of Justflows, with an Update to vX button and Check for updates. An active plugin is switched to the new build without a restart. If the new build fails to activate, the plugin is marked as an error and the reason is shown.

Each plugin and theme has an Auto-update switch. A background job checks shortly after boot and then twice a day, and installs updates within the same major version. Major updates wait for an administrator, beta builds are only offered while beta installs are allowed, and JUSTFLOWS_DISABLE_AUTO_UPDATE=1 turns automatic updates off. Updates go through the same signature, digest, and commercial-listing checks as installs, and are written to the audit log. API: GET /api/marketplace/updates, POST /api/marketplace/update, PUT /api/marketplace/auto-update (administrator).

Lifecycle APIs

ActionHTTPCLI
ListGET /api/pluginsjustflows plugin list
InstallPOST /api/pluginsjustflows plugin install <path>
ActivatePOST /api/plugins/:id/activatejustflows plugin activate <id>
DeactivatePOST /api/plugins/:id/deactivatejustflows plugin deactivate <id>
DeleteDELETE /api/plugins/:id—

Actions fired: plugin.installed, plugin.activated, plugin.deactivated, plugin.uninstalled. Runtime loads active plugins in plugin-runtime before deferred routes register.

A plugin can declare a setupPath, an admin page relative to /admin/plugins/<id>, and activating it opens that page for first-run setup. Admin sidebar pages are contributed through the admin.menu filter (needs admin:extend) and re-validated by GET /api/plugins/admin-menu. Plugin settings, secrets, and schema live in plugin_data, not site_settings, and every plugin implements a deleteData hook the host calls on uninstall.

Installed is not active

Uploading a package only installs it. Its module, admin navigation, blocks, and public routes become available after activation and are removed immediately on deactivation. Activation, deactivation, and deletion revalidate cached public pages.

Bundled content extensions

  • Forms — a fully standalone plugin. It ships its own block, its routes under /api/forms/ (such as /api/forms/submit), a framed admin app, and the public jf-forms.js enhancement script, and sends submission notifications through the permission-gated ctx.mail.send() API. Forms 0.4.0 can start Elsa Workflows when a form is sent. Keep it active if your site uses forms; the default Contact pattern cannot be imported until its block is active.
  • Gallery — registers its own block with Grid, Masonry, Carousel, Slideshow, and List layouts and a lightbox. Carousel uses scroll snap; Slideshow cross-fades; the public output needs no client-side gallery library.
  • Analytics — adds its public behavior only while the extension is active.
  • Cookie Consent — a categorised consent banner and preference center, per-category script and embed gating, and versioned consent records. Its admin screen ships in the plugin, in all eight admin languages. See Cookie consent.
  • SEO Toolkit — meta descriptions, Open Graph and Twitter Card tags, JSON-LD, title templates, the per-page SEO fields, and RSS, Atom, and JSON feeds. See SEO.
  • Shop — catalog, the /shop page, cart, checkout, orders, customers, payments, shipping, tax, discounts, and order emails. See the Shop guide.
  • Demo mode — a Demo role and demo sign-in that resets on a schedule. Plugins restore their own data through the justflows.demo.targets, justflows.demo.snapshot, and justflows.demo.restore hooks.

Minimal plugin

ts
import type { PluginModule } from "@justflows/sdk";

const plugin: PluginModule = {
  manifest: {
    id: "justflows.acme-welcome",
    name: "Acme Welcome",
    version: "1.0.0",
    license: "GPL-2.0-or-later",
    permissions: [],
    main: "index.js",
  },
  activate(ctx) {
    ctx.hooks.action("content.published", (event) => {
      ctx.logger.info("Published", { contentId: event.contentId });
    });
  },
};

export default plugin;

Ship a front-end asset bundle

For public-site JavaScript — or a standalone stylesheet — declare an assets block ({ dir?, scripts?, styles? }) in justflows.json and drop the files in the package. The host serves <dir>/** at /ext/<pluginId>/** and concatenates every active plugin's scripts and styles into one content-hashed `/jf-plugins.<hash>.js` / `.css` bundle added to every public page. No ctx.http route, no html.head filter. Each script is wrapped in its own IIFE; write them as progressive enhancement (no import/export), .js/.mjs/.css only, at most 20 of each. PLUGIN_ASSETS_BUNDLE=0 emits one tag per file. The static-site exporter downloads the bundle automatically, so a plugin front-end works on a CDN deployment with no extra wiring.

Ship your own admin app

A plugin's admin screens can be its own app rather than React pages compiled into the host bundle. Declare adminApp ({ dir?, routes: [{ path, entry, title }] }) alongside an adminMenu item and ship an HTML build; the host serves it at /ext/<pluginId>/admin/** and mounts each declared route in a same-origin <iframe> inside the admin shell. The two sides talk only over postMessage via the @justflows/admin-bridge package (ready, resize, navigate, and setLayout from the frame; context and route from the host) — no shared React runtime, no core page or route. The frame is same-origin, so it reads the CSRF cookie itself and calls the plugin's own ctx.http routes for data. bridge.setLayout("fill") gives the frame the whole content area while the sidebar and section tabs stay. Rules: dir/entry relative with no .., entry must be .html, each path is relative to /admin/plugins/<id> (omit it for the root), at most 40 routes. adminApp.locales lists a catalog per admin language; the host passes them to the frame, and adminMenu labels use them too.

An adminApp route nested under one of the plugin's own pages (for example products/bulk-edit under products) does not need its own adminMenu entry: it stays out of the sidebar and uses the parent page's section. Bulk edit: when a plugin lists a content type in the host's content list and ships a {path}/bulk-edit route, selecting rows shows a Bulk edit button next to Delete. The selected ids are handed to the plugin page through sessionStorage (jf.admin.bulkSelection).

  • Menu labels. An adminMenu item's labelKey is looked up in the plugin's own adminApp.locales catalogs first, and labels ({ "nl": "Beoordelingen" }) translates it without an admin app. roles names the roles that may open the page, such as one the plugin registers; without it, administrators and editors may. An unknown domain falls back to extensions.
  • Own sidebar section (Justflows 0.4.1). Items with domain: "plugin" are listed in a section for that plugin, above Extensions. adminSection names it (label, labelKey, or labels) and sets its icon: an emoji, or a .png, .svg, or .webp file in the admin app folder.
  • Content editor panels. editorPanels adds up to 5 sections next to Content, Discussion, and Advanced, each with up to 20 fields (text, textarea, url, image, or checkbox) saved in the entry's fields. Labels can be one string or one per locale, placeholder can use {title}, {excerpt}, and {url}, and indexableOnly limits the panel to content types included in search engines. From Justflows 0.4.1 a panel can embed an admin-app screen instead (app), which receives the entry's contentId, drafts included.
  • Workspace cards (Justflows 0.4.1). workspacePanels adds up to 5 cards to Platform → Workspace on the main site, each embedding an admin-app screen that receives the workspaceId.
  • A switch per website. A plugin that registers the website flag <plugin id>.enabled gets a switch in the website's Features list; off hides the plugin's admin pages on that website, and the plugin refuses its own routes with ctx.quotas.check. Register it with whenUnset: "off" to keep it off until a plan or an operator turns it on.
  • All of these need admin:extend.

Publish a health check

Declare diagnostics:publish and register a read-only check during activation with ctx.diagnostics.register({ id, label, run() }). It appears in Admin → System → Diagnostics. Check IDs are namespaced by plugin, execution is time-bounded by the host, and returned details are recursively redacted before display or export — checks must not return credentials, personal data, or raw provider responses. Registrations are removed automatically on deactivation.

Ship your own stylesheet

Public components rendered by a plugin should ship their CSS with that plugin. Keep source CSS under src/styles/, minify or copy it to dist/styles/ during pnpm build, read the built file once, and append it with the async theme.css filter during activation. Do not put plugin-specific rules in the active theme and do not inject a second stylesheet link through html.head.

ts
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";

const marker = "/* acme.catalog */";
const css = (await readFile(
  fileURLToPath(new URL("./styles/catalog.css", import.meta.url)),
  "utf8",
)).trim();

ctx.hooks.filter("theme.css", (current) =>
  current.includes(marker) ? current : `${current}\n${marker}\n${css}\n`,
);
  • Package the ready-to-serve file below dist/; installed plugins do not run TypeScript or a CSS toolchain.
  • Use plugin-namespaced classes and theme tokens such as --color-*, --space-*, and --radius-*.
  • Plugin CSS is added after theme styles and Customizer tokens, but before the site owner's Additional CSS.
  • Deactivation removes the filter and cache revalidation rebuilds /theme.css without the plugin CSS.
  • The combined plugin contribution is limited to 512 KiB. It is trusted extension output, so never concatenate request or site data into CSS.

Note

The theme.css filter runs once per cached stylesheet build, not per page request, and may be async. Cache file contents in the plugin process. See plugins/hello-world for a minimal example.

Block fields

A plugin block's schema field can set label and help for the inspector, optionLabels for the text shown per fixed choice, optionsUrl for a same-origin route the editor GETs for choices (answering { options: [{ value, label }] }), multiple for a checklist saved as a string array, and showWhen: { field, equals } to hide the field unless another prop matches. A field's type can also be media (one image URL) or images (a list of { src, alt, caption } added by upload, from the media library, or by URL). For those, the host adds a responsive prop before render; pass it through the SDK's applyResponsiveProp and renderResponsiveImage to get the same <picture>, srcset, and lazy loading as core.image, and use escapeHtml for captions. The page builder previews a plugin block with its own render, sanitized like an HTML block. See PluginBlockField.

ts
source: { type: "select", label: "Show", options: ["all", "picked"], optionLabels: { all: "Everything", picked: "Pick items" }, default: "all" },
items: {
  type: "string",
  label: "Items",
  multiple: true,
  optionsUrl: "/ext/acme.featured/blocks/options/items",
  showWhen: { field: "source", equals: "picked" },
},

Route policy

A plugin declares CSRF exemption, a host rate limit, and raw-body capture on the ctx.http route itself, as a third argument (PluginHttpRouteOptions). The host applies that policy; core never special-cases plugin URLs, and deactivating the plugin drops the policy with the route.

ts
ctx.http.post("payments/hooks/:gateway/:token", handler, {
  csrf: false,      // only when the call is authenticated another way, e.g. a signed webhook
  rawBody: true,    // exact request bytes on req.rawBody for signature checks
  rateLimit: { limit: 60, windowMs: 60_000, key: "payment-hook" },
});

Add mcp to a route's options to offer it to AI agents as one MCP tool, with the route's own handler and checks (see Bring your own AI). Management API keys (Authorization: Bearer jfk_…) authenticate plugin routes too: such requests skip CSRF, arrive with role: "api-key", and follow the key's site, IP, Origin, expiry, and rate-limit rules.

A route's response can also return redirect: "/path" to send the browser to a same-site path (303 unless status is already a redirect; absolute and protocol-relative URLs are ignored), or renderPage: "slug" to answer with one of the site's published pages in the active theme at the URL the visitor asked for, uncached. revalidate: true drops this site's caches after a config write; revalidate: "network" drops every site's, and needs platform:network and a request on the main site. req.trustedOrigin is true when the Origin header is this site or an origin the CORS allow-list vouches for, false for any other origin, and absent when there was none — drop a beacon another site fires at yours when it is false. Signed-in sessions now carry displayName.

Background jobs

With jobs:register, ctx.jobs.register() runs a handler on a five-part cron schedule. With perSite: true it runs once for every site where the plugin is active, inside that site's context and database, and gets siteId; one site's failure does not stop the others.

ctx.jobs.enqueue() stores each run in the job_queue table, so it survives a restart, and resolves once it is stored. Every call is its own run with its own payload. A process claims a due run with a lease and a heartbeat, so one run executes on one process at a time, and a run interrupted by a crash is picked up again. Failed runs are retried after 1, 5, 25, and then every 60 minutes until maxAttempts (default 3). Administrators see waiting, running, and failed runs under Diagnostics → Job queue and can retry them; payloads are never shown. Make handlers safe to repeat.

ts
ctx.jobs.register({
  name: "reconcile",
  schedule: "*/5 * * * *",
  perSite: true,
  handler: async () => {
    await repairOpenWork(ctx);
    return { success: true };
  },
});

await ctx.jobs.enqueue("send-receipt", { orderId }, { delayMs: 60_000 });

Plugin tables

ctx.databases.ensureSchema() creates your tables and, on every later activation, adds the declared columns and indexes existing tables lack (columnsAdded, indexesAdded). Added columns are nullable and not unique; types are never changed and nothing is dropped. For work that must not race or half-finish, use the row primitives inside transaction(): insert() returns false when a unique key is already taken, update() returns the matched rows for compare-and-set, increment() adds deltas with a floor, and find() / findOne() take orderBy and lock. find() also takes range and an after cursor to read any number of rows, 500 at a time. All of it works on PostgreSQL, MySQL, and MariaDB.

ts
await ctx.databases.transaction(async (tx) => {
  await tx.findOne("inventory", { id }, { lock: true });
  const moved = await tx.increment("inventory", { id }, { available: -qty }, { min: { available: 0 } });
  if (moved !== 1) throw new Error("Not enough stock");
  await tx.insert("stock_movements", { id: newId(), inventory_id: id, delta: -qty });
});

Private files and uploads

With files:private, ctx.files stores files for the current site that are never public, such as products sold as downloads: put, get, delete, and list. A route answers { file: { key } } to have the host stream one, with Range support and without revealing where it is stored. A route registered with binaryBody: { maxBytes } receives the raw request body as a Buffer (up to 1 GiB) to accept uploads. Files go to the site's storage from Settings → Storage, the installation's S3 bucket under .private/, or PRIVATE_STORAGE_PATH, and count toward the files.count and files.bytes limits.

ts
ctx.http.get("downloads/:token", async (req) => {
  const grant = await findGrant(req.params.token);
  if (!grant) return { status: 404, body: { error: "Not found" } };
  return { file: { key: grant.fileKey, filename: grant.name } };
});

Public API URLs

Declare apiNamespace (for example "shop") in both manifests and your routes are also served at /api/<namespace>/…, with the same site, authorization, CSRF, rate-limit, and body policies. Build URLs with ctx.http.url() on the server or pluginApiUrl() from the SDK in the browser. /ext/<id>/… keeps working as an alias. Routes can opt into structuredErrors: true so host guard failures answer { error: { code, message } }.

More context APIs

  • ctx.mail.send({ attachments }) attaches up to 5 files (PDF, PNG, JPEG, CSV, plain text, calendar; 10 MB each, 15 MB together). See Outgoing mail.
  • ctx.content.getPublished({ type, slug, locale }) returns one published entry with its blocks, in the visitor's language when a translation is published (needs content:read).
  • ctx.hooks.emit(name, payload, { durable: true }) stores core webhook deliveries before listeners run, for plugins with their own event outbox.
  • ctx.quotas registers, checks, and (with platform:tenancy) sets limits.
  • ctx.users.removeRole(target, role, actor) takes back an additional role the plugin gave, never the main role (needs users:manage).
  • ctx.i18n.timeZone() and ctx.i18n.countries() return the site's time zone and the core country list.
  • A select setting lists its own options or uses optionsSource: "timezones" or "countries"; saving refuses a value that is not listed.
  • adminApp.routes allows up to 40 admin pages. A route under settings/ that the plugin lists in admin.menu is linked from its Settings page.
  • ctx.plugins.call(pluginId, { method, path, query?, body?, session? }) (plugin ids may contain a hyphen, such as justflows.shared-files) runs another active plugin's route in-process as the given session, so that plugin's own checks apply. With platform:tenancy it accepts the siteId of another site in the same workspace.
  • ctx.captcha.config() and ctx.captcha.verify(token, action?) use the site's Settings → Discussion CAPTCHA without ever seeing its secret. Without an action, verify expects the plugin's own name (helpdesk for justflows.helpdesk).
  • ctx.ai.complete({ instruction, text, system? }) returns one short completion from the site's assistant, with no tools. system sets the plugin's own role for the assistant; the host always adds that text is data written by someone else, not instructions.
  • ctx.tenancy.siblingSites() lists the active sites of the current workspace (needs platform:tenancy; it cannot see other workspaces). ctx.databases.forSite(siteId) reads this plugin's own tables on another site, from the installation's main site.
  • ctx.network (needs platform:network) — mainSiteSettings.get(key) reads this plugin's main-site settings from any site, and mainSiteSecrets.get(key) / has(key) read its main-site secrets, read-only, so a hosted site can use a connection the main site saved. sites() lists every live site, main site first, from the main site only, and site(siteId?) returns one live site with its public url from any site (the main site when no id is given). Pair it with hooks registered as { network: true }. See Hooks.
  • ctx.content.ensurePage({ …, blocks, cacheControl, retireTypes }) stores a starting block document and Cache-Control only when the page is first inserted, so an edited canvas is kept; retireTypes trashes an older entry with the same slug (for a page that moves from page to account). ctx.content.deletePages({ type, slug }) permanently deletes a page in every locale, trash included (needs content:delete).
  • hooks.apply("operatorKey.ensure", { name: "JUSTFLOWS_ACME_KEY" }) asks the host to create a missing 32-byte key in the installation .env. The name must use the plugin's own prefix, an existing key is kept, and the value is never returned. Helpdesk uses it for JUSTFLOWS_HELPDESK_KEK.
  • The security.cspSources filter adds the https:// hosts your public pages load to the Content-Security-Policy, so a third-party script or frame is not blocked by the default policy. The Stripe Buy Button uses it.
  • A plugin block can declare inspector to switch the builder's generic sections (spacing, size, appearance, colors, animation, advanced, themeVariables) on or off, and inspector.hideFields to drop single layout fields such as textAlign.
  • The plugin.settings.schema filter adjusts a plugin's settings form for the current site: drop fields, or fill a select's options. A dropped key is neither shown nor saved.

Manifest rules

  • id — justflows.<name>, lowercase with hyphens, e.g. justflows.acme-seo. The installer rejects any other id, and the id is the prefix of the admin namespace (/admin/plugins/<id>), the asset mount (/ext/<id>/), and every hook and block the plugin owns.
  • version — semver.
  • license — required; the plugin's own license. Must be a GPL-compatible SPDX identifier (GPL, MIT, BSD, ISC, …). Not inherited from MIT core.
  • permissions — declared up front; sensitive ones are network:outbound, users:manage, settings:manage, auth:hook, mail:send, mail:transport, mail:templates, mail:hook, platform:tenancy, and platform:network.
  • main — entry file, default index.js.
  • Optional minJustflowsVersion / maxJustflowsVersion, description, author, homepage.
  • Plugins, themes, and CSS providers share an engines.justflows range the installer enforces before a package leaves staging; the public SDK export surface is snapshotted in CI so an export cannot vanish without a deprecation cycle. The legacy top-level justflows range is still accepted as a deprecated alias.
  • Optional requires: ["justflows.shop"] — plugins that must already be installed on this site. Install is refused while one is missing, and activation is refused until each one is active. A plugin cannot require itself. Shop quotes uses it.

Plugin permissions

content:read|create|update|delete|publish, media:read|upload|delete, users:read|manage, settings:read|manage, network:outbound, admin:extend, jobs:register, auth:hook, mail:transport|templates|hook|send, diagnostics:publish, platform:tenancy (sensitive: manage workspaces, set limits, and listen to domain and quota hooks; see Multisite), platform:network (sensitive: register { network: true } hooks that run on every site and use ctx.network), files:private (store private files with ctx.files).

Context (`activate(ctx)`)

  • hooks — typed action / gate / filter registration (see Hooks). Core actions include comments.created, fired when a comment is stored as pending or approved, with ids and the status but no email, IP address, or comment text.
  • cache — namespaced jf-cache (plugin:{id}:…). remember, get, set, delete, invalidate.
  • settings — plugin settings store.
  • logger — attributed to your plugin id.
  • content — ensureType, ensurePage, deleteType for plugins that own CMS types; listPublished(query?) reads published entries by type / locale / author / date (needs content:read). deleteCreatedBy(userId) permanently deletes a user's content, uploads, and comments and drops their working revisions elsewhere; it never deletes the user and refuses an administrator (needs content:delete).
  • users — create() a user in a role your plugin registered, addRole({ userId } | { email }, role, actor) to give an existing user one of your roles as an additional role, and get(userId) to read a user with all their roles (all need users:manage). Plugin HTTP sessions include roles, main role first.
  • media — placeholder(kind) and placeholderHtml(kind) return the site's placeholder from a block's render(); registerPlaceholder() ships a default for your own kind.
  • databases / secrets — prefixed plugin tables (ensureSchema, upsert, find, delete) and encrypted secret storage.
  • tenancy — current() returns the workspace and site for this request (workspace id, site id, hostname, user and database mode, never a password). listWorkspaces, listSites, createWorkspace, addSite, suspend, reactivate, and deleteWorkspace need platform:tenancy.
  • cookies — declare() a non-essential cookie and list() the resolved site registry.
  • capabilities — register() a runtime capability so it appears in the role editor and access policy.
  • patterns — register() a validated, plugin-scoped block pattern for the editor library.
  • diagnostics — register() a read-only health check for Admin → System → Diagnostics (needs diagnostics:publish).
  • mail — send() through the host transport without seeing credentials (needs mail:send); registerTemplate() ships versioned system-email templates.
  • i18n — defaultLocale() and locales() return the site's configured locales, read-only.
  • runtime — the running Justflows, SDK package, and SDK API versions.
  • pluginId, version, permissions set.

Good to know

You do not unregister hooks on deactivate — the loader disposes registrations when the plugin stops. The html.head, html.title, and html.canonical filters receive a PublicPageContext: the page's locale, title, content type and fields, description, canonical URL, share image, origin, and whether it is indexable. Import types from @justflows/sdk only. Author guides in the CE repo: docs/PLUGINS.md, docs/MANIFEST.md, docs/PERMISSIONS.md, docs/PACKAGING.md, docs/THEMES.md, docs/BLOCKS.md, docs/TESTING-EXTENSIONS.md.

Your errors show in Diagnostics

Errors your plugin logs with ctx.logger.error(), exceptions from its hook handlers, and uncaught route failures all appear in Admin → System → Diagnostics → Recent errors as plugin:<id>. There is no opt-in or opt-out.