Plugins: architecture and authoring guide

Status: shipped.

Plugins are a hosted-build feature. The UI is hidden in a local (VITE_EDITION=local) build and a local build never contacts the registry — pluginsEnabled() gates the feature, pluginRegistryEnabled() gates the marketplace, and a self-hosted editor fails both. A project containing plugin content still opens, edits and saves losslessly there: custom layers, plugin effects and proxy subtrees round-trip byte-for-byte with the plugin absent, including the plugins[] block naming id and version. It simply has nothing to run them with.

The cross-repo system reference is PLUGIN_SYSTEM_REFERENCE.md. When it and this file disagree, the code decides, then that file, then this one.


0. The one-paragraph version#

A plugin is a packageplugin.json plus an ES module — that the user installs from a .zip or a folder. The manifest is validated and its requested permissions shown before any code exists anywhere; only after the user accepts does the package's entry module get sent to a dedicated Worker, which locks down its own network globals and then imports the code. From there the plugin can only send messages naming API methods; the host checks each one against the permissions that were granted and executes it inside runDocumentEdit, so anything a plugin changes is a single undo. A plugin that wedges its event loop stops answering a heartbeat and is terminated — the editor never notices. Installs persist across reloads.

 pick .zip / folder
        │  readPluginZip / readPluginFolder      ← bytes → files, no execution
        ▼
   plugin.json ──► parseManifest                 ← strict: id, semver, apiVersion,
        │                                          safe paths, known permissions
        ▼
   consent screen  ("this plugin will be able to: …")
        │  user accepts
        ▼
   pluginStore ──► PluginDatabase                ← index AND payload, ONE
        │          (IndexedDB, one transaction)    transaction; survives reload
        │
        ▼
   PluginHost.start ──► new Worker(pluginWorker.ts)
                            │ lockdown()          ← fetch/XHR/WS/IDB/importScripts
                            │ await import(blob)   ← the plugin's own module
                            │ activate(motion)
                            ▼
                       postMessage {k:'call', method, args}
                            │
        ┌───────────────────┴─────────────────────┐
        │ METHOD_PERMISSIONS[method] granted?     │  no → refused, by name
        ▼                                         │
   hostApi[method](...)  inside runDocumentEdit ──┘   ← one undo entry

1. The threat model, and why it is a Worker#

The previous host evaluated a user-picked .js file with new Function in the page's realm, with live defaultSceneGraph / defaultAnimation handles bound in. That is not "scene access"; it is everything the page can do.

What "everything the page can do" is worth has since changed, and in the right direction. The renderer used to hold two things worth stealing — the account bearer JWT and the user's own AI provider keys — and the sandbox was partly justified by that. On the desktop build it now holds neither. Provider keys live in the main process behind a write-only vault with no read-back verb (electron/aiKeyVault.ts); the session tokens live there too, and the renderer asks for REQUESTS to be made rather than for the credential that makes them possible (electron/apiSession.ts, electron/apiProxy.ts).

That does not make the sandbox less necessary. It changes what a sandbox escape costs, from "the attacker now has the user's account and their provider billing, permanently" to "the attacker can act as the user while the app is open". Those are different incidents. The controls are layered deliberately: the sandbox bounds what plugin code can reach, and the credential split bounds what reaching it is worth.

"The user chose the file" is not a control. Downloading plugins from strangers is the normal distribution model for creative tools — it is exactly what After Effects users are conditioned to do — so social engineering is the expected attack, not an exotic one.

Failure Host realm + new Function Worker sandbox
Plugin loops forever Editor frozen permanently Worker terminated in ~12 s, editor untouched
Plugin reads the JWT / AI keys localStorage.getItem(…) No localStorage in a worker realm — and neither secret is in the renderer at all on desktop
A non-plugin renderer compromise (XSS, a bad dependency) Takes both secrets Can spend the session while the app runs; cannot take it elsewhere
Plugin phones home fetch(…) fetch replaced with a throwing stub before import
Plugin reads the UI / forges clicks Full DOM No document, no window
Plugin deletes the project Direct singleton handle Needs scene:write, and it is one Ctrl-Z
User reloads Everything uninstalled Installs persist and restart

Verified live: a plugin's own probe reports fetch:DENIED XMLHttpRequest:DENIED WebSocket:DENIED importScripts:DENIED indexedDB:DENIED localStorage:ABSENT document:ABSENT window:ABSENT, and a while(true) command returns control to the editor immediately and is killed by the heartbeat.

Panels get the same treatment on the UI side: sandbox="allow-scripts" without allow-same-origin, so the frame has an opaque origin and cannot read this document, our cookies or our localStorage. Its only exit is postMessage, which the host accepts solely from frames it registered, on the origin it registered them with, and forwards only to the worker that owns that frame — routing comes from the registration, not from anything the message says. A panel cannot name an API method, a layer, or another plugin.

The panel document is loaded from public/plugin-panel.html and receives the plugin's markup by postMessage after load. It used to be delivered with srcdoc, and that quietly made the entire panel feature decoration: a srcdoc document inherits the embedder's CSP, the app ships script-src 'self' with no 'unsafe-inline', and a panel is by definition inline script — so panels rendered as static markup, motionPanel was never defined, and not one message ever reached a plugin. No error, no clue. A document's own <meta> policy can only add restrictions, so the frame could not opt back in; loading it from a real URL is the only way to give it a policy of its own.

That policy (in the shell) is tighter than the app's for everything except inline script: default-src 'none', connect-src 'none'. Verified live — a panel's fetch fails and a remote <img> is refused.

This stays true now that net:fetch exists, and it is deliberate. A plugin granted network access does not get a panel that can reach its declared hosts — widening the shell's connect-src would hand the capability to the wrong realm. A panel is inline script from the package with nothing between it and the socket; a worker's request goes through the host, which checks the permission, the grant and that plugin's own manifest, then caps redirects, size and rate. noHostRealmEval.test.ts refuses the change.


2. File map#

Concern File
Manifest schema + validation + permission text src/core/plugins/manifest.ts
.zip / folder reading, size and zip-slip limits src/core/plugins/pluginPackage.ts
Wire protocol + method→permission table src/core/plugins/protocol.ts
The sandbox (worker side) src/core/plugins/pluginWorker.ts
Worker construction (ESM-only, stubbed in tests) src/core/plugins/spawnPluginWorker.ts
Method implementations (host side) src/core/plugins/hostApi.ts
Install / supervise / permission gate / panels bridge src/core/plugins/PluginHost.ts
Persistence src/stores/pluginStore.ts
The plugin list (sidebar + dashboard) src/layout/Plugins/PluginsList.tsx
A plugin's page: listing, status, log, permissions, reload src/layout/Plugins/PluginDetailTab.tsx
Consent screen + its overlay src/layout/Plugins/ConsentSheet.tsx
Install from disk (menu, drop, folder) src/layout/Plugins/useDiskInstall.tsx
Layer-kind schema + validation src/core/plugins/layerKindSchema.ts
Custom layers in a document src/core/plugins/customLayers.ts
Which kinds exist, and who may touch them src/core/plugins/layerKindRegistry.ts
Proxy regeneration + ownership src/core/plugins/proxySubtree.ts
IPC: the one validating registration wrapper electron/ipcGuard.ts
Session + tokens, in main electron/apiSession.ts, electron/credentialStore.ts
Authenticated requests + streams, in main electron/apiProxy.ts, electron/apiBase.ts
Docked panel + sandboxed frame src/layout/Plugins/PluginPanel.tsx
Panel host document (its own CSP) public/plugin-panel.html
Plugins menu, built from what is installed src/layout/Menu/pluginMenu.ts
Starter template generator src/layout/Plugins/starterPlugin.ts

Tests: pluginPackage.test.ts (format), pluginHost.test.ts (lifecycle, permission gate, argument validation, command namespacing), pluginBridge.test.ts (panel provenance + routing), noHostRealmEval.test.ts (the architectural guard — no new Function, no non-literal dynamic import, lockdown before import, no allow-same-origin).


3. Package format#

my-plugin/
  plugin.json      required, at the package root
  main.js          the entry ES module
  panel.html       optional UI

Zipping the folder is fine — one wrapping directory is stripped automatically.

{
  "id": "studio.acme.easing-lab",   // reverse-DNS, lowercase, unique
  "name": "Easing Lab",
  "version": "1.2.0",               // semver
  "description": "…",               // shown to the user before install
  "author": "Acme Studio",
  "homepage": "https://…",          // http(s) only
  "apiVersion": 5,                  // the manifest GRAMMAR version
  "main": "main.js",
  "panel": "panel.html",            // optional
  "permissions": ["scene:read", "animation:write"],
  "requires": ["scene.read", "animation.write"],   // optional; see below
  "optional": ["webgpu"]
}

apiVersion is about the grammar, not about what you may call#

Two numbers, separate from 5 onward:

Constant Now Answers
MANIFEST_VERSION 5 what grammar the host can read — apiVersion is checked against this
HOST_API_VERSION 5 what the host can do; reported to you at runtime

They were one number and it made every host-method addition look like a manifest change, telling authors their manifests were out of date when nothing about them was. Bump apiVersion when you use a newer manifest field; use requires to say what the host must be able to do.

requires and optional#

Capability strings are additive and permanent — never renamed, never removed, never repurposed, because your manifest is signed and a string that changed meaning would silently change what you asked for.

scene.read · scene.write · scene.proxy · scene.batch · animation.read · animation.write · assets.read · assets.write · timeline · net.fetch · storage.global · storage.project · effects.single · effects.multipass · layerkinds · panels · wasm · webgpu (runtime — depends on the machine)

requires is checked at install, and a refusal names the reason: a capability the host knows but this machine lacks ("needs WebGPU") reads differently from one no version has ever had, which is a typo.

Put webgpu in requires if your plugin is only effects. On the WebGL2 tier a plugin effect renders its input unchanged, so the plugin is not degraded, it is inert. Refusing to install is a better answer than looking healthy and doing nothing. Put it in optional instead if effects are a bonus, and feature-detect: effects.add answers { active: false, reason: 'webgpu-unavailable' } there.

Omitting requires is not "I need nothing". A manifest without one is treated as needing whatever its apiVersion implied before capabilities existed, so every already-published plugin keeps working. Write one when you want a specific answer.

