Extensions as data: freeze the document at install, never fetch it again
Split an extension system into a handful of built-in adapters plus declarative documents that are reviewed once, frozen verbatim into the database, and never re-fetched — closing the TOCTOU gap that makes runtime plugins dangerous.
My gateway’s provider integrations started life as npm packages — @glueco/plugin-llm-groq, @glueco/plugin-llm-openai, five of them, plus a template. Adding a provider meant authoring a package, publishing it, editing a proxy.plugins.ts enable list, running a codegen script, and redeploying. For a product whose whole premise is “everyone self-hosts their own instance,” this was fatal: a self-hoster can’t extend their own gateway without becoming an npm publisher. Worse, an audit of the five packages showed they were roughly 85% copy-paste of each other — the differences were configuration (base URL, auth header shape, model list, pricing), not code.
The obvious fix — fetch and execute plugin code at runtime — was rejected outright. Serverless can’t safely install or run fetched code, and a gateway that holds API keys should never execute anything a third party authored. The migration spec (Cookey’s update.md) records it as a non-negotiable: “Executable plugins fetched at runtime — rejected. Connectors are pure data, always.”
Code for the maintainer, data for everyone else
The replacement splits the old plugin concept along the code/data line:
- Adapters are real TypeScript, built into the gateway, and there are only five (OpenAI-compatible, Anthropic, Gemini, mail, generic HTTP passthrough). They know wire protocols. Adding one is a code change by the maintainer, reviewed like any code.
- Connectors are declarative JSON documents — “here’s how to talk to Groq”: which adapter, which base URL, which models, which pricing. They can come from a marketplace repo, any URL, or an in-app builder, installed at runtime through the admin UI with no deploy.
The boundary is enforced by the schema itself: a connector document has no field that can hold code. The set of things a malicious connector can do is exactly the set of things the document schema can express — point at a host (which the review screen shows you), name models, claim prices. It cannot read other secrets, cannot execute, cannot exfiltrate beyond the hosts it declares.
The second half of the boundary is temporal, and it’s the part most plugin systems get wrong: the document is frozen at install and the gateway never fetches it again. From docs/CONNECTOR_SPEC.md: connectors are “validated at install, frozen into the database, and never re-fetched at request time.”
Frozen at install
The install flow is a two-step preview/confirm, designed so the bytes the admin reviewed are the bytes that get installed. The admin API (apps/proxy/src/app/api/admin/connectors/route.ts) accepts:
{ url, preview: true } → SSRF-guarded fetch + validate → review screen
{ url, document } → confirm-install: freeze the EXACT previewed
document (no re-fetch, no TOCTOU)
The confirm step echoes the previewed document back rather than fetching the URL a second time. This closes the time-of-check/time-of-use gap: a connector author who serves a benign document to the preview and a hostile one afterward gains nothing, because the second fetch never happens. The freeze itself is a plain JSONB write (apps/proxy/src/server/connectors/registry.ts):
/**
* Install (or explicitly replace) a connector from a validated document.
* The exact JSON is frozen into the row; the gateway NEVER re-fetches a
* connector at request time.
*/
export async function installConnector(
raw: unknown,
source: ConnectorSource,
options: { sourceUrl?: string; replaceExisting?: boolean } = {},
): Promise<Connector> {
const result = validateConnectorFull(raw);
if (!result.valid) {
throw new ConnectorInstallError(
"Connector document failed validation",
result.errors,
);
}
const document = result.document;
// …conflict check, then:
const row = await prisma.connector.upsert({
where: { connectorId: document.id },
create: {
connectorId: document.id,
resourceType: document.resourceType,
version: document.version,
source,
sourceUrl: options.sourceUrl,
document: document as unknown as Prisma.InputJsonValue,
},
update: { /* explicit replace path */ },
});
invalidateConnectorCache();
return row;
}
Updates get the same treatment as installs, inverted: a daily cron only records that a newer version exists upstream. Applying it is a manual review with a structured diff of the frozen document versus the candidate — and any newly added egress hosts are highlighted red, because “which hosts can reach my credential” is the one field where a connector update can change your risk. Nothing updates itself.
For a reader adapting this pattern: the load-bearing pieces are (1) a schema with no executable fields, validated at the trust boundary; (2) storing the validated document verbatim in your database as the sole runtime source of truth; (3) a confirm step that submits the reviewed content rather than the URL; and (4) request-time code that reads only from the frozen row. The registry module with its install/resolve functions sits server-side next to your other data-access code; the request pipeline calls resolveConnector(id) and never touches the network for configuration.
What breaks, and what refuses to
Bad input dies at the validation gate with structured, path-level errors — an admin pasting a malformed document gets told which field, not a 500. The fetch itself is SSRF-guarded (5-second timeout, 64 KB cap, private IP ranges rejected on every redirect hop), so “install from URL” can’t be repurposed to probe the gateway’s network. If the marketplace or source URL vanishes after install, nothing happens at all: installed connectors are self-contained rows, and the gateway keeps serving traffic with the configuration the owner approved. The failure mode of the entire extension ecosystem being offline is you can’t install new things — never existing things break.
The honest cost: frozen documents go stale. A provider changes its base URL or pricing, and your connector keeps the old value until the owner reviews an update. That’s the trade — staleness you can see and act on, in exchange for a supply chain that cannot move underneath you.
Extending stops being a deploy
Extending the system stops being a deploy. A self-hoster adds a provider from the marketplace in a browser session, and the diff between “what I reviewed” and “what runs” is zero bytes, permanently. Security review effort concentrates where it can actually pay off: five adapters’ worth of code under normal code review, one JSON schema as the entire third-party attack surface, and one review screen whose job is to show egress hosts before anything is trusted. When someone proposes the inevitable “what if connectors could run a little JavaScript for custom transforms” — and someone will — the rejected-alternatives section of the spec is the artifact that keeps the boundary from eroding one convenience at a time.