import { Hono } from 'hono'; import { z } from '@substrat-run/contracts'; import type { Connection, ConnectionActivity, ConnectionActivitySource, ConnectionCredential, ConnectionProbe, PlatformActorId } from '@substrat-run/contracts'; import type { ScopeHost } from '@substrat-run/kernel'; import type { PlatformActorAuth, BuilderAuth, Principal } from './auth.js'; import { type VerticalClient } from './vertical-client.js'; import type { DeployVerticalFn, FetchVerticalAssetFn, FetchVerticalModulesFn } from './deploy.js'; import type { PatchScriptBindingsFn } from './wfp.js'; import { type ObservabilityReader } from './observability.js'; import type { PlatformRuntime } from './platform-runtime.js'; import { type DoNamespaceReader } from './do-namespaces.js'; import type { DirectoryBackupStore, ScopeBackupStore } from './backups.js'; import { type CustomHostnameProvisioner } from './custom-hostnames.js'; /** * What one provider can answer about a live connection (#605) — the seam behind * `POST /tenants/:t/connections/:id/verify` and `GET …/activity`. * * Both halves take the connection ROW, never a credential: opening the secret is the * connector's own act through `HostAdmin`, so the plaintext still never crosses this * package. `probe` says whether the provider accepts the credential and whose account * it is; `activity` projects the connector's dispatch ledger into the declared shape — * a projection precisely because a raw ledger row may carry connector secrets (Scrive's * callback capability token), and redaction has to be structural. */ export interface ConnectionInspector { probe?: (host: ScopeHost, connection: Connection) => Promise; activity?: (host: ScopeHost, connection: Connection, opts: { live: boolean; source: ConnectionActivitySource; }) => Promise; /** * The stored credential, REDUCED — identifiers whole, secrets masked by the connector's * own rule. The one read in this package's vicinity that touches plaintext, and it is * the connector that touches it: only the connector knows which of its fields are * identifiers. What comes back is never usable as a credential. */ credential?: (host: ScopeHost, connection: Connection) => Promise; /** * Check a credential that is not stored yet — the connect-time gate (#605). * * Distinct from `probe` because there is nothing to open: the candidate secret is passed * straight in, no connection row is touched, and no health is written (a candidate's * failure is not a fact about the live connection). Registered ⇒ every upsert of this * provider is checked before it writes, and a REFUSED credential never lands. */ probeCandidate?: (secret: Record) => Promise; } export interface ControlPlaneApiOptions { /** * The platform's margin over list price for model usage it provides (#1054), whole * percent, applied at read time by `GET /model-usage/summary`. One global number * today; a per-provider rate is an additive change later. Default 20. */ modelMarginPercent?: number; host: ScopeHost; /** * How to reach each vertical, by slug (K-31). Absent slugs simply cannot be * provisioned — the route 501s rather than pretending, because a control plane that * silently does nothing is worse than one that says it cannot. * * A static map is the milestone-one shape, the same one the router carries and with * the same Workers-for-Platforms swap later. */ verticals?: Record; /** * The standing grants each connector declares, by provider (#726 gap 2) — e.g. * `{ scrive: SCRIVE_CONNECTION_GRANTS }`. * * Every reconcile heals a connection toward this floor before gathering, which is what * makes a missing capability repairable by a PUSH rather than by re-typing a working * credential. Absent ⇒ nothing is healed and the gather behaves exactly as before, so a * host that configures no connectors is unaffected. */ connectorGrants?: Readonly>; /** * Resolves a vertical dynamically — the dispatch swap for provisioning (orchestration.md * §5.4), the mirror of the router's `verticalFor`. Given a slug, the host looks up the * vertical's `prod` channel version and returns a `VerticalClient` over * `env.DISPATCH.get(deploymentRef)`. Tried after the static `verticals` map, so a * pushed vertical is provisionable with no redeploy. Absent ⇒ only static bindings. */ resolveVertical?: (slug: string, actor: PlatformActorId) => Promise; /** * Resolves a vertical at a SPECIFIC version — a scope's data DO lives in the * deployment of the version it was provisioned/bound to (`scope.verticalVersionId`), * NOT necessarily the `prod` channel. Because each `substrat push` is a separate WfP * script with its own DO namespace, introspection must reach the BOUND version's * deployment (the same one the router serves the app from), or it reads an empty DO * once an installed app lags prod. The `/tables` route prefers this over * `resolveVertical`; the latter (prod) stays the fallback for a scope with no bound * version. Absent ⇒ the route uses prod-channel/static resolution only. */ resolveVerticalVersion?: (slug: string, versionId: string, actor: PlatformActorId) => Promise; /** * Resolves a vertical by a KNOWN dispatch script name (#286) — the direct form the * other two resolvers reduce to. Introspection and restore use it for a scope whose * `servingRef` is set: that scope's data lives in the stable serving script, and * neither the bound version's script (data left behind) nor the prod channel (may * have moved on) is the right door. Absent ⇒ serving scopes fall back to the other * resolvers, which is only correct before any scope has adopted the serving script. */ resolveVerticalRef?: (deploymentRef: string) => Promise; /** * Uploads a built vertical bundle to the platform runtime (a WfP dispatch * namespace), injected by the host so this package holds no Cloudflare SDK and the * builder never holds a Cloudflare credential (D-34). Absent ⇒ the deploy route * 501s. See `deploy.ts`. */ deployVertical?: DeployVerticalFn; /** * Reads a script's module contents back from the platform runtime (#286) — the * archive script is the bundle store the serving upload reads from. Host-injected * like `deployVertical`. Absent ⇒ promotion moves channels without serving in * place (the pre-#286 behavior: scopes stay on per-version dispatch). */ fetchVerticalModules?: FetchVerticalModulesFn; /** * Reads one static file's bytes back from a script in the namespace (#578) — the * asset twin of `fetchVerticalModules`. The runtime's asset store dedupes per * SCRIPT, not namespace-wide, so the first serve of an asset-carrying version onto * the stable serving script always finds its hashes missing there; this seam is how * the serve recovers the bytes the push uploaded to the version's archive script. * Host-injected like `deployVertical` (on Cloudflare, a dispatch fetch — the archive * script's edge serves its own assets without invoking the worker). Absent ⇒ a * re-serve can only ride what the stable script already holds and refuses honestly * otherwise. */ fetchVerticalAsset?: FetchVerticalAssetFn; /** * Ensures per-tenant store D1 bindings exist on a dispatch script without a redeploy * (#301) — the attach step that makes a freshly-minted tenant store reachable in the * vertical's worker at request time (`createWfpBindingsPatcher`). Host-injected like * `deployVertical`. Absent ⇒ stores still mint and handles still ride the provision * callback (the pure adapter needs no binding), but no script is patched. */ patchScriptBindings?: PatchScriptBindingsFn; /** * Backoff for retrying a TRANSIENT vertical failure during install (#424 case 2). * The install chain patches script bindings and then immediately calls the vertical, * which can race Cloudflare script-settings propagation — the vertical answers 503 * "no tenant store attached" moments before it would have succeeded. Every step is * idempotent by design, so the endpoint rides that window out instead of surfacing a * one-shot failure. Honest refusals (any 4xx, and 501) are never retried. Tests pass * short/empty. */ provisionRetryDelaysMs?: readonly number[]; /** * Resolves the platform actor from the request. No default: an unauthenticated * control plane is not a sensible fallback, and a package that shipped one * would eventually be deployed with it (control-plane.md §6). */ authenticate: PlatformActorAuth; /** * Resolves a BUILDER principal — a tenant user acting on their own verticals * (builder-plane.md §4). Tried only after `authenticate` declines, so staff and * service auth are unchanged and remain a superset. Absent ⇒ no builder path: * the surface is staff/service-only exactly as before. A builder is confined to * the vertical-management routes and to the verticals their tenant owns. */ authenticateBuilder?: BuilderAuth; /** * Signs tenant-scoped push tokens (push-token.ts) — the CI credential the dashboard * mints into a customer repo. Absent ⇒ the mint route 501s. A dedicated secret, * never PLATFORM_SECRET (injected into pushed verticals) and never the service * token; set once, out of routine rotation (rotating it revokes every issued token). */ pushTokenSecret?: string; /** * Keys the pseudonymizer behind a masked export (#1034, `pseudonymize.ts`). The salt * never reaches the dump and no mapping is stored, so it is not a decryption key — * what it buys is STABILITY: with one configured, two pulls of the same scope read * the same, which is what makes a masked copy usable as a standing preview. * * Absent ⇒ a fresh random salt per export. Still deterministic within one response * (a customer reads the same on every screen and in the timeline) and strictly * safer, since two exports cannot be correlated — just not stable across pulls. */ maskSalt?: string; /** * Cloudflare-native observability reads (design/observability.md §4.1) — * host-injected like `deployVertical`, so this package holds no credential and the * Cloudflare token never leaves the platform (D-34). Absent ⇒ the observability * routes 501. Staff-only for now: the routes are deliberately NOT in * `BUILDER_ROUTES` — the builder view needs owner-narrowing (only scripts whose * registry `ownerTenant` is the caller's) before it can be opened, and default-deny * means forgetting that costs a feature, never a leak. */ observability?: ObservabilityReader; /** * Per-provider connection **inspectors** (#605), keyed by provider slug — what makes * an integration something an operator can interrogate rather than trust. * * Host-injected for the same reason `deployVertical` and `observability` are: this * package holds no connector and must learn no provider's vocabulary. The host wires * the same connector closure it already registers for dispatch and sweep (the * `sweepers` idiom, `apps/control-plane/src/worker.ts`), and the routes below stay * pure transport over a declared, provider-agnostic shape. * * An unregistered provider 501s rather than answering emptily — "this platform cannot * verify a Fortnox key yet" is a true statement; "your Fortnox key is fine" is not. */ connectionInspectors?: Record; /** * Where this platform's compute actually runs — the coordinates a staff surface needs * to hand an operator a link INTO the provider's own console (the right script, the * right database, the right bucket), rather than a bare id they have to hunt for. * * Host-injected like `observability`, and deliberately NOT a credential: it is the * account/namespace the deployment already advertises in every dispatch URL. Absent ⇒ * the route answers `null` and the console renders identifiers with no links, which is * exactly the self-host / pure-adapter shape (no provider console to point at). */ platformRuntime?: PlatformRuntime; /** * Resolves a script's Durable Object namespaces to the ids the provider's dashboard * addresses them by (`do-namespaces.ts`) — what turns "your DO is named ``, * somewhere in this list" into a link to the right namespace. Host-injected, credential * on the host side. Absent ⇒ the route 501s and the console keeps its list-level link. */ doNamespaces?: DoNamespaceReader; /** * Where a reap's recoverable copy is stored (#493) — host-injected like * `observability`, so this package holds no bucket binding. When present, reaping a * scope writes a full-fidelity dump here FIRST and records its ref on the admin-log * entry; a store that throws aborts the reap, because a wipe with no copy is exactly * what the seam exists to prevent. * * Absent ⇒ a reap that did not explicitly ask for a backup proceeds without one (the * self-host / embedded / test shape, where there is no platform bucket), and one that * DID ask is refused 501 rather than silently reaping. That asymmetry is deliberate: * the console always asks, so a control plane deployed with the binding missing fails * loudly instead of quietly dropping the guarantee — the lesson `PLATFORM_BASE_DOMAINS` * taught when it silently went unset. */ scopeBackups?: ScopeBackupStore; /** * Where the platform's OWN copies live (#40) — the directory, not a tenant's scope. * Host-injected on the same posture as `scopeBackups`, and pointable at the same * bucket (the key prefixes keep the two apart) or at a different one. * * Absent ⇒ the directory backup routes answer 501 and the cron's backup phase is * skipped. Loud, never silent: a control plane running with no directory copy is a * platform one bug away from unrecoverable, and that must be visible rather than * inferred from an absence of backups nobody looked for. */ directoryBackups?: DirectoryBackupStore; /** * Issues + polls Cloudflare-for-SaaS custom hostnames (#305, §4.7) — host-injected * like `deployVertical`, so this package holds no Cloudflare credential (D-34). When * present, binding a CUSTOM domain kicks off issuance (create → `verifying` + DNS * records) instead of leaving a bare `pending` row that only a manual status flip * could clear. Absent ⇒ a custom bind records `pending` and issuance never runs (the * self-host / dev shape, where there is no CF-for-SaaS zone). */ provisionHostname?: CustomHostnameProvisioner; /** * The platform's base domains — the wildcard-covered zones a PLATFORM hostname is * minted under (`substrat.run`, `global.substrat.run`, …). A bind AT or UNDER one of * these rides the wildcard cert and goes straight to `active`; anything else is a * custom domain and walks issuance. Empty/absent ⇒ every bind is treated as custom * (correct for a deployment that mints no platform hostnames). */ platformBaseDomains?: string[]; /** * The CLI version advisory (#971) — what `substrat` reads off every response to nudge * a builder whose CLI has fallen behind (`packages/cli/src/version.ts`). The control * plane is the authority on whether a CLI is still *compatible*, which npm's `latest` * tag is not, so the two values are deployment facts, not package metadata: * `minVersion` the oldest CLI this platform still accepts pushes from (the floor) * `latestVersion` the newest published CLI * Each is emitted only when supplied, as {@link CLI_MIN_VERSION_HEADER} / * {@link CLI_LATEST_VERSION_HEADER}. Absent ⇒ no header at all, and a CLI that reads * nothing says nothing — a server that stays silent costs nobody anything. Advisory * only: this package does not refuse a push below the floor. */ cliAdvisory?: { minVersion?: string; latestVersion?: string; }; } /** * Response headers carrying the CLI version advisory (`ControlPlaneApiOptions.cliAdvisory`). * The CLI reads these by the same spelling (`packages/cli/src/version.ts`); exported so a * host or a test names the header rather than restating the string. */ export declare const CLI_MIN_VERSION_HEADER = "x-substrat-cli-min-version"; export declare const CLI_LATEST_VERSION_HEADER = "x-substrat-cli-latest-version"; type Vars = { actor: PlatformActorId; principal: Principal; }; /** * The K-35 denial-log filter (#867). Bounded by default like every other HTTP read * here: the log's volume is attacker-influenceable by design (a probing client mints * rows), so an unbounded `GET` is exactly the wrong default. * * The DECODER half of contracts' `denialFilterParams` (#971), and deliberately not the * same schema: a query param arrives as a string, so `limit` is coerced here and given * this route's own default, which the filter itself must not carry. What the two DO owe * each other is the field set — Zod strips an unknown key, so a field added to the * filter and sent by every client would be dropped here in silence. Exported (not from * `index.ts` — this is not public surface) so `api.test.ts` can pin that. */ export declare const denialLogQuery: z.ZodObject<{ actor: z.ZodOptional; permission: z.ZodOptional; operation: z.ZodOptional; since: z.ZodOptional; until: z.ZodOptional; limit: z.ZodDefault>; groupBy: z.ZodOptional>; }, z.core.$strip>; export declare const DEFAULT_MODEL_MARGIN_PERCENT = 20; /** * The audited HTTP surface over `HostAdmin` (control-plane.md §4.5). * * This is the OUT-OF-BAND control plane §3 argues for: it is not module code, it * never receives a `ctx`, and it never runs in a scope's serialization domain — * so `boundary-lint` is untouched (§7). It is one router over the kernel seam, * mounted by whichever transport is hosting it (a Node server locally, a Worker * holding the `controlPlane` binding on Cloudflare). * * Two rules hold everywhere below, and they are the reason this can exist at all: * * 1. **The actor comes from the authenticated request, never the body.** §4.4: * every field of an audit row except before/after is stamped platform-side, * "never supplied by the caller". A route that read an actor from JSON would * make the entire trail forgeable, which is the one thing that must not be * retrofitted (K-20). Note there is no route here that accepts an `actor` * field at all — it is unrepresentable, not merely ignored. * 2. **Reads are exposed; enforcement writes are not.** defineRole / assignRole / * grant / grantToOrg / addMember are on `HostAdmin` but get no route: the * console's v1 job is the tenant registry, lifecycle, entitlements and * history. The ONE exception is the identity-mirror pair under * `/tenants/:tenantId/identities` (service/staff only): builder auth resolves * a CLI session against THIS deployment's directory, but identity links are * born in the Dashboard's own deployment — a different DO — so the dashboard * mirrors them here. `resolveIdentity` especially stays off — it is the auth * adapter's read path, not an admin surface. */ export declare function createControlPlaneApi(options: ControlPlaneApiOptions): Hono<{ Variables: Vars; }>; export {}; //# sourceMappingURL=api.d.ts.map