Capability allow-list
A plugin declares what it’s allowed to do via Plugin.capabilities, a JSON value with three recognised namespaces: http, store, auth. There is no capability that lets a plugin reach HubSpot directly — see ADR-0020.
Where capabilities live and how to set them
Capabilities belong to the plugin resource, not to the flow step that invokes it (the step only supplies logical_name and config — see Promotion and version pinning). Set them at create time or update them later:
# at creation
curl -X POST "$BASE/api/plugins" -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{"name": "compass_doc_generator", "capabilities": {"http": ["generate-doc.example.cloudfunctions.net"], "store": ["read", "write"], "auth": ["doc_generator"]}}'
# updating an existing plugin's capabilities
curl -X PATCH "$BASE/api/plugins/$PLUGIN_ID" -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{"capabilities": {"http": ["generate-doc.example.cloudfunctions.net"], "store": ["read", "write"], "auth": ["doc_generator"]}}'The plugsync CLI does not currently have a dedicated command to set or update capabilities on an existing plugin — plugsync plugin push only uploads the bundle and source; capabilities are untouched by it. plugsync plugin init scaffolds plugin.json with an empty object ("capabilities": {}), which is only used on the first plugsync plugin push (the one that creates the plugin, when plugin.json’s id is still null). Fill in your real capability object in plugin.json before that first push, or PATCH /api/plugins/{id} afterwards as above.
Format
The canonical shape is a JSON object, namespace -> list of granted values:
{"http": ["*.ifigest.it"], "store": ["read"], "auth": ["doc_generator"]}An equivalent flat-list shape is also accepted everywhere capabilities are read at runtime (app/services/plugin_capabilities.py, the single parser shared by the push-time linter and the runtime invoker so the two can never drift):
["http:*.ifigest.it", "store:read", "auth:doc_generator"]Both shapes parse to the same grants. POST/PATCH /api/plugins accept both, validate the namespaces (anything outside http, store, auth is rejected with a 422), and persist the canonical object shape. Every backend test and every real example in this codebase (Compass’s compass_doc_generator) uses the object shape; prefer it.
http: outbound HTTP allow-list
A list of host patterns your plugin’s bundle is allowed to call, matched with fnmatch glob semantics (* wildcards). An exact hostname with no wildcard characters is a valid literal-match entry.
{"http": ["generate-doc.example.cloudfunctions.net"]}or with a wildcard covering a whole domain:
{"http": ["*.ifigest.it"]}Enforcement is push-time only, and best-effort. POST /api/plugins/{id}/push runs a static linter (app/services/plugin_bundle_linter.py) over your bundled JS text: it scans for fetch("https://..."), http(s).request("https://..."), and bare URL string literals, extracts each hostname, and rejects the push with 422 plugin_capability_violation if any hostname isn’t covered by an allowed glob. It also rejects require/import of the raw Node network/process modules (net, child_process, dgram, dns) outright, regardless of capabilities.
This is explicitly not a runtime network sandbox: string concatenation, template literals, eval, and minified/obfuscated bundles can trivially evade the linter’s text scan, and there is no egress proxy or VPC-level enforcement of the allow-list once the Lambda is running. The residual risk is accepted by design — the real trust boundary is the linter (catches accidental/negligent violations) plus the promotion gate plus the org’s tier contract, not sandboxed execution. See ADR-0003.
store: worker-mediated key/value store
{"store": ["read", "write"]}Grants your plugin a small persistent key/value store, scoped to (connector_id, plugin name). The Lambda itself never touches the database directly — the flow_worker mediates every read and write:
store: ["read"]pre-loads keys declared in the step’splugin.config.store_keysintopayload.storebefore invoking your handler.store: ["write"]lets the runtime applystore_writesentries from yourPluginResponseafter your handler returns.
See payload-contract.md for the exact resolution mechanics and generate-doc-walkthrough.md for the working example (one-document-per-deal idempotency).
auth: scoped outbound credentials
{"auth": ["doc_generator"]}Each entry is the name of an EventSource (a rest source with config.outbound.credential_name set) whose resolved outbound headers your plugin is allowed to see, under payload.auth[<source_name>]. Only that one source’s headers are exposed — never the full credential map, and never a HubSpot credential (HubSpot OAuth tokens are excluded from this resolver entirely; see ADR-0020).
Reserved, not implemented
A hubspot:<verb> capability namespace (e.g. hubspot:get_associated, hubspot:update_object) letting a plugin request a mediated, allow-listed HubSpot operation is described in ADR-0020 as the sanctioned future widening path — deferred, not implemented (tracked as issue #381). Do not declare it; nothing in the runtime recognises it today, and POST/PATCH /api/plugins reject any namespace outside http, store, auth with a 422.
Tier gate
POST /api/plugins and POST /api/plugins/{id}/push both require the org’s tier to have plugin_system_enabled, enforced by _assert_plugin_system_allowed in app/api/plugins.py; every other tier gets 402 Payment Required.
Creating a mode=test connector requires the same plugin_system_enabled flag, but through a separate, inline check in create_connector (app/api/connectors.py), not through _assert_plugin_system_allowed — the two gates are duplicated, not shared code, so a future change to one will not automatically apply to the other.