Add-on system
The registry / catalog / entitlement pattern that carries every harvested capability into the backbone as a first-class, gated add-on — plus the shared core guards they all reuse.
Add-on system
Capabilities harvested from the sibling WEB projects don't land as loose code. Every one is
a self-contained module under lib/addons/ described
by an AddonManifest and registered in one central registry. The manifest carries
provenance (which repo the IP came from + its license) and, for paid features, the
entitlement that gates it. The directive is harvest-only — no sibling app is vendored
in; only the capability is absorbed and rebuilt on the backbone's own primitives.
The human ledger of what was harvested, from where, and what was dropped on the way in is
lib/addons/HARVEST.md. This doc is the
as-built map of the system that holds those add-ons.
The registry (source of truth at runtime)
lib/addons/registry.ts is a single
Map<string, AddonManifest>. Manifests are pure data — importing a manifest never
mutates global state, which keeps tests deterministic. Registration happens exactly once,
centrally, in lib/addons/index.ts: it
imports every manifest and loops registerAddon(m) over them.
| Function | Behavior |
|---|---|
registerAddon(manifest) | Adds it; throws on a duplicate id so wiring mistakes fail loudly. |
getAddon(id) | Look one up. |
listAddons() | All registered add-ons, sorted by category then id (for a god-mode capability page). |
isAddonEnabled(id, granted) | The gate — see below. |
Always import the public API from @/lib/addons (the barrel) — that import guarantees the
registry is populated. index.ts also re-exports each active add-on's own public surface
(safeFetch, encryptSecret, log, errors, json, runSelfScan, …) from that one path.
The manifest shape
From lib/addons/types.ts:
interface AddonManifest {
id: string // stable kebab-case id
title: string
category: AddonCategory // security | observability | api | billing | email |
// webhooks | dns | db | scraper | browser | identity
description: string
core: boolean // core = always-on platform primitive (no gate)
entitlement?: string // plan feature key required to enable a non-core add-on
status: AddonStatus // 'active' | 'planned'
provenance: AddonProvenance // origin repo + sourcePath + license + harvestedAt
env?: string[] // env vars this add-on reads (surfaced by ops)
}
provenance.origin is one of native (authored here), a sibling repo (usermails,
altohost, pyrosync, extrastate, servoagent), or external (a third-party MIT
pattern reimplemented — code is ours, no upstream code copied and no upstream name in any
shipping identifier).
Entitlement gating
Two layers, split cleanly:
-
Is this add-on on for a given granted set? —
isAddonEnabled(id, granted). Core add-ons are always on. A non-core add-on with noentitlementis on. Otherwise itsentitlementstring must be present ingranted. Planned add-ons (status !== 'active') are never enabled. -
What does a license grant? —
lib/billing/entitlement-gate.tsresolves a license key → tier (licenseTierForKey) → granted add-on set (grantedAddonsForTier) →isAddonEnabled. The tier→add-on matrix is the single machine-readable maplib/billing/addon-entitlements.ts(ADDON_GRANTS).Plan.featuresis marketing copy;ADDON_GRANTSis the enforced map.
In a route, gate after the bearer is authenticated:
const gate = await agentGate(req, 'browser-scrape', 30)
if ('res' in gate) return gate.res
const denied = await requireAddon(gate.key, 'browser') // 403 { code: 'addon_not_entitled' } if not granted
if (denied) return denied
Current grant matrix (from ADDON_GRANTS; light ops add-ons at team+, resource-heavy ones
at business+):
| Tier | Granted add-ons |
|---|---|
solo | — |
team | dns_integrity, webhooks |
business | + email, scraper, browser, attack_surface |
msp / enterprise | + billing |
Shared core guards (always-on, reused everywhere)
The feature add-ons don't re-implement fetch safety, encryption, or error shapes — they reuse
these core add-ons (core: true, no gate). Import them all from @/lib/addons.
| Guard | id | What it gives you | File |
|---|---|---|---|
| SSRF guard | ssrf | assertPublicUrl/isPublicUrl (creation-time host/IP check) + assertFetchableUrl/safeFetch (request-time resolve-and-pin: resolves the host and re-checks every returned IP, follows redirects manually re-validating each hop). Closes DNS rebinding. SafeFetchError carries a code + status. Escape hatch HOSTSSH_ALLOW_PRIVATE_URLS=true for dev/tests. | ssrf/safe-fetch.ts, ssrf/ssrf.ts |
| Secrets-at-rest | crypto | encryptSecret/decryptSecret — AES-256-GCM v1:<iv>:<tag>:<ct> envelope under HOSTSSH_ENC_KEY (32 bytes / 64 hex); fail-closed if the key is missing/short. Plus sealSensitiveHeaders/isSensitiveHeaderName. | crypto/crypto.ts |
| Structured logger | logger | log.info/warn(event, data?) and log.error(event, data?, err?) (only error takes the error arg) — JSON lines in prod, readable in dev. | logger/logger.ts |
| Typed API errors | errors | ApiError + the errors.* factory (badRequest 400, validation 422, unauthorized 401, forbidden 403, notFound 404, conflict 409, rateLimited 429, planLimit 402, internal 500). | errors/errors.ts |
| API responses | api-responses | json/paginated/errorResponse/errorToResponse/parseBody/parsePagination over web-standard Response.json. Writes/errors carry cache-control: no-store. errorToResponse renders an ApiError to its status; hides internals in production. | responses/responses.ts |
| Rate limiter | rate-limit | In-memory fixed-window per-instance limiting (native; swap for Redis at horizontal scale). | control-plane/web/lib/ratelimit.ts |
The harvested feature add-ons
Each ships as status: 'active', gated by its entitlement. Detailed developer docs:
| Add-on | id / entitlement | Origin | One-line summary | Doc |
|---|---|---|---|---|
| Outbound Webhooks | webhooks | altohost | Durable Postgres delivery queue, pure circuit breaker, Stripe-style signing, SSRF-safe POST. | addons-webhooks.md |
| Web Scraper | scraper | pyrosync | Rule-driven extraction: SSRF-safe fetch + DOM-free regex/JSON-LD; css/xpath delegated to a DomExtractor seam. | addons-browser-scraper.md |
| Browser Automation | browser | external (browser-use patterns) | Indexed-DOM observe→plan→act agent + rendered scrape; Playwright gated into services/browser-worker. | addons-browser-scraper.md |
| Attack-Surface Self-Scan | id attack-surface · ent attack_surface | external (Raccoon patterns) | Owner-gated port sweep + curated exposed-file probe; DNS-TXT ownership proof required. | addons-attack-surface.md |
| DNS Integrity | id dns-integrity · ent dns_integrity | usermails | Read-only mail-DNS health score + SPF/DKIM/DMARC domain-auth verify over node:dns. | addons-dns-integrity.md |
| Identity & Auth | identity | extrastate | Clerk-style better-auth server (orgs/passkey/2FA/JWT); active org = canonical tenantId. | addons-identity.md |
| Email & Deliverability | email | usermails | Send engine + native queue + send API. (Real outbound gated on a Go MTA sidecar.) | see lib/addons/email/README.md |
| Stripe Billing | billing | pyrosync | Planned — catalogued in the registry; isAddonEnabled returns false until it ships. | — |
Adding an add-on
- Author the module under
lib/addons/<id>/reusing the shared guards (never re-implement fetch/crypto/error shapes). - Write
lib/addons/<id>/manifest.tsexporting anAddonManifest(fill in provenance +entitlement). - Import + register it in
index.tsand re-export its public API there. - If it's paid, add its
entitlementto the right tiers inADDON_GRANTS. - Record it in
HARVEST.md(origin, source, what was adapted/dropped).