Docs

Working on the code

DocsWorking on the code

Plugins

10 sections10 min readdocs/plugins.md

On this page10 sections
  1. 1Licensing: your plugin is yours
  2. 2Tier 1 plugins - Go, compiled in
  3. 3Remote providers - HTTP, out of process
  4. 4Configure payment plugins in the dashboard
  5. 5What the SDK gives a gateway author
  6. 6What the SDK gives a plugin that owns a domain
  7. 7The frontend halves
  8. 8Testing a plugin
  9. 9Known limitations
  10. 10Which one

Two ways to extend the backend, and the choice between them is mostly about where the code runs.

Licensing: your plugin is yours

Skelet itself is GPL-3.0, but that does not reach your plugin. The Skelet Plugin Exception, an additional permission under GPL section 7, lets you license an extension under any terms you choose - proprietary included - with no obligation to publish its source.

That covers everything on this page: Tier 1 plugins compiled into the binary, remote providers, storefronts, dashboard extensions, language packs, themes. A merchant’s private customisation and an agency’s client work are equally exempt.

The line is modification of Skelet’s own source. Change Skelet and convey the result and the GPL governs that; write something that extends it and the exception applies. Importing the SDK does not by itself make your code a derivative work.

Tier 1 plugins - Go, compiled in

A plugin is a Go package compiled into the binary, written against the public SDK in apps/backend/sdk. It registers itself from init() and gets a typed handle for everything else: payment providers, shipping carriers, HTTP routes, event subscriptions, scheduled jobs, config fields, GraphQL schema extensions, and its own migrations.

The SDK never exposes internal/* types. Its API surface is pinned in sdk/api.json so a breaking change is a visible diff, and a second gate runs apidiff against the last SDK tag to classify that diff by Go’s own compatibility rules - an added interface method reads as an addition to a text diff but breaks every plugin implementing it, and apidiff says so. sdk/sdktest ships a fake host so a plugin can be tested without booting the app, and it is covered by the same gates.

apps/backend/core/plugins/example exercises every registration verb - start there. apps/backend/core/plugins/plugins.json decides which plugin directories compile in.

A plugin in its own repository requires github.com/skeletg/skelet/apps/backend/sdk, plus .../apps/backend/core if it adds GraphQL resolvers. Both are published as tags named after their directory, like apps/backend/sdk/v0.1.0. Until the first of those is pushed, go get can’t fetch them, so build a plugin inside this tree under apps/backend/core/plugins/.

Payment gateways are plugins too, each in its own repository named after its slug - naming the repo after the slug is what makes the install need no flags. A deployment that takes card payment installs the gateway it needs:

skelet cli plugin add github.com/acme/payment-gateway@<version>

A fresh build therefore ships with NO gateway compiled in - offline payment methods (COD, bank transfer, pay on pickup, purchase order) work out of the box, card payment is an install away. Gateways are deliberately Tier 1 rather than remote providers - PCI scope argues for in-process code over an HTTP hop.

Installing a plugin from outside the tree

A plugin doesn’t have to live in this repository. Two ways in, and the first is the one to reach for.

plugin add - a Go module dependency. Nothing is copied into this repo:

skelet cli plugin add github.com/acme/[email protected]
skelet cli plugin add github.com/acme/skelet-plugin-fraud        # latest

go get fetches it, plugins/plugins.json records the module path, and plugins/firstparty is regenerated to import it. You get a version, a checksum in go.sum, one copy instead of two, and go get -u as the upgrade path.

A module plugin’s GraphQL SDL lives read-only in the module cache at a path that carries its version and differs per machine, so skelet gen copies it into plugins/.modules/ first (git-ignored, rebuilt every run) and points gqlgen there. That directory is derived output - delete it whenever.

A plugin with no published module is written in plugins/<slug>/ instead - plugin new scaffolds one. Those are the only two layouts, so a plugin’s source is either in the module cache or in-tree, never behind a replace pointing at a checkout elsewhere.

plugin install - vendored source. The fallback, for a plugin that is not a module: no go.mod, or no repo you can fetch from.

skelet cli plugin install https://github.com/acme/[email protected]
skelet cli plugin install ../my-plugin --slug my-plugin

The source is copied to plugins/<slug>/ with its .git, go.mod, go.sum and vendor/ stripped - from then on it compiles as part of this module, its own dependencies pulled in by go mod tidy and any self-imports rewritten onto the vendored path. A build failure parks the plugin (plugins/_<slug>) so the tree keeps compiling while you fix it.

Either way, installing compiles the code into core with full privileges - read it before you install it. The manifest’s capabilities are a disclosure, not a sandbox.

One thing to know about graph-extending plugins: their resolvers reference types in core’s generated graph/*/model package, which core produces from that plugin’s own SDL. So the order is always fetch, then codegen, then compile - and such a plugin never builds standalone in its own repo. A plugin with no GraphQL does.