Limits: 2 MB per file, 8 MB per package, 200 files, text extensions only. Paths containing .. are refused at the format level.

Those ceilings are on the uncompressed size, and both readers check them before allocating anything — the editor in fflate's filter, the registry against the zip's central directory. A ceiling applied after the allocation it exists to prevent is not a ceiling: 64 MB of zeros stores in 65 KB, so a compliant 8 MB archive checked on its compressed bytes can unpack to gigabytes. There is a third check, a 200× inflation ratio, for the archive that stays under both absolute limits and is still pathological. The registry counts every entry where the editor counts only the files it keeps — deliberately stricter, since the promise is one-directional (refuse anything the editor would refuse).

main is loaded as one file — bundle your plugin if it has dependencies.

WebAssembly is allowed#

WebAssembly is not removed at lockdown, and .wasm is a recognised binary extension in the package format. Capability: wasm.

It is allowed because it does not widen the sandbox, and that is the whole argument. A .wasm inside the package carries the same signature and the same 2 MB per-file cap as the JavaScript beside it, so it is exactly as reviewed and exactly as attributable. An instantiated module gets no imports the plugin's own JS did not hand it — no DOM, no host methods, no syscalls — so it reaches precisely what that JS could reach, which is the method table and nothing else. A wasm module cannot ask the host for anything; your JS asks, as before. Refusing it would not have shrunk the sandbox, only pushed authors toward shipping the same algorithm as minified JavaScript, which is harder to review, not easier.

WebAssembly.instantiateStreaming and compileStreaming are removed, not merely unused. Both take a Response, and a worker with no network has nothing to give them; leaving them present would be an API that looks available and fails in a way that reads as a host bug.

Gap, stated plainly: the worker cannot read its own .wasm file yet. The boot message carries the manifest, the entry module's source, the grants and the capabilities — not binaries. So today the only way to get a module into the worker is to embed it in main.js (base64 or a byte array) and WebAssembly.instantiate that, which works and wastes about a third of the per-file budget to encoding. A package.read(path) verb is the obvious fix and is not built. Until it is, wasm in requires promises that the engine is present, not that your file is reachable.

The honest reason to reach for it is a real one — a solver, a codec, a tracker — not speed on the message boundary, which dominates anything small.


4. Permissions#

Permission The plugin can
scene:read See layer names, structure and scalar properties
scene:proxy Write only inside its own layer kind's proxy subtree
scene:write Create, change and delete layers anywhere — including paths, gradients and strokes
animation:read Read keyframes and sample animated values
animation:write Create and change keyframes and expressions
assets:read Read the pixels of images already in the composition
assets:write Create images and place them as layers
net:fetch Contact the hosts listed in contributes.netand only those
timeline Read the current time and move the playhead
composition:write Create, rename, open and delete compositions
audio:read Read the loudness of audio layers over time
export:frames Receive every rendered frame of a composition it exports
import:files Read the contents of files you open with its format

audio:read is the decoded WAVEFORM, not audio settings. Level, pan and fades are ordinary animatable properties (audioLevelDb and friends) that animation:read/animation:write already reach — this is how loud the audio actually is, which is what "convert audio to keyframes" needs. Peaks only: raw samples are not offered, because a plugin that could read PCM could reconstruct the recording, and with net:fetch take it away. Both verbs return null when there is no audio or it has not finished decoding, so polling is the right shape rather than catching.

composition:write is deliberately NOT part of scene:write. "Modify your layers" is a statement about the composition the user is looking at; adding and removing compositions restructures the project around them, and deleting one takes every layer in it. Folding the two together would have made an existing grant silently mean more than it did when the user gave it. Listing the project's compositions is scene:read — comp names are project data of the same kind as layer names.

Deleting the LAST composition does not fail: the host mints a fresh empty one, because a project with nothing open has nowhere to draw. The call still returns true, because the composition it named really is gone.

Registering commands, showing notifications, opening the plugin's own panel, reading the ACTIVE composition's settings and using your own storage need no permission — they neither read project data nor change it.

If you are writing a generator, ask for scene:read + scene:proxy, not scene:write. A plugin that builds a subtree under its own layer only ever touches its own children, and the wider permission made its consent screen say "create, change, delete, reparent layers" — indistinguishable from a plugin that could rearrange the user's whole project. The narrow scope was always enforced by setProxyChildren; what was missing was a way to ask for it. scene:write implies scene:proxy, so an existing manifest needs no edit.

The one thing scene:read + scene:proxy cannot do is create the parent layer itself — and it should not. The user adds it from Layer ▸ New, which is the moment they chose to have your content in their project.

Ask for the fewest you need: the list is the install screen. A refused call returns an error naming the missing permission rather than silently doing nothing, so a plugin can degrade deliberately (motion.has('scene:write')).

Consent is per permission, not one yes over the list: the install screen ticks everything the manifest asks for, and the user may untick any of it. They can also change their mind later — Plugins ▸ Manage Plugins… ▸ Permissions on the row, which restarts the plugin with the new set (the worker was told what it had at boot). A grant is always intersected with the manifest, so nothing can hand a plugin more than it disclosed.

Write for this: check motion.has(p) rather than assuming, and let a refusal disable a feature instead of throwing.


5. Writing a plugin#

Installing happens on the dashboard's Plugins page, not in the editor's Plugins panel — it lives beside publishing, since both are about getting a plugin into the world. The editor's panel finds and runs what is already installed.

Download starter template there produces a working package. Install it with Choose folder…, and from then on iterate with the row's Reload, which works from either surface: it re-reads the folder and reinstalls without asking for consent again, unless the manifest has started asking for something new. (The picker still opens — a browser cannot re-read a directory without a gesture, and a stored handle needs its permission re-granted after a restart anyway. What Reload removes is the consent screen on every edit.)

So the trip to the dashboard is once per plugin, not once per edit.

The entry module exports activate:

export function activate(motion) {
  motion.commands.register(
    { id: 'bounce', label: 'Bounce selection', icon: 'zap', needsSelection: true },
    async ({ selection }) => {
      const t = await motion.timeline.getTime();
      for (const id of selection) {
        await motion.animation.setKeyframes(id, 'y', [
          { t,        value: 0,   easing: 'easeOut' },
          { t + 0.18, value: -60, easing: 'easeIn'  },
          { t + 0.42, value: 0,   easing: 'easeOut' },
        ]);
      }
      await motion.ui.notify(`Bounced ${selection.length} layer(s)`, 'success');
    },
  );
}

export default { activate } and export default function (motion) also work. Every motion.* call returns a promise — it is a message to the editor, not a function call into it.

API#

motion.manifest                            // your own manifest
motion.permissions / motion.has(p)         // what the user actually granted
 
motion.ui.notify(message, level)           // info | success | warning | error
motion.ui.openPanel() / closePanel()
motion.ui.sendToPanel(data) / onPanelMessage(fn)
 
motion.commands.register(spec, handler)    // spec: { id, label, icon?, needsSelection? }
motion.composition.get()                   // { name, width, height, fps, durationSeconds }
motion.composition.list()                  // [{ id, name, width, height, fps, durationSeconds, active }]
motion.composition.create({ name, width, height, fps, durationSeconds })  // → id, and opens it
motion.composition.open(id) / rename(id, name) / delete(id)
 
motion.scene.getSelection() / setSelection(ids)
motion.scene.getLayers() / getLayer(id)
motion.scene.createLayer({ kind, name, x, y })   // shape | text | group | null | image
motion.scene.setProperty(id, prop, value)         // scalar, or a structured value (below)
motion.scene.renameLayer(id, name) / deleteLayer(id)
motion.scene.setParent(id, parentId | null)      // null → composition root
motion.scene.setVisible(id, bool) / setLocked(id, bool)
 
motion.effects.list(layerId)                     // [{ id, type, enabled, params }]
motion.effects.add(layerId, type)                // → the new effect's id
motion.effects.remove(layerId, effectId)
motion.effects.setParam(layerId, effectId, key, value)
 
motion.animation.getTracks(id) / sample(id, prop, time)
motion.animation.setKeyframe(id, prop, time, value, easing)
motion.animation.setKeyframes(id, prop, [{ t, value, easing }])   // prefer this
motion.animation.removeKeyframe(id, prop, time)
motion.animation.setExpression(id, prop, source)
 
motion.timeline.getTime() / setTime(seconds)
 
motion.audio.getPeaks(layerId)             // { buckets, duration, peaks[] } | null
motion.audio.getAmplitude(layerId, sec)    // 0..1 | null  — drive animation from sound
 
motion.onRenderFinished(fn)                // post-render action; returns unsubscribe
motion.exporters.register(id, handlers)    // provide an output format (API 6)
motion.importers.register(id, handlers)    // read an input format (API 6)
 
motion.scene.apply(ops)                          // many mutations, ONE undo entry
motion.storage.get(key, scope) / set(key, value, scope)
motion.storage.delete(key, scope) / list(scope)  // scope: 'global' | 'project'

Prefer setKeyframes over a loop of setKeyframe: the bulk API sorts once and notifies once. Writing a generated track a keyframe at a time is quadratic and is what used to freeze the app on imports.

The widget vocabulary — richer UI without plugin markup#

A layer kind declares typed properties and the host draws them. The vocabulary is wider than it was:

"props": {
  "spin":    { "type": "angle",   "default": 0, "animatable": true },
  "soft":    { "type": "boolean", "default": false, "group": "Edges" },
  "feather": { "type": "number",  "default": 0, "group": "Edges",
               "showIf": { "prop": "soft", "equals": true } },
  "notes":   { "type": "string",  "default": "", "multiline": true }
}
angle A dial. Degrees, unbounded — a revolution is a legitimate value, and clamping to 0–360 would make a spin stop at the wrap. Animatable.
group A flat section heading. Ungrouped props stay in one unlabelled run at the top, so every plugin written before this reads exactly as it did.
multiline A textarea. string only — refused on anything else rather than ignored.
showIf Show this property only when a sibling has a value. The sibling must exist and cannot be the property itself; both are install errors.

Groups are flat by design and there is no nesting: a plugin that could nest groups could hide a property inside a collapsed one the user never opens, which is a different thing from organising a panel. A showIf-hidden row is removed rather than greyed — a control the plugin says does not apply is not one the user should be left wondering how to enable.

