Skip to main content
This page follows one request from the baseline start command to a provider route handler, and names the file that owns each step. Each built-in service runs in its own provider process, so the path splits into the host side (the CLI) and the provider side (core’s runProvider). For the user-facing view of the same system, see Architecture in the Reference section.

Request flow

Host and provider processes

The host and each provider speak the protocol in core/src/protocol.ts over Node IPC:
  • The provider says hello (protocol version, name, version, stateVersion, fault kinds). The host checks the version and answers welcome.
  • start carries the service’s seed section, tokens, identities, faults, saved state or generated secrets, and the port. The provider merges identities with its mapIdentity, runs prepareSeed, builds the app with createServer, restores or seeds, and answers ready with its URL and secrets. listen binds the port.
  • snapshot, restore, reset, faults, and stop do what they say. The provider sends dirty after a write, so the host saves only providers that changed.
  • A request to /_baseline/control, /_baseline/faults, or /_baseline/state on a provider’s port is sent to the host as control and answered there, with the host’s token and host checks.
core/src/run-provider.ts is the provider side, and every provider’s src/provider.ts is one call to it. core/src/provider-client.ts is the host side of one process. packages/cli/src/supervisor/ starts providers in parallel, answers their control requests, restarts a crashed one from the last state it holds (three times a minute at most), and stops them. The CLI forks the resolved package’s dist/provider.js: from the project, the cache, or, in this repository, the workspace package (supervisor/provider-source.ts). The CLI’s tarball holds no provider code; scripts/check-bundle.mjs reads the build’s metafile to prove it, and scripts/slim-cli-acceptance.mjs installs the packed CLI and pulls the defaults from a local registry. A provider’s own tests boot its plugin in process with @deploybaseline/testing.

The CLI registry

packages/cli/src/registry.ts holds SLOTS, every built-in provider by name with an explicit port slot, and reads the rest of what the CLI knows about each provider from catalog.generated.json. The main CLI process never imports provider code: list, --help, init, config validation, and /docs work from that data alone, and packages/cli/src/__tests__/bundle.test.ts fails if the binary or the programmatic API comes to import a provider. Provider code runs only in a provider process.
  • With no config and no --service, each default service listens on the base port plus its slot (serviceSlot), so GitHub is on 4001 and Twilio on 4012.
  • A released slot never changes and is never reused. A new provider takes the next unused number (pnpm new:provider picks it), and packages/cli/src/__tests__/registry.test.ts pins the released slots.
  • DEFAULT_SERVICES is the set that starts with no config (the 0.14.0 providers). Providers added later start only when named.

Package data

pnpm provider-metadata (scripts/provider-metadata.mjs) reads every manifest from source and writes:
  • a baseline field in the provider’s package.json: protocol, entry (./dist/provider.js), name, seedKey, aliases, stateVersion, label, endpoints, identities, init, and apiReference
  • init.json, the starter config init writes
  • packages/cli/src/catalog.generated.json, the same data for every built-in provider
CI runs it with --check. The host reads a provider package through packages/cli/src/providers/package.ts, which checks the baseline field and refuses a package that speaks another protocol. A provider’s dist/provider.js inlines core and every runtime dependency (jose, graphql, ws), so it starts from an unpacked tarball with nothing installed next to it; provider-package.test.ts packs Stripe and GitHub and checks that.

Provider packages that are not built in

A services.<name>.provider: npm:<package>@<range> entry (packages/cli/src/providers/reference.ts) runs a package through the same resolver and supervisor, with no workspace fallback and no port slot. resolveProjectProviders in packages/cli/src/providers/project.ts refuses one whose exact version and integrity baseline.lock does not mark trusted: true, unless a confirm callback says yes; a yes pins it before it runs. start passes a terminal prompt (providers/trust-prompt.ts) when stdin and stdout are a terminal and CI is unset, and a watch worker sends the question to the CLI process over IPC. trust grants it without a prompt. examples/provider-template is the package a third party starts from, and scripts/third-party-acceptance.mjs packs it, serves it from a local registry (scripts/lib/local-registry.mjs), and runs it next to Stripe in CI.

Manifest versus load()

Each provider exports a ProviderManifest from src/manifest.ts (published as @deploybaseline/<name>/manifest). The interface lives in packages/@deploybaseline/core/src/manifest.ts. The package data above is generated from it, so the manifest stays the one place to edit. The manifest must stay cheap: importing it must not load the provider. That is what keeps --help and list fast, and scripts/check-bundle.mjs fails CI when --help loads a provider chunk or more code than the budget in packages/cli/bundle-budget.json.

