Skip to content
SheetForge

Web Plugin Market

The web app has a plugin market for installing SheetForge plugins into the browser, plus a separate path for sideloading an unreviewed plugin from a GitHub URL. The same market, rebuilt in UIToolkit, also ships inside Unity.

The registry — one static file, two readers

The market listing comes from a single static registry file (public/registry/plugins.json) that both the web app and the in-Unity market window read. There is one list, so the two surfaces always show the same catalog.

SheetForge does not host the plugin binaries. Each author's own GitHub release does. The registry entry is metadata plus a pinned hash — not a copy of the plugin.

Hash pinning — integrity before the core sees a byte

Every registry entry carries the SHA-256 captured when the plugin was approved. On acquisition, every received byte is checked against that hash before it ever reaches the core. A one-byte mismatch is refused.

This is what makes "the binary lives on someone else's GitHub" safe: the release can be re-downloaded from anywhere, but only the exact approved bytes are ever loaded.

One-click install through the server proxy

Browsers cannot fetch GitHub release assets directly — cross-origin (CORS) rules block it. So acquisition goes through a server-proxy route (api/market/artifact, wired via the ArtifactProxy in lib/market/install.ts), which fetches the release server-side and streams it back. Install is therefore one click.

A manual download-and-upload path remains as a secondary fallback for when the proxy cannot reach a release. It passes the identical hash check — an uploaded file is trusted no more than a proxied one.

The compatibility gate — after integrity, before registration

Passing the hash check proves the bytes are the approved ones. It says nothing about whether this host can read that plugin's format, which is a second and separate question.

The answer comes from the verified DLL itself: an assembly-level declaration naming the plugin-format generation it was built for, and the lowest host version it wants.

  • The listing advertises, the assembly decides. A registry entry carries the same two values (pluginFormat, minHost), so the catalog can show them before you download. The gate itself reads the declaration out of the bytes that just passed verification. A listing can be out of date; a compiled declaration cannot be.
  • One gate, three paths. Market install, sideload and a plain local file all ask the same predicate, so no route can quietly accept what another refuses.
  • All or nothing per assembly. A refused assembly registers nothing — never half its contracts — and the refusal names what the assembly declared against what this host reads.
  • No declaration is fine. A plugin built before the attribute existed is read as the earliest generation with no host requirement, so it loads exactly as it always did.

Caching and unloading

Bytes that pass the hash check are cached in the browser (IndexedDB), so a plugin enabled in the list loads automatically on later visits — you enable it once.

Disabling or removing a plugin takes effect immediately: the app tells the core to drop that assembly from the plugin composition and rebuild the session, so its cell types, enums, templates, colour presets, studio actions and canvas overlays disappear without a refresh. (.NET itself never unloads an assembly — the bytes stay resident in the page, inert; if you enable the same plugin again, the core reactivates the resident assembly instead of loading it a second time.)

Sideloading an unreviewed plugin from a GitHub URL

Separately from the curated registry, you can hand the app an arbitrary GitHub URL to sideload an unreviewed plugin (lib/market/sideload.ts, served by api/market/github). This is for trying a plugin that is not in the catalog.

Because this axis takes a user-supplied URL rather than a vetted registry entry, it is the one place that enforces SSRF defense directly:

  • The URL must resolve to a GitHub-family hostgithub.com, raw.githubusercontent.com, release-assets.githubusercontent.com and the like — matched exactly, lowercased.
  • Redirects that would leak into an internal network are blocked at that boundary.

Sideloaded artifacts are cached under a distinct owner tag (sideload:). So an unreviewed plugin that happens to share bytes with a registry entry is kept separate from the curated one — a sideload never masquerades as an approved plugin.

GitHub as the import source

Both paths route plugin bytes through the same pipeline:

  • Registry install pulls the artifact hosted on the author's GitHub release through the server proxy, then checks it against the pinned SHA-256.
  • Sideload accepts a raw GitHub URL, guarded to GitHub-family hosts, then checks the bytes the same way.

In both cases the browser's CORS limitation on GitHub release assets is handled server-side, while integrity is preserved by the hash check on the client before the core loads anything.

The market inside Unity

The Unity plugin market was rebuilt in UIToolkit to look like the web market — a card grid beside a detail pane, reading the same static registry, with the same hash-mismatch rejection and the full-trust install notice.

Sign-in and the browser-kept plugin list and presets stay web-only; the Unity window is the catalog and install surface.