@vendure/cloud-config
v0.3.2
Published
The Cloud Config interface a Vendure Cloud application is written against.
Readme
@vendure/cloud-config
The interface package a Vendure Cloud application depends on. It holds the Cloud Config type and the checks that keep the ownership boundary honest, per ADR-0017, ADR-0043, ADR-0044 and ADR-0047.
A customer writes a configuration file and no entrypoint, importing from @vendure/cloud
rather than from here:
import { defineCloudConfig, defineVendureExtension } from "@vendure/cloud";
import { DefaultSearchPlugin } from "@vendure/core";
import { ErpPlugin } from "./plugins/erp/erp.plugin.js";
export default defineCloudConfig({
plugins: [
DefaultSearchPlugin.init({ bufferUpdates: false }),
defineVendureExtension({
id: "acme.erp",
plugins: [ErpPlugin],
migrations: ["./migrations/*.js"],
outboundHosts: ["erp.acme.com"],
}),
],
});Vendure Extensions
A Vendure Extension is a named group of the customer's own plugins carrying the three pieces of metadata the platform reads (ADR-0047). It is written inline as above, or exported by a package and referenced by name; the wrapper does not distinguish the two, and a third-party extension's author declares its metadata so a consumer does not restate it. That ADR's table records why every other field the original proposal carried was removed, and the input is a function argument so an object literal reintroducing one fails the build.
defineCloudConfig flattens the extensions out of the plugins array before anything else
sees it, keeping declaration order, and records which extension declared each plugin.
PlatformFieldSetByPluginError reads that map, so a plugin inside an extension that writes a
platform-owned field is named together with the extension it came from. Everything
downstream — hoistCloudPluginsLast, recordPlatformFieldWrites, the two assertions —
operates on the flattened array and needed no change.
A migrations glob resolves against the file that declared the extension, not against
the configuration file that references it. defineVendureExtension reads its own call site
off the stack for this, which is the one piece of machinery here that is not obvious; the
reasoning, including why import.meta.url is not asked for and why the stack has to be read
through our own Error.prepareStackTrace, is at the function in src/vendure-extension.ts.
test/managed-build.test.ts proves it against compiled output laid out and run the way the
managed build produces it, because nothing about the trap survives being tested against this
package's own TypeScript sources.
Migrations are resolved into an ordered class list
A glob never reaches TypeORM. The config handle carries loadMigrations(), which expands each
extension's globs, imports what they match, and returns the migration classes with the
extension that declared each one:
const { loadMigrations } = readCloudConfig(handle);
const loaded = await loadMigrations();
// loaded[n] is { migration, name, timestamp, file, extension }
// dbConnectionOptions.migrations = loaded.map((entry) => entry.migration)It is a function on the handle rather than a value on it, and it is bound to that handle's own extensions rather than callable with any array. ADR-0048's "Ordering is a property of the array we build" argues both, and is the place to change if either stops being true.
The order is ascending timestamp, then extension declaration order, then class name.
TypeORM sorts the set by timestamp and Array.prototype.sort is stable, so equal timestamps
run in whatever order the array holds them; today that order comes from glob.sync, which
does not sort, so a tie resolves by filesystem walk order and can differ between the
customer's machine and the managed build. The last two keys make the tie a value the platform
computed. Ordering across extensions is not a contract
(ADR-0048):
an extension cannot declare that it must migrate after another, and nothing here adds a way
to say so.
The ledger identity is TypeORM's own — the migration class name, read off an instance so that
a migration declaring name as an instance field is called here what it is called in the
ledger. A glob that contributes no migrations raises MigrationGlobMatchedNoMigrationsError
naming the extension and the glob as it was written. It distinguishes a glob that matched
nothing at all, which is far more often a wrong path than an extension with no migrations,
from one whose every match was filtered out as not being a migration file.
Secrets
secret('NAME') reads a customer-supplied value, registers the name, and returns a plain
string. Values arrive as ordinary environment variables (ADR-0030), so this is a read helper
over process.env rather than a second delivery mechanism.
export default defineCloudConfig({
plugins: [
PaymentPlugin.init({ apiKey: secret("STRIPE_SECRET_KEY") }),
],
});It exists because process.env gives the platform no hook. Three things follow from the call
that a direct read cannot provide:
- Every missing name is reported at once. A name that is unset — or set to an empty or
whitespace-only value, which counts as unset because an empty credential fails inside a
payment call rather than at boot — is recorded rather than thrown on.
defineCloudConfigthen raisesMissingSecretsErrornaming all of them, so a configuration with four unset secrets takes one deploy to fix rather than four. - The value is trimmed, so a credential that picked up a trailing newline from a
file-based secret store or a
.envloader does not reach the caller intact and fail in the same place an empty one would. Only the ends: a multi-line value such as a PEM key keeps its interior exactly, per ADR-0030's note that customers put private keys in these variables. - The declared set is exactly what the code asked for. Registration is a side effect of
the call, so it cannot drift from the code the way a hand-written
requirements.secretslist would.listDeclaredSecrets()is the entrypoint's accessor after composition. - The value can be redacted.
redactSecrets(text)replaces every resolved value wherever it appears. A value read straight out ofprocess.envis an ordinary string that nothing downstream can recognise.
The argument has to be a string literal, enforced by the type rather than by a lint rule.
Passing a variable makes the parameter type never and fails the build. A literal is what
makes the declared set readable by static analysis, and a type constraint needs no tooling in
the customer's project.
Reading a secret at request time is not supported
secret() resolves once, while the configuration object is being built. A call made later —
inside a request handler, a job processor, or a lazily constructed client — happens after
defineCloudConfig has already checked the registry, so a missing value is not reported at
boot and is not part of the declared set.
The answer is to read the secret during composition and hold it, which is the natural shape when secrets are plugin options:
// Yes: resolved once, declared, redactable.
PaymentPlugin.init({ apiKey: secret("STRIPE_SECRET_KEY") })
// No: resolved per request, invisible to the missing-secret report.
class PaymentService {
charge() {
const key = secret("STRIPE_SECRET_KEY");
}
}A plugin that genuinely needs the value later should take it as an option and keep it, rather than reaching for it at the point of use.
Declared outbound hosts
A Vendure Extension declares the hosts it talks to, and installOutboundHostCheck reports a
call to a host it did not declare, naming the extension
(ADR-0028's amendment).
Vendure Cloud: Extension acme.erp attempted outbound access to api.other-erp.example.
Declared outbound hosts: erp.acme.comThe check runs inside the customer's own process rather than in the network path, and attribution is the reason. A firewall sees a source address and a TLS server name and cannot say which extension made the call, so it cannot produce that message. Reading the stack can.
It is reported and never blocked (CLO-412 records the reasoning). This is a hygiene control over trusted code, not a security boundary — code written to bypass it will succeed, and a compromised dependency can exfiltrate through a declared host anyway. So blocking stops no attacker, and everything it would stop is an honest extension whose declaration is incomplete. Escalating to a block is left available and would need its own stated, announced decision, the same condition ADR-0045 put on its own advisory.
An extension that declares no hosts is not checked. The declaration is the opt-in. Reading
"declared nothing" as "may reach nothing" would report against every extension in existence
today, and a report that fires on everything is not read. Note that defineVendureExtension
normalises an absent outboundHosts and an explicit [] to the same empty array, so
"deliberately talks to nothing" cannot currently be expressed distinctly from "did not say".
A call the stack attributes to no extension is not checked either. Vendure core's own traffic, the database driver's, and anything reached from platform code land there, and reporting them would drown the signal.
That is also the one real limit of reading the stack, and it is about where the code is rather than about async boundaries. Attribution needs a frame belonging to the extension somewhere on the stack when the socket opens. An extension that supplies a host as data and lets other code dial it has no such frame, and that call is dropped. Crossing a timer, an await, or a helper the extension did not write does not cause this — the extension's own frame is still further down the same stack, and the suite covers all three.
What it costs, and where it is imprecise
An allowed call costs one Set lookup. The host is compared against the union of every
declared host first, and only a host that appears in none of them is attributed, because
capturing a stack is the expensive part. The cost of that ordering is that an extension
reaching a host another extension declared is not reported.
Interception is globalThis.fetch plus net.Socket.prototype.connect. The socket is where
http.request, https.request, net.connect and every client library end up, and — unlike
replacing net.connect on the module object — it behaves the same whether the caller is ESM
or CommonJS. An ES module's named import is a snapshot taken at link time, so rewriting the
module object reaches CommonJS callers only, which would be the same control behaving
differently in the two module systems.
Each extension and host pair is reported once per process. An extension polling an undeclared
host every second has one thing wrong with it, and the same interception seeing it at both
fetch and the socket underneath is not two mistakes.
The boundary, and where each half is enforced
A Cloud Plugin is a plugin Vendure Cloud supplies in place of one the customer would
otherwise declare — CloudAssetServerPlugin for AssetServerPlugin, and the three ADR-0044
lists as injected. The term is deliberately not "platform plugin", which reads as a plugin
belonging to Vendure Platform.
PLATFORM_OWNED_FIELDS is the single source for all four checks below, so they cannot
disagree about which VendureConfig fields Vendure Cloud owns.
| Check | Runs at | Catches |
| --- | --- | --- |
| The branded Cloud Config type | build | a config literal setting a platform-owned field |
| listPlatformOwnedFieldsSetIn | boot | a config assembled programmatically, past the type |
| assertPlatformFieldsHold | boot, after composition | a plugin that rewrote a platform-owned value after the Cloud Plugin ran |
| assertNoCustomerPluginWroteAPlatformField | boot, after composition | ADR-0044's converse check — a customer plugin whose write a Cloud Plugin overwrote |
Platform-owned keys stay present in the type carrying a brand that explains itself,
rather than being removed with Omit. ADR-0043 requires the build-time error to carry a
message we wrote, and an omitted key yields TypeScript's own wording instead. The brand's
text is derived from PLATFORM_OWNED_FIELDS, so the compile-time and runtime messages are
the same string.
Three things worth knowing before changing this package
A customer installs @vendure/cloud and imports from it, not from here.
ADR-0045
makes that package a facade whose exact-pinned dependencies are this package and the
separately published plugin packages. What it re-exports today and what is still to join it
is in packages/cloud/README.md.
This package is a peer dependency of every plugin package, and a direct dependency of
nothing but the facade. That is what stops a customer's tree holding two copies of it. Two
copies mean instanceof CloudConfigError stops holding across the seam, and a boot failure a
caller catches becomes an uncaught one — ADR-0045 argues the case, and the peer dependency is
what turns it from a silent boot failure into an install-time resolution error naming both
versions. Importing this package directly still works and is supported; it is what a plugin
package does.
Nothing published is bundled or minified, which is a decision rather than a property of
how the build happens to be written. A bundler is the route back to a second copy of
@vendure/core, and a minifier rewrites the class names that CloudConfigError and
pluginName read at runtime to build the messages ADR-0044 requires. tsc can do neither,
and packages/cloud/test/published-output.test.ts builds every publishable package and
asserts it: one emitted file per source file, every dependency still a bare import, every
exported class still carrying its own name, and the entry point's import loading no more of the
module graph than a stated budget.
Read ADR-0045's packaging decision only. Its delivery decision — that the customer pins these
packages in their own lockfile and the platform reports a vulnerable version rather than
replacing it — is superseded by
ADR-0046.
Customers declare these as development dependencies and the Cloud Runtime supplies the
production copies, resolved from the @vendure/core version they declared.
It has to stay loadable from CommonJS, and that constrains the code.
ADR-0049:
this package emits ESM and lists require alongside import in its exports map, pointing at
the same file, so a customer whose application emits CommonJS can load it through Node's
require(esm). Vendure's own application template emits CommonJS, so that is most customers
arriving from a self-hosted store.
The standing cost is that no top-level await may appear anywhere in this package's module
graph, ever. require(esm) refuses a graph containing one, at runtime in the customer's
container, and nothing here would notice: every suite in this repository loads the package as
ESM, where top-level await is legal. test/managed-build.test.ts builds and runs its fixture
in both module systems, which is what catches it.
No runtime dependency on @vendure/core. @vendure/core is a peer dependency and is
imported for types only. The plugin metadata key core reads is restated as a constant and
pinned to core's real value by a test, so a rename there fails our build rather than
silently disabling attribution.
Runtime implementation
CLO-355 supplies the platform-owned server, worker and migrate entrypoints, managed Postgres
connection with rotating IAM credentials, structured logger, default cache/job queue/scheduler,
shallow health check, one-copy core guard and local substitutes. The managed build typechecks the
Cloud Config and installs the private implementation beside the single supplied copy of
@vendure/core.
The four wrapper plugins remain separate delivery work: CLO-399, CLO-400, CLO-401 and CLO-402. Extension migration globs are resolved into the managed database configuration at boot. The ownership snapshot on the Deployment row and the migration transaction, timeout and orphaned-schema controls remain CLO-423 and CLO-424.
apps/demo-store is the reference Cloud Config: it contains only vendure-config.ts, with no
customer-owned run-mode entrypoints.
