Extend it without forking it
Skelet is built to be built on, through contracts that are versioned and checked: a Go SDK, two GraphQL schemas, signed webhooks and providers, and registries in both frontends.
$ ./skelet cli plugin --helpTier 1 plugin scaffolding and managementAvailable Commands: add Add a plugin as a Go module dependency and compile it in disable Leave a plugin in the tree but out of the build enable Compile a plugin into the build install Vendor an out-of-tree plugin into plugins/ and compile it in list Show every plugin compiled into this binary and what its manifest declares migrate Apply or roll back Tier 1 plugin SQL migrations new Generate boilerplate for a new Tier 1 plugin uninstall Roll back every migration + delete config rows for one pluginA plugin is a Go package written against the public SDK. It registers itself from init() and gets a typed handle for everything else - and it never sees core's internals.
- Payment providers, shipping carriers, promotion rules and price resolvers
- Routes, events, scheduled jobs, config, permissions and its own GraphQL
- The SDK surface pinned in
sdk/api.json, and diffed against the last release - Installed by module path:
./skelet cli plugin add - Card gateways are plugins, kept in process for PCI scope
- Uninstalling sweeps exactly what a plugin registered
plugins/acme-fraud/plugin.go
package acmefraud
import (
"net/http"
"github.com/skeletg/skelet/apps/backend/sdk"
)
func init() {
p := sdk.RegisterPlugin(sdk.Manifest{
Slug: "acme-fraud",
Name: "Acme Fraud",
Version: "1.2.0",
SDKRange: "^0.1.0",
Consumes: []string{sdk.EventCheckoutValidating},
})
// mounted at /api/v1/plugins/acme-fraud/ping
p.RegisterRoute("GET ping", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("pong"))
})
// before checkout commits - an error refuses it
p.SubscribeSync(sdk.EventCheckoutValidating, 100, screen)
p.RegisterConfigField("fraud/acme/api_key", sdk.ConfigField{
Type: sdk.TypeSecret,
Description: "Acme API key",
})
}Gosdk ^0.1.0gofmt ✓
The manifest: slug, version, the SDK range it was built for and the events it listens to.
Routes mount under
/api/v1/plugins/<slug>/, behind the host's kill switch, rate limit, timeout and panic isolation.A sync subscription runs before the change commits and can refuse it. Async ones run after, with a replay-deduped variant.
Config fields show up in Settings, secrets masked.
Admin and storefront share a process and the services under it, but each endpoint has its own schema, its own generated executable and its own resolver tree.
- Storefront introspection can never see an admin operation
- Playgrounds at
/playground/adminand/playground/frontin development - Anonymous storefront queries listed, so they can be cached
- Every admin resolver guarded - a test fails on one that isn't
graph/schema/admin/order.graphqls
extend type Query {
# channelId scopes to one channel's orders, omit it for the global list
orders(channelId: ID): [Order!]!
}
extend type Mutation {
createDraftOrder(input: CreateDraftOrderInput!): Order!
placeDraftOrder(id: ID!): Order!
cancelOrder(id: ID!): Order!
}POST /api/admin/graphql
graph/schema/front/cart.graphqls
extend type Query {
myCart: Cart!
cart(id: ID!): Cart
pickupLocations: [PickupLocation!]!
}
extend type Mutation {
createGuestCart: Cart!
# Names and prices the line from the catalog by SKU
addToCart(cartId: ID!, sku: String!, quantity: Float!): Cart!
}POST /api/front/graphql
Admin mutations exist only in the admin schema - the storefront endpoint has no way to reach them.
myCartanswers for whoever is signed in; a guest's cart goes by its id.Lines are priced from the catalog by SKU, never by what the client sends.
The GraphQL executables, the data layer, the Signals wire contract and both frontends' types are all generated from their schemas - there are no hand-written database calls, and drift fails the build.
- gqlgen from
.graphqls, sqlc from.sql, buf fromproto/ - Both frontends' TypeScript types from the live schemas
- The permission, webhook and settings tables in the docs, from the code that registers them
- The remote-provider contract, from the structs core sends
- 01bufproto/skelet/signals/v1the core-to-Signals wire contract
- 02plugin setplugins/plugins.jsonplugins/firstparty and the SDL list gqlgen reads
- 03sqlcinternal/db/query/*.sqlthe data layer, in both Go modules
- 04gqlgen · admingraph/schema/adminthe admin executable and resolver stubs
- 05gqlgen · frontgraph/schema/frontthe storefront executable and resolver stubs
- 06plugin graphgqlgen's plugin stubsdelegation into each plugin's own resolvers
- 07provider contractcore's structsremoteprovider/contract/*.schema.json
- 08docs tablesthe registriespermissions, webhook events, settings
- 09security headerslib/httpx/securityheaders.gothe nginx and Caddy copies
- 10dashboard typesthe admin schemaapps/dashboard
- 11storefront typesthe front schemaapps/channels/*
Async webhooks hear about a change after it commits. Sync ones are asked before it does - and their answer can refuse it, or change it.
- Async deliveries retried at 10s, 60s and 5 minutes, then a dead-letter list to replay from
- A delivery record for every attempt, its request and its response
- Wildcards:
*, or a namespace likeorder.* - Global, or bound to one channel
- A circuit breaker for endpoints that keep failing
- Target URLs checked against an SSRF guard
Request
POST /hooks/skelet HTTP/1.1
- Content-Type:
- application/json
- X-Webhook-Event:
- checkout.validating
- X-Webhook-Timestamp:
- 1792920660
- X-Webhook-Api-Version:
- v1
- X-Webhook-Signature-256:
- sha256=9f2c6a1e…b7e41a
{
"apiVersion": "v1",
"event": "checkout.validating",
"timestamp": "2026-10-25T09:31:00Z",
"data": {
"OrderID": "0192f4c1-7a3e-7b20-9d41-5c8e2a6f1b03",
"Email": "ava.thompson@example.com",
"Subtotal": 34500,
"PaymentProvider": "acme-pay",
"RiskScore": 0,
"Tags": null
}
}Response
HTTP/1.1 200 OK
- Content-Type:
- application/json
{
"reject": false,
"riskScore": 91,
"tags": ["manual-review"]
}113 events: 92 fire after the change commits, 21 before it - and a sync webhook subscribes only to sync ones.
HMAC-SHA256 over the timestamp, a dot and the raw body. Check the timestamp too: five minutes is the recommended window.
Answer
trueto refuse: it aborts under either failure policy, and areasonbeside it reaches the shopper or the admin.Short of refusing, some events take changes: discounts add up across webhooks, the risk score takes the highest, tags append.
A remote provider is an endpoint you own that core calls mid-request - for tax, shipping rates, a fraud verdict - and waits on. One POST, a signature to check, JSON back. Nothing to install, nothing to rebuild.
- 16 kinds, from shipping and tax to fraud, search and FX
fail_openby default, so core falls back to its own rules;fail_closedwhen a wrong answer is worse- A circuit breaker: five failures open it for 30 seconds
- An optional answer cache, up to an hour, keyed on the payload alone
- Timeouts set per provider, five seconds by default
./skelet providertests your endpoint against the contract, forged signatures included
your-service/verify.ts
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody must be the unparsed body
export function verify(secret: string, headers: Record<string, string>, rawBody: Buffer): boolean {
const ts = headers["x-provider-timestamp"];
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const want = createHmac("sha256", secret).update(ts).update(".").update(rawBody).digest("hex");
const got = (headers["x-provider-signature-256"] ?? "").replace(/^sha256=/, "");
const a = Buffer.from(got), b = Buffer.from(want);
return a.length === b.length && timingSafeEqual(a, b);
}TypeScriptX-Provider-Signature-256
- tax_provider.schema.jsonTax owed on an order
- shipping_carrier.schema.jsonRate a basket to a destination
- delivery_points.schema.jsonPO boxes and lockers for a delivery
- shipping_tracking.schema.jsonCarrier tracking status
- address_validator.schema.jsonValid or not, and a normalized form
- promotion_condition.schema.jsonWhether a rule matches the cart
- promotion_action.schema.jsonThe discount a matched rule applies
- pricing.schema.jsonOverride a line's unit price
- currency_rate.schema.jsonFX rates for a base currency
- fraud_risk.schema.jsonApprove or reject an order
- returns_policy.schema.jsonApprove or refuse a return
- inventory_availability.schema.jsonSalable quantity per product
- recommendation.schema.jsonRelated products for a product page
- reviews.schema.jsonReview aggregates from elsewhere
- search.schema.jsonFree-text search, typeahead and /search
- search.browse.schema.jsonFaceted browse - the second half of search
- customer_segments.schema.jsonSegments a customer belongs to
Refuse anything older than five minutes - core doesn't cap a timestamp's age, so without this a captured request replays forever.
Sign over the header's timestamp, not the body's, and hash the raw bytes before parsing anything.
Compare in constant time -
timingSafeEqual,hmac.compare_digest,hash_equals.
An integration token calls the admin API with the permissions it was given and nothing else - and everything it does is recorded against the integration, not a person.
- One token per integration, revoked on its own
- Everything it does named in the audit log
- Inventory syncs labelled
integration:<name> - No locale scoping - permissions and channel only
Request
POST /api/admin/graphql HTTP/1.1
- X-Api-Token:
- sk_••••••••••••
- Content-Type:
- application/json
query {
orders(channelId: "9ead3bed-c091-4757-bd3c-ab6bad95f353") {
number
}
}Response
HTTP/1.1 200 OK
{
"data": {
"orders": [{ "number": "1048" }, { "number": "1047" }]
}
}Request
POST /api/admin/graphql HTTP/1.1
- X-Api-Token:
- sk_••••••••••••
- Content-Type:
- application/json
mutation {
regenerateIntegrationToken(id: "0192f4c1-8b54-7e11-a2c7-1d9f3e40b6a8") {
__typename
}
}Response
HTTP/1.1 200 OK
{
"errors": [
{
"message": "this needs a signed-in admin - integration tokens can't use it",
"path": ["regenerateIntegrationToken"],
"extensions": { "code": "ADMIN_ONLY", "class": "FORBIDDEN" }
}
],
"data": null
}A token holds a flat set of permission slugs, the ones roles grant, and can do whatever they allow.
Pin it to one channel and its grants satisfy only that channel's checks - global gates refuse it.
What only a person may do - accounts, sessions, other tokens - refuses it with
ADMIN_ONLY.
A plugin's interface registers into core's pages instead of editing them: nav entries and detail sections in the admin, slots and account links in the storefront.
- Registered at import time - no core page edited
- Dashboard plugins picked up by a glob, storefront ones by one line in
loaded.ts - Admin translations through
RegisterAdminTranslations - Copied into place today, npm packages once the SDKs exist
- A storefront is a template you fork:
./skelet new channel
apps/dashboard/src/plugins/acme-fraud/plugin.tsx
import { extendGroup } from '@/nav/registry'
import { registerDetailSection } from '@/plugins/detail-section-registry'
import { DetailPageSection } from '@/components/layout/detail-page'
import { FlaggedOrders, RiskPanel } from './pages'
// a page under Orders, in the sidebar
extendGroup('orders', [
{ title: 'Flagged orders', path: '/orders/flagged', element: <FlaggedOrders />, permission: 'orders.view' },
])
// a section at the foot of every order's page
registerDetailSection<{ id: string }>({
id: 'acme-fraud:risk',
target: 'order',
render: ({ entity }) => (
<DetailPageSection title="Risk">
<RiskPanel orderId={entity.id} />
</DetailPageSection>
),
})DashboardReact
app/plugins/acme-fraud/index.tsx
import { ShieldCheckIcon } from 'lucide-react'
import { registerAccountNavItem, registerSlot } from '@/app/plugins/registry'
import { TrustNote } from './TrustNote'
// under the cart's totals, handed what the host already has
registerSlot<{ subtotal: number; currency: string }>({
id: 'acme-fraud:trust',
zone: 'cart:summary',
render: ({ subtotal, currency }) => <TrustNote subtotal={subtotal} currency={currency} />,
})
// a link in the account sidebar
registerAccountNavItem({
id: 'acme-fraud:verify',
href: '/account/verify',
label: 'Verify your identity',
Icon: ShieldCheckIcon,
})StorefrontNext.js
Nav entries land inside a core group by its id - or in a group of their own with
registerNavNode.Sections render at the end of the matching detail page, each owning its own state and save.
Storefront slots:
cart:summaryunder the totals, handed the subtotal and currency, andcart:actionsbeside Proceed to checkout.Account links share the built-in ones' look, active state and sizing.
A fake host for the quick checks, and the real app assembled for the ones that matter - a plugin in the money path writes the same scenarios core does.
sdk/sdktest: a fake host for wiring and handler logic, no database- End-to-end on disposable databases, dropped and recreated every run
- Plugins' scenarios run with core's, nothing copied across
- Storefront browser tests with Playwright, in a container
plugins/acme-credit/credit_test.go
package acmecredit_test
import (
"testing"
"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))
if err != nil {
t.Fatal(err)
}
h.Settle(t)
h.Cancel(t, o.ID)
}Gogo test
$ ./skelet test e2e -run TestCreditIsReservedInThePlacementTx# core's scenarios and ./plugins/... together, against# skelet_e2e and skelet_e2e_signals - dropped and recreated$ ./skelet test storefront vetrina-demo -g addresses# Playwright in Microsoft's image, inside the storefront's# network - the host needs no Node or browsere2ekitassembles the real app - its services, its outbox - against a database made for the run.Fixtures for a store, carts and customers, so a test reads like the scenario it checks.
Placing, settling, cancelling and outbox replays, driven the way core drives its own.
Every dev command goes through ./skelet - the toolchains run in containers, so Docker is all a machine needs, and the gates take about a second.
- Run bare for the grouped list,
skelet help <command>for one ./skelet check: every gate, the same as the pre-commit hook and CI./skelet doctorfor when something feels off- Files written as your user, never root
$ ./skeletskelet - the dev commands for this repoCommon setup configure this clone (run this first) up start the dev stack [--lean skips OpenSearch, Signals, dev tools] check every gate that can fail a commit doctor diagnose your environmentContainers go go inside the app [skelet go test ./internal/...] cli the ops CLI [skelet cli admin create-superuser]Codegen gen (all) | sqlc | gqlgen | bufQuality test go test + frontend typechecks + unit testsScaffold new channel <name> [--from <storefront>] [--tracked]$ ./skelet cli plugin new --helpCreates plugins/<slug>/ with a starter set of files matchingthe plugin contract.Layout produced (depending on flags): plugins/<slug>/ plugin.go init() - registry calls provider.go (--payment-provider) migrations.go (--with-migrations) migrations/ 0001_initial.up.sql (--with-migrations) 0001_initial.down.sql (--with-migrations)The plugin is enabled in plugins/plugins.json and plugins/firstparty isregenerated, so there is nothing to paste by hand - the command finisheswith a checklist of what is left.What you build stays yours
Skelet is GPL-3.0, but the plugin exception covers everything on this page - plugins compiled in, remote providers, storefronts, dashboard extensions, language packs and themes. License them however you like, proprietary included, with no obligation to publish the source.
The line is modifying Skelet's own source. Change Skelet and convey it, and the GPL governs that; write something that extends it, and the exception applies.
