Spillway CMSStructured content like Drupal. Page building like Puck. Setup like Ghost. AI that drafts, people who approve.
Menu

Modules

Spillway can be extended in two ways, and the difference is about trust.

Trusted modulesSandboxed extensions
Runs asJavaScript, in the server processWebAssembly, in a sandbox with declared capabilities
Can doEverything Node can: blocks with server rendering, field types, admin panels, providers, hooks, AI toolsHooks (entity saved, form submitted, page rendered), transforms, webhooks, AI tools and page builder blocks (HTML through a small host API)
EnabledFrom the admin, with a graceful restartFrom the admin, instantly
LimitsNone beyond the capability checks on the module API100 ms of guest time and 2 s wall clock per call, 32 MB memory, HTTP only to declared hosts (5 s, 1 MB)
Write it inPlain ES modules (@spillway-cms/sdk for types)Anything that compiles to plain Wasm (AssemblyScript, Rust, Zig, C, or the SDK's assembler)

Both kinds live in <DATA_DIR>/modules/<name>/ with a manifest.json and an entry file, are listed under System → Modules, and are recorded in the module table with their manifest, enabled state, settings and the last error.

Trust model

A trusted module is server code: once loaded it runs in the Node process with the data directory, the database and every environment variable in reach, and nothing in Spillway can contain it. Treat it like a deployment artifact, not like content.

  • Sandboxed extensions can be installed, enabled and removed from the admin by anyone with site.settings (the manager and admin roles), from the registry or as an uploaded .tgz. They run in Wasm with declared capabilities and cannot reach anything the manifest does not name; http:<host> capabilities may not name localhost, *.local, *.internal or a private address, and every request they make goes through the same outbound guard as webhooks (private ranges refused, connection pinned to the vetted address).
  • Trusted modules cannot be installed or enabled from the web by default. Uploading or registry-installing a manifest with "tier": "trusted" answers 403 trusted_install_disabled. The operator opts in out of band by starting the server with SPILLWAY_TRUSTED_MODULE_INSTALL=1; even then only an admin (users.manage) may install one, and every such install is written to the audit log as module.install_trusted next to the usual module.install row.
  • Modules the operator places on disk under <DATA_DIR>/modules/<name>/ are discovered at boot as source: local and listed disabled. An admin enables them from the admin without the flag (the code is already on the machine by the operator's hand); a manager cannot. Enabling, disabling or removing any trusted module needs users.manage, because each of those restarts the server.
  • Restarts are rate limited to one per five minutes (429 restart_rate_limited), and the limit survives the restart itself (a stamp file next to the data), so a module cannot be toggled in a loop to keep the site down.
  • --safe-mode boots without loading any module; use it to recover from a module that breaks the site.

In short: manager can extend the site with sandboxed code; only an admin with the operator's consent can add code that runs in the process. The registry's sha256 proves a tarball matches its index, not that either is authentic, so keep REGISTRY_URL on https:// and read a trusted module before you enable it.

The manifest

{
  "name": "hello-banner",
  "version": "0.1.0",
  "tier": "trusted",
  "description": "Adds a Banner block and logs every publish.",
  "spillway": "^0.1.0",
  "capabilities": ["blocks", "hooks"],
  "hooks": ["entity.afterPublish"],
  "main": "index.js",
  "homepage": "https://example.org/hello-banner",
  "license": "MIT"
}
  • name: lowercase letters, digits and dashes, optionally scoped (@acme/name). The folder is name (acme__name for scoped names).
  • version: semver. spillway: the semver range of core versions the module supports (^0.1.0, >=0.1.0 <0.3.0, *). A module outside the range is refused at install and disabled at boot.
  • tier: trusted (entry is .js/.mjs) or sandboxed (entry is .wasm).
  • capabilities: what the module is allowed to do (below). Declaring hooks without the capability, or an unknown capability, fails validation.
  • hooks: which of the five hooks the module listens to.
  • main: the entry file, relative to the module folder; no ...
  • aiTools (sandboxed only): [{ name, description, inputSchema? }]; needs ai.tools.
  • blocks (both tiers): [{ key, label, category?, description?, fields, defaults?, preview? }], the blocks the module offers to the page builder; needs blocks. See Blocks in the page builder.

The manifest is validated with the schema in packages/core/src/modules/manifest.ts; the SDK's checkManifest() runs the same checks at build time.

Capabilities

CapabilityTrusted modulesSandboxed extensions
hooksapi.on(hook, handler)the hooks list in the manifest
blocksapi.registerBlock(...), and blocks in the manifest with blocks.jsblocks in the manifest, rendered by render_block
fieldsapi.registerFieldType(...)not available
adminapi.registerAdminPage(...)not available
ai.toolsapi.registerAiTool(...)aiTools in the manifest
entity.readapi.entities.get(id)sw_entity_read
site.readapi.site.get()not available
media.readapi.media.get(id)not available
entity.writereserved for a future write APInot available
http:<host>api.http(url) to that hostsw_http to that host (host or host:port, exact match)

For sandboxed extensions the capabilities are enforced by the host: an import the manifest does not cover (sw_http without an http: host, sw_entity_read without entity.read) refuses to instantiate, and a request to an undeclared host returns { "error": ... } to the guest. For trusted modules the capabilities gate the api helpers and are otherwise a declaration: the code runs in-process and could ignore them, which is why trusted modules need a restart and your trust. A trusted module that calls a helper it has no capability for throws at boot and is disabled automatically.

Hooks

HookModePayloadCan
entity.beforeSavechain{ action: 'create' | 'update', type, entity: { id, title, slug, status, locale } | null, data }replace data, or abort (the API answers 422 hook_abort with { module, hook } in details)
entity.afterSavenotify{ entity, before, action }observe
entity.afterPublishnotify{ entity }observe
form.afterSubmitnotify{ form: { id, key, label }, submission }observe (runs after the submission is stored, before connectors are reported)
page.beforeRenderchain{ entity: { id, type, path, title, status, locale }, data, preview }replace data (the snapshot about to render) or abort

Chain hooks run every registered handler in order (order option, lower first; then registration order) and feed each one the payload the previous one returned; an abort stops the chain. A handler that throws is logged and skipped, so a broken module never blocks saving; only an explicit abort does. Notify hooks run after the write has committed, never throw to the caller and are fire-and-forget.

Hook handlers return { payload } to change the payload, { abort: { message } } to abort, or nothing.

Trusted modules

// index.js
/** @type {import('@spillway-cms/sdk').ModuleDefinition} */
const mod = {
  setup(api) {
    api.registerBlock({
      name: 'Banner',
      fields: { text: { type: 'text', label: 'Text' } },
      defaultProps: { text: 'Hello' },
      render: ({ text }, h) => h('div', { className: 'sw-banner' }, String(text)),
      // or: html: ({ text }) => `<div class="sw-banner">${escape(text)}</div>`
    });
    api.on('entity.afterPublish', ({ entity }) => api.log.info(`published ${entity.title}`));
  },
};
export default mod;

setup(api) runs once at boot. api offers:

  • registerBlock({ name, fields, defaultProps?, render?(props, h), html?(props), resolve?(props, env), description?, aiHint?, category? }): a Puck block. render gets the host's createElement (h) so the module needs no React; html returns an HTML string the module escaped itself; resolve fills props from server data before render and may add cache tags. Block names are PascalCase and core blocks win on a clash. Blocks registered this way render on the public site and in previews but are not offered by the page builder; declare the block in the manifest instead (Blocks in the page builder) when editors should drop it on pages.
  • registerFieldType({ key, label, control?, options?, validate? }), registerAdminPage(path, bundleUrl, label?), registerProvider(kind, impl), registerAiTool({ name, description, inputSchema?, run }): recorded in the module registry and listed by GET /api/modules/contributions. Static files under the module's public/ folder are served at /modules/<name>/<file> while the module is loaded, which is where an admin page bundle lives.
  • on(hook, handler, { order? }): only for hooks listed in the manifest.
  • log.debug|info|warn|error(msg): goes to the server log and the module's log buffer (last 200 lines, GET /api/modules/:id/logs).
  • settings: the JSON saved under Settings in the admin; updated live. manifest, coreVersion, dir.
  • entities.get, site.get, media.get, http(url, init): gated by capabilities.

The entry is loaded with a dynamic import(), so it must be an ES module and must bundle its own dependencies (there is no node_modules next to it). @spillway-cms/sdk is never needed at runtime: defineModule is an identity function with types.

Installing, enabling, disabling or removing a trusted module schedules a graceful restart: the server stops accepting connections, finishes in-flight requests (10 s at most) and exits with code 75. The supervisor starts it again: restart: unless-stopped in docker-compose.yml, Fly, systemd Restart=always, pm2, or a shell loop in development. SIGHUP triggers the same restart. The admin shows a "Restarting" banner and reconnects when the new process answers with a different boot id. If the trusted module throws in setup at boot, it is disabled automatically and the error is kept in lastError.

Sandboxed extensions

A sandboxed extension is a plain WebAssembly module (no WASI) with this ABI:

exports
  memory                                  the linear memory (or import it as spillway.memory)
  alloc(len: i32) -> ptr: i32             reserve `len` bytes the host may write to
  handle(ptr: i32, len: i32) -> ptr: i32  the input is UTF-8 JSON at [ptr, ptr+len); returns a pointer
                                          to [u32 little-endian length][UTF-8 JSON]
imports (module "spillway", all optional)
  sw_log(level: i32, ptr: i32, len: i32)          0 debug, 1 info, 2 warn, 3 error
  sw_http(ptr: i32, len: i32) -> ptr: i32         JSON { method?, url, headers?, body? } in; length-prefixed
                                                  JSON { status, headers, body } or { error } out
  sw_entity_read(ptr: i32, len: i32) -> ptr: i32  an entity id (JSON string) in; the Entity JSON or null out

The host calls handle with { "hook": "<name>", "payload": { ... }, "settings": { ... } } and expects { "payload"?: ..., "abort"?: { "message": "..." } } back; {} means "no change". AI tools are called with { "hook": "ai.tool", "payload": { "name", "input", "settings" } } and return { "payload": <result> }.

Limits, enforced by the host (packages/core/src/modules/sandbox/host.ts):

  • Guest time: 100 ms per call of time spent executing Wasm; time waiting on host calls is not counted. Over the limit, the worker thread running the module is terminated and recreated for the next call; the call fails with timeout.
  • Wall clock: 2 s per call, host calls included (wall_timeout).
  • Memory: the module's memory must declare a maximum of at most 512 pages (32 MB), or import spillway.memory and get one capped at 512 pages. memory.grow past the maximum fails inside the guest. Modules without a maximum are refused.
  • HTTP: only to hosts declared as http:<host>, 5 s timeout, 1 MB response, no redirects followed.
  • Payloads: 1 MB in and out.

Each sandboxed module runs in its own worker thread; calls to the same module are serialized. A failed call is logged, recorded as the module's lastError, and treated like any other hook error (chain hooks fail open; aborts only come from an explicit abort). A module that fails to instantiate is disabled automatically.

The SDK ships a tiny assembler (@spillway-cms/sdk/wasm) that emits a valid module with memory, alloc and handle without any toolchain; examples/modules/profanity-guard/build.mjs uses it to build a real banned-word scanner. With a toolchain, export the three functions and keep the memory maximum under 512 pages (AssemblyScript: --maximumMemory 512; Rust: -C link-arg=--max-memory=33554432).

Blocks in the page builder

Both tiers can offer blocks that editors drag onto pages. The manifest declares them; the module renders them to HTML on the server; the page builder builds its fields from the declaration and shows the server's rendering while the editor types. A page stores such a block as a ModuleBlock node (blocks.md):

{ "type": "ModuleBlock", "props": { "id": "ModuleBlock-1", "module": "hello-banner", "blockKey": "notice", "props": { "text": "Open day on May 4", "tone": "info" } } }

(blockKey, not key: React reserves key.) The block's own props live under props, shaped by the manifest's field definitions; anything the manifest does not declare is dropped before the module sees it, strings are capped at 10,000 characters, numbers clamped to min/max, select values checked against the options, arrays capped at 100 items.

The declaration

{
  "name": "hello-banner",
  "version": "0.2.0",
  "tier": "trusted",
  "spillway": "^0.1.0",
  "capabilities": ["blocks"],
  "main": "index.js",
  "blocks": [
    {
      "key": "notice",
      "label": "Notice",
      "category": "Modules",
      "description": "A short notice with an optional link.",
      "fields": {
        "text": { "type": "textarea", "label": "Text" },
        "href": { "type": "link", "label": "Link (optional)" },
        "linkLabel": { "type": "text", "label": "Link label" },
        "tone": { "type": "select", "label": "Tone", "options": [{ "label": "Info", "value": "info" }, { "label": "Warning", "value": "warning" }] },
        "dismissible": { "type": "boolean", "label": "Show a close hint" },
        "items": { "type": "array", "label": "Items", "maxItems": 10, "arrayFields": { "title": { "type": "text" }, "image": { "type": "media" } } }
      },
      "defaults": { "text": "We publish release notes every Friday.", "tone": "info", "dismissible": false }
    }
  ]
}
  • key: lowercase letters, digits and dashes, unique within the module. label is what the builder shows; category names the sidebar group (default Modules).
  • fields: up to 40, each of type text, textarea, number (min, max, step), select or radio (with options), boolean (a Yes/No choice), media (the media library picker; the value is the chosen item's public URL, ready for an <img src>), link (a path or URL) or array (arrayFields of the other kinds, maxItems up to 100; arrays do not nest). The builder renders them with its own widgets, so a module ships no admin code for its fields.
  • defaults: the props a freshly dropped block starts with; every key must name a field.
  • preview: html, the only value today: the builder's canvas shows the server's rendering (POST /api/render/block, debounced while the editor types).

Declaring blocks needs the blocks capability; the SDK's checkManifest and checkBlock run the same checks as the host, and the registry refuses a manifest that fails them.

Block styles in a module block

A module block can take part in the brand's block styles, so editors pick its look by name like a core block's and the module never shows a color field. Declare one of:

  • "styles": "Cards": the block uses a core block's style set (one of Hero, Cards, Button, CallToAction, Heading, Text). It follows the site's styles for that block, Card styles for an event list, say, and the Style field lists them.
  • "styleSlots": the block's own slots, like its fields: at most 8, each with options (1 to 20 { "label", "value" }, values in kebab-case) and an optional default (the first value otherwise). The site gets one built-in style, Default, made of each slot's default, and can add more in Brand → Styles, where the block appears under its label. Its style set is named <module>/<blockKey>.
{ "key": "event-list", "label": "Event list", "fields": { "limit": { "type": "number" } }, "styles": "Cards" }
{ "key": "agenda", "label": "Agenda", "fields": { "title": { "type": "text" } },
  "styleSlots": { "density": { "label": "Density", "options": [{ "label": "Cozy", "value": "cozy" }, { "label": "Compact", "value": "compact" }], "default": "cozy" } } }

Not both on one block; either reserves the prop names style and styleValues, so no field may use them. The page stores the chosen key next to the block's values, { "type": "ModuleBlock", "props": { "module": "events", "blockKey": "event-list", "props": { ... }, "style": "filled" } }, and the render receives the key as props.style and the slot values as props.styleValues ({ "border": "none", "fill": "neutral-50", "image": "top" }), resolved by the server (a missing or unknown key gives the default). The wrapper <div class="sw-module-block ..."> also carries the style's class: sw-style-cards-filled for a block on the Cards styles, whose rules the page includes, so a trusted module that marks its items up with the core card classes (sw-card, sw-card__image, sw-card__body) gets the brand's look for free; for its own slots, sw-style-m-<module>-<blockKey>-<key>, with no rules: the module renders the values itself (a sandboxed block, whose classes are stripped, does so through its markup). Pages carrying a styled module block are tagged style:<set>:<key> and purged when the style changes.

Trusted modules: blocks.js

A trusted module ships blocks.js next to its entry: an ES module whose default export maps each block key to a render, optionally with a resolver for server data. Like the core block library it must be server-safe: no hooks, no window or document, no client-only imports.

// blocks.js
export default {
  notice: {
    // Optional: fill props from server data first. `ctx.entities.get` needs the entity.read capability.
    async resolve(props, ctx) {
      return { greeting: ctx.settings.greeting ?? 'Hello' };
    },
    // `h` is the host's React.createElement; return an element, or an HTML string you escaped yourself.
    render({ text, href, linkLabel, tone, greeting }, h, ctx) {
      const link = href ? h('a', { href: String(href) }, String(linkLabel || 'Read more')) : null;
      return h('div', { className: `sw-notice sw-notice-${tone}`, role: 'note' }, `${greeting}: ${text}`, ' ', link, ctx.preview ? ' (preview)' : '');
    },
  },
};

ctx carries path, locale, preview (true in the builder and on /_preview), site: { id, name }, the module's settings, log and entities (null without entity.read). The markup is the module's own: a trusted module is server code already, so nothing is sanitised. With @spillway-cms/sdk, defineBlock({ key, label, fields, defaults, render, resolve }) checks a definition, manifestBlocks([...]) strips the functions for manifest.json and defineBlocks([...]) builds the blocks.js export, so one file holds the definition and the render. A module that declares blocks but ships no blocks.js, or a blocks.js without a render for a declared key, fails to load and is disabled.

Sandboxed extensions: render_block

The host calls the ordinary handle export with the hook name render_block:

{ "hook": "render_block", "payload": { "key": "notice", "props": { "text": "..." }, "settings": { }, "ctx": { "path": "/about", "locale": "en", "preview": false, "site": { "id": "default", "name": "Riverbend" } } } }

and expects { "payload": "<p>...</p>" } (or { "payload": { "html": "..." } }) back. An abort, a non-string payload or a call over the limits (100 ms of guest time, 2 s wall clock, 1 MB of output) renders the placeholder and is recorded as the module's lastError. The module may call sw_entity_read (with entity.read) and sw_http (declared hosts) while rendering, within the same limits; renders are serialised with the module's hooks.

The HTML goes through the rich text sanitiser before it reaches a page, the same allow-list as a Text block in HTML mode. The limits, then: headings (h2 to h4; h1 becomes h2), paragraphs, inline formatting, links (http(s), mailto, tel and relative paths only), lists, tables, images, figure, blockquote, code, pre, span, sub and sup survive; div, section and other wrappers are unwrapped (their text stays); class, id, style, data-* and event handler attributes are dropped; scripts, styles, frames, forms, SVG and embeds are removed with their content; the result is capped at 256 KB. The ModuleBlock wraps the result in <div class="sw-module-block sw-module-block--<module>-<key>">, which is the hook for site CSS. A sandboxed block cannot carry its own stylesheet or script; use the theme's custom tokens and the site's stylesheet for its look.

The profanity-guard example answers render_block with a fixed paragraph (examples/modules/profanity-guard/build.mjs); a module compiled from a real language builds the string from payload.props.

In the builder and on the page

  • GET /api/builder/config → { blocks: BuilderModuleBlock[], safeMode, styles, styleSlots } (any signed-in user): the blocks of every loaded module, each with module, tier and the declaration (styles and styleSlots included), and the site's published block styles with available for the person's role. A styled block gets the Style field first in the fields panel. The page builder reads it when it opens and lists every block as its own item in the blocks panel, under the block's category (Modules unless the manifest names another; a category named like a core group, such as Content, joins that group). Editors drag the block onto the page like any core block: it arrives with its defaults, and the fields panel shows the block's declared fields. The page still stores { type: "ModuleBlock", props: { module, blockKey, props } }: the builder converts on the way in and out, so shared blocks, AI layouts, the renderer and the headless API see the stored shape. A page holding a block of a module that is not loaded keeps it as a generic module block with its placeholder.
  • POST /api/render/block { module, key, props, style?, path?, locale? } → { html, placeholder, label, reason? } (content.create, content.edit_own or content.edit_any): the HTML the block would render, with preview: true. The builder calls it, debounced, as the props change, and shows the HTML on the canvas.
  • On a page the ModuleBlock resolver asks the module system for the HTML at render time (the public page, /_preview, workstream previews, the headless render API). Pages carrying a block are tagged module:<name>; enabling, disabling, removing, reinstalling the module or saving its settings evicts them, and a page is cached again on the next request.
  • When the module is disabled, uninstalled, in safe mode, does not declare the key any more, or fails to render, the block degrades to a dashed placeholder naming the block (Module block: Notice (module not enabled)); the page never errors because of a module. Trusted modules register their blocks at boot like everything else, so a trusted module enabled from the admin shows its blocks after the restart.
  • The registry's blocks check lists the declared blocks, fails a trusted module that ships no blocks.js, and the sandbox smoke load renders the first block with its defaults (a block that cannot render fails the publish).

The admin and the API

System → Modules lists installed modules with tier, version, capabilities, hooks, source and last error; toggles enable/disable; edits settings as JSON; shows the log drawer; installs from the registry (search, then Install) or by uploading a .tgz; and shows the restart banner.

All routes need site.settings; installing a trusted module also needs SPILLWAY_TRUSTED_MODULE_INSTALL=1 and users.manage, and enabling, disabling or removing a trusted module needs users.manage (see Trust model):

GET    /api/modules                      InstalledModule[]
GET    /api/modules/status               { safeMode, restartPending, restartReason, bootId, coreVersion, modulesDir }
GET    /api/modules/registry?q=          { items: RegistryEntry[] }
GET    /api/modules/contributions        blocks, field types, admin pages, providers, AI tools and hooks of loaded modules
POST   /api/modules/install              { source: 'registry', name, version? } or multipart "file" (.tgz/.tar) → 201 InstalledModule
POST   /api/modules/:id/enable           POST /api/modules/:id/disable
DELETE /api/modules/:id                  removes the files too
PUT    /api/modules/:id/settings         a JSON object (64 KB max)
GET    /api/modules/:id/logs             { lines: string[] } (last 200, in memory)

Every change is written to the audit log. Errors specific to the trust model: 403 trusted_install_disabled (a trusted archive without the operator flag, or without users.manage) and 429 restart_rate_limited (a second restart within five minutes). Module archives are limited to 64 MB.

Publishing to the registry

The registry is a static JSON index; any web server or CDN can host one, and REGISTRY_URL points Spillway at it (default https://registry.spillwaycms.org/index.json; file:///path/index.json for local development). A kit may name a module it needs under requires.modules (kits.md); the kit installer then installs it from this index through the same installer and under the same trust model (a trusted-tier module still needs SPILLWAY_TRUSTED_MODULE_INSTALL=1 and an admin).

{
  "modules": {
    "hello-banner": {
      "description": "Adds a Banner block.",
      "tier": "trusted",
      "homepage": "https://example.org/hello-banner",
      "versions": {
        "0.1.0": {
          "tarball": "hello-banner-0.1.0.tgz",
          "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
          "manifest": { "name": "hello-banner", "version": "0.1.0", "tier": "trusted", "...": "..." }
        }
      }
    }
  }
}

To publish: build the module folder (manifest at the root, or inside a single top directory as npm pack does), tar -czf hello-banner-0.1.0.tgz -C hello-banner ., compute sha256sum, upload the tarball next to the index (or anywhere on https: tarball may be absolute) and add the version. Spillway downloads the tarball, verifies the sha256 before extracting, checks the manifest inside matches the name, version and tier the index promised, extracts with a reader that refuses absolute paths, .. segments and links, and only then records and loads the module.

Every entry must declare its tier (trusted or sandboxed): it is what the admin sees and consents to before the archive is opened, so an index without it is refused as a whole, and a tarball whose manifest claims another tier is refused at install. The registry URL must be https:// (or file:// for a local one); plain http:// needs REGISTRY_ALLOW_HTTP=1, and a web index may never point at a file:// tarball. Index and tarball fetches go through the outbound SSRF guard (security.md), so a registry on a private network needs SPILLWAY_ALLOW_PRIVATE_URLS=1.

Signing the index

A registry operator signs the index with an ed25519 key so a CDN or DNS compromise cannot replace tarballs and checksums together:

spillway registry keygen --out ./keys                        # private.pem (keep it offline) and public.txt
spillway registry sign index.json --key ./keys/private.pem   # adds "signature" to the file (or --out signed.json)

The signature covers the canonical JSON of the document (every object key sorted, no whitespace, the signature field left out), so pretty-printing or reordering keys does not break it; regenerate it after every change. Sites set REGISTRY_PUBLIC_KEY to the contents of public.txt (base64 of the raw 32-byte key; the SPKI DER or a PEM block are accepted too). From then on an unsigned index is refused with registry_unsigned, a tampered one with registry_bad_signature, and the admin's registry search shows the error instead of a listing. Without the variable, unsigned indexes still work, as before.

Scaffold a new module with npx spillway-module create <name> --tier trusted|sandboxed (from @spillway-cms/sdk). The result is installable as is.

Safe mode

node packages/core/dist/server.js --safe-mode (or SPILLWAY_SAFE_MODE=1, also honored by spillway start) boots without loading any third-party code: modules keep their enabled state and are listed in the admin with a safe-mode banner, hooks and blocks from modules are absent, and enable/install are refused with 409 safe_mode until a normal restart. Use it when a module takes the site down.

Examples

  • examples/modules/hello-banner: trusted; a Banner block registered from setup, a Notice block declared in the manifest and rendered by blocks.js for the page builder, and an entity.afterPublish log line.
  • examples/modules/profanity-guard: sandboxed; an entity.beforeSave hook that aborts when the content contains a banned word and a notice block answered by render_block, assembled by hand from build.mjs.

Copy either folder into <DATA_DIR>/modules/ and enable it in the admin, or pack it and upload it.

Spillway 0.1.0 · Edit this page on GitHub

Was this page helpful?