The plugin system, as it stands
Plugins are a hosted-build feature. The UI is hidden in a local build and a local build never contacts the registry. A project containing plugin content still opens and saves losslessly there — custom layers, plugin effects and proxy subtrees round-trip byte-for-byte with the plugin absent — it simply has nothing to run them with. See §0.
This was written against the source of both repositories rather than against the other documents: schemas, the method table and the manifest interfaces were read out of the files that define them, and the constants, routes and gates below were checked in the file that implements each one.
That is a description of how it was written, not a guarantee that stays true.
An earlier version of this line claimed every fact had been extracted
programmatically and re-verified — a claim the document cannot keep, because it
ages the moment either repository moves and nothing re-checks it. Where a fact
here is held down by something, the something is named: the shared fixtures
(§11), docFeatureCounts-style guards, and the live run in §10. Everything else
is a good-faith reading of the code on the date below. When this file and the
code disagree, the code is right.
Last checked against source: 2026-08-09.
Verified live on 2026-08-09 against a running backend: a real signed package was published privately, probed anonymously, flipped to public, downloaded, and its signature checked byte for byte. The results are in §10.
Related documents.
PLUGINS.mdis the authoring guide — longer, written for someone building a plugin, and the place where design reasoning lives.PLUGIN_SYSTEM_FOR_AI.mdis a condensed map for agents. This file is the cross-repo system reference: it is the only one that carries the registry's database schema alongside the editor's contracts. When they disagree, the code decides, then this file, then the others.
0. Which builds have this#
| Build | Plugins UI | Registry | A project containing plugin content |
|---|---|---|---|
Hosted (VITE_EDITION unset / server) |
Yes | Yes | Runs |
Local / self-hosted (VITE_EDITION=local) |
Hidden | Never contacted | Opens, edits, saves — plugin content inert but intact |
Two predicates, deliberately separate:
pluginsEnabled()— whether the feature exists at all. Gates the panel, the menu, effect and layer-kind registration, and the host's boot.pluginRegistryEnabled()— whether the marketplace is reachable. Gates browse, install-from-registry, the update check, and the revocation fetch.
A local build fails both. It is not a stripped binary with a hidden switch: the registry client is never called, so a self-hosted editor makes no request to premation.com on account of plugins, ever.
Losslessness is the invariant that makes this safe. A file authored on a
hosted build and opened on a local one keeps its custom layers, plugin effects
and proxy subtrees exactly as written, including the plugins[] provenance
block naming id and version; saving it again reproduces the same bytes. The
inverse — a local build quietly dropping what it could not run — would turn
"open this to look at it" into data loss, so it is covered by a round-trip test
that serialises, parses and re-serialises with the plugin uninstalled.
1. What it is#
A plugin is a signed zip containing a manifest and a single ES module. The
editor runs it in a Web Worker with no DOM, no globals from the app, and no
network of its own. It talks to the editor over postMessage, through one
method table, gated per-call against permissions the user granted at install.
Two repositories:
| Repo | Role |
|---|---|
| motion-editor | Package format, sandbox, host API, permission gate, UI, renderer integration |
| motion-back | The registry: publishing, storage, visibility, listings, reports, review, revocation |
Neither imports the other. Where both must agree — the manifest grammar, the
method→permission table, what an old apiVersion implied — they share
byte-identical JSON fixtures and each runs its own copy against them. That
is the only mechanism keeping them in step, and it is now enforced by a checksum
rather than asked for in a comment: see §11.
2. The package#
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.
Limits#
| Limit | Value | Applies to |
|---|---|---|
| Package size | 8 MB | uncompressed |
| Single file | 2 MB | uncompressed |
| File count | 200 | |
| Inflation ratio | 200× | per entry |
| Shader source | 64 KB | per pass |
| Passes per effect | 4 | |
| Pass cost budget | 3 | sum of scale² |
| Decoded image | 64 MB / 8192 px per side | assets API |
| Shader compile | 5 s per pass, 10 s per chain | |
| Worker boot | 8 s | per plugin |
storage — global |
1 MB | per plugin, IndexedDB |
storage — project |
256 KB | per plugin, in the document |
storage — one value |
64 KB | any scope |
scene.apply ops |
10,000 | per batch |
scene.apply payload |
8 MB | per batch |
| Declared net hosts | 8 | exact hosts, per manifest |
| Revocation fetch | 5 s | per attempt |
| README render | 512 KB | falls back to plain <pre> |
Both readers check the declared uncompressed size before allocating, then
re-check the real inflated length afterwards. The editor uses fflate's filter;
the registry reads the zip's central directory. Sizes checked on compressed
bytes bound nothing — 64 MB of zeros stores in 65 KB.
File types: .js .mjs .json .html .htm .css .svg .txt .md .wgsl .glsl as text,
.png .jpg .jpeg .webp .wasm as binary. Paths containing .. are refused.
main is loaded as one file — bundle dependencies in.
3. Manifest schema (editor)#
interface PluginManifest {
id: string; // reverse-DNS, lowercase, permanent
name: string;
version: string; // semver
description: string;
author?: string;
homepage?: string; // http(s) only
apiVersion: number; // the manifest GRAMMAR version
main: string; // package-relative path
permissions: PluginPermission[];
requires?: Capability[]; // refuse to install without these
optional?: Capability[]; // feature-detect at runtime
contributes: PluginContributes;
activationEvents: ActivationEvent[];
}
interface PluginContributes {
commands: PluginCommandContribution[];
panels: PluginPanelContribution[];
layerKinds: LayerKindContribution[]; // apiVersion ≥ 3
effects: EffectContribution[]; // apiVersion ≥ 4
net: NetContribution | null; // apiVersion ≥ 4
}
type ActivationEvent =
| 'onStartup'
| `onCommand:${string}`
| `onPanel:${string}`
| `onLayerKind:${string}`;Two version numbers, moving independently#
| Constant | Value | Answers |
|---|---|---|
MANIFEST_VERSION |
5 | What GRAMMAR this host can read. apiVersion is compared against this |
HOST_API_VERSION |
5 | What the host can DO. Reported to the plugin; not what apiVersion is checked against |
They were one number through 4 and are separate from 5 on. The reason is that they answer different questions and were drifting apart in practice: adding a host method does not change how a manifest is written, and adding a manifest field does not change what a plugin may call. With one number, every method addition forced a grammar bump that told authors their manifests were outdated when nothing about them was.
contributes requires apiVersion ≥ 2. Omitting activationEvents means
['onStartup'].
Capabilities#
requires is what a plugin cannot run without; optional is what it will use
if present. Capability strings are additive and permanent — never renamed,
never removed, never repurposed — because a manifest is signed and a string that
changed meaning would silently change what a published plugin asked for.
Static (present in every hosted build):
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
Runtime (depends on the machine): webgpu
webgpu is why the concept exists. A plugin effect is WGSL, and on the WebGL2
tier it renders its input unchanged — an effect plugin installed there is not
degraded, it is inert. Putting webgpu in requires is how an author says
"there is no point installing me here", which beats a plugin that looks healthy
and quietly does nothing.
The check runs at install, not at first call. A plugin that installs and then fails is worse than one that never installs: the user has already approved its permissions, it sits in the list looking fine, and the failure surfaces later attached to whatever they were doing.
Two refusals, distinguished on purpose: a capability this host knows but this machine lacks ("needs WebGPU") is an upgrade; one no version has ever had is a typo in the manifest.
A manifest with no requires is not treated as needing nothing. It is
granted the set its apiVersion implied before capabilities existed —
CAPABILITIES_BY_API_VERSION, a pinned table rather than a computed one, so the
static list above can grow without retroactively making an API-4 plugin look
like it asked for things that did not exist when it was signed. The table is
asserted against __fixtures__/capabilityBackCompat.json, held byte-identically
in both repos.
Permissions#
Nine, and the consent screen is this list. Consent is per permission — the
user may untick any of them — and a grant is always manifest ∩ user choice.
Any increase re-enters consent.
| Permission | Grants |
|---|---|
scene:read |
Layer names, structure, scalar properties |
scene:proxy |
Write only inside a layer kind's own proxy subtree |
scene:write |
Create, change, delete, reparent layers anywhere |
animation:read |
Read keyframes, sample animated values |
animation:write |
Create and change keyframes and expressions |
assets:read |
Read pixels of images already in the composition |
assets:write |
Create images |
net:fetch |
Contact only the hosts in contributes.net |
timeline |
Read the time and move the playhead |
scene:proxy exists so a generator plugin can stop asking for the whole scene.
A plugin that builds a subtree under its own layer needs to write exactly there,
and scene:write was the only way to say it — a consent screen reading "create,
change, delete, reparent layers" for a plugin that only ever touches its own
children. The scope was already enforced by setProxyChildren; what was missing
was a way to ask for the narrow thing.
scene:write implies scene:proxy. An existing manifest asking for the
wider permission does not need editing, and the implication is expanded at grant
time rather than at the gate, so what is stored is what was consented to.
scene:read + scene:proxy cannot create the PARENT layer, and should not: the
user adds it from Layer ▸ New, which is the moment they chose to have this
plugin's content in their project.
Registering commands, notifications, opening the plugin's own panel and reading composition settings need no permission.
Property types#
Used by layerKinds.props and effects.params, one parser for both.
number · string · boolean · enum · color · asset · layer · point
- Animatable:
number,color,boolean. assetandlayerare references and carry no default — no id a package names exists in someone else's project.layeris valid only as an effect parameter; a layer kind has no bind group to resolve it against.pointis{ x, y }in composition pixels, reaching the shader as avec2<f32>. An object rather than[x, y], because a tuple is the shape most easily transposed and a swapped point draws something plausible in the wrong place. Not normalised to UV and not range-checked — the shader hastexelSizeif it wants UV, and a position outside the layer is ordinary for a light or a vignette centre.- Its alignment is the one to know:
vec2<f32>is 8 bytes aligned to 8, the only member that is neither 4 nor 16, so it sorts between thevec4s and the scalars and can leave padding on both sides. Covered by five cases in the uniform-layout oracle. - Not animatable. A two-channel track is real work in the timeline and the graph editor, not a schema entry.
- No on-canvas drag handle yet. It renders as an X/Y row in the inspector.
The hit-testing and drag arithmetic exist and are tested
(
core/effects/pluginPointHandles.ts); what is missing is the SVG and pointer plumbing inEffectHandleOverlay, which today assumes a handle is two scalar params offset from a rest position in layer-local space — none of which is true of a point.
- Its alignment is the one to know:
4. Registry schema (motion-back, Prisma)#
model Plugin {
id String @id // the manifest id, not a uuid
ownerId String
owner User @relation(...)
publisherKey String // SPKI base64, ECDSA P-256
nextPublisherKey String? // authorised, not yet used
nextPublisherKeyMethod PluginKeyRotationMethod?
previousPublisherKey String?
keyRotatedAt DateTime?
name String
description String
homepage String?
latestVersion String // denormalised from newest version
blocked Boolean @default(false) // operator takedown
blockedReason String?
visibility PluginVisibility @default(public) // owner's choice
installs Int @default(0) // deduplicated; browse ranks on THIS
downloads Int @default(0) // raw fetches; internal, never ranked
publisherId String?
publisher Publisher? @relation(...)
readme String?
readmeHtml String? // DEPRECATED, always NULL — see below
changelog String?
license String?
categories String[] @default([])
media PluginMedia[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
versions PluginVersion[]
cases PluginCase[]
rotations PluginKeyRotation[]
@@index([ownerId]) @@index([updatedAt]) @@index([publisherId]) @@index([installs])
}
model PluginInstallDay { // what makes `installs` deduplicated
id String @id @default(uuid())
pluginId String
version String
ipHash String // HMAC, keyed; never the address
day String // YYYY-MM-DD, UTC
@@unique([pluginId, version, ipHash, day])
@@index([day]) // so a retention sweep is one scan
}
model PluginVersion {
id String @id @default(uuid())
pluginId String
version String
packageBytes Bytes // the signed archive itself
size Int
sha256 String
signature String
permissions String[]
apiVersion Int
hasPanel Boolean @default(false)
contributes Json?
activationEvents String[] @default([])
riskScore Int @default(0) // publish-time scanner
scanFindings Json?
reviewStatus PluginReviewStatus @default(approved)
reviewNote String?
reviewedAt DateTime?
reviewedById String?
createdAt DateTime @default(now())
@@unique([pluginId, version])
@@index([reviewStatus, riskScore])
}
model Publisher {
id String @id @default(uuid())
namespace String @unique // the plugin id prefix, permanent
displayName String
ownerUserId String
verified Boolean @default(false) // granted by an operator
verifiedDomain String?
plugins Plugin[]
}
model PluginMedia {
id String @id @default(uuid())
pluginId String
kind PluginMediaKind // icon | screenshot
bytes Bytes
mime String
width Int
height Int
sort Int @default(0)
}
model PluginCase {
id String @id @default(uuid())
pluginId String
version String @default("")
status PluginCaseStatus @default(new)
reportCount Int @default(0) // raw volume; shown, tie-break only
reporterCount Int @default(0) // DISTINCT reporters; the queue's sort
resolvedAt DateTime?
reports PluginReport[]
@@unique([pluginId, version])
@@index([status, reporterCount])
}
model PluginReport {
id String @id @default(uuid())
caseId String
category PluginReportCategory
message String?
reporterUserId String? // nullable: reports need no account
reporterIpHash String?
}
model PluginKeyRotation {
id String @id @default(uuid())
pluginId String
fromKey String
toKey String
method PluginKeyRotationMethod
createdAt DateTime @default(now())
}
enum PluginVisibility { public private }
enum PluginReviewStatus { pending approved changes_requested blocked }
enum PluginMediaKind { icon screenshot }
enum PluginKeyRotationMethod { backup dashboard }
enum PluginCaseStatus { new triaging actioned dismissed }
enum PluginReportCategory { malicious impersonation broken inappropriate license }Package bytes live in Postgres as Bytea, up to 8 MB per version, one row
per version, kept forever because versions are immutable. The reason is
transactional simplicity: one transaction covers metadata and bytes, one backup
covers both, and there is no second system to keep consistent. (An earlier note
here claimed object storage would re-encode the bytes and break the signature.
That is false — object storage returns exactly what was PUT — and a wrong reason
in a comment is what makes someone reopen a settled question.)
The consequence that bites is Prisma's default: no select means every scalar,
including the 8 MB blob. The review queue over fifty pending versions is up to
400 MB of heap to render a list of names. Every PluginVersion query carries an
explicit select except the two download paths and publish, and
packageBytesSelection.spec.ts sweeps for the rule rather than for a list of
blessed lines. GET /plugins/admin/storage reports total bytes, bytes by
plugin, and version count, so the growth is measured before it is a surprise.
readmeHtml is deprecated and always NULL. It held HTML rendered at write
time, on the reasoning that one crossing of a security boundary is easier to
audit than one per reader. What that missed is repair: a renderer bug leaves
every row written before the fix poisoned, patching the renderer does not clean
them, and the dashboard injects this into a page where operators are signed in —
so "poisoned" means stored XSS against a live session. README is rendered on
read from readme, which makes the renderer in effect always the deployed one.
plugin-listing.ts is construct-only, not parse-then-sanitise: it escapes
first and then builds the allowed tags itself, so there is no parser whose
disagreement with a browser's could be exploited. Generated <code> and <a>
spans are parked behind sentinels before the emphasis pass — without that, an
emphasis rewrite could reach inside a generated href, which is the mXSS shape.
Rendering is capped at 512 KB with a plain-text <pre> fallback.
5. Registry HTTP API#
Public (no token; Cache-Control: public where marked):
| Method | Path | Notes |
|---|---|---|
GET |
/plugins |
Browse. public visibility only, blocked:false, approved versions only. max-age=60 |
GET |
/plugins/:id |
Detail. 404 for private. max-age=60 |
GET |
/plugins/:id/versions/:version/download |
no-store — moves the counters (below). Refuses a version not approved, with the same 404 as a version that does not exist |
GET |
/plugins/categories · /plugins/permissions |
Static vocabulary, max-age=3600 |
GET |
/plugins/media/:mediaId |
Icons and screenshots |
GET |
/plugins/revocations |
Signed list; uploads nothing |
POST |
/plugins/:id/report |
The one public route that writes |
Authenticated:
| Method | Path | Notes |
|---|---|---|
POST |
/plugins |
Publish (multipart: file, signature, publicKey, visibility, backupKey) |
GET |
/plugins/mine/list |
Own shelf, private and blocked included, carries visibility |
GET |
/plugins/mine/:id/detail |
Owner's view, no-store |
GET |
/plugins/mine/:id/versions/:version/download |
Owner download, no-store. Moves neither counter — an author fetching their own package is not an install. Guarded by authentication, not ownership, so a signed-in stranger reaching it counts normally |
POST |
/plugins/updates |
Batch update check for the caller's installed set |
PATCH |
/plugins/:id/listing |
readme, changelog, license, categories, visibility |
POST/DELETE |
/plugins/:id/media/:kind, /plugins/media/:id |
Listing images |
POST/DELETE |
/plugins/:id/keys/authorise |
Key rotation (authorising needs the account password) |
DELETE |
/plugins/:id |
Owner withdraws |
POST |
/publishers · GET /publishers/mine |
Namespace |
Operator only: POST /plugins/:id/block, DELETE /plugins/:id/admin,
GET /plugins/admin/storage, the review queue.
Route order is load-bearing. Express matches in declaration order and
:idmatches any single segment, so a literal route declared after a same-length parameter route is unreachable.plugins.routes.spec.tsrefuses that structurally.
Visibility, exactly#
private means: absent from browse, 404 on detail, 404 on download, and absent
from the update check — for everyone except the owner, who reaches it
through the two mine/ routes.
The public routes do not recognise an owner. They are publicly cacheable, so a response that varied by caller could be stored by a shared cache and served to the next person.
The refusal is byte-identical to a plugin that never existed. A 403 saying "this is private" tells anyone who guesses an id that it exists.
Going private stops new installs. Copies already installed keep working.
The two counters#
| Column | Moves | Public? | Ranked on? |
|---|---|---|---|
downloads |
Every fetch of the bytes by a non-owner | No | Never |
installs |
At most once per (plugin, version, address, day) |
Yes | Yes |
One column could not be both. installs is indexed, browse orders on it, and
the route that moved it is an unauthenticated public GET — so a shell loop moved
marketplace rankings, and the listings worth pushing up are exactly the ones
somebody would bother to push.
installs is not a count of unique people: an office behind one NAT counts
once, a commuter counts several times as their address changes. It is a ceiling
on how fast the number can be moved from one place, which is the property a
ranking needs.
The PluginInstallDay unique constraint is the deduplication. The insert is
attempted first and the counter moves only if it succeeded, so two concurrent
downloads race in Postgres — a read-then-write would let both through. A
download with no resolvable address moves downloads but not installs: with
nothing to deduplicate against, counting it would hand back the unlimited
increment. Addresses are stored only as a keyed HMAC, under a label distinct
from the one abuse reports use, so the two tables cannot be joined to answer
"which reporter also downloaded this".
Reports and the review queue#
Reports need no account. They aggregate into a PluginCase per
(plugin, version), and the queue orders on distinct reporters, not raw
volume: "forty people noticed" and "one person clicked forty times" are the same
reportCount, and sorting on that hands the top of a reviewer's day to whoever
is most determined. Both numbers are returned, because their difference is
itself the signal.
A reporter is their account when signed in and their address hash when not, matched only within its own namespace — so the answer does not depend on arrival order. A report with neither moves the raw count only.
| Ceiling | Value | Bounds |
|---|---|---|
| Per account, rolling hour | 10 | rate |
| Per address, rolling hour | 20 | rate |
| Per account, per case | 3 | pile-on |
| Per address, per plugin, no window | 8 | a campaign |
The last one closes the gap the per-case cap leaves: a case is keyed on (plugin, version) and the reporter picks the version, so three reports against 1.0.0, three against 1.0.1 and three against a version that never shipped never trip it — while the queue fills with cases about one plugin, which reads to a reviewer as a plugin in serious trouble.
No threshold on any count blocks a plugin. A case escalates to a human and nothing else. An automatic block driven by a count is a takedown button handed to anyone who can make the count go up, and reporting is cheap by design.
6. The editor: storage and lifecycle#
Where an installed plugin lives#
| Data | Store | Bound |
|---|---|---|
| Package files and binaries | IndexedDB, packages |
browser quota |
| Metadata index (manifest, grants, source, pinned key, security events) | IndexedDB, index |
1 MB total |
A plugin's own global bag |
IndexedDB, storage |
1 MB per plugin |
A plugin's own project bag |
inside the project document | 256 KB per plugin |
All three stores are PluginDatabase, at DB_VERSION = 3.
The index used to live in localStorage. Two storage systems meant two writes
with no way to commit them together, so a crash or a quota failure between them
left an index entry with no package (a plugin that lists and cannot start,
forever) or a package no index points at (megabytes of software the user
believes they removed). put and remove now name both stores in one
transaction and resolve on oncomplete — a request can succeed while the
transaction aborts, and reporting success there is the torn write itself.
hydrate() stays, because it still catches what a transaction cannot: an
aborted commit, a database the browser evicted, and every record written by an
older build. It reconciles both directions and records what it did in
lastHydration, which the Plugins panel renders — silently dropping something a
user installed is how a plugin manager loses trust. It also performs the
one-time move off localStorage, verify-then-clear: the old key is removed
only after the new record has been read back, because clear-then-write would
lose every installed plugin the one time the write failed.
The two bags have opposite lifetimes, which is why they are separate stores
rather than one record. A global bag survives update and uninstall — it is a
plugin's settings, and deleting them on update would make every upgrade a reset.
A project bag travels with the file, so a project sent to a colleague carries
what the plugin remembered about it. Both are namespaced per plugin, capped at
64 KB per value, and a write past the quota throws
storage-quota-exceeded rather than silently truncating.
An installed plugin persists across restarts until the user uninstalls it. Nothing on the server removes a plugin from a machine that has it. Withdrawal and blocking stop distribution, not execution.
Status#
starting → running, or inactive (lazy, not yet needed), stopped,
error. Boot is bounded at 8 s; a worker that misses pings is terminated.
PluginHost exposes a revision counter; UI reads it through
useSyncExternalStore.
The sandbox#
- Web Worker, ESM, lockdown applied before the plugin's module is imported.
- No
new Function, no non-literal dynamicimport. - WebAssembly is allowed.
WebAssemblysurvives lockdown and.wasmis a recognised binary extension. A.wasmfile carries the same signature and the same 2 MB cap as the JavaScript beside it, and an instantiated module receives no imports the plugin's own JS did not hand it — so it reaches exactly what that JS could. Capabilitywasm.WebAssembly.instantiateStreamingandcompileStreamingare removed: both take a network response, and the worker has no network. Gap: the boot message carries the entry module's source and notbinaries, so a worker cannot read its own.wasmfile. Embedding it inmain.jsworks; apackage.read(path)verb is the fix and is not built. - Panels run in a sandboxed iframe without
allow-same-origin, with their own CSP (connect-src 'none'— a panel has no network even when the plugin hasnet:fetch). - A panel talks to its own plugin and nothing else:
motionPanel.send/motionPanel.onMessage. - Inline
<script>runs in a panel.<style>works anywhere in the document, including before any body content, and a<link rel="stylesheet">pointing at a file in the package is inlined by the host before the markup reaches the frame — a relative href would otherwise resolve against the app's origin, where the package's files do not exist, and the frame has no network to fetch one with. An href the host cannot resolve is left as written and fails.
7. The host API — all 38 methods#
Reached only by a call message from the worker, after the permission below has
been checked. Every argument is re-validated host-side.
| Method | Permission |
|---|---|
ui.notify · ui.openPanel · ui.closePanel |
— |
commands.register |
— |
composition.get |
— |
storage.get · set · delete · list |
— |
scene.getSelection · setSelection · getLayers · getLayer · onLayerChanged |
scene:read |
scene.setProxyChildren |
scene:proxy |
scene.createLayer · setProperty · renameLayer · deleteLayer · setParent · setVisible · setLocked |
scene:write |
scene.apply |
union of the ops in the batch |
effects.list |
scene:read |
effects.add · effects.remove · effects.setParam |
scene:write |
animation.getTracks · animation.sample |
animation:read |
animation.setKeyframe · setKeyframes · removeKeyframe · setExpression |
animation:write |
assets.getImage |
assets:read |
assets.createImage |
assets:write |
net.fetch |
net:fetch |
timeline.getTime · timeline.setTime |
timeline |
storage.* needs no permission. A plugin's own bag is not the user's data — it
is the plugin's, namespaced to it, readable by nothing else — and a consent
screen line for "this plugin can remember its own settings" is a line that
teaches people to click through consent screens.
Undo granularity#
One host call is one undo entry, labelled with the plugin's name. Twelve calls are twelve entries, and undoing a plugin's work means pressing Ctrl-Z twelve times.
scene.apply is the answer to that, and the reason it exists is as much about
undo as about round trips. A batch is one entry: everything in it applies or
none of it does, and one Ctrl-Z takes it all back. It also coalesces store
notifications — a naive loop of 40 mutations produced 82 re-renders, because the
scene graph bumps independently of the host, so the coalescing has to happen at
the store (batchScene), not in hostApi.
Limits: 10,000 ops, 8 MB. An op may reference an earlier op's result by index
({ ref: n }), which is how a batch creates a layer and then parents to it. The
permission required is the union of the ops present, so a read-only batch
needs no write grant.
Notes that change what you write:
scene.createLayeracceptsshape,text,group,null,image(image needs anassetId), or<pluginId>.<kindId>for a kind you declared.- Inside
scene.apply, creates anchor to where op 0 landed, not to the user's selection.insertPrimitiveparents to the selection, so a batch of a thousand creates would otherwise build a thousand-deep chain. setParentpreserves world pose — grouping does not move the layer. Cycles and cross-composition moves are refused.- 3D is not a flag: writing
z,rotationX,rotationYas numbers is what makes a layer 3D. effects.addreturns the new effect's id. An unknown type is an error. So is removing, or setting a parameter on, an id not on the layer. On the WebGL2 tier it returns{ id, active: false, reason: 'webgpu-unavailable' }— the effect is stored in the project and will draw on a WebGPU machine, but saying so beats returning an id that draws nothing.- Prefer
animation.setKeyframesover a loop — the bulk call sorts and notifies once. ui.sendToPanel/ui.onPanelMessageare worker-side; they post rather than call the host.
8. Contributions#
Commands#
Appear in the Plugins menu under the plugin's name, and in the palette. Declared commands show whether or not the worker has run; runtime-registered ones appear once it is up.
Panels#
panel.html, sandboxed. placement decides where:
placement |
Lands |
|---|---|
shared (default) |
A tab in the one Plugin Panels panel |
sidebar |
Own tab in the left rail — requires icon |
inspector |
Own tab in the right rail — requires icon |
Rails hand out 3 (left) and 2 (right) plugin slots; past that a panel is demoted to the shared host and the plugin's row says so. Nothing the user clicks closes a plugin panel — disabling or uninstalling removes it.
Layer kinds (API 3)#
A plugin invents a layer type with declared props. render is none, proxy
(generates real child layers, which survive uninstall) or shader. Proxy
children are diffed on a stable key; a user editing a generated child
detaches it from plugin ownership.
Effects (API 4)#
WGSL as data — a plugin never runs code in the frame loop, which is why an effect keeps working with the worker stopped.
You write one function, @fragment fn fs(...), reading params.<name>, src
and samp. The host generates the bindings, the vertex stage, and the uniform
struct. Declaring @group, @binding or @vertex is refused, as is any
fragment entry not named fs.
- The uniform block starts with the renderer's 64-byte header (
mvp,uvRect), then the host's 32-byte pass block; parameters begin at offset 96. See below. - Members are ordered by alignment descending — every
vec4first. boolean→f32. Colour is 0..1.- A
layerparameter adds a fourth binding, named after the parameter, so a shader can sample a second texture. Max one per effect. - The validator refuses
while/loop, non-literal loop bounds, loops over 256 or nested past 3, storage/atomics/@compute/discard. - Compilation is bounded at 5 s; failure renders passthrough, never a broken frame. A device loss while a plugin effect is drawing disables that effect by name — reversibly.
- WebGPU only. On the WebGL2 tier a plugin effect renders its input
unchanged. This is now reported rather than silent:
effects.addanswers{ active: false, reason: 'webgpu-unavailable' }, the plugin's row in the manager says its effects cannot draw on this renderer, and a manifest may putwebgpuinrequiresto refuse installation outright. The effect is still saved with the project and draws on a machine that can.
Multi-pass (capability effects.multipass)#
An effect may declare up to 4 passes instead of one shader. The two are
mutually exclusive — an effect declaring both does not say which one draws.
interface EffectPass {
name: string; // camelCase, unique in the chain
wgsl: string; // same one-`fs` contract, same validator
scale?: 1 | 0.5 | 0.25; // target downsample, default 1
reads?: 'previous' | 'origin' | 'both'; // default 'previous'
}The host allocates the targets, ping-pongs them and sequences the draws. A plugin never sees a target, never allocates one, and still never runs code in the frame loop.
Execution reuses the renderer's existing spatial-effects chain: registerEffects
composes one shader per pass, snapshotToFrameScene emits one scene entry per
pass, and runEffectsChain — which already ping-pongs for a layer carrying a
blur then a glow — runs them in order. No new render mechanism was added.
scale — downsampled passes#
A pass declaring scale: 0.5 or 0.25 renders into a target that fraction of
the viewport, from a pool declared beside the backdrop-blur chain and in the
same rgba16float. The upsample is free: whatever reads the result samples the
smaller texture through a linear sampler.
This is what makes a large blur affordable, and the reason is worth stating
because it is not just "fewer pixels". A pass at scale s steps 1/(W·s) in
UV — 1/s pixels of the original — so the same tap count reaches further.
Verified on hardware: the sample blur spreads 14 composition pixels at full
scale and 48 at quarter, from an identical shader.
The texel size a pass receives comes from its own target, never the viewport. That is the one thing here that fails silently — a quarter-scale blur handed viewport texels steps a quarter as far as asked and looks merely soft, which an author debugs as their own kernel. The probe measures it as a ratio for exactly that reason.
reads: origin — compositing against the original#
The chain's pass-0 input is blitted into PLUGIN_ORIGIN before pass 0 and
bound at binding 4 for the passes that ask. That is what a composite step
needs: a bloom adds its blurred copy back over the original, which by then is
several ping-pongs ago and overwritten.
Its own target, not a reservation out of the effect pool. Borrowing would contend with the slot glow takes for its wide lobe, and would make a chain's legal length depend on what else is stacked on the layer — a plugin that worked alone and broke when a user added a glow beneath it.
Captured by blit rather than by remembering a name, because curTex at that
moment belongs to the ping-pong pool and is drawn over within two passes. Taken
only when some pass in the chain declares it: capturing always would cost a
full-screen blit per plugin effect per frame, for every separable blur that
never looks at it.
reads on pass 0 is refused permanently and separately — its src and its
origin are the same texture, so naming one is a statement no renderer can
satisfy.
A full chain is now expressible: 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.
Bindings per pass. 0 uniform, 1 src (the previous pass's output, or the
layer for pass 0), 2 samp, 3 the optional layer param texture, 4 optional
origin — the pass-0 input, reachable only once reads is renderable.
origin stays at 4 even when 3 is unused: sliding it down would make a binding
number depend on an unrelated part of the manifest, which the generator and the
resource-binding side would each have to derive separately.
reads on pass 0 is refused permanently and separately — its src and its
origin are the same texture, so naming one is a statement no renderer can
satisfy. That refusal keeps its own message, because "wait for a later version"
would be wrong advice.
The cost budget. A pass costs scale² — its share of the pixels — and a
chain may total 3. Both numbers differ from the work order that specified
them (1/scale², budget 6), which was inverted and self-contradictory:
1/scale² scores quarter scale, the cheapest pass allowed, at sixteen times a
full one and puts a single one over the budget; and under either exponent four
full-scale passes cost 4, so a budget of 6 does not refuse the chain the same
brief says it must.
| Chain | Cost | |
|---|---|---|
| Separable blur, two full-scale passes | 2 | ✓ |
| Bloom: bright pass, two ¼-scale blurs, composite | ≈2.13 | ✓ |
| Four ¼-scale passes | 0.25 | ✓ |
| Four full-scale passes | 4 | ✗ |
Compilation. 5 s per pass, 10 s total. Any pass failing renders the whole effect passthrough and marks it failed by name — never a partially applied chain. Device loss disables by name, reversibly, as before.
Registry naming. A single-pass effect keeps the bare
<pluginId>.<effectId>, unchanged, so every already-published effect resolves
exactly as it did. A chain suffixes each pass: acme.blur#horizontal.
A working two-pass separable Gaussian blur ships as
src/layout/Plugins/blurSamplePlugin.ts.
The host pass block — why parameters start at 96#
A pass needs its own texel size: a blur samples uv ± texelSize, and a pass at
scale: 0.25 renders into a quarter-size target, so the value differs per pass
and an author cannot compute it. A 32-byte host block sits between the
renderer's header and the author's parameters.
offset 0 renderer header mvp (48) + uvRect (16) 64 bytes
offset 64 host pass block texelSize : vec2<f32>
passScale : f32
passIndex : f32
_reserved : vec4<f32> 32 bytes
offset 96 parameters, alignment-descending as before
_reserved rounds the block to 32 so the parameter base is 16-aligned, and so
the next field this has to carry does not move every offset again.
The block is emitted for single-pass effects too. Two layouts would make every parameter offset depend on a condition that the CPU packer and the shader generator each evaluate separately — the exact shape of the bug that made the 64-byte renderer header necessary in the first place.
Network (API 4)#
net:fetch ⟺ contributes.net, both directions, max 8 exact hosts.
The request is made in the main process, because the renderer's CSP names
our backend and never a plugin's host. The renderer checks plugin identity,
declared hosts, grant, budget and the redirect hop; main re-checks scheme and
the resolved address, applies a byte cap and timeout, sends no cookies, and
returns 3xx without following. DNS rebinding is why main resolves: blocking
localhost by name stops nothing.
9. Trust and distribution#
Signing. ECDSA P-256 / SHA-256, IEEE-P1363, key as SPKI. The registry pins the public key on first publish; every later version must verify against it. The editor re-checks on the user's machine against the key stored with the installed copy — not the key the download claims.
| Attack | Result |
|---|---|
| Package altered in transit or on a CDN | Fails local verification |
| Someone else publishes under your id | Refused — the id belongs to the first publisher |
| Registry account stolen | Refused — the thief has no signing key |
| Registry compromised, serves a new key | Refused on update — the client pins the stored key |
| Publisher ships something malicious under their own key | Not covered. That is what the permission screen is for |
Getting a key at all. The publish flow asks Use an existing key… / Create a
new key… / Cancel, and the second generates one in the main process, writes it
0600 where the user chooses, and signs with it immediately. Byte-compatible
with scripts/sign-plugin.mjs keygen — same curve, same field names, same
encodings — so the two are interchangeable.
Added because the flow previously opened a file picker demanding a key that only the CLI script could make, which anyone who installed the app rather than cloning the repo had no way to produce. A dialog that cannot be satisfied is worse than a missing feature: it reads as "you should already have this".
Publishing from the editor. The renderer sends package bytes and a
visibility choice to the main process, which opens a file picker for the key,
signs, attaches the session and uploads. The private key never enters the
renderer and is never stored — one picker per publish. A browser tab falls back
to scripts/sign-plugin.mjs.
Key rotation. A backup key may be registered at first publish (free of risk — no install base yet). Later, authorising one needs the account password, and rotation happens only when a package actually arrives signed with it.
The authorised successor is published on the detail response
(nextPublisherKey, nextPublisherKeyMethod), which reverses a deliberate
earlier omission. Withholding it looked prudent — why tell an attacker which key
to steal next — but it made the prompt useless: a client first learned of the
new key in the same response asking it to trust that key, which is no evidence
at all. So every rotation produced the same generic prompt, including the one a
thief with a stolen account most wants shown. Publishing it lets an installed
copy pin the successor before it is used, and then tell three cases apart:
| The installed copy already pinned this key | Prompt |
|---|---|
Yes, and it was registered as a backup at first publish |
Quiet — it was authorised before there was anything to steal |
Yes, authorised later via dashboard |
Prompt, naming the date and method |
| No | Prompt hard — this key was never announced |
Every case is recorded in the plugin's securityEvents (capped at 20), which
the manager shows. The public PluginKeyRotation history is append-only: a
history a publisher could edit would be worth nothing.
Note the asymmetry in KeyChangeRequest.authorisation: it can be dashboard or
unknown, never backup. A backup rotation that matches a locally pinned key
does not reach the prompt at all, so there is no path that would produce it —
and a value that can never occur is a branch nobody tests.
Publish-time scanning produces a risk score and findings, both stored on the version — and gates nothing. A publish goes live.
The gate was removed deliberately. The scanner is a pattern match over source a hostile author fully controls, so anyone reading the public rule list can score zero; it stopped the careless and not the deliberate. What it reliably stopped was honest authors — the first plugin ever published against this registry was held for a permission mismatch, silently, and disappeared from the marketplace its author had just published to.
The findings now go to the author, as warnings on the publish response.
They were withheld while they gated publication (naming the rules you tripped is
naming what to avoid tripping); with no gate there is nothing left to protect,
and permission-undeclared in particular tells an author their plugin will have
calls refused at runtime — which a successful publish otherwise hides completely.
reviewStatus still exists and download still refuses anything that is not
approved. Nothing writes pending any more; what remains are the operator
decisions blocked and changes_requested, which still bite. The protections
that were always doing the work are unchanged: the sandbox, the permission
screen, signature pinning, the report path into a human, and an operator's block
— which reaches machines that already installed it, as no pre-publication gate
ever could.
Reports need no account, aggregate into a PluginCase per plugin+version,
and are metered four ways — see §5. No count
blocks anything automatically.
Revocation is a signed, cached, public manifest the client matches locally, uploading nothing. A revoked plugin is reported to anyone running it and their copy keeps working — the package is blocked, not deleted.
It is fetched at host boot, not when the manager is opened, and the cached
list is enforced before enabled plugins are started. Those are two separate
facts and the second was the real defect: a 304 — the common cold start — left
the client with a perfectly good cached list and no code path that acted on it,
so a revoked plugin kept running until someone happened to open the Plugins
panel. The fetch is bounded at 5 s and sends If-None-Match; the ETag is a
content tag over {seq, entries} only, because issuedAt is per-request and an
ETag including it could never match. Freshness is re-based on the last
confirmation (including a 304), not the last full body — otherwise a
correctly-unchanged list ages into "stale" and warns about nothing.
The operator key is pinned locally. acceptRevocationList takes exactly one
argument for that reason, and a standing test sweeps for a second — remembering
an ETag is a separate function, because the fix for "the pin is being widened"
is to delete the argument, not to widen the sweep.
Immutability. Re-publishing an existing version is refused: two different
sets of bytes claiming to be 1.2.0 would make the signature guarantee
unusable.
Updates are checked only when the manager is opened — never on a timer. An update asking for more permissions goes back through consent.
10. What was verified live#
Against a running backend, with a real signed package (auditco.secret-tool):
| Step | Result |
|---|---|
Publish with visibility=private |
stored, latestVersion 1.0.0 |
| Anonymous browse | not listed (total: 0) |
| Anonymous detail / download | 404 / 404 |
| 404 body vs a nonexistent id | indistinguishable |
Owner mine/list |
listed, visibility: private |
| Owner authenticated download | 200, full package |
PATCH to public → browse |
listed |
PATCH to public → anonymous download |
200 |
| Downloaded bytes vs published bytes | identical |
| Signature against publisher key | verifies |
sha256 vs metadata |
matches |
| One byte flipped | verification fails |
Owner DELETE → browse / detail / download |
0 / 404 / 404 |
Shader compilation is verified separately on a real GPU: the composed WGSL for a
two-texture effect compiles with zero diagnostics and builds a valid four-binding
pipeline (npm run verify-plugin-effect, plus the uniform-offset probe).
A chain actually executes — npm run verify-plugin-chain, real adapter,
2026-08-10. The sample plugin's own composed passes (bundled out of the app with
esbuild, not retyped) run over a 4×4 bright square:
| stage | spread X | spread Y |
|---|---|---|
| source | 4 | 4 |
after horizontal |
14 | 4 |
after vertical |
14 | 14 |
Each pass widened its own axis and left the other alone. That discriminates four
distinct failures at once: a pass that never ran, a texelSize of zero (no
widening at all), pass 0 drawn twice (X widens again), and a pass blurring both
axes. Confirmed non-vacuous by composing pass 0 twice — X went 14 → 20 with Y
stuck at 4, and the probe failed.
This probe exists because unit tests could not answer the question. Two composed
shaders, two registry entries and two scene entries are all equally consistent
with a renderer that draws the first one twice — and for a while, that is
roughly what was happening: passes parsed, composed, budgeted and documented,
with nothing executing it, and every test green.
Uniform-offset probe, re-run on real hardware, 2026-08-10:
| amount | mean output | pass block |
|---|---|---|
| 0.00 | 0.00 | 255 |
| 0.25 | 64.00 | 255 |
| 0.50 | 127.00 | 255 |
| 0.75 | 191.00 | 255 |
| 1.00 | 255.00 | 255 |
out = 254.80·amount + 0.00, R² = 1.0000, parameter read from offset 96.
★ The fit is identical to the one recorded for the 64-byte layout, and that is exactly why the probe gained a second channel. Moving the parameter and the packing together produces the same slope, so the slope alone cannot distinguish the new layout from the old one — or from a build that dropped the pass block and left
amountback at 64. The shader now also returns whethertexelSize,passScaleandpassIndexarrived intact, in the green channel. Confirmed non-vacuous by corrupting one packed value: green fell to 0 across all five samples while the fit stayed at R² = 1.0000.
Not verified live, and worth being explicit about: the counting split (§5) and the distinct-reporter ordering are covered by unit tests against a store that enforces the unique constraints, not by a run against Postgres. The constraint expressions themselves are in the migrations and have not been executed against a real database from this work.
11. File map#
motion-editor
| Concern | File |
|---|---|
| Which builds have plugins at all | src/core/config/edition.ts |
| Manifest schema, validation, permission text | src/core/plugins/manifest.ts |
| Capability list, back-compat table, install check | src/core/plugins/capabilities.ts |
| Zip/folder reading, size and zip-slip limits | src/core/plugins/pluginPackage.ts |
| Wire protocol, method→permission table | src/core/plugins/protocol.ts |
| Sandbox (worker side) | src/core/plugins/pluginWorker.ts |
| Host method implementations | src/core/plugins/hostApi.ts |
| Batch op grammar and validation | src/core/plugins/sceneBatch.ts |
| Plugin storage — scopes, quotas, project capture | src/core/plugins/pluginStorage.ts |
| Install, supervise, permission gate, panels | src/core/plugins/PluginHost.ts |
| Persistence — index and payload in one transaction | src/stores/pluginStore.ts · src/core/services/PluginDatabase.ts |
Panel HTML: path resolution, <link> inlining |
src/layout/Plugins/panelHtml.ts · public/plugin-panel.html |
| README rendering in the manager (sandboxed frame) | src/layout/Plugins/readmeDocument.ts · ReadmeFrame.tsx |
| Layer-kind schema · custom layers · registry · proxies | layerKindSchema.ts · customLayers.ts · layerKindRegistry.ts · proxySubtree.ts |
| Effect schema, uniform layout, WGSL gate | effectSchema.ts · wgslValidation.ts · pluginEffectMaterial.ts · pluginEffects.ts |
| Network policy · main-process transport | pluginNetFetch.ts · electron/pluginNet.ts |
| Registry client · revocation | src/core/plugins/registry.ts · revocation.ts |
| Publishing from the app | electron/pluginPublish.ts |
| The list, detail, consent, panels | src/layout/Plugins/* |
| Renderer integration | src/core/rendering/snapshotToFrameScene.ts, packages/renderer/.../CompositionPass.ts |
motion-back
| Concern | File |
|---|---|
| Whether the registry is served at all | src/plugins/plugins.edition.ts |
| Package reading and manifest validation | src/plugins/plugin-package.ts |
| Publish, browse, detail, download, visibility, counters | src/plugins/plugins.service.ts |
| Routes | src/plugins/plugins.controller.ts · publishers.controller.ts · review.controller.ts |
| Signature verification | src/plugins/plugin-signature.ts |
| Publish-time scanner | src/plugins/plugin-scan.ts |
| README rendering (construct-only) and listing validation | src/plugins/plugin-listing.ts |
| Client address and its keyed hash | src/plugins/client-ip.ts |
| Reports · review · revocation | reports.service.ts · review.service.ts · revocation.service.ts |
| Schema | prisma/schema.prisma |
Shared, byte-identical in both — five files, digests in
__fixtures__/CHECKSUMS.txt:
manifests.json (grammar cases) · permissions.json (consent text) ·
methodPermissions.json (method→permission) · reportCategories.json ·
capabilityBackCompat.json (what an apiVersion implied)
Identity is enforced, not conventional: scripts/fixtures-hash.mjs is
itself byte-identical in both repos, hashes all five with CRLF→LF
normalisation, and fails with a message naming the sibling repository. Before
this, "keep these in sync" was a sentence in a comment, which is the same as no
mechanism at all.
methodPermissions.json holds 28 entries for 38 methods. A method
needing no permission is absent rather than mapped to null: ui.*,
commands.register, composition.get and storage.* are free, and
scene.apply resolves to the union of the ops in the batch, so none of them has
one fixed answer to share.
The README XSS corpus (readmeXss.json) is not shared — rendering happens
only in the registry, so it lives only there.
12. Where things appear for a user#
| Contribution | Found in |
|---|---|
commands.register / contributes.commands |
Plugins menu, under the plugin's name, and the command palette |
contributes.panels |
Wherever placement sends it, plus a Your plugin: Panel command |
contributes.effects |
The effects browser, in a folder named after the plugin |
contributes.layerKinds |
The layer-creation menu |
ui.notify |
A toast, always prefixed with the plugin's name |
Installing a plugin happens on the dashboard's Plugins page, beside publishing. The editor's Plugins panel finds and runs what is already installed; the row's Reload re-reads a folder install from either surface.
A plugin's row also carries, when they apply: what hydrate() reconciled at
boot, whether its effects can draw on this renderer, a takedown notice (which
for malicious cannot be dismissed unread), and its securityEvents — the
record of every key rotation it has been through and how each was authorised.
Nothing the user clicks closes a plugin surface. A ✕ on a plugin panel would be a control that means "hide this until something else brings it back", which is not a state the user can reason about; disabling or uninstalling the plugin is what removes it, and both are reversible from one place.
In a local build none of this appears — see §0.