Remote providers - HTTP, out of process

Provider-shaped webhooks: a merchant HTTP endpoint that implements a role the platform needs (quoting tax, rating a shipment) rather than observing an event. No Go, no rebuild, any language.

Full contract, the signed envelope and the per-kind schemas: remote-providers.md. skelet provider conformance-tests an endpoint against it.

Configure payment plugins in the dashboard

Settings → Payment providers lists installed gateway plugins, their versions and runtime status. Select a provider to edit its registered credentials and test/live settings using the page-header Save. Secret values stay masked and are not stored in browser drafts. The page shows the backend webhook path; with a channel selected it includes the channel routing parameter. Plugin runtime enablement follows its registered scope, so the built-in plugin switch is edited at Global scope.

Settings → Checkout → Payment controls which registered methods a channel offers, their country rules, checkout labels, display order and manual-payment instructions. A disabled plugin is excluded from eligible methods and cannot be enabled for checkout. Restarting the backend preserves merchant labels and ordering.

Installing still requires adding a compatible plugin module and rebuilding the backend; the dashboard does not install code. Backend installation does not install card-entry or authentication UI in a storefront. Each gateway needs its matching storefront integration. Runtime status reports whether the plugin can run; it is not a credential or connectivity test.

What the SDK gives a gateway author

  • sdk/httpx - the outbound HTTP client: auth hook, 1MiB body cap, torn-read propagation, and the gateway’s dedupe header driven by a positional idempotency-key parameter, so forgetting it is a compile error. Hand-rolling this loop per gateway is how the same money bugs get written six times over - use the package.
  • sdk.NamespacedConfig("payment/<slug>") - the default-scope reader for health probes and resolvers. Both it and the host’s bound reader refuse full paths (keys starting payment/ or plugin/), because a double prefix resolves nothing and leaves the gateway silently inert.
  • Attributed registrations - config fields, permissions, events, migrations and data patches register through the *Plugin handle, so plugin uninstall sweeps a plugin’s exact footprint and a second plugin claiming the same key fails at boot naming both.
  • Manifest.SecurityOrigins - the browser-side origins your integration needs (script/connect/frame/form-action/payment). cmd/secheadgen composes them into the CSP and Permissions-Policy during skelet gen, so installing a gateway never means editing core’s security headers.
  • Channel-scoped webhooks - register the gateway push URL with ?channel=<uuid> and WebhookRequest.ChannelID carries it; config reads and the kill switch then resolve per channel. Without the parameter, default scope, as before.

What the SDK gives a plugin that owns a domain

A gateway is the small case. A plugin that owns tables, a graph surface and a place in the money path needs more - say company accounts with credit lines and their own checkout tender, all out of tree. This is what the SDK gives it.

  • sdk.Database() - the application pool, for a plugin whose own migrations created tables to read on the request path. The same pool core uses, not a sandbox: scope your SQL to tables you created, prefix them with your slug, and reach for an event or a registration seam wherever core has one. Resolve the handle on first use, never in init().
  • order.tendering - a sync event inside the placement transaction, carrying the balance still owed after core’s gift-card and store-credit tenders. Type-assert to sdk.Tendering, match on TenderProvider(), and write through PlacementTx() so a reservation commits or rolls back with the order. Returning an error refuses the placement; return a UserError and the shopper reads why.
  • RegisterPriceResolver - what a specific customer pays for a product, resolved ahead of list and tier pricing. Fail-soft by contract: an error reads as “no plugin price” rather than breaking add-to-cart.
  • sdk.RequireChannelPermission / sdk.PermittedChannels - the channel-scoped half of the admin permission model. Where your object has a channel, gate on these rather than the bare RequirePermission, or an admin scoped to one store can act on another’s records. Core’s authz coverage tests FOLLOW the generated delegation into your source and fail the build if a plugin field has no guard, so this is enforced, not advisory.
  • sdk.ReadCart / sdk.CreateDraftOrder - reading a shopper’s cart (scope-checked host-side) and turning agreed lines into a draft order for review. The pair a quote-shaped feature needs.
  • sdk.CustomerID / sdk.AdminID / sdk.RequestChannelID - who is asking, for scoping a storefront answer and for audit attribution. AdminID is attribution only, never a check.