ServicePlugin.register

load() resolves to a LoadedService whose plugin implements ServicePlugin (core/src/plugin.ts):
createServer in core/src/server.ts builds one app per service: it mounts font routes, the error handlers, CORS, the auth middleware, and rate limit headers, then calls plugin.register, then installs a JSON 404. The app is core’s HTTP layer (core/src/http.ts), which follows Hono’s API and also records route hits and response samples for the test gates when the matching environment variables are set. Providers usually split routes into src/routes/*.ts functions that take a RouteContext ({ app, store, webhooks, baseUrl, tokenMap }). Seeding runs plugin.seed() first and then seedFromConfig(store, baseUrl, config, webhooks) with the provider’s section of the config file.

Auth and fallback identities

The auth middleware (core/src/middleware/auth.ts) reads Authorization: Bearer <token> or token <token>:
  1. A known token from the token map sets authUser, authToken, and authScopes on the context.
  2. An unknown, non-empty token falls back to the provider’s defaultFallback() identity (for Resend, re_test_admin). This is why any token works out of the box.
  3. A JWT that starts with eyJ is checked with the service’s AppKeyResolver when it has one (GitHub Apps).
Providers that need stricter rules (Resend API key permissions, for example) add their own middleware in register.

Store and saved state

Store (core/src/store.ts) holds typed collections with indexes (store.collection<T>("resend.emails", ["uuid"])) plus a key value area (store.setData, store.getData) for maps and flags. All provider state lives in the store. Saved state is a snapshot of the store, the token map, and the webhook subscriptions (ServiceState in core/src/state.ts). Anything kept in a module variable or a closure inside register is lost on restart. When saved state was written with a different stateVersion, Baseline refuses it with a clear message instead of serving broken data. Persistence backends implement PersistenceAdapter (core/src/persistence.ts): a JSON file by default, or @deploybaseline/state-sqlite and @deploybaseline/state-libsql.

Webhooks

WebhookDispatcher (core/src/webhooks.ts) keeps subscriptions, delivers events, and logs every delivery. With a retry policy it retries network errors, 429, and 5xx in the background with exponential backoff. renderWebhookDeliveries in ui.ts draws the delivery log for an inspector tab. Sign outbound payloads with the helpers in core/src/webhook-signing.ts, never with hand-built HMAC headers. Each has a verify* partner for tests: svixSignature, githubSignature256 (also Meta’s X-Hub-Signature-256), hmacSha256Hex, hmacSha256Base64, ed25519Signature. A scheme only one provider uses lives in that provider: stripeSignature in stripe/src/signing.ts, slackSignature in slack/src/signing.ts. Each provider bundles its own copy of core, so core carries what more than one provider shares.

Composed services

A provider with several APIs behind one port and one seed key (Google: Auth, Gmail, Calendar, Drive, Sheets, Docs) is built with composeService from core/src/compose.ts:
  • Each ServiceModule registers its own routes and declares the collections it owns.
  • Collection names must be namespaced (gmail.messages). Two modules may not own the same collection or register the same route, and composeService throws at startup if they do.
  • Modules register in array order, so a module with catch-all routes goes last.
  • moduleRoutes() reports which module registered which route, which the coverage gate uses for per-module matrices.
When splitting an existing provider into modules, keep the existing collection names: saved state refers to them.

Shared UI

Every emulator page (inspectors, OAuth sign-in and consent, checkout, inboxes) is rendered with packages/@deploybaseline/core/src/ui.ts, which supplies the shared head (Geist fonts, favicon, CSS), the brand bar, and the footer. Use the existing classes (.inspector-table, .s-card, .org-row, .badge, .empty) instead of inline styles. If a new page type does not fit, add the styles and a render function to ui.ts so every emulator can reuse them.

GraphQL and WebSockets

  • GraphQL providers serve their endpoint with graphqlEndpoint and build the schema with buildSchema from @deploybaseline/graphql. Root fields (Query.issues) are recorded alongside routes, so coverage matrices list them too.
  • WebSocket endpoints are registered with app.upgrade and appear as WS /path routes. The @deploybaseline/ws kit provides the handshake, connection registry, heartbeat, and resume buffer.