import { type AssetEntry, type AssetsNeed, type DeclaredOperationOutput, type EmittedModel, type EnvVarSpec, type PermissionRegistry, type PermissionsInput, type RuntimeNeeds, type VersionOrigin, type DeployManifest } from '@substrat-run/contracts'; /** * Derive the vertical's permission registry (D-39/D-41) from its declared TypeScript surface — * the single typed source. `package.json` `substrat.permissions` points at the module that * exports a `definePermissions(...)` result; we bundle it (all packages left EXTERNAL, so a * node-ful entry still resolves its own `node_modules` — including native addons — at import) * into a temp module *inside* the vertical dir, then import it to read the surface as data and * derive the registry with the same `buildPermissionRegistry` the permission checkpoint uses. * * The control plane never does this — it re-parses the wire manifest at the trust boundary * (self-serve-deploy.md §4). `push` runs on the builder's own machine, so importing the * builder's own entry is not a new trust boundary. A missing pointer, a missing entry, or an * entry that exports no `permissions` is a hard error: a deployable vertical must declare its * surface — absence is never silently an empty surface (D-41). * * The same import also reads an optional `envSpec` export (#1206): the manifest's declared * config surface, re-exported from the entry so `src/manifest.ts` is its single declaration. * Optional because pre-#1206 verticals declare it in package.json `substrat.envSpec` instead — * see `resolveDeclaredEnvSpec` for how the two are reconciled. */ /** * Every module's declared schedules, flattened with the owning module id (#1232) — * what the deploy manifest carries so the dashboard can compute next-due off * `everyMinutes`. Undefined when no module declares any, so the field stays absent. */ export declare function flattenDeclaredSchedules(permissions: PermissionsInput): DeployManifest['schedules'] | undefined; /** * The declared event surface (#1234) — every type each module says it emits or * consumes, flattened with its module beside it. The declared half of a * declared-vs-observed finding: the platform can see which types an app's outbox * actually carries, and nothing tells it which ones were promised. */ export declare function flattenDeclaredEvents(permissions: PermissionsInput): { events: NonNullable; truncated: boolean; }; /** The freshness twin of `flattenDeclaredSchedules` (#1232) — `within.hours` exists * nowhere off the manifest, and the dashboard's declared-vs-observed read needs it. */ export declare function flattenDeclaredFreshness(permissions: PermissionsInput): DeployManifest['freshness'] | undefined; export interface DeclaredSurface { readonly registry: PermissionRegistry; /** The entry's `envSpec` export, validated — undefined when the entry exports none. */ readonly envSpec: EnvVarSpec[] | undefined; /** Every module's declared schedules, flattened with the owning module id (#1232) — * undefined when no module declares any, so the manifest field stays absent. */ readonly schedules: DeployManifest['schedules'] | undefined; /** Every module's freshness expectations, flattened the same way (#1232). */ readonly freshness: DeployManifest['freshness'] | undefined; /** * Every module's declared emits/consumes, flattened the same way (#1234) — the * declared half of a declared-vs-observed finding. * * Unlike its neighbours this is never `undefined`: `[]` is a FACT ("these modules * declare no events") and the manifest field being absent is a different one ("this * version predates the field"). The reader turns the second into "nothing to say" * and would have turned the first into it too, hiding the provider findings it could * perfectly well have made. */ readonly declaredEvents: NonNullable; /** True when the surface above hit its cap and is a sample — see the manifest field. */ readonly declaredEventsTruncated: boolean; } export declare function deriveDeclaredSurface(dir: string): Promise; /** The declared permission surface alone — `deriveDeclaredSurface` for the callers that * want only the registry (D-41). */ export declare function deriveRegistry(dir: string): Promise; /** * Which `envSpec` a push ships (#1206). The code-side declaration (the entry's `envSpec` * export — `src/manifest.ts`, re-exported) is canonical when it exists: it is the copy the * worker actually reads at runtime (`resolveEnvSpec(manifest.envSpec, env)`), so a key * declared only there used to be a key nobody could ever set — the settings form is rendered * from what the push uploads, and the push read only package.json. Deriving closes that. * * A vertical that has NOT adopted the export keeps the pre-#1206 behaviour: package.json * `substrat.envSpec` ships, unchanged. One that has adopted it and still carries the * package.json copy is refused on drift rather than warned: the duplicated copy silently * losing a key is the exact defect, and a warning in CI logs is a diff surfaced nowhere. * An identical leftover copy passes with a note, so adoption is a two-step that cannot * wedge a release between its steps. */ export declare function resolveDeclaredEnvSpec(derived: EnvVarSpec[] | undefined, pkgCopy: readonly unknown[] | undefined, log?: (message: string) => void): readonly unknown[] | undefined; /** * The permission digest (D-39): a content hash of the vertical's declared permission surface — * what the promotion checkpoint compares to fire "permissions changed". A pure function of the * registry CONTENT (formatting-independent), so it moves iff a key, description, role, or grant * shape moves. */ export declare function permissionDigest(registry: PermissionRegistry): Promise; /** The declared permission surface as `substrat push --check` reports it: the registry the * push would ship, and the digest the promotion checkpoint would compare. */ export interface PermissionSurface { readonly registry: PermissionRegistry; /** `digests.permission` — moves iff a key, description, role or grant shape moves. */ readonly digest: string; /** The code-side `envSpec` declaration, when the entry exports one (#1206) — already * checked against any leftover package.json copy, so a drift threw before this existed. */ readonly envSpec?: readonly EnvVarSpec[]; } /** * The push's permission preflight, on its own (#1205). * * Everything a push does to the declared surface — resolve `package.json` * `substrat.permissions`, bundle the entry, import it, derive the registry, hash it — happens * before any credential is needed and touches no network. A vertical that wants that as a CI * gate was reaching it by deep-importing `dist/push.js`, which is not a public surface: no * `exports` map declares it, so any file move breaks a consumer nothing upstream knows about. * The alternative — a second implementation of the derivation — is the two-descriptions defect * this whole area exists to remove. So the gate is the CLI's own command, and the internals * stay internal. * * Every failure is a throw carrying its own remedy (see `deriveRegistry`): a missing pointer, * a pointer naming a file that has moved, an entry that stopped exporting `permissions`, and * an entry that cannot be imported outside the vertical's runtime. The CLI's top-level handler * turns each into a non-zero exit, which is what makes this usable as a gate. */ export declare function checkPermissionSurface(dir: string): Promise; /** * Render a checked surface for a human reading CI output: every key with the module(s) that * declare it and its description, every role with the keys it holds, every entity-grant shape, * then the digest. Sorted throughout (`buildPermissionRegistry` guarantees it), so two runs of * the same tree produce byte-identical text and a diff between them is a real surface change. */ export declare function formatPermissionSurface(surface: PermissionSurface, label?: string): string; export declare function assetContentType(path: string): string; /** A collected static file: its manifest row and the bytes that ride the upload. */ export interface CollectedAsset { entry: AssetEntry; content: Buffer; } /** * Read the vertical's built static files and content-address them (#340) — the substrate * side of Cloudflare's asset manifest. Runs AFTER the build (the directory is build output), * on the builder's own machine, so reading it is not a trust boundary; the control plane * re-derives every hash from the bytes it receives regardless. * * Paths are `/`-rooted and `/`-separated on every OS: the manifest key is a URL path, not a * filesystem path, and a Windows push must produce the same manifest as a Linux one or the * two would not dedup against each other. */ export declare function collectAssets(root: string, need: AssetsNeed): Promise; /** * The Substrat→Cloudflare mapping (D-38): derive the wrangler config a `runtimeNeeds` * vertical never authors. The result feeds BOTH the bundler (written to disk, passed via * `--config`) and the manifest extraction below — one object, so what we bundle and what * we declare cannot drift. The compatibility date is the platform's RUNTIME_BASELINE; * a builder states needs, not substrate config. * * `assets` is deliberately NOT emitted here (#340) even though `runtimeNeeds.assets` exists: * wrangler's job in a push is to bundle the worker, and the static files are read straight * from the declared directory by `collectAssets` afterwards. Handing wrangler an assets block * it would only re-walk buys nothing and puts a second, differently-implemented manifest on * the path to the same upload. */ export declare function wranglerConfigFor(needs: RuntimeNeeds): Record; /** * The static-assets need for this push (#340), from EITHER vocabulary: `runtimeNeeds.assets` * (D-38, the substrate form) or a hand-authored wrangler.jsonc `assets` block, whose keys are * Cloudflare's snake_case. One function so both paths land on the same parsed shape and the * manifest cannot depend on which config style the vertical uses. * * An `assets.binding` (programmatic `env.ASSETS.fetch(...)` from worker code) is REFUSED * rather than dropped: it is a real binding, it is not on the §4 allowlist, and silently * ignoring it would ship a worker whose `env.ASSETS` is undefined at runtime — a deploy that * looks successful and 500s on first request. Serving files needs no binding. */ export declare function readAssetsNeed(cfg: Record, needs: RuntimeNeeds | undefined): AssetsNeed | undefined; /** The vertical's `substrat.runtimeNeeds` block, parsed — or undefined for the wrangler.jsonc path. */ export declare function readRuntimeNeeds(dir: string): RuntimeNeeds | undefined; /** * The UI-reachability preflight (#881): a scaffolded `app/` that the manifest never * declares ships a vertical whose front end is real, tested, and answers 404 at its own * hostname. * * Every gate that runs before this one is blind to it by construction. `pnpm test` never * touches `server.ts`, `boundary-lint` has no opinion about static files, and the push * itself must stay legal for a vertical that genuinely has no UI — so this is the only * point where both halves are visible at once: SPA source in the tree, and nothing in the * manifest that would build or serve it. * * It refuses only where the UI is PROVABLY unreachable, so that a refusal is never a * guess: * - `app/index.html` exists — Vite's own entry marker, and what the playbook scaffolds. * - no `assets` in EITHER vocabulary (runtimeNeeds or a hand-authored wrangler.jsonc), * so nothing is uploaded to the runtime's asset store. * - no inlined-assets module under `src/` — the pre-#340 base64 pattern serves its files * from the worker and therefore declares nothing, correctly (`servesInlinedAssets`). * * `--allow-unserved-ui` is the deliberate override for the case this cannot see from the * tree alone: an `app/` that is a mock, a fixture, or built and deployed by somebody else. */ export declare function assertUiIsServed(dir: string, needs: RuntimeNeeds | undefined, assets: AssetsNeed | undefined, allowUnservedUi?: boolean): void; /** * The layer rules, run on the way out (#955). * * Every mechanical rule the platform advertises — R2's ambient-env ban (#862), R5's private * tables, R6's clock, R7's engine-catch, R4's spine — lives in `boundary-lint`, which until * now ran only in THIS repo's CI and in the builder studio. A vertical developed anywhere * else reached production having been checked by nothing: `substrat push` built the bundle, * uploaded it, and the control plane admitted it. That made the rules advisory for every * real customer, which is the opposite of the claim CLAUDE.md makes for them. * * So the push is the gate. It runs on the SOURCE tree, before the wrangler build, because * that is where the rules are legible and because a refusal is worth more in a second than * at the end of a build the vertical was going to ship broken anyway. This is the builder's * own machine — the same trust position `deriveRegistry` already runs in — not a platform- * side check over the uploaded bundle; that one is a separate question, tracked in #861. * * Two shapes deliberately do NOT refuse: * * - No module code found. `boundary-lint`'s own CLI exits 2 here, and it is right to: a * linter invoked directly and told to check a tree it cannot find has failed at its job. * A push has not — the layout may simply not be one auto-detection knows, and refusing * would make `substrat push` unusable for a project whose only fault is an unusual * directory. It prints what it did instead, so the absence is visible rather than a * green light. * - Engines declared but unresolvable (R5's ownership map is empty). Same reasoning, and * the note says what went unchecked. * * `--skip-lint` is the escape hatch, and it says out loud that the push was ungated — a * flag that silently weakens a gate is a flag that becomes the default in someone's CI. * * CALLED TWICE, ON PURPOSE. `cli.ts` runs it as the FIRST thing a push does — before the * registry round-trip that picks the next version — so a violating tree costs one local * scan and not a network error standing where the diagnostic should be. `push()` runs it * too, so the gate belongs to the push rather than to one command's argument handling. * * The RECEIPT is how those two stop being two scans and two all-clears. It is deliberately * not a cache: nothing is remembered between calls, so a tree that changes is re-checked, * and only a caller passing back the receipt it just got for THIS directory — the CLI's own * handoff, three statements later — skips the second run. A `push()` reached any other way * lints, which is the behaviour that matters: the platform does not lint the uploaded * bundle (#861), so the CLI's own check is the only one there is. */ export interface LayerRulesChecked { /** The absolute directory this check covered. */ readonly root: string; /** * What the check found — the value that rides the push as `origin.gate`, so the platform * records it on the version (#955). `skipped` (`--skip-lint`) and `none` (no module code * found) both mean nothing was checked, and are carried because a receipt that cannot say * so is a receipt that launders the bypass: handed to a `push()` that did NOT ask to skip, * it would stand in for a check that never ran, and without even the ungated notice. */ readonly gate: NonNullable; } /** * `log` is where the gate's own narration goes, and it is a parameter because one caller * needs it off stdout: `substrat push --check --json` prints the registry as the ONLY thing * on stdout, so a redirect into a file is a usable artifact. Everything else takes the * default and reads exactly as before. */ export declare function assertLayerRules(dir: string, skipLint?: boolean, log?: (message: string) => void): LayerRulesChecked; export interface PushOptions { dir: string; slug: string; version: string; name?: string; /** * The workspace this push is FOR (the project pin — cli.ts resolves --tenant → * SUBSTRAT_TENANT → package.json `substrat.tenant`). Sent with the bundle so the * control plane can HONOR it regardless of who is authenticated: a builder session * with a different workspace is refused (not silently redirected), and a staff * session's claim lands as the pinned tenant's — prefixed and owned like the * equivalent builder push — instead of platform-owned with the pin dropped. */ tenant?: string; /** The vertical's declared env-spec (from package.json `substrat.envSpec`), carried to the * registry so the platform can render a config form for it. Validated control-plane-side. * The FALLBACK copy only (#1206): when the permissions entry exports `envSpec`, that * code-side declaration ships instead, and this copy drifting from it refuses the push. */ envSpec?: readonly unknown[]; /** Registry-driven install fields (marketplace-publish.md §3), from package.json `substrat.*`. */ ownerGrants?: readonly unknown[]; entitlements?: readonly unknown[]; provides?: readonly unknown[]; requires?: readonly unknown[]; /** Declared provisioner intent (#455), from package.json `substrat.provisions`: the target * verticals this manager provisions tenants of. A request the console reviews — the * tenant-provisioner capability itself stays a staff-flipped registry flag. */ provisions?: readonly unknown[]; /** Declared model-runtime intent (#1054), from package.json `substrat.usesModels`: bind * the platform's model runtime as `env.AI`. A request, visible at the admit checkpoint. */ usesModels?: boolean; /** Declared email-sender intent (#303), from package.json `substrat.sendsEmail`: this vertical * wants to send transactional mail. A request the console reviews — the `emailSender` * capability itself stays a staff-flipped registry flag. */ sendsEmail?: boolean; /** The surfaces the vertical serves (K-26), from package.json `substrat.surfaces` — * labels only; buys the dashboard a hostname-binding picker + a push-time warning. */ surfaces?: readonly unknown[]; /** Declared outbound hosts (#303, D-46), from package.json `substrat.outbound`: the * third-party hosts the worker fetches directly, enforced by the egress worker. * Undefined is still SENT as `[]` — a new-CLI push always declares its outbound * surface, and "no third-party egress" is the least-privilege default. */ outbound?: readonly unknown[]; /** * Acknowledge a lineage fork (#388): a first push of a NEW registry id whose name * matches an existing lineage under a different owner is refused by the control plane * (it is almost always a mis-identified project, and installs of the existing lineage * would never see the push). This flag — the CLI's --allow-fork — makes running a * separate same-named lineage a deliberate choice. */ allowFork?: boolean; /** * Acknowledge a UI the push will not serve (#881) — the CLI's --allow-unserved-ui. * See `assertUiIsServed`: an `app/` the manifest never declares is normally a mistake * that only shows up as a 404 on the live hostname, so it is refused by default. */ allowUnservedUi?: boolean; /** * Push code the layer rules never saw (#955) — the CLI's --skip-lint. See * `assertLayerRules`: the gate refuses on a violation, and this is the deliberate, * self-announcing way past it. */ skipLint?: boolean; /** * The receipt from a check the caller already ran on THIS directory (#955) — the CLI's * pre-flight, which gates before the registry round-trip so a violation never costs a * network call first. Anything else, including a receipt for another directory, lints. */ linted?: LayerRulesChecked; controlPlaneUrl: string; /** The auth header to send — a bearer session or an x-service-token (see config.resolveAuth). */ authHeader: Record; } /** * Build a vertical and push its bundle to the platform's deploy endpoint * (self-serve-deploy.md). The worker is built with `wrangler --dry-run --outdir` — the * control plane holds the Cloudflare credential, this never does (D-34). The version * lands PENDING; admission still gates serving. Authenticated with the caller's own * credential (`opts.authHeader` — a browser session or a service token), never a * hand-picked `--actor`. */ /** * The deploy config for a push: `substrat.runtimeNeeds` (D-38, derived) wins, a * hand-authored wrangler.jsonc is the fallback — and NEITHER is a refusal with the * remedy in it, not an ENOENT stack trace. The remedy leads with runtimeNeeds * because that is the substrate-vocabulary path; wrangler.jsonc stays legal but * is not what a refusal should teach. */ export declare function resolveWranglerConfig(dir: string): { cfg: Record; needs: RuntimeNeeds | undefined; }; /** * Where the derived wrangler config is written for the build. * * ABSOLUTE, and that is the whole point: `wrangler` is spawned with `cwd` set to this * same directory, and `dir` arrives from argv unresolved (`cli.ts`). A relative * `--config demos/ticket0/.wrangler.substrat.json` is then resolved a SECOND time * against that cwd — `demos/ticket0/demos/ticket0/…`, which exists nowhere. It is why * this only ever broke when the push named a directory (CI) and never when it ran from * inside one, where `dir` is '.' and joining twice is a no-op. */ export declare function generatedConfigPath(dir: string): string; export declare function push(opts: PushOptions): Promise<{ id: string; admission: string; deploymentRef: string; verticalSlug: string; warnings?: string[]; }>; /** Push defaults read from a vertical's package.json, so `substrat push` needs no flags. */ export interface VerticalMeta { /** Registry slug: an explicit `substrat.slug`, else the package name with scope + a leading `demo-` stripped. */ slug: string; /** * Whether the slug came from an explicit `substrat.slug` pin rather than being derived * from the package name. A derived slug silently FOLLOWS a package rename — the #399 * lineage fork — so push prints a pin-it hint while this is false. */ slugExplicit: boolean; /** Display name: an explicit `substrat.name`, else the slug title-cased. */ name: string; /** * The workspace this project pushes to — `substrat.tenant`. Repo-scoped and reviewable, * because which tenant owns a vertical is a property of the project, not the machine: * the first push of a slug CLAIMS `/`, so a machine-wide default silently * pointing at the wrong workspace would claim it for the wrong owner. Undefined → the * CLI prompts (interactive) or refuses (non-TTY); it never guesses. */ tenant: string | undefined; /** package.json `version` — only a seed for the FIRST push of a brand-new slug. */ versionSeed: string | undefined; /** The vertical's declared env-spec, from package.json `substrat.envSpec` — the static, * code-free source the CLI can read at push time (like slug/name). Undefined if none. */ envSpec: readonly unknown[] | undefined; /** Registry-driven install fields, from package.json `substrat.{ownerGrants,entitlements,provides,requires}`. */ ownerGrants: readonly unknown[] | undefined; entitlements: readonly unknown[] | undefined; provides: readonly unknown[] | undefined; requires: readonly unknown[] | undefined; /** Declared provisioner intent (#455), from package.json `substrat.provisions`. */ provisions: readonly unknown[] | undefined; /** Declared email-sender intent (#303), from package.json `substrat.sendsEmail`. */ sendsEmail: boolean | undefined; /** Declared model-runtime intent (#1054), from package.json `substrat.usesModels`. */ usesModels: boolean | undefined; /** Declared surfaces (K-26), from package.json `substrat.surfaces`: `[{ name, label }]`. */ surfaces: readonly unknown[] | undefined; /** Declared outbound hosts (#303, D-46), from package.json `substrat.outbound`. Undefined * = the key is absent, which the push STILL sends as `[]` — a new-CLI push always * declares its outbound surface, and no third-party egress is the default. */ outbound: readonly unknown[] | undefined; } /** * Derive push defaults from the vertical directory's package.json (the "it's already in * package.json" the CLI shouldn't make you retype). An explicit `"substrat": { slug, name }` * block wins; otherwise the slug is the package name's last segment with a `demo-` prefix * stripped (`@substrat-run/demo-meridian` → `meridian`) and the name is that title-cased. * Returns empty strings when there is no package.json — the caller then requires flags. */ export declare function readVerticalMeta(dir: string): VerticalMeta; /** * The vertical's emitted entity model (#1214), from the `model.json` beside its * package.json — the artifact `pnpm lint:model` emits and gates (#697). `undefined` when * there is none: a vertical that has not adopted the entity registry pushes exactly as it * did before. A present-but-malformed file REFUSES the push with the artifact named — it * means the file was hand-edited or emitted by an incompatible toolchain, and the control * plane would bounce the manifest anyway; failing here costs no network round-trip. */ /** * One line about the entity model, for the person reading a push or a `--check`. * * The absence of a `model.json` is a legitimate state and stays one — it is not an error * and this does not make it one. What it stopped being is SILENT. Every gate we ship passed * without mentioning the artifact, so the first and only place a builder learned their * version carried no model was a dashboard panel, after a successful deploy, saying so. * That is the wrong end of the loop for a fact the CLI has in its hand before the upload. * * Stated in both directions deliberately. A notice nobody ever sees the other half of reads * as noise; the count on the push that DOES carry one is what makes its absence conspicuous * the next time, and it is the cheapest possible confirmation that the file being emitted is * the file being shipped. */ export declare function formatDeclaredModel(model: EmittedModel | undefined): string; export declare function readDeclaredModel(dir: string): EmittedModel | undefined; /** * What each operation declares it RETURNS (#1321), from the `openapi.json` beside * the vertical's package.json — the artifact `pnpm lint:api` emits and gates. * * Read from the emitted document rather than from the operation declarations * because the CLI has the file and not the module graph, and because the document * is already the gated, reviewed statement of the API surface. A paged read * contributes its ENTRY's fields: the envelope is the transport's, and the * question is which of the vertical's own fields anything returns. * * `undefined` when there is no `openapi.json` (a vertical that emits none pushes * exactly as before) and, unlike `model.json`, a malformed one does NOT refuse the * push: this surface is an observability nicety, and failing a deploy over it * would trade a working release for a dashboard panel. */ export declare function readDeclaredOutputSurface(dir: string): DeclaredOperationOutput[] | undefined; /** * Pin the pushed-to workspace into the project's package.json (`substrat.tenant`) so every * later push — any teammate, any machine, CI — lands in the same workspace without asking. * Preserves the file's indentation style (best-effort sniff); throws if there is no * parseable package.json — the caller only offers pinning when meta came from one. */ export declare function pinTenant(dir: string, tenant: string): void; /** * The next version to push for a slug: the registry's highest semver, patch-bumped — so a * builder never hand-tracks the number. Falls back to the package.json seed (or `0.0.1`) for * the first push of a slug the registry has never seen. A non-semver latest is bumped as-is * would be wrong, so those are skipped when finding the max. * * Takes CANDIDATE slugs because the caller may not know the registry id its push will * land on: a pinned push claims `/` in general but a legacy bare row * owned by the pin stays bare — so cli.ts asks for both and the max across them wins * (they are the same lineage; at most one exists in practice). */ export declare function nextVersion(controlPlaneUrl: string, header: Record, slugs: readonly string[], seed: string | undefined): Promise; /** * A preview's version LABEL — a semver PRERELEASE (`-.`), never a release * coordinate. `parseSemver` is anchored `^\d+\.\d+\.\d+$`, so a prerelease is skipped when * the registry max is computed (`nextVersion` above): a preview push is legible — it names * the release it rehearses — yet FREE, unable to collide with or advance the coordinate the * repo owns. Auto-bumping to a real coordinate is what put holes in our registry (issue #509, * ask (e)). `` disambiguates successive pushes to the same tag on the same base. */ export declare function previewVersion(controlPlaneUrl: string, header: Record, slugs: readonly string[], seed: string | undefined, tag: string): Promise; //# sourceMappingURL=push.d.ts.map