There is still no way to render your own markup, and there will not be. A plugin that could draw into the inspector could draw a convincing permission prompt, and every plugin's panel would age differently from the app around it. If the vocabulary is missing something your control genuinely needs, that is a request for another entry in the table above — the answer is a wider vocabulary, never an escape hatch. Your own panel is where free-form UI lives, in its own sandboxed frame.

Importers — reading a format the editor cannot (API 6)#

"contributes": { "importers": [
  { "id": "tga", "label": "Truevision TGA", "extensions": ["tga", "vda"] }
] }
motion.importers.register('tga', {
  decode({ name, bytes }) { return { width, height, pixels } },  // RGBA8
})

Return pixels and nothing else — not a name, not a folder, not a layer. Everything after the decode is the path every other import already takes, which is what makes a plugin format a first-class import rather than a parallel one.

A plugin never opens a file. It is handed the bytes of one the user chose, and only for an extension it declared — that is what import:files grants, and its consent line says so. Formats the editor already reads are refused: a plugin shadowing .png turns a working import into a plugin bug the user has no reason to suspect.

The host validates what you return against the size you reported, so a decoder whose buffer does not match its dimensions fails by name rather than making the host read past the end of an array.

Presets and behaviours (API 6)#

"contributes": { "presets": [
  { "name": "Drift", "tracks": [],
    "expressions": [{ "prop": "transform.y", "expr": "wiggle(2, 30)" }] }
] }

They appear in the Presets panel foldered under your plugin's name, and apply whether or not your worker is running — a preset is data.

You cannot register an expression FUNCTION, and it is worth knowing why, because it is two walls rather than one:

  1. An expression is evaluated inside the render, per property per frame, and plugin code lives in a Worker. Same wall that makes effects WGSL-only.
  2. The interpreter is a closed vocabulary on purpose. Expressions are parsed and interpreted, never eval'd — new Function is refused by the app's CSP, and relaxing that would let any shared project run code in a renderer holding your auth token. A plugin-supplied name would mean either running plugin code (wall 1) or re-opening that hole.

So you ship expression source, which goes through the same interpreter a user-typed expression does and reaches nothing extra. It is not syntax- checked at publish: the registry has no expression engine, and checking on one side only would produce a preset that publishes and then refuses to install. A broken one surfaces inline and editable, like your own.

applyFn and builtin are refused by name. A manifest cannot carry a function, but a string under applyFn would arrive truthy and non-callable and the apply path would call it.

Exporters — writing a format the editor does not know (API 6)#

Declare it, then claim it:

"contributes": { "exporters": [
  { "id": "webp", "label": "Animated WebP", "extension": "webp" }
] }
motion.exporters.register('webp', {
  begin(info) { this.enc = new MyEncoder(info.width, info.height, info.fps) },
  addFrame(f) { this.enc.push(f.pixels) },   // RGBA8, Uint8ClampedArray
  finish() { return this.enc.bytes() },      // ArrayBuffer | Uint8Array
  dispose() { this.enc?.free() },            // cancel / failure path
})

Your format appears in the export dropdown after the built-ins, hinted with your plugin's name. Choosing it starts your plugin — the user naming your format is a stronger signal than any activation event.

The host writes the file. You return bytes; the save dialog, output directory and overwrite prompt stay where they already are. An exporter that could write its own file would be an exporter that could write somewhere else.

This is the one place plugin JS runs per frame, and the reason effects cannot is instructive: an effect runs inside a synchronous render sixty times a second, so a Worker hop is fatal. An export is already a frame-at-a-time loop that takes minutes and blocks nothing interactive. Frames are transferred, not copied — a 4K frame is 33 MB — so the buffer is yours and gone from the host.

Frames are export:frames, and it is an alarming permission on purpose. To encode a composition you see every rendered pixel of it: more than assets:read (the images already in the project) and more than scene:read (its structure). Held with net:fetch it is the finished video leaving the machine, and the consent line says so.

