# `createHost` — top-level host orchestration entry point _(no submodules)_ ## Purpose `createHost(config)` composes every internal driver (`LifecycleDriver`, `ReloadDriver`, `PluginLedger`, `CapabilityRegistry`, `RequestPipeline`, admin API) into a runnable host. Production deployments hand their HTTP engine's `fetch(request)` handler straight to `host.fetch(...)`; integration tests drive the same surface programmatically. The resulting `Host` instance carries an authoritative install order (topo-sorted by manifest deps), exposes start/stop hooks, and serves three kinds of traffic: admin endpoints under `adminPrefix` (default `/admin`), internal probes (`/internal/livez`, `/internal/readyz`), and per-request plugin traffic through the §6 11-step pipeline. ## Features - Zod-validated config with stable `HOST.CONFIG_INVALID` failure code. - Topological dependency-graph install via `resolveInstallOrder`. - Lifecycle + reload + plugin-ledger + capability-registry shared across the admin API and the per-request pipeline. - Per-mode response-validation strictness — `production` defaults to `enforce`, `development` to `warn`. - Admin API mountable at any prefix or disable-able via `adminPrefix: null`. - `/internal/livez` returns `200 ok`; `/internal/readyz` returns `{state, plugins}` with the registered install order. - Graceful shutdown with `graceMs` budget; sequential reverse-topo stop sequence. ## Usage: Basic ```ts import { createHost } from "@suaveplan/api-host/host"; import { Database } from "bun:sqlite"; const host = await createHost({ adapters: { router, auth, tenant, capabilities, rateLimit, idempotency, csrf, audit, span, db: new Database(":memory:"), }, plugins: [ { module: billingModule, manifest: billingManifest, manifestHash: "sha256:...", specifier: "file:///billing.js" }, ], }); await host.start(); Bun.serve({ port: 3000, fetch: (req) => host.fetch(req) }); ``` ## Usage: Advanced Disable the admin surface for non-admin pods + supply a request-id generator for log correlation: ```ts const host = await createHost({ adapters, plugins, adminPrefix: null, mode: "development", requestId: () => `req_${process.pid}_${counter++}`, }); ``` Install a plugin at runtime (post-start): ```ts await host.installPlugin({ module: notificationsModule, manifest: notificationsManifest, manifestHash: "sha256:...", specifier: "file:///notifications.js", }); expect(host.plugins()).toContain("@acme/notifications"); ``` ## API Reference ```ts function createHost(config: HostConfig): Promise; interface Host { start(): Promise; stop(graceMs?: number): Promise; fetch(request: Request): Promise; installPlugin(install: HostPluginInstall): Promise; uninstallPlugin(name: string): Promise; plugins(): readonly string[]; readonly instruments: HostInstruments; } interface HostConfig { adapters: HostAdapters; plugins: ReadonlyArray; adminPrefix?: string | null; mode?: "production" | "development"; instruments?: HostInstruments; logger?: BoundaryLogger & PipelineRequestLogger; hostId?: string; hostVersion?: string; /** * Fallback tenant — see "Default tenant" below. `undefined` / * `null` propagate `ctx.tenant === null` on resolver miss for * auth-optional routes; a `TenantContext` value preserves the * legacy `{id: "public", slug: "public"}` injection. */ defaultTenant?: TenantContext | null; } ``` ### Default tenant (foundation gap 5) Prior to `0.1.0` the request pipeline injected a hard-coded `{id: "public", slug: "public"}` `TenantContext` whenever the configured `TenantResolverChain` returned `null` on a public (auth-optional) route. That made the `"public"` literal load-bearing across every handler that introspected `ctx.tenantId`, blocking deployments that wanted to distinguish "no tenant" from "the public tenant". `HostConfig.defaultTenant` now controls the fallback explicitly: | Value | Behaviour on resolver miss + auth-optional route | | --------------- | --------------------------------------------------------- | | `undefined` | `ctx.tenant === null` (the new default) | | `null` | Same as `undefined` — documents the intent explicitly | | `TenantContext` | Inject verbatim — preserves legacy `"public"` semantics | Auth-required routes (`auth: `) still raise `HOST.TENANT_NOT_RESOLVED` (400) on a resolver miss regardless of `defaultTenant` — the fallback is a public-route convenience, not a silent bypass of tenant-required policy. ## Implementation Notes `createHost` is the only entry point that touches every internal driver; everything else in `@suaveplan/api-host` is composable in isolation. Tests SHOULD prefer the focused driver imports (`@suaveplan/api-host/lifecycle`, `@suaveplan/api-host/request-pipeline`, etc.) and reach for `createHost` only when the integration path matters. The admin sub-router is mounted via Hono's `app.fetch(...)` directly against a re-shaped `Request` whose pathname strips the configured `adminPrefix`. This avoids a Hono `app.route(...)` import-cycle with the admin-api package and keeps the prefix configurable without forcing the admin router to know its own mount point. `installPlugin` post-start short-circuits the topo-sort by appending to the existing install order — adding a plugin at runtime never re-orders previously-started plugins. A plugin with unresolved deps will fail lifecycle install just as it would at start; the host surfaces that via `LifecycleDriver`'s state-machine codes. The reload resolver bag is intentionally minimal — Stage 3 will wire `@suaveplan/api-loader`'s `ModuleResolver` here. Until then, calling `reload(...)` throws `HOST.INTERNAL_INVARIANT` to make the missing adapter loud rather than silent.