Security & Sandboxing
How PuntoSuite isolates plugins, what protects shop operators, what you must declare in your manifest, and what runtime behavior to expect.
PuntoSuite runs plugins through a mediated, fail-closed security model: untrusted code never touches the DOM or Node built-ins by default, and every host capability is something you opt into explicitly. This page explains what protects users, what you must declare, and what to expect at runtime so your plugin works with the sandbox instead of fighting it.
For the API you call inside that sandbox, see API reference. For the fields referenced here, see Manifest.
Worker isolation (default-on for non-bundled plugins)
A non-bundled plugin (anything installed from the Marketplace or sideloaded — not shipped with
the app) runs inside a node:worker_threads Worker by default. Inside that worker:
- No DOM, ever. There is no
window,document, or renderer access. UI is contributed only through the mediatedui.*surface of theBodegaAPI. - No Node built-ins unless the matching capability is declared (see below). All host access goes
over an RPC channel to the main process via the
BodegaAPIproxy.
The worker boundary is the sandbox. Denied built-ins are blocked at module resolution and patched at runtime, so an
import('node:fs')orrequire('node:child_process')you did not declare surfaces a clearCapability deniederror instead of running silently.
Bundled official plugins (shipped inside the app, loaded from a read-only location) keep an in-process fast path and are trusted separately — you do not author those.
Capabilities vs permissions — they are different
This is the single most common point of confusion. PuntoSuite has two independent gates:
| Gate | Field | What it controls | Default |
|---|---|---|---|
| Capabilities | capabilities | Access to Node built-in modules (fs, net, child_process, worker) | Deny-all for non-bundled plugins |
| Permissions | permissions | Access to BodegaAPI data surfaces (db.*) | Each surface denied until its token is listed |
They do not overlap. Listing a permission grants you nothing at the Node level; declaring a
capability grants you nothing on the BodegaAPI.
Capabilities gate Node built-ins (deny-by-default)
capabilities is an array of 'net' | 'fs' | 'child_process' | 'worker'. Each token unlocks a fixed
set of Node modules:
| Capability | Unlocks (Node modules) |
|---|---|
net | net, tls, http, https, dgram, dns (and their node: forms) |
fs | fs, fs/promises |
child_process | child_process |
worker | worker_threads, cluster |
For non-bundled plugins the default is deny-all: omit capabilities and you get zero Node
built-ins — but you still get the full mediated BodegaAPI. Declare only what you genuinely need.
{
"capabilities": ["net"],
"net": { "allow": ["api.example.com"] }
}
Most plugins need no capabilities at all. The
BodegaAPI(config, db reads, UI, theming, hooks) covers the common cases without any Node access.
Permissions gate BodegaAPI data surfaces
permissions is an array of string tokens. Today the only recognized tokens are:
| Permission | Grants |
|---|---|
ipc:products:read | db.products.getById() / db.products.list() |
ipc:sales:read | db.sales.getById() / db.sales.list() |
These reads are read-only and auto-scoped to the active business by the host (you never pass
a businessId). Calling a db.* method without its permission rejects. Any token the schema does
not recognize fails manifest validation — so an unknown permission is a hard error, not a silent
no-op.
Network egress: net.allow and the SSRF guard
If you declare the net capability, you must also list the hosts you will reach in net.allow.
Outbound requests are SSRF-guarded: egress is restricted to the declared allowlist, so a plugin
cannot pivot to internal or metadata endpoints.
{
"capabilities": ["net"],
"net": { "allow": ["api.example.com", "cdn.example.com"] }
}
Declare the narrowest host list that works. The allowlist is meaningful only together with the net
capability — net.allow without net grants no network access.
Trust tiers (fail-closed)
How a plugin arrived determines how much the host trusts it. The model is fail-closed: a folder that simply appears on disk never auto-runs.
| Tier | What it is | Behavior |
|---|---|---|
| Bundled | Shipped with the app, loaded from a read-only packaged location | Auto-loads; in-process fast path |
| Marketplace (signed / digest) | Installed from the Marketplace with signature/digest trust metadata | Trust metadata is advisory; still needs operator confirmation, still runs in a worker |
| Sideload | Anything else (e.g. a folder dropped into the plugins dir) | Operator must confirm in Settings > Plugins before it loads |
The tier is resolved from a read-only/packaged-location check plus the install trust record; anything that is not packaged and not a known Marketplace install falls through to sideload — the most restrictive tier. A higher reputation or "signed" badge never relaxes runtime containment: non-bundled plugins always run in the worker regardless of trust metadata.
See Publishing for how Marketplace trust metadata is produced, and Troubleshooting for confirming a sideload.
Signed blocklist (offline kill-switch)
PuntoSuite is offline-first, so revocation cannot depend on a live connection. An Ed25519-signed revocation list is cached locally and verified on every boot before any non-bundled plugin loads. If your plugin ID is on that list, it is refused at load — this is the kill switch.
- The list is signed with BodegaSuite's own key and checked offline at boot.
- It is anti-rollback: each list carries a monotonic sequence number, and a list with a lower number than the cached one is rejected, so a stale copy cannot be replayed to undo a revocation.
You do not interact with the blocklist; just be aware a published plugin can be revoked retroactively.
Contribution input is capped
Everything you contribute through ui.* is host-validated and capped, so a buggy or hostile plugin
cannot flood the operator's UI. Stay within these limits:
| Surface | Caps |
|---|---|
ui.registerPanel | <=32 panels per plugin; id <=64 chars, charset [a-zA-Z0-9._-]; title <=120; icon <=40 |
ui.registerTheme | <=16 themes per plugin |
ui.notify | ~5 toasts per 10s per plugin; message clamped to ~500 chars |
Navbar click delivery is spoof-guarded: ui.onNavItemClick only receives clicks for items your
plugin actually registered. See Contributions for the full surface.
Auto-disable on repeated failures
The host watches your hook and filter callbacks. If a callback throws ~5 consecutive times, the
plugin is torn down automatically. Validate inputs, handle async errors, and never let a hot-path
hook (e.g. sale:afterComplete) throw unconditionally — repeated failures will evict you.
api.hooks.addAction('sale:afterComplete', async (sale) => {
try {
await doWork(sale)
} catch (err) {
// log and recover; do not let it throw on every sale
}
})
See Hooks & API for the hooks core fires today.
What to declare — quick checklist
- Reading products or sales? Add
ipc:products:read/ipc:sales:readtopermissions. - Talking to a network host? Add
nettocapabilitiesand the host tonet.allow. - Touching the filesystem or spawning processes? Add
fs/child_process— only if truly needed. - Need none of the above? Declare nothing; the
BodegaAPIis enough for most plugins. - Track every disposer returned by
register*/addAction/addFilterand release them indeactivate()— see Lifecycle.
Notes on what is not yet wired
- The default-deny capability enforcement, ESM-loader bypass closing, and worker default-on apply to the worker-isolation path for non-bundled plugins per ADR-023; bundled official plugins keep the in-process path by design.
- Nested workers: a plugin that does not hold the
workercapability is blocked from constructing a nestedworker_threads.Worker/clusterfork — the host neutralizes those constructors (for bothrequireandimport) after it finishes its own bootstrap, so a child context can't be used to escape the sandbox. A plugin that is grantedworkercan create them, but a child it spawns does not inherit these denials — so grantingworkeris a high-trust decision, reserved for audited cases.