The frontend halves

A plugin’s Go arrives through go get. Its UI does not, and shipping TypeScript through a Go module buys nothing - so dashboard and storefront code lives in its own repository, destined for npm once there are SDK packages for the two apps to depend on.

Until then it is copied into place by hand. What core provides either way is the registry each half hooks into, so a plugin’s UI never edits a core page.

Dashboard. apps/dashboard/src/plugins/<slug>/ with a plugin.tsx in it. The app’s src/plugins/loader.ts glob imports every one eagerly, and the import-time extendGroup / registerNavNode / registerDetailRoute calls are the whole wiring.

Storefront. app/plugins/<slug>/ plus one line in app/plugins/loaded.ts. Routes need no registration - Next’s filesystem router is already the glob, so a page copied to its real path simply exists. What app/plugins/registry.ts covers is the places a plugin appears INSIDE a core page: registerAccountNavItem for the account sidebar, and registerSlot for a zone like cart:summary or cart:actions.

Everything in the storefront registry is client-safe on purpose. The host components are all "use client", so a server-rendered slot could not mount in them; a plugin needing server data calls its own "use server" action from the slot, which is how a plugin shows its own data at checkout without core fetching it on the plugin’s behalf.

loaded.ts is a hand-maintained import list rather than a glob, for the same reason plugins/firstparty is generated from plugins.json: the enabled set decides what loads, not whatever is sitting in a directory.

Note that a storefront is a template you fork, not an app every install shares. Treat a plugin’s storefront half as reference markup to restyle rather than a drop-in.

Testing a plugin

Two levels, and they answer different questions.

sdk/sdktest is the in-memory fake host. A plugin registers through the real SDK and the test drives events, config, the kill switch and permissions against the fake, with no database and no boot. Right for registration wiring and handler logic.

internal/e2ekit is the real thing: one assembled fx graph, the real services, the real outbox. A plugin that owns part of the money path writes ordinary *_test.go files in its own directory and drives it the way core’s own scenarios do:

package myplugin_test

import "github.com/skeletg/skelet/apps/backend/core/internal/e2ekit"

// Embedded, so the plugin's own helpers can hang off it
type harness struct{ *e2ekit.Harness }

func newHarness(t *testing.T) *harness { return &harness{e2ekit.New(t)} }

func TestCreditIsReservedInThePlacementTx(t *testing.T) {
	h := newHarness(t)
	f := h.NewStore(t, 20, 2000)
	o, err := h.TryPlace(t, f.Channel, h.NewGuestCart(t, f, 1))
	// ... h.Settle(t), h.Cancel(t, o.ID), h.RearmOutbox(t, ...), h.D.<any service>
}

skelet test e2e runs ./plugins/... alongside ./internal/e2e/..., so these go green with core’s suite and need no copying into it. The depguard rule that bars plugins/** from importing internal/ carries "!$test", which is what makes the import above legal.

A credit-line tender is the case this was built for: credit reserved inside the placement transaction, released on cancel, prorated to the unshipped share, idempotent under outbox redelivery.

Known limitations

  • sdktest fidelity. The fake host covers every registration verb but does not model Instrument (timeouts, rate limits, kill-switch translation) or fail_closed, so those behaviors are testable only against the real host - reach for internal/e2ekit when that is what you need to prove.

Which one

Tier 1 pluginRemote provider
LanguageGoanything that speaks HTTP
Deployswith the backendindependently
Latencyin-processa network hop, with a timeout and cache
Reachthe whole SDK surfaceone declared role
Use whenit needs the event bus, migrations, or PCI scopeit is someone else’s service, or not written in Go