Extensions the editor writes itself are refusedmp4, png, wav and the rest. Not collision (the host's formats are matched first, so a duplicate would never be reached) but honesty: a .mp4 a plugin produced is a file whose contents its name does not predict, and the failure lands wherever the user takes it next. Four exporters per plugin; ids may not contain a dot, because the host addresses yours as plugin:<pluginId>.<exporterId> and splits on the last one.

A step that overruns 60 s, or a plugin that stops answering, fails the export with a message naming the plugin rather than stalling the render queue.

Post-render actions#

motion.onRenderFinished(fn) fires when a render leaves the queue. Declare onRenderFinished in activationEvents to be started by one — a plugin that only reacts to renders has nothing to do until a render happens, and waking it at startup is a worker idling for an event most sessions never fire.

motion.onRenderFinished((r) => {
  if (r.status !== 'done') return
  motion.net.fetch('https://hooks.example.com/render', {
    method: 'POST',
    body: JSON.stringify({ comp: r.compositionName, file: r.fileName, ms: r.elapsedMs }),
  })
})

status is done (a file was written), skipped (it rendered, but you dismissed the save dialog — there is no file, so a plugin that uploads on completion must not fire here), or failed (with error). The rest is compositionName, fileName, format, width, height, fps, durationSec, elapsedMs.

You get metadata, never the render. No encoded bytes and no directory — fileName is the basename only. Handing a plugin the file would make "post-render action" mean "exfiltrate the render", which is a different feature needing a different consent screen; handing it the path would tell it where you keep your work, which it has no use for and — holding net:fetch — could send.

Gated on scene:read, with no permission of its own: everything in the payload is either the composition's own name and size, which scene:read already covers, or the fact that a render happened.

Listeners are a list, so registering two is two — and a handler that throws is logged to your plugin's row without silencing the others.

Structured values — paths, gradients and strokes#

setProperty takes a number, a string or a boolean for any property. Four properties additionally take a structured value, which is how a plugin gives a layer an actual shape rather than only moving one around.

// An outline. Tangents are optional — omit them for a polyline.
motion.scene.setProperty(id, 'points', [
  { x: -50, y: -50 },
  { x:  50, y: -50 },
  { x:   0, y:  50, inX: -10, inY: 0, outX: 10, outY: 0 },
])
 
// Several outlines on one layer — a donut, a letter with a counter.
motion.scene.setProperty(id, 'subpaths', [
  { points: outer },
  { points: inner, open: false },
])
 
// Solid or gradient fill.
motion.scene.setProperty(id, 'fillPaint', {
  type: 'linear', angle: 45,
  stops: [{ offset: 0, color: '#ff0055' }, { offset: 1, color: '#0055ff' }],
})
 
// A stroke. PATCHED onto the layer's existing one, so setting the width
// does not reset the cap, the join or the dash pattern.
motion.scene.setProperty(id, 'stroke', { width: 4, color: '#000000', dash: [6, 3] })

Declare scene.structured in requires if your plugin cannot work without it — it is a capability, not a version, so an older host says so at install rather than at the call.

No new permission. A structured write is still "change a property of a layer", so it rides on scene:write (or scene:proxy inside your own subtree). Splitting it out would add a line to the consent screen for a distinction the user cannot act on.

Geometry is created if the layer has none. A shape layer made with scene.createLayer carries no outline — the primitives that ship with one get it from the insert path, which you do not go through. Writing points gives it one.

The value is parsed, not stored. Every field is rebuilt: non-finite numbers are refused (a NaN in a path becomes a layer that cannot be drawn, measured or clicked, and nothing points back at you), colours must be #rgb / #rrggbb / #rrggbbaa, and gradient stop ids you supply are ignored — the host mints them. Errors name the exact index, so "points[41].y" must be a finite number is what you get rather than a silent bad path.

Bounds are refusals, not clamps: 10 000 points per path, 256 subpaths, 64 gradient stops, 32 dash segments. Clamping would hand you a path that is not the one you built with no way to notice.

Validation completes before anything is written, so a refused call has changed nothing — including inside a scene.apply batch.

scene.apply — many mutations, one undo entry#

One host call is one undo entry. Twelve calls are twelve entries, and a user undoing your plugin's work presses Ctrl-Z twelve times without knowing how many to expect. That is the real reason to batch, more than the round trips.

const [rowId] = await motion.scene.apply([
  { op: 'createLayer', kind: 'group', name: 'Row' },
  { op: 'createLayer', kind: 'shape', name: 'A', parent: { ref: 0 } },
  { op: 'createLayer', kind: 'shape', name: 'B', parent: { ref: 0 } },
  { op: 'setProperty', layer: { ref: 1 }, prop: 'x', value: 0 },
  { op: 'setProperty', layer: { ref: 2 }, prop: 'x', value: 120 },
]);
  • { ref: n } is the result of op n — how a batch creates something and then refers to it.
  • Everything applies or nothing does, and it is one undo entry.
  • Store notifications are coalesced at the store, so 1,000 creates cost one re-render rather than 2,000.
  • The permission needed is the union of the ops present: a read-only batch needs no write grant.
  • Limits: 10,000 ops, 8 MB. An error names the failing op's index.
  • Creates anchor to where op 0 landed, not to the user's selection — otherwise a thousand creates would build a thousand-deep chain, because the underlying insert parents to whatever is selected.

storage — remembering things#

await motion.storage.set('lastPreset', 'wobble');            // scope defaults to 'global'
await motion.storage.set('seed', 42, 'project');
const seed = await motion.storage.get('seed', 'project');

Two scopes, and picking the wrong one is the mistake worth avoiding:

Scope Lives in Survives Use it for
global IndexedDB, 1 MB per plugin update and uninstall preferences, an API base URL, "don't show this again"
project the project document, 256 KB per plugin travels with the file anything that describes this project

global surviving uninstall is deliberate: a reinstall that forgot everything would make every update feel like a reset. project travelling with the file is what lets a colleague open the project and see what your plugin computed.

Values are capped at 64 KB. A write past a quota throws with code === 'storage-quota-exceeded' rather than silently truncating — catch it and tell the user, because a plugin that quietly stops remembering is a bug report nobody can reproduce.

Keys are namespaced to your plugin id. No other plugin can read them, and no permission is required: your own settings are not the user's data.

setParent does not move the layer on screen — it adopts the local transform that reproduces where it already is, so grouping is not a nudge. It is refused if it would make a layer its own ancestor, or cross compositions.

effects.add takes a TYPE and returns the new effect's id; everything after addresses that id. Your own effects are <pluginId>.<effectId> and are addable only while your plugin is running. A type this editor does not have is an error rather than a silent no-op — as is removing or setting a parameter on an effect id that is not on the layer, both of which would otherwise succeed quietly and leave you debugging a project that did not change.

Panels#

panel.html is plain HTML, run in the sandboxed frame with two globals:

motionPanel.send(data);        // → your plugin's onPanelMessage
motionPanel.onMessage(fn);     // ← your plugin's sendToPanel

The panel talks to your plugin only. It has no access to the editor, and no access to the network — inline <script> runs, fetch does not.

A panel docks like every other panel — it can be moved between the two docks and popped out into its own window.

Where it lands: placement#

You declare what kind of panel it is; the host decides where it goes.

"panels": [
  { "id": "main", "title": "Easing Lab", "entry": "panel.html",
    "placement": "sidebar", "icon": "graph-value" }
]
placement Where it appears
shared (default) A tab inside the one Plugin Panels panel in the right inspector, shared with every other shared panel
sidebar Its own tab in the left sidebar, beside Scene, Assets and Library
inspector Its own tab in the right inspector, beside Properties and Effects

Pick shared unless your panel is a place the user goes rather than a control they reach for. It costs no rail space, and it is what every panel written before this field existed already gets.

sidebar and inspector require an icon — the rail shows glyphs, not titles, so a panel without one is a tab the user cannot tell from anybody else's. Names come from the editor's icon set and are checked when the package is validated, by the editor and by the registry, so a typo is a publish error rather than a generic glyph you never notice.

A tab of your own is granted, not guaranteed. Each rail hands out a fixed number of plugin slots (3 on the left, 2 on the right); past that a panel is demoted to the shared host. It still opens and motion.ui.openPanel() still reveals it — it just does not own a glyph. Which happened is printed on your plugin's row in the Plugins panel, so a demotion never reads as your plugin being broken. Write the panel so it works either way: there is deliberately no API to ask where you ended up, because there is nothing useful you could do differently.

Getting it on screen#

motion.ui.openPanel() reveals your panel wherever it landed. The user can also reach it from Plugins ▸ Your plugin: Panel, or by clicking its rail tab — which, if you declared onPanel:<id> in activationEvents, is what starts your plugin in the first place. The tab exists whenever your plugin is installed and enabled, running or not; the panel states its status until the worker is up.

Nothing the user clicks closes it. There is no ✕ on a plugin panel, on a plugin tab, or on the Plugins panel. A panel belongs to the rail for as long as its plugin is installed and enabled; disabling or uninstalling from the Plugins panel is what removes it, and that also stops the worker. So do not build a "close me" control into your panel expecting the tab to go away — motion.ui.closePanel() switches away from a shared tab, and does nothing at all to a tab of your own.

Where your plugin shows up#

Contribution Where the user finds it
commands.register(...) The Plugins menu, under your plugin's name, and ⌘⇧P
panel in the manifest Wherever its placement sends it (above) + a Your plugin: Panel command
ui.notify(...) A toast, always prefixed with your plugin's name
The package itself Plugins ▸ Manage Plugins… — status, permissions, enable/disable, uninstall

A plugin that is installed but not running still appears in the menu, disabled, saying why. Nothing an installed plugin does is invisible.


6. Supervision#

  • Boot timeout — 8 s to activate, then stopped with a reason.
  • Heartbeat — a ping every 4 s; two unanswered ⇒ terminated as "stopped responding". A wedged plugin cannot wedge the editor because its loop is in another thread.
  • Errors are surfaced, not swallowed — a fatal shows in the manager row with a Restart button and as a toast prefixed with the plugin's name.
  • Log — each row has one. It carries the plugin's own console.* output (forwarded from the worker, where DevTools is not something a user of the packaged app has), every call the permission gate refused, and the crash that stopped it. Kept after the plugin dies — that is when it gets read — and bounded at 200 lines so a logging loop cannot grow the host.
  • Enable / disable is distinct from uninstall: disabling terminates the worker and unregisters its commands but keeps the package.
  • A panel never outlives its worker. Stopping a plugin — disabled, crashed, uninstalled — closes its panel. A frame still on screen with nothing answering it reads as the editor being broken.

7. The registry#

Plugins ▸ Manage Plugins… ▸ Browse installs from the registry that lives in motion-back (src/plugins/). A registry install is not a shortcut past the permission screen — the download is verified, then parsed by the same package reader a local file goes through, then shown on the same consent screen.

What is actually guaranteed#

Trust on first use. A publisher generates a keypair; the registry records the public key the first time a plugin id is published, and every later version must carry a signature that verifies against that same key. The editor re-checks the signature on the user's machine, against the key stored with the installed copy — not the key the download claims. So:

Attack Result
Package modified in transit or on a CDN Fails verification locally, not installed
Someone else publishes under your plugin id Refused: id is owned by the first publisher
Your registry account is stolen Refused: the thief has no signing key
Registry itself is compromised and serves a new key Refused on update: the client pins the stored key
A publisher ships something malicious under their own key Not covered. Signing says who, never whether they meant well — which is why the permission screen still exists.

ECDSA P-256 / SHA-256, signature as IEEE-P1363, key as SPKI. The editor's verifier is src/core/plugins/registry.ts; the registry that signs packages is a separate hosted service and is not part of this repository. A test signs with Node and verifies with WebCrypto, because that seam breaking silently would mean nothing installs.

Publishing#

From the app. Hit publish and you are asked how to sign:

Use an existing key… opens a file picker for your *.json key file
Create a new key… asks where to save one, makes it, and signs with it right away

Pick Create a new key… the first time. There is nothing to find beforehand — a signing key is a file you make, not something the registry issues you.

From the command line, for scripted releases:

node scripts/sign-plugin.mjs keygen --out ./my-plugin.key.json
node scripts/sign-plugin.mjs publish my-plugin.zip --key ./my-plugin.key.json --token <access token>

Both produce and accept the same file — P-256, { privateKey, publicKey } as base64 PKCS8 and SPKI — so you can start in the app and script it later, or the other way round.

The private key never leaves the machine, and Premation never stores it: publish sends the package, the signature and the public key, and you are asked for the key file each time. Remembering it in the OS keychain would make anything running as you able to publish as you, which is the compromise the signing model exists to survive.

Keep the key file, and back it up. It is your publisher identity. The registry pins it on your first publish and every later version of that plugin must verify against it — so losing it means republishing under a new id, and anyone who has it can publish as you. That is the cost of the guarantee rather than an oversight.

Register a backup key on your first publish, while you have no install base and it costs nothing. Authorising one later needs your account password and prompts every user who already has the plugin.

Published versions are immutable: re-publishing an existing version is refused, because two different sets of bytes claiming to be 1.2.0 would make the signature guarantee unusable.

Updates#

Checked only when the manager is opened — never on a timer, never in the background. This is the editor asking the registry, on the screen where the answer is the point — not a plugin reaching anywhere. A plugin's own network path, where it has one, is motion.net.fetch (§13). A failed check is silent, so working offline does not produce errors.

An update that asks for more permissions than were granted goes back through the consent screen rather than installing quietly. A plugin withdrawn by an operator is reported to anyone running it, and their copy keeps working — the package is blocked, not deleted, because breaking someone's project is usually a bigger harm than the one a takedown addresses.

8. Deliberately out of scope#

These are settled decisions, written down so they stop being re-proposed.

  • Rating, comments, curation. The registry lists what was published; it does not editorialise, and there is no ranking signal beyond the deduplicated install count. The raw download count exists but is internal and never ranked on — two numbers on a listing invite a comparison the inflatable one always wins.
  • Fetching a URL the user supplies at runtime. Considered and rejected. It sounds like a small addition to net:fetch and is not: the whole guarantee is that a plugin's reachable hosts are declared, signed, and shown on the consent screen, so a plugin that can be handed an arbitrary URL has consent for "contact the internet" no matter what the screen said. There is no runtime host allowlist, no host-mediated URL dialog, and no net.requestHost. If your plugin needs a user-chosen endpoint, declare the host it belongs to.
  • Automatic blocking on a report threshold. Reports are cheap by design — no account needed — so a count that blocks is a takedown button handed to anyone who can make the count go up. A case escalates to a human; nothing else.
  • Plugin-to-plugin communication. One worker and one frame each, and no shared channel between them. Two plugins that can talk are two plugins whose combined permissions are the union of what the user granted separately, which is not what the consent screen said.
  • Multi-file entry modules. main is a single ES module; bundle first.
  • Background or periodic update checks. Only when the manager is opened. The single exception is the revocation list, which is a safety mechanism and uploads nothing — see §10.
  • Render-path plugins. Shipped in API 4 — see §12. This said "a plugin cannot draw pixels" and stopped being true. It arrived the way this entry predicted: a separate class with a synchronous, deterministic contract (WGSL as data, never JS in the frame loop) rather than an extension of the command API.
  • Documents referencing plugins. No longer true as of API 3 — see §9. A document containing a plugin-defined layer names the plugin that defines it. The guarantee it replaced is spelled out there in full.

9. Layer kinds (API 3)#

A plugin can declare a layer type the editor has never heard of, with animatable properties that appear in the timeline and the graph editor and behave like native ones. You ship a schema, not a widget: the host renders the inspector from it with its own components, so a plugin contributes no markup and no CSS.

"apiVersion": 3,
"contributes": {
  "layerKinds": [{
    "id": "depthImage",              // camelCase; namespaced as <pluginId>.depthImage
    "label": "Depth Image",
    "render": "proxy",               // required — see below
    "schemaVersion": 1,              // monotonic; drives onMigrateLayer
    "props": {
      "focal":  { "type": "number", "default": 50, "min": 0, "max": 100, "animatable": true },
      "source": { "type": "asset",  "assetKind": "image" },
      "mode":   { "type": "enum",   "values": ["parallax", "displace"], "default": "parallax" }
    }
  }]
}

Only number, color and boolean may be animatable — a string keyframe is not something the interpolator can do, and accepting one here would push the failure into the graph editor after you had shipped. Every default is checked against its own constraints at install time, for the same reason.

render is part of the schema, not a runtime choice#

  • "none" — a controller. It draws nothing; its properties exist to drive other layers. Shown as a null-style gizmo, selectable in the viewport.
  • "proxy" — you maintain a subtree of native layers as children and the host renders those. The custom layer is the authored, animatable interface; the children are its output.
  • "shader" — reserved, refused with a version message. Not a typo on your part.

proxy ships first because it is the one whose documents survive your plugin being uninstalled: the children are ordinary layers and keep rendering.

The one thing to get right: authored versus animated#

scene.onLayerChanged fires for AUTHORED property edits only. It never fires for animated value changes.

This is the contract, not a tuning detail, and getting it wrong is the mistake whose symptom will not look like your bug.

An animatable property changes every frame during playback. If regeneration were driven by value changes, per-frame regeneration would be the steady state rather than an edge case — and coalescing cannot save you, because coalescing protects against a burst that ends and animation never ends.

  • You regenerate when the authored schema changes: the user drags focal, picks a different mode, assigns an asset. Bursts are coalesced by the host, so a drag produces one regeneration, not one per pointer event.
  • The host animates what you already generated, through ordinary expression bindings on the children.

Enforced structurally rather than by discipline: both behaviours hook the scene graph's authored write path, and animation samples tracks without ever writing a property — so playback cannot reach the notifier at all.

Referencing a parent property from a proxy child#

// In the child you generate, not at runtime:
layer('Depth Image', 'plugin.focal')

Write the name; the host stores an id. A name is resolved to the layer's stable id at AUTHORING time and stored as layer('#n_a1b2c3', 'plugin.focal'). Nothing at evaluation time looks a layer up by name, which is what makes the binding survive a rename — before this, renaming a depth layer silently made every child read 0, with the symptom appearing nowhere near the rename.

The # prefix keeps the two unambiguous: without it, a layer whose NAME happened to equal another layer's id would resolve to the wrong one. It is available to user-authored expressions too, and the resolution layer treats both forms identically. A name that resolves to nothing is left exactly as written rather than rewritten to #undefined — an already-broken reference should not become a permanently broken and untraceable one. Documents written before this are repaired on load; unresolvable references are reported, never dropped.

A user's own expressions are not rewritten to ids, and do not need to be. The source text is what they typed and what they see when they open the expression editor, so replacing a layer name with #n_a1b2c3 would make their formula unreadable to them in order to fix a problem they have not hit. The rename carries the references instead: renaming a layer updates every expression that named it to the NEW NAME, in the same undo entry, so the text stays readable and the reference stays correct.

Two details matter if you are reasoning about this from a plugin:

  • The rewrite is keyed on resolution, not on matching text. Layer names are not unique and layer('Panel') means the first Panel in traversal order, so renaming a second layer of that name leaves every reference alone — rewriting by text match would silently retarget them to the layer being renamed.
  • Renaming a layer to a name another layer already holds can steal that name's resolution. Nothing errors and no text changes; the affected expressions are named in a warning rather than rewritten, because which layer the author meant is not something the editor can know.

Note the plugin. prefix on the property. Your declared properties animate under plugin.<name>, fixed rather than per-plugin because a stored track key must not depend on which plugin is installed. It is reserved: no native property may begin with it. It never appears in the inspector — users see your label.

This binding shape was chosen over "the host pushes evaluated values into children each frame" for two reasons, one measured and one structural:

  • Measured. 48 bound children, sampled every frame, cost ~0.5 ms/frame in the slowest realm available (jest's VM, an upper bound) against a 16.7 ms budget, and scale linearly. proxyBindingCost.test.ts keeps that honest.
  • Structural, and the reason it wins. The binding is evaluated by the ENGINE, with no plugin involved — so a proxy subtree animates correctly in a document opened without your plugin installed. The missing-plugin fallback comes for free instead of being a second code path to keep working.

The cost is that your generated output is expression-bearing, so plugin-written expressions carry authoredBy: <pluginId>. A document full of expressions with no origin label is unpickable later.

Regenerating: setProxyChildren#

motion.scene.onLayerChanged('depthImage', async ({ layerId }) => {
  const layer = await motion.scene.getLayer(layerId);
  await motion.scene.setProxyChildren(layerId, [
    { key: 'plane-0', kind: 'shape', name: 'Near',
      expressions: { x: `layer('${layer.name}', 'plugin.focal')` } },
    { key: 'plane-1', kind: 'shape', name: 'Far' },
  ]);
});

key must be stable across regenerations. The host DIFFS on it: a child whose key is unchanged keeps its scene-graph layer id. Churn the keys and you churn the ids — and layer ids are referenced by selection, by parenting, by other layers' expressions and by the undo stack, so a user's selection jumps, an unrelated layer('Near', …) goes dead, and undo granularity collapses. All of it lands far from the parameter tweak that caused it.

A regeneration is one undo entry, labelled with your plugin's name. The host rate-limits regeneration per plugin: a plugin that regenerates in response to its own regeneration is a loop, and the failure mode is a wedged editor, so it is stopped by the host rather than left to author discipline.

Who owns a generated child#

A user editing one of your generated layers detaches the WHOLE subtree from your plugin, permanently, and your next setProxyChildren is refused.

The alternative — refusing the user's edit — was rejected. The point of render: 'proxy' is that your output is ORDINARY layers; a subtree the user may look at but not touch is a black box, and it would make your output your property rather than their document.

Detaching the whole subtree rather than the one child edited is deliberate: a half-owned subtree is a state neither side can reason about, and your next regeneration would have to diff around a hole the user created.

Nothing is destroyed. Detaching clears a mark; every layer stays exactly as it was, now belonging to the user. Generated children are marked in the document, not only in the UI, so a collaborator sees the same thing.

What a document stores, and what happens without your plugin#

A custom layer serialises as one component whose TYPE carries the namespace (pluginLayer:<pluginId>.<kindId>), with your declared props on it under their own names. The document also carries a top-level list of the plugins it references (id, version, publisher, kinds used), derived from its CONTENTS at save time — never from what happens to be installed, because a project saved on a machine missing the plugin must still name it.

Without your plugin:

  1. The layer is never lost. Not on uninstall, not on open, not on save-and-reopen.
  2. It still renders, if it is proxy — the children are ordinary layers. And it still ANIMATES, because the bindings are evaluated by the engine.
  3. It is inert and says so: properties read-only, your logic not run, a non-blocking banner naming what is missing with an offer to install it.
  4. Keyframes survive untouched.
  5. Reinstalling reactivates it in place, with the original values.

What is NOT guaranteed is the authored interface: the custom layer's properties are read-only, so changing focal does nothing until the plugin is back. The subtree is a frozen snapshot of the last regeneration.

Schema versions#

schemaVersion is monotonic and stored per layer.

  • Plugin newer than the document → you get one chance to migrate via onMigrateLayer(oldProps, fromVersion), run inside runDocumentEdit as one undo entry and validated like any other plugin input. Anything that fails validation falls back to that property's DEFAULT — but a property your migration did not mention KEEPS its value if it still validates. Defaulting an unrelated, still-valid, animated property because a migration was buggy is destructive; keeping it is at worst occasionally wrong. On any drop the pre-migration props are QUARANTINED under __preMigration, so a reset is recoverable rather than merely reported. Keyframes are never touched.
  • Plugin OLDER than the document → marked inert, never guessed. The older plugin cannot know what the newer one stored.

Known gaps in this API#

Found by writing a real depth/parallax plugin against it. Listed rather than worked around, because a workaround in one plugin is a missing API the next author hits without it.

1. onLayerKind cannot bootstrap your own first layer. Fixed. The host creates it, from your schema, under Layer ▸ New ▸ <your label> — built from the registry, so your kind appears whether or not your worker is running. Choosing it creates the layer at your declared defaults and THEN activates you, exactly as opening a document does. You keep pure lazy activation; declare onLayerKind:<id> and nothing else. 2. No asset picker. Fixed. An asset prop renders a picker listing the project's images. Only images, because assetKind can only be image; an id whose asset has gone stays selected and is marked (missing) rather than silently clearing, so a lost reference is something the user can fix instead of something they have to notice. 3. render: 'none' has a gizmo but no dedicated overlay. It is selectable and draggable; it draws as a plain container.


10. Revocation — what a user sees when a plugin is withdrawn#

An operator takedown used to reach a user only when they happened to open the plugin manager. It now reaches them within a boot, and mid-session if the app is already running.

How it works. The client fetches a small signed list from GET /plugins/revocations — public, cached, no auth — and matches it locally. It uploads nothing. That is deliberate and it is the reason revocation is not built on POST /plugins/updates, which sends the user's whole installed set: the enforcement mechanism must not be the thing that tells the registry who runs what.

The list is signed with an operator key pinned in the app, not a publisher key. A publisher key says "the same author made this"; the operator key says "the registry says stop". An author who could sign a revocation list could un-revoke their own plugin, or revoke a competitor's.

What a user sees. The plugin stops — immediately, not at the next restart — and is disabled. A toast names it and gives the operator's reason verbatim, and the same reason goes to the plugin's log. It cannot be re-enabled or reinstalled while it is listed.

What does not happen. The package is not deleted and nothing they made is destroyed. Documents that reference the plugin keep opening; a proxy layer's generated children keep rendering and keep animating, because their bindings are evaluated by the engine. Breaking someone's project is usually a bigger harm than the one a takedown addresses.

When the list cannot be fetched, the last verified one keeps applying and the failure is silent — being offline is normal. A list past its freshness window is still enforced and its staleness surfaced, because a client that stopped enforcing a stale list would make "block the fetch" the entire exploit. A list with a lower sequence number than one already seen is refused, so a replayed older list cannot un-revoke anything.

With no operator key configured, the client refuses every list and the server answers 503 rather than serving an unsigned one. An unsigned kill switch is one anybody can pull.

The key is live. The operator keypair is generated by motion-back npm run operator-key; its private half is MOTION_REVOCATION_KEY on the server and exists nowhere else, and its public half is a pinned constant in the editor. Because it is pinned rather than fetched, rotating it costs an app release — which is the correct price for a control of this weight, since a key the server can choose is a key an attacker who controls the server can choose.

That pin is the one part of the chain no unit test can check on its own: a typo, an empty constant, or a keypair regenerated and never redeployed all produce a client that silently refuses every list, which is indistinguishable from a registry with nothing to revoke. So revocationKeyIsPinned.test.ts verifies a fixture signed by the real operator private key, and the server's revocation.service.spec.ts verifies its own output with the client's exact primitives (spki import, 64-byte IEEE P1363). Rotate the key and both must be regenerated — the tests failing is the intended way to find out.


11. Trust and safety#

Signing says who. Permissions say what. Neither says whether the author meant well, and no amount of cryptography will — a correctly signed package from a verified publisher, asking only for permissions it genuinely uses, can still do something nobody consented to. Everything in this section exists because that gap is real and is not closable by better cryptography.

Reporting a plugin#

Anyone can report one, from the plugin's detail tab or from the row's context menu in the Plugins panel. Five categories — malicious behaviour, impersonation, broken or abandoned, inappropriate content, license violation — plus an optional message.

No account is required. The endpoint takes an identity when the caller has one and refuses nobody, because the moment worth reporting is often before installing: the person best placed to notice a listing impersonating another plugin has not signed up, and a dialog demanding an account first would simply lose the report.

The publisher is never told who reported them. Both halves matter and they pull in opposite directions: a report we cannot attribute is one we cannot meter or weigh, so the reporter is recorded server-side; a reporter the accused can identify gets retaliated against and stops reporting, so nothing publisher-facing or reviewer-facing carries it. Addresses are stored as a salted HMAC, never raw — the IPv4 space is small enough to enumerate, so an unsalted digest of an address is a lookup table, not a one-way function.

Reports collapse into cases. A plugin that starts misbehaving gets reported by forty people in an hour, all about the same version, all correct. Forty rows in an inbox is forty decisions about one artefact, and a reviewer makes most of them badly out of fatigue. So reports attach to a case keyed on (plugin, version), and the count becomes signal — forty people noticed — rather than volume to wade through.

A dismissed case reopens when someone reports it again. Without that, one dismissal is permanent immunity: every later report lands on a closed case nobody looks at, and the reviewer who was wrong in week one never finds out in week six. A plugin that turns malicious after review is exactly what the queue is for.

Publish-time scanning#

Automated, advisory, and fast. It gates review, not publication.

The package is scanned after the signature verifies and the manifest parses, never before — the same order the client uses on install, and for the same reason: unverified bytes must not reach a parser. Checks cover obfuscation heuristics, very long single lines, base64 blobs that decode to code, computed dynamic import(), eval and the Function constructor, decode-then-execute pairs, and panel-bridge use by a package that declares no panel.

The highest-signal check is permission/behaviour mismatch, and it is interesting in both directions. A package asking for scene:write that never writes is either a copy-pasted manifest or someone establishing a permission to use later, after the reviews stop — either way the consent screen overstates what the code does, and a consent screen that overstates is one users learn to click through. A package calling a method it never asked for will be refused at runtime, so it is untested code or a build against a different manifest.

Results attach to the version as a risk score plus findings, and gate nothing. Your publish is live the moment it succeeds: downloadable, searchable, and latestVersion.

The findings come back to you as warnings on the publish response. Read them — the highest-signal one is permission-undeclared, which means your code calls a method your manifest never asked permission for. Those calls are refused at runtime, so the plugin installs, looks healthy, and silently fails at the one thing it was written to do. A successful publish cannot tell you that; the warning can.

The scanner is not the security boundary. The sandbox is. Every check is a pattern match over source a hostile author controls completely, and every one can be evaded by someone who reads the source — which is public. If the platform's safety ever depends on a finding here, the platform is not safe. Findings are prompts for a person, never verdicts.

That is also why it no longer gates. A check that stops only the people who were not trying to get past it, while silently burying honest authors who mistyped a permission, was costing more than it bought. The score is still computed and stored, because "what did the scanner see when this shipped?" is the first useful question when an abuse report arrives.

The reviewer queue#

Admin-only, at /admin/plugins/review. Open cases, ordered by distinct reporters rather than age — a queue sorted oldest-first puts a low-risk package from Tuesday above eleven reports of data theft that arrived this morning, which is the ordering that gets a queue abandoned.

Its held-versions half is now permanently empty: nothing writes pending. The code is kept because blocked and changes_requested are still real operator decisions, and because a deployment that wants the gate back needs one line.

Per-case actions: approve, request changes (with a note the publisher reads), block the version, block the plugin, suspend the publisher. Every one requires a reason, and every one is recorded in the shared audit log with actor, timestamp and reason.

The reason is the product. For a block it is written to blockedReason, signed into the revocation list, and shown to the user when their copy stops mid-session. The sentence an operator types in the console is the sentence a stranger reads when their work is interrupted — which is why a minimum length is enforced. "No" is indistinguishable from a bug, and the user's next move is to reinstall the thing that was just taken away from them.

Blocking writes the revocation list directly. There is no separate "publish the revocation" step: RevocationService derives its signed list from the same blocked column the block sets. A kill switch with a manual second stage is one that gets left half-pulled.

Two deliberate separations:

  • Blocking a version ≠ blocking a plugin. One build being bad does not mean users on the previous version should lose it.
  • Suspending a publisher leaves their plugins up. Taking down everything an author ever shipped punishes every user of every one of their plugins for something the author did. Block the ones that need blocking, explicitly, so the audit log shows each was a decision rather than a side effect.

What a publisher sees#

Their own shelf carries the review state of any version of theirs that is not live, with the reviewer's note — so a held version is never an unexplained silence they have to email someone about.

They do not see the risk score or the findings. The score is an internal triage number that reads as a rating, and the findings are a list of the exact patterns the scanner looks for, which is the evasion guide.

Changing a signing key#

Trust-on-first-use used to mean a key change was refused permanently, and a publisher who lost their key republished under a new id. That is defensible as a guarantee and brutal as a product: it discards the install base, the install count, and the id every existing document references — and it punishes the one thing we most want authors to do, which is not keep a signing key somewhere that survives losing a laptop.

Rotation replaces "never" with "only with something else you already proved". There are three gates, and a stolen account clears exactly one of them:

  1. Authorising a key needs the account password, re-entered. Either register a backup alongside your first publish — the one moment it is free of risk, since there is no install base to hijack yet — or add one from the dashboard later. Publishing can never authorise a key: if it could, a stolen session would be enough to take a plugin.
  2. Rotating needs a package actually signed with that key. An authorisation that never ships anything changes nothing, and the authorisation is spent on use — two keys that can both sign forever is what rotation exists to end.
  3. Every installed copy needs its own user to accept. The editor never re-pins silently. It shows "the publisher's signing key changed", explains that this is also what an account takeover looks like, and offers keeping the current version as the emphasised choice. Declining is not an error; the installed plugin goes on working.

The change is on the plugin's public listing — when it happened and how it was authorised — because "the publisher's key changed" is precisely what someone who took over an account would want accepted quietly.

If the editor's key-change prompt is unavailable for any reason, the update is refused, not accepted. A missing dialog is not consent.


12. Effects (API 4)#

A plugin can draw pixels. It ships WGSL and a typed parameter schema; it does not ship a callback.

Shaders as data, never JS in the frame loop#

This is the constraint everything else follows from, and it is structural rather than a performance preference. Plugin code lives in a Worker, so reaching it means postMessage, which means awaiting a reply inside what has to be a synchronous render. One async hop per effect per frame is playback that stutters and an export that takes minutes, and no amount of batching fixes an architecture that has to ask another thread what colour a pixel is.

So your JS registers an effect and drives its parameters. It is never in the loop — which is also why your effect keeps working in a document opened by someone whose editor never started your worker.

{
  "apiVersion": 4,
  "contributes": {
    "effects": [{
      "id": "tint",
      "label": "Tint",
      "shader": "@fragment\nfn fs(@location(0) uv : vec2<f32>) -> @location(0) vec4<f32> {\n  return textureSample(src, samp, uv) * params.amount;\n}",
      "params": {
        "amount": { "type": "number", "default": 1, "min": 0, "max": 2, "animatable": true }
      }
    }]
  }
}

Parameters are ordinary properties#

params uses the same schema layerKinds.props does, validated by the same code. An animatable parameter becomes a keyframe track keyed exactly like every other property — no new machinery in the animation engine, nothing special in the timeline or the graph editor.

Only number, color and boolean are accepted. string has no bytes in a uniform block, asset is a reference rather than a value, and enum would need an index mapping you had to keep in your head and in step with your schema. All three are refused at install rather than discovered from a black frame.

You write one function. The host writes everything else.#

Write a @fragment entry point named fs, and read params.<name>, src and samp. That is the whole surface.

You must not declare @group, @binding, or a @vertex shader — all three are refused at install. The host generates the parameter block, the input texture, the sampler and the vertex stage, and prepends them to your source.

Three reasons, and none of them is tidiness:

  • The vertex stage is identical for every effect — the same full-screen quad transform. Asking each author to hand-copy a matrix multiply whose only possible contribution is a bug is not an interface.
  • The uniform block starts with the renderer's own header. mvp and uvRect occupy its first 64 bytes and the vertex stage reads the transform from exactly there. A block that began with your first parameter would compile, bind, and draw a quad with a garbage transform — nothing would error.
  • Hand-written uniform layout is a padding bug that surfaces as wrong colours rather than as an error.

You also get a host pass block at offset 64, and one field in it is worth knowing about even for a single-pass effect:

params.texelSize   // vec2 — one over the target's dimensions
params.passScale   // this pass's downsample
params.passIndex   // 0-based

texelSize is how you sample a neighbour: uv + vec2(params.texelSize.x, 0.0) is one pixel to the right, at whatever resolution the host allocated. Hardcoding a resolution is correct on your composition and wrong on everyone else's, by an amount that reads as a bad kernel rather than a bad assumption.

After that block, the generated struct orders your parameters by alignment, descending — every vec4 first — starting at offset 96. A scalar before a vec4 would leave a 12-byte hole the struct does not describe, and every member after it would read shifted bytes: no compile error, no exception, just wrong colours that look like your maths.

More than one pass#

Declare passes instead of shader — up to four, each with its own WGSL:

{
  "id": "gaussian",
  "label": "Gaussian Blur",
  "params": { "radius": { "type": "number", "default": 8, "min": 0, "max": 32 } },
  "passes": [
    { "name": "horizontal", "wgsl": "…" },
    { "name": "vertical",   "wgsl": "…", "reads": "previous" }
  ]
}

The host allocates the intermediate targets, ping-pongs them and runs the passes in order. You never see a target.

Field
scale 1, 0.5 or 0.25. The target's downsample
reads previous (default), origin, both. origin is the chain's input, at binding 4

Use scale for anything with a large radius. A pass at scale s renders into a target that fraction of the viewport, and — this is the part worth internalising — the same tap count then reaches 1/s times further, because one texel of a quarter-size target is four pixels of the image. So a quarter-scale blur is both sixteen times cheaper and four times wider than the identical shader at full scale. Measured on hardware: 14 composition pixels of spread at full, 48 at quarter.

params.texelSize is always your own target's, so you write the kernel once and it behaves correctly at every scale.

reads: "origin" gives you origin at binding 4 — the image as it entered your chain, before any of your passes touched it. That is what a composite step needs: a bloom adds its blurred copy back over the original, and by the time you get there the original is several ping-pongs ago.

// The last pass of a bloom.
let base  = textureSample(origin, samp, uv);
let light = textureSample(src,    samp, uv);
return base + light * params.intensity;

both is the same binding; use it when you read src as well, which a composite almost always does. reads on the first pass is refused permanently — its src and its origin are the same texture.

A full chain: four passes, each at full, half or quarter scale, each reading the previous pass or the original. Separable blurs, convolutions, iterative filters, downsampled large-radius work, and bloom.

reads on the first pass is refused permanently, for a different reason: its src and its origin are the same texture, so no version will make that valid.

The cost budget is 3, where a pass costs scale². A separable blur is 2 and four full-scale passes is 4, which is refused. The budget already understands downsampling — a ¼-scale pass costs a sixteenth of a full one — so a bloom will fit at about 2.13 the day scale renders.

com.example.separable-blur is a complete working sample. Two things in it are not obvious and will both bite on a first attempt:

  • The loop bound must be a numeric literal — not a const, which the validator's regex does not resolve, and certainly not a uniform. Loop to a fixed maximum and multiply the taps beyond your live radius by zero. A GPU was not saving that work anyway.
  • Handle the parameter's zero. At radius 0 a Gaussian's sigma is 0 and every weight is NaN, which draws black — at the setting nobody changes first.

A chain compiles to one pipeline per pass, registered as <pluginId>.<effectId>#<passName>. A single-pass effect keeps the bare <pluginId>.<effectId> it always had. If any pass fails to compile the whole effect renders passthrough and is marked failed by name — never a half-applied chain.

The entry point must be called fs because that is the name the render pipeline looks for, and every built-in shader here uses it. A differently-named one compiles and then fails to bind, with a driver error naming nothing you wrote — so it is refused at install with a message that says what to rename.

What the validator refuses, and why#

A GPU cannot be preempted. A fragment shader that takes too long is not slow — it is a hang, and the operating system's answer is to reset the device, which on Windows destroys every GPU context in the process. So one plugin's shader can black out a viewport for a document that has nothing else wrong with it.

Refused before compilation:

  • A loop whose bound is not a literal. for (var i = 0; i < params.count; …) lets a slider decide how long the GPU spends per pixel. Bounds must be literal, at most 256 per loop, nested at most 3 deep — bounds multiply.
  • while and loop, which have no syntactic bound at all.
  • discard — effects composite, so a discarded fragment shows the layer beneath rather than transparency. Use alpha = 0.0.
  • Storage buffers, atomics, @compute — an effect reads its declared parameters and the input texture, and nothing else.
  • Sources over 64 KB, or roughly 2000 statements.

Unlike the publish-time package scanner, which is advisory because it reasons about intent, this refuses syntax. A loop whose bound is not a literal has no bounded cost whoever wrote it and whatever they meant.

When it goes wrong anyway#

  • Compilation is bounded. A driver that has not answered in 5 seconds is not waited on further.
  • Failure is passthrough, never a broken frame. An effect that cannot compile renders its input unchanged. A missing or black layer reads as "my project is corrupted".
  • Device loss is attributed. If the graphics device resets while one of your effects is drawing, that effect is disabled by name and the user is told which plugin. This is a suspicion and is worded as one — a device can also be lost because a driver updated or another application hung the GPU, and a loss with no plugin effect drawing blames nobody. The user can turn it back on, which recompiles it and puts it through every gate again.

The layout is checked against a real GPU#

npm run verify-plugin-effect renders a plugin-shaped effect at several parameter values on an actual WebGPU adapter and fits a line through the results. Three outcomes it can tell apart: output tracking the parameter (the shader ran and read it from the right offset), output flat at the wrong value (read from the wrong offset — the bug that actually shipped, where the generated struct omitted the renderer's 64-byte header and the first parameter landed on mvp), and output flat at the input value (never ran at all).

The golden-pixel gate cannot substitute: it runs WebGL2, where a plugin effect is the host-generated passthrough, so the scene would pass while proving nothing. uniformLayoutOracle.test.ts checks the same property statically and runs everywhere; this is the version that asks a device.

A skip is not a pass. On a machine with no adapter the probe exits 0 and says so, which is deliberate — but a probe that fails now exits 1 and says which. It did not always: it loaded its page from a data: URL, an opaque origin where isSecureContext is false and navigator.gpu therefore does not exist at any hardware, and reported that as "no WebGPU adapter on this machine". It skipped on every machine, for months, while reading as an environment limit.

render: "shader" on a layer kind#

Live as of API 4. It was a reserved value refused with a version message before that, so an author who tried it early was told "not supported in this version" rather than "unknown render strategy".

Note the cost against "proxy": a proxy leaves ordinary layers behind and keeps rendering after an uninstall, and a shader kind does not draw at all without the plugin that provides its shader. Prefer "proxy" when your output can be expressed as native layers.

Known limits, stated#

  • WGSL only, so WebGPU only. The renderer falls back to WebGL2, which needs GLSL. A plugin effect does not render on that tier. Requiring both languages from every author to serve a fallback was judged the worse trade — but this is a real gap, not a detail, and it is now said out loud rather than left to be discovered:

    Where What the user or author sees
    effects.add { id, active: false, reason: 'webgpu-unavailable' }
    The plugin's row in the manager a muted line saying its effects cannot draw on this renderer
    requires: ["webgpu"] the install is refused, with the reason
    The project file the effect is saved and draws on a WebGPU machine

    Muted rather than red, deliberately: the plugin is fine and the work is not lost. This is a fact about the machine, not a fault in the plugin.

  • The statement ceiling is a proxy for cost, not a cost model. A real one would mean writing a WGSL front end, and a hand-written parser fed hostile input is a worse liability than the thing it would protect.

  • Eight passes, and a cost budget of 6. A pass costs scale² — its share of the layer's pixels — so eight quarter-scale passes cost 0.5 and eight full-scale ones cost 8 and are refused. Four full-scale passes (cost 4) now fit; they did not under the old budget of 3, which existed specifically to refuse them.

    The ceiling is a constant and cannot adapt to the machine, which is worth knowing before asking for it: it is checked during manifest validation, and the registry validates the same manifest on a server with no GPU. A hardware-dependent budget would let a plugin publish and then be refused at install with nothing naming the machine that drew the line. Adapting to the hardware is a render-time decision, not a manifest one.

  • A chain gets one origin, not one per pass. It is the image entering the whole chain, captured before pass 0 — not "the pass before the previous one".

  • One layer parameter per effect, and it is shared by every pass rather than being per-pass.

Gaps found rebuilding the depth plugin on shaders#

The depth/parallax plugin has now been built three times against this API and found a real gap each time — it is the only exercise here written from the outside. depthPluginRebuild.test.ts is the report, executable: each gap is an assertion that pins the current limitation and fails when it is lifted.

  1. An effect cannot sample a second texture. A depth plugin displaces one image by another, and the generated bind group has exactly one texture. The renderer already models this — DISPLACEMENT_MAP_MATERIAL carries a second texture at binding 3, and FrameScene has mapLayerId for naming the layer that supplies it — so the capability exists and the plugin contract cannot reach it. A layer-typed parameter plus a fourth binding is the obvious shape; note that layer is not in the prop vocabulary at all, so the fix is not only in EFFECT_PARAM_TYPES.

  2. A render: "shader" layer kind is not connected to an effect. The strategy says a kind draws itself; nothing says with what. A plugin declaring both a kind and an effect has no way to state the relationship, so such a manifest is accepted and means less than it appears to.

  3. An effect cannot read time or composition size. Both would need a host-filled parameter — the concept the built-in effects already have as EffectParamDef's 'resolved' type, with no plugin-facing equivalent. The workaround is an animatable number the user keyframes by hand, which is per-document rather than per-effect and breaks when the frame rate changes.

  4. A shader kind is forced to declare properties it does not have. parseLayerKinds refuses a kind with no props — correct for none and proxy, where props are the authored interface, and wrong for shader, whose parameters live on its effect. Today an author invents a property to satisfy a rule written before their render strategy existed, and then leaves it unread: a control that does nothing, which is what the rule exists to prevent.

None is fixed here. A gap report that quietly patches what it finds stops being a report, and three of these are contract changes both validators would have to agree on.


13. Network (API 4)#

A plugin can contact the internet. It declares which hosts, the user approves them by name, and the request is made by the host — never by the plugin.

Why hosts are declared, and why they are exact#

Every other permission bounds what a plugin can touch. This one bounds where it can send, and that is a different kind of question. "Can contact websites" is not a decision anyone can act on; "can contact api.acme.com" is. So the manifest lists hosts, the consent screen prints them verbatim, and the host checks every request against that same list.

{
  "apiVersion": 4,
  "permissions": ["scene:read", "net:fetch"],
  "contributes": {
    "net": { "hosts": ["api.acme.com", "cdn.acme.com"] }
  }
}

The permission and the block imply each other, both ways. net:fetch with no hosts puts a permission on the install screen with nothing under it. Hosts with no permission is the shape of a plugin that adds the permission in a later version, once the list has been sitting in the manifest unread.

Wildcards are refused. *.example.com on a consent screen is a category, not a destination, and the whole value of the list is that a user can read it and recognise what is on it. The cost is real — three subdomains means three entries — and it falls on the author who knows their own infrastructure rather than on the user deciding whether to trust it. Eight hosts is the cap: a list nobody reads is a list nobody checks.

net:fetch is the one permission whose danger is a combination. A plugin holding scene:read and net:fetch together can copy the user's project somewhere else. That is not a flaw in the design — it is what the pair means — so the text says it in those words rather than listing two capabilities and leaving the user to multiply them.

What the host enforces, per request#

The plugin has no fetch; fetch, XMLHttpRequest and WebSocket are all removed at worker lockdown. motion.net.fetch(url, init) is a message, and the host checks it like any other:

Rule Why
HTTPS only Plain HTTP is readable and modifiable by anything on the path, and this traffic carries whatever the plugin was granted
Exact host match api.example.com does not permit evil.api.example.com, and neither permits the other
Every redirect hop re-checked redirect: "manual"; a 302 off the list is refused, not followed
Max 4 hops A redirect chain is not a loophole to walk
Resolved address checked, not just the name Below
8 MB response cap Counted as bytes arrive, not from content-length — a header is a claim
15 s timeout
60 requests/minute per plugin Refused destinations count too, so probing is not free
credentials: "omit" The user's cookies are not the plugin's to spend
Response headers filtered content-type, content-length, etag, last-modified — the rest is fingerprinting surface

Refusals name only the host, never the full URL: the URL is attacker-chosen and may end up in a log or a screenshot, and the host is the part a user can act on.

DNS rebinding, which is the interesting one#

Declaring api.acme.com and blocking localhost by name stops nothing. A host the author controls can resolve to 127.0.0.1 — the name is on the list, and the socket lands on the user's own machine. So the check is on the resolved address: loopback, link-local, RFC1918, carrier-grade NAT, benchmark ranges, multicast, IPv6 loopback and unique-local, and IPv4-mapped-IPv6 spellings of all of them.

The request is made by the main process, and why#

A renderer cannot resolve DNS, and it cannot reach a plugin's hosts either. The app shell ships a CSP whose connect-src names our backend, our media origins and localhost — api.acme.com is not on it, so a renderer-side plugin request is refused before a socket opens.

The fix people reach for is to widen connect-src to cover every host every installed plugin declared. That loosens the policy for the whole renderer, not for the plugin: any script that ever runs there inherits the widened reach as a side effect of a plugin the user installed for something unrelated.

So the request moves instead of the policy. The renderer's ceiling stays exactly where it was, and the work is split:

Renderer (pluginNetFetch.ts) Main (electron/pluginNet.ts)
Which plugin is asking
Declared hosts, the grant, the budget
The redirect loop and hop budget
https only
Resolved address refused if private
Byte cap, timeout, no cookies
Opens the socket

The overlap is deliberate. Main is where the connection happens, so it does not take a destination on trust from a caller — even one it believes. The renderer is where the manifest and the grant live, so main cannot know whether a host was declared. Neither side is sufficient alone.

Redirects are not followed in main. A hop is a new destination and has to be re-checked against the plugin's declared hosts, which main cannot see. So a 3xx comes back as a 3xx with its Location, and the renderer re-runs the same check it ran on the original.

This is not a general fetch bridge. apiProxy.ts refuses to be one because main attaches the user's bearer to its requests, and an open relay would spend that credential on any URL. These verbs attach nothing — no token, no cookie, no key — and ipcGuard keeps them out of reach of a plugin panel, which is a subframe. What is left to protect is the user's own network, and the table above is how.

installPluginNetBridge() runs at the renderer entry, before any plugin host boots. In a browser build there is no bridge, the resolver stays null, and netGuardStatus() reports rebindingCheck: false rather than implying a protection that is not running.

The panel stays network-free#

A plugin granted net:fetch does not get a panel that can reach its declared hosts. The shell keeps connect-src 'none'. Widening it would hand the capability to the wrong realm: a panel is inline script from the package with nothing between it and the socket, while a worker's request passes the permission gate, the grant, the manifest, and every cap above. There is a regression guard in noHostRealmEval.test.ts, and it fails if either the policy loosens or the frame path starts reading contributes.net.

Both validators, one corpus#

motion-back re-implements every rule above — it must, since it accepts the package before any editor sees it, and a package the registry accepts and the editor then refuses is a download that cannot work. The two are kept honest by __fixtures__/manifests.json, byte-identical in both repos, with 13 cases for net alone. The permission text is shared the same way (permissions.json), because the sentence a user reads on the marketplace and the one they read on the install screen must be the same sentence.


14. Scale, and the metric that decides it#

Nothing in this section is built. It is written down so it is not rediscovered as an emergency, which is the only reason to write it down before it is needed.

Package bytes stay in Postgres until one number says otherwise#

They are in PluginVersion.packageBytes because the client verifies a signature over those exact bytes, and every hop that could re-encode them turns a delivery detail into a signature failure a user reads as "this plugin is compromised".

The number that decides the move is not total storage. totalBytes is the obvious metric and the least urgent — Postgres is comfortable holding tens of gigabytes of Bytea, and the pain it eventually causes (backup and restore windows) arrives slowly and visibly.

The one that bites first is peakResponseBytes. Serving a download reads the whole row into the Node process and base64-encodes it, so an 8 MB package becomes ~10.7 MB of string, per concurrent download, in the heap. Ten simultaneous installs of the largest allowed package is ~107 MB of transient heap on top of everything else. That is what becomes an out-of-memory restart under a launch spike, and it is driven by package size and concurrency — neither of which appears in a storage total.

GET /plugins/admin/storage (operator only) reports all of it. Watch p95Bytes and maxBytes against download concurrency; when their product approaches the process memory limit, move the bytes.

The precondition is already in place#

Moving bytes to object storage means bytes and metadata stop sharing an origin. What binds them again is the digest — and the digest has to travel with the metadata, not with the bytes:

  • sha256 is in the browse listing, the detail response and the update offer.
  • fetchRegistryPackage verifies the downloaded bytes against the digest its CALLER was given, and deliberately ignores the sha256 in the download response. A digest that arrives alongside the bytes it describes cannot detect anything about them.
  • Absent a digest, the install falls back to the signature alone, which is what it always had. The digest is not the security boundary and must never be described as one: it answers "are these the bytes the registry named", while only the pinned publisher key survives a compromised registry.

This shipped before the move rather than during it, because adding a field to a response is cheap now and a protocol change made under pressure afterwards.

Update checks could stop identifying users#

POST /plugins/updates sends the caller's installed plugin set, which tells the server what software is on a specific person's machine. The revocation list shows the alternative: a signed, cached, public manifest the client matches locally, uploading nothing. If that model is extended to updates, POST /plugins/updates can be retired.

Gated on the same "when the numbers demand it" rule, and worth noting the tension: the manifest grows with the catalogue, so it trades a request that scales with one user's installs for a download that scales with every published plugin. There is a catalogue size where that stops being a good trade.

The trusted tier is not being built#

Verified publishers only, an unmistakable install-time warning, a Node host with filesystem and subprocess access. Defensible as an opt-in tier a user accepts with eyes open; catastrophic as a default. Not before Stage 1 is mature — and Stage 1 shipped days ago, so the answer today is no.

Route order is load-bearing#

Express matches in declaration order and :id matches any single segment. The signed revocation list was declared below @Get(':id') and therefore answered 404 "no plugin revocations" — the safety mechanism the whole design rests on, unreachable, with nothing reporting it because the client swallows that failure on purpose so being offline is not an error.

Neither existing test could see it: the guard test reads decorator metadata, where the route is present and correctly public. Only the order was wrong. plugins.routes.spec.ts now refuses it structurally — within one method, a leading-literal route must be declared before any same-length route whose first segment is a parameter — and plugins.public.spec.ts checks the real response over real HTTP.


15. Incident runbook#

Who blocks a plugin, how, what the user sees, and what is said publicly. Written before it is needed, because the first time this path runs should not also be the first time anyone reads it.

Severity, and what it changes#

What it looks like First move
P1 Actively harmful: exfiltrating projects, destroying documents, a malicious update to a popular plugin Block immediately, ask questions after
P2 Harmful if used: an undisclosed capability, a permission grab, impersonation of another publisher Hold the version, contact the publisher
P3 Wrong but not dangerous: broken package, misleading listing, licence complaint Normal review queue

Blocking is reversible and cheap. A P1 judgement call that turns out wrong costs a publisher a few hours; the reverse costs users their work. Block first.

The path#

  1. A report arrivesPOST /plugins/:id/report, from a user who may not have an account. It lands in the case queue, deduplicated per plugin and version.
  2. A reviewer triages at /admin/plugins/review. Publish-time scanner findings are attached and are advisory — a high score is a reason to look, never a reason to act on its own.
  3. An operator blocks. Requires the admin role. A reason of at least 8 characters is mandatory, and it is not bureaucracy: that string is what the user is shown, so "spam" helps nobody.
  4. The revocation list picks it up automatically. blocked + blockedReason are what RevocationService signs. There is no separate "publish the revocation" step to forget.
  5. Running installs stop at next boot, and mid-session for anyone who has the manager open.

What the user sees#

They are told the plugin was withdrawn and given the reason string verbatim. Their copy keeps working. Blocking hides a plugin from browse and refuses new downloads; it does not delete anything. Breaking someone's project is usually a bigger harm than the one the takedown addresses.

The exception is DELETE /plugins/:id/admin, which destroys the bytes and the version history and cannot be undone. It exists for content that must not remain on our servers at all, and for nothing else. It is not the tool for abuse.

Verify it actually worked#

The failure mode here is silence. fetchRevocationList swallows errors on purpose so that being offline is not an error a user sees while opening a panel — which means a broken revocation path reports nothing at all. It was broken exactly this way once (§14).

So after blocking anything, check the list directly:

curl -s https://<host>/api/plugins/revocations

Confirm three things: the response has payload and signature (not a 404 body — a 404 from a route-order mistake and a genuinely empty list are different failures), the plugin id appears in entries, and seq has increased. A client refuses a list whose seq is not newer than the one it holds, so a stalled sequence means the block does not propagate.

What is said publicly#

  • Name what happened, not who reported it. Reporters are never identified, including to the publisher.
  • The reason string is public, because the user already sees it. Write it knowing that.
  • Do not describe the exploit while installs are still running. "This version could read files outside your project" is enough; the method is not.
  • Say when it is resolved, in the same place. A withdrawal notice with no follow-up reads as an unresolved accusation forever.

Key compromise#

A stolen publisher key is the one case blocking does not fix, because the attacker can sign. Block every affected version, then require rotation — the publisher re-authorises with their account password, not just a session, and every installed copy prompts its own user before accepting the new key. Three gates, and a stolen account clears only one.

Was this page helpful?