Files
zeekay 1d84cca4e5
CI/CD / containment (push) Successful in 3m7s
Hanzo CI/CD / cicd (push) Failing after 34m52s
CI/CD / gate (push) Failing after 35m0s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
openapi: declare the published contract, default-deny
A product reached the public SDK by being served. That is the wrong
default: the surface a customer can call was decided by whoever mounted a
route, and 2325 operations — admin, billing, commerce, o11y, treasury —
were one generator run away from a published client.

openapi.Public(path, method) is the third declaration seam, beside
Describe and Compat. It renders x-public: true, and the weave projects the
SAME composition twice: openapi.yaml is everything the fleet serves and is
what our own clients are cut from; public.yaml beside it is what declared
itself part of the contract. An operation that says nothing is internal, so
nothing reaches a customer by anyone forgetting.

The first declaration is inference and nothing else: 18 operations across
chat, completions, messages, embeddings, rerank, models, and the image,
video and audio doors. Each modality has its OWN door — publishing text
alone would ship a catalog naming zen-image and zen-video that no client
could call.

Two files, one command, one composition. `make check` now covers
public.yaml, so the two documents cannot come to describe two commits.

Also: name /v1/tags on the destinations row. It is destinations' browser
half, a bare noun, so the /v1 remainder answered it — 404 for a path the
document publishes and track.js fetches on every page load.
2026-08-06 21:01:24 -07:00

843 lines
40 KiB
Go

// Package openapi projects the LIVE zip/fiber router into an OpenAPI 3.1
// document. The spec is not a description of the router — it IS the router,
// read through app.Fiber().GetRoutes(). There is no second route registry, so a
// document and the app that produced it cannot drift: the only way to change it
// is to change the routes it is read from. (Register/Describe add a registry of
// BODIES and PROSE, never of routes: a declaration renders only on a route the
// router carries, so the paths remain the router's alone.)
//
// THAT GUARANTEE IS PER APP, AND IT ENDS AT THE APP. [Spec] and [Mount] read a
// router that is right there. The FLEET document — what api.hanzo.ai serves —
// cannot: the light host mounts no subsystem, so [MountFleet] weaves the
// projections 116 app binaries wrote when they were BUILT (fleet.go). Between the
// projection and the request sit two gaps no reading of any router closes: the
// subset can be older than the code (mk/fleet.mk check regenerates it
// from source and refuses the diff), and the deployed front door can hand the
// path to somebody else entirely (only a probe of the live host sees that). See
// fleetInfo for what the published document may therefore claim.
//
// This mirrors the rule zapface/wire.go states for transports — two transports,
// ONE dispatch path. ZAP and OpenAPI are two PROJECTIONS of one route table.
//
// # Reading the router is not a workaround; it is the only total source
//
// Two properties make the LIVE router the only honest source, and no static scan
// of the source tree a substitute:
//
// - Routes are composed at runtime. POST /v1/kms/auth/login is registered as
// Group("/v1/kms/auth").Post("/login") — that path literal does not exist
// anywhere in the tree, so no grep can find it. Only the assembled router
// knows it.
// - The route set is a function of DEPLOYMENT CONFIG. Subsystems mount only
// when cfg.Enabled(name), and several gate routes internally (kms registers
// its secret routes only `if kc != nil`). The spec therefore VARIES PER
// DEPLOYMENT, correctly: a deployment that does not mount admin does not
// advertise admin. That is a feature — each deployment describes itself —
// and it is why the document is generated per-process, not built once in CI.
//
// # Two readings of one router, folded into one document
//
// zip ships its own OpenAPI projection (zip/openapi.go) driven by the typed-op
// registry (zip.Get[In, Out] → app.ops), which carries the real request/response
// Go types and therefore real JSON Schema — plus the prose cmd/zipdoc lifts out
// of the handlers' doc comments at build time. It is the better reading, and it
// is not duplicated here: Typed READS it and Fold lays it over the router
// projection.
//
// The two are not rivals because GetRoutes() is a strict SUPERSET of app.ops
// (registerTyped registers a fiber route too). So the router gives the TOTAL set
// of operations and the registry gives DETAIL for the subset that has any:
//
// router → every operation exists, with its address and product.
// registry → the typed ones also carry schemas, parameters, responses,
// descriptions and examples.
//
// One document, no gaps and no invention. Migrating a raw handler to a typed op
// is what earns it the second half, and it needs no generator change: the same
// fold picks it up on the next run.
package openapi
import (
"encoding/json"
"fmt"
"net/http"
"slices"
"sort"
"strings"
"sync"
"github.com/zap-proto/zip"
)
// Path is the canonical spec endpoint. House law: /v1/ only, no /api/ prefix,
// and never a v2 — the document's own shape is versioned by its `openapi` field.
const Path = "/v1/openapi.json"
// Route is one live route, reduced to what the router actually knows: where a
// request goes. Method and Path are the whole of it.
//
// There is deliberately no handler count here. See Document's note on chained
// handlers for why that number cannot be interpreted.
type Route struct {
Method string
Path string // fiber pattern, e.g. /v1/kms/orgs/:org/secrets/*
}
// methods is the set OpenAPI 3.1 admits as Path Item fields. Both exclusions
// below are FORCED — by representability and by stability — never by taste. A
// method this generator can state stably is emitted even if a CLI would not use
// it (OPTIONS, TRACE); curating that is the consumer's job, not the projection's.
//
// CONNECT is excluded because OpenAPI 3.1 has no `connect` Path Item field.
// Fiber routes it (25 live CONNECT routes come from All() registrations); the
// document format simply cannot express it.
//
// HEAD is excluded because it cannot be stated stably. Fiber auto-generates a
// HEAD for every GET by COPYING the route — same path, same handler stack
// (fiber/router.go:793) — and the `autoHead` flag marking it is unexported, so
// an auto-generated HEAD is indistinguishable from a hand-registered one through
// the public API. Worse, that generation runs in startupProcess() (Listen/Test),
// NOT at registration: the same app yields a different HEAD set before and after
// boot (measured: 25 explicit HEAD routes from All() pre-boot, ~469 post-boot
// once every GET is mirrored). Including HEAD would make the projection depend
// on lifecycle stage — the CI exec path (never listens) and the live endpoint
// (has listened) would emit different documents, which is exactly the drift this
// package exists to prevent. Excluding it makes the projection total and stable.
// TRACE and OPTIONS are excluded for the reason CONNECT and HEAD are: All()
// binds them mechanically, nobody DECLARED them, and a published operation is a
// promise. They are transport, not product.
//
// TRACE echoes the request back. It is a debugging verb, it is the Cross-Site
// Tracing vector, and it should not be reachable on a public API at all — so
// advertising 33 of them in the contract is worse than merely routing them,
// because the contract is what SDKs, the CLI and the MCP tool list are built
// from. OPTIONS is CORS preflight: a browser sends it, a person never does, and
// `hanzo meet options` is not a command anyone wants.
//
// Between them they were 66 of the document's operations — 66 SDK methods, 66
// CLI verbs and 66 MCP tools that existed because a route table was read as a
// product surface.
var methods = map[string]bool{
"GET": true, "PUT": true, "POST": true, "DELETE": true, "PATCH": true,
}
// Methods returns the methods this generator publishes, sorted.
//
// It exists for ONE caller shape: a subsystem whose whole surface is an All()
// registration. All() binds every method at one path, so there is no per-method
// registration site to hang prose on — the subsystem declares its prose in a loop
// instead, and that loop has to cover EXACTLY what the document renders. Reading
// the projection's own set is what makes it exact: a method added or dropped here
// moves both halves at once, so the loop can neither describe an operation nobody
// publishes nor miss one that is published.
func Methods() []string {
out := make([]string, 0, len(methods))
for m := range methods {
out = append(out, m)
}
sort.Strings(out)
return out
}
// Live reads the router. It is the SOLE adapter from fiber to data — every other
// function here is a pure function of []Route, so the projection is testable
// without a router and the fiber coupling has exactly one home.
//
// GetRoutes(true) drops Use() middleware entries: middleware matches path
// PREFIXES and is not an operation. That filter is fiber's own (app.go:822), not
// a reimplementation of it.
func Live(app *zip.App) []Route {
fr := app.Fiber().GetRoutes(true)
out := make([]Route, 0, len(fr))
for _, r := range fr {
if !methods[r.Method] {
continue
}
out = append(out, Route{Method: r.Method, Path: r.Path})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Path != out[j].Path {
return out[i].Path < out[j].Path
}
return out[i].Method < out[j].Method
})
return out
}
// Product is the product axis, and it is mechanical: the first path segment
// after /v1/ IS the product (/v1/kms/* → kms, /v1/billing/* → billing). No
// judgment, no table to maintain, nothing to keep in sync.
//
// It is deliberately NOT the subsystem name: clients/billing serves both
// /v1/billing/* and /v1/finance/* (finance.go:57), so the mount that owns a
// route and the product a caller names are different values. The CLI wants the
// one in the URL.
//
// Returns "" when the first segment is not a product name — a parameter (:org),
// a wildcard (*), or a file (openapi.json) — and for anything outside /v1
// (/health, /.well-known/*, /git/*, /tasks/*). Those get no tag rather than a
// fabricated one.
func Product(path string) string {
rest, ok := strings.CutPrefix(path, "/v1/")
if !ok {
return ""
}
seg, _, _ := strings.Cut(rest, "/")
if seg == "" || strings.ContainsAny(seg, ":*.+?{}") {
return ""
}
return seg
}
// Info is the OpenAPI info block.
type Info struct {
Title string `json:"title"`
Description string `json:"description,omitempty"`
Version string `json:"version"`
}
// Server is an OpenAPI server entry.
type Server struct {
URL string `json:"url"`
}
// Tag is an OpenAPI tag — one per product, so a consumer can read the product
// list off the document without walking every path.
//
// Description says what the product IS, in the words of the package that
// implements it (Synopsis). It is omitted rather than filled: a product whose
// package carries no doc comment is a product nobody has described yet, and
// inventing a sentence here would make that indistinguishable from one somebody
// wrote. The NAME is never conditional on it — the tag list stays a function of
// the document's operations, which is the one thing a consumer enumerating
// products can rely on.
type Tag struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
// Schema is the sliver of JSON Schema this generator can honestly assert as a
// CLOSED struct: primitive types for path parameters, and — for routes whose
// subsystem declared its bodies via Register — objects, arrays, and $refs
// derived by reflection from the handler's own binding structs. Typed-op
// schemas do NOT pass through it: zip already derives arbitrary JSON Schema
// from the In/Out Go types, and restating that open vocabulary here would be a
// second, lossier copy — they travel as `any` (see Fold).
// Format qualifies Type where the type alone is not the whole fact. It carries
// exactly one value today — "binary" on the string that stands for a raw byte
// body ([Binary]) — because that is the only distinction this generator can make
// that a consumer acts on: an SDK generator emits a file/bytes parameter for
// `string/binary` and a text parameter for a bare `string`.
// OneOf carries the alternatives of a POLYMORPHIC body ([OneOf] the declaration
// value) and is empty on every other schema. It is the one place this generator
// says "several shapes, and the caller picks" — a fact a single Go type cannot
// state, which is why declaring one alternative and calling it the wire would
// under-describe a route that accepts three.
type Schema struct {
Type string `json:"type,omitempty"`
Format string `json:"format,omitempty"`
Ref string `json:"$ref,omitempty"`
Items *Schema `json:"items,omitempty"`
Properties map[string]*Schema `json:"properties,omitempty"`
AdditionalProperties *Schema `json:"additionalProperties,omitempty"`
OneOf []*Schema `json:"oneOf,omitempty"`
}
// Parameter is an OpenAPI parameter object.
//
// From the ROUTER only path parameters are emitted: they are structural — the
// router matches on them — so they are derivable. From the REGISTRY a typed op's
// query parameters come too, with descriptions, because its input type says what
// they are. Schema is an open map because JSON Schema is an open vocabulary: the
// router asserts `{"type": "string"}` and nothing more, while a typed op's field
// can be any shape zip derives from its Go type.
//
// Example is that field's value in the op's own example. A BODYLESS op — every
// GET and, since zip v1.18.0, every DELETE — has no requestBody for the doc
// comment's Example to live in, so zip splits it across the parameters that
// carry it. Without a name for it here the round-trip through [Typed] dropped
// it, and every GET and DELETE reached the published reference with no example
// at all.
type Parameter struct {
Name string `json:"name"`
In string `json:"in"`
Required bool `json:"required"`
Description string `json:"description,omitempty"`
Schema map[string]any `json:"schema,omitempty"`
Example json.RawMessage `json:"example,omitempty"`
}
// Operation is one operation.
//
// Everything past Parameters is omitempty and comes from exactly two seams, both
// anchored in the handler's own Go types so neither can drift:
//
// - Register/Describe (register.go): a subsystem declares its binding structs
// — schema by reflection — and, for a handler the wire refuses to let become
// a typed op, its prose; both attach only when the router carries the route.
// - zip's typed ops, folded in from the registry (Fold): Summary/Description
// from the lifted godoc, query parameters and bodies from the In/Out types.
//
// A route neither seam knows keeps exactly the structural facts the router can
// prove, and asserts no status code, content type or prose it has no evidence
// for — OpenAPI 3.1 makes `responses` OPTIONAL (3.0 required it), so absent
// stays valid and absent beats invented.
//
// RequestBody and Responses are `any` because the two seams produce different
// (JSON-identical) shapes: Register builds the closed *RequestBody /
// map[string]*Response, the fold reuses zip's open maps verbatim.
// App is PROVENANCE: the registry that registered this operation. It is the one
// question a reader of a wrong operation has — who do I file this against — and
// until it was recorded the answer took a bisect of 116 subsets. For an app's own
// route it is the app name, so the code is apps/<name> in this repo; for an
// operation reached through a door it is the module behind that door, so the code
// is that repo. Either way it names a place to go.
//
// It is written exactly once per operation, by whichever producer knows: [Project]
// stamps the relay's source as the operation enters, [Weave] stamps the part's app
// for everything else, and neither overwrites a value already there.
//
// Public is AUDIENCE: this operation is part of the published contract. It is a
// declared fact, never a derived one — see openapi/public.go for why the split is
// a per-operation mark and not a list of prefixes, and [Publish] for the
// projection that reads it. Default-deny: absent means internal, so an operation
// that says nothing about its audience cannot leak into a public SDK. It rides in
// the document as an extension rather than as a tag because the tag axis already
// means PRODUCT — `compat` had to be filtered back out of it by [Products], and a
// second orthogonal fact in the same slot would be that wart twice.
type Operation struct {
OperationID string `json:"operationId"`
Summary string `json:"summary,omitempty"`
Description string `json:"description,omitempty"`
Tags []string `json:"tags,omitempty"`
Parameters []Parameter `json:"parameters,omitempty"`
RequestBody any `json:"requestBody,omitempty"`
Responses any `json:"responses,omitempty"`
App string `json:"x-app,omitempty"`
Public bool `json:"x-public,omitempty"`
}
// Components holds the named schemas operations reference by $ref, so an SDK
// generator mints one named type per Go struct instead of an anonymous shape per
// operation. Open-typed: Register contributes *Schema values, the typed fold
// contributes zip's JSON Schema verbatim — both marshal to the same vocabulary.
type Components struct {
Schemas map[string]any `json:"schemas,omitempty"`
}
// PathItem maps a lowercased HTTP method to its operation.
type PathItem map[string]*Operation
// Document is an OpenAPI 3.1 document.
//
// # What IS derivable from the router, and what is NOT
//
// The router is a dispatch table: pattern → handler. It knows how to MATCH a
// request, not what a request or response CONTAINS. Concretely, fiber's Route
// struct (fiber/v3@v3.2.1 router.go:46) is only {Method, Name, Path, Params,
// Handlers} — there is no payload information in it to read.
//
// Derivable (asserted here, all of it structural):
// - method — the stack a route is registered in.
// - path — the registered pattern, verbatim.
// - path parameters — the pattern's own :name segments; a router that could
// not name them could not match them. Always required:true (a fiber path
// param is optional only if written :name?, which cloud does not use).
// - product tag — the first /v1/ segment of that same pattern.
//
// NOT derivable from the router (no amount of router-reading changes it — these
// come from the typed registry via Fold, and are absent on a route that has no
// typed op):
// - request body schema. The router holds a func(*zip.Ctx) error. The request
// type is a LOCAL VARIABLE inside the handler body — clients/kms putSecret
// is the representative case: `var req secretPutRequest; json.Unmarshal(
// ctx.Body(), &req)`. The type exists in the package but never appears in
// the handler's signature, and Go has no reflection from a func value to the
// types it unmarshals internally. cloud.Handle[S] does not help: its type
// parameter S is the SERVICE (service.go:90), not the payload. cloud.Typed
// does not help either: it is an any→*zip.App mount adapter.
// What cannot be DERIVED can still be DECLARED: Register (register.go) is
// the seam a subsystem uses to state its binding structs once, next to its
// route table, and the schema is reflected from those structs.
// - response body schema / status codes — same dead end, at the far end,
// with the same declaration seam (the success shape under a "2XX" range,
// because the exact code lives in the handler body).
// - query and header parameters — read positionally via c.Query("k") at
// runtime; not part of the match, so the router has never heard of them.
// - auth requirements — enforced by middleware and by guards wrapped around
// handlers (guard(s, cloud.Handle(s, listSecrets))), invisible as data.
// - summaries/descriptions — prose that exists only in Go comments (a typed
// op's zipdoc lift, or a refused route's Describe declaration).
// - wildcard semantics — a fiber `*` matches MULTIPLE segments greedily;
// OpenAPI's {param} matches one. The emitted {wildcardN} is the closest
// honest approximation and is NOT equivalent.
// - whether a route is a real endpoint or a PROXY PREFIX. A catch-all like
// app.Post("/v1/billing/*") forwards to another service; the operations
// behind it (POST /v1/billing/deposit and friends) are not routes in this
// process and cannot appear. For products whose whole surface is one
// catch-all, this document can name the prefix and nothing under it.
//
// The consequence for a consumer: this document is a complete and exact map of
// the API's SHAPE (every operation, its address, its product), and it describes
// PAYLOADS exactly as far as the typed registry does. A CLI can build its full
// command tree — `hanzo <product> <resource> <verb>` — and bind path params from
// it with no judgment calls, everywhere. It can typecheck a request body and
// pretty-print a response only for a typed op, and behind a catch-all it cannot
// enumerate subcommands at all.
//
// The path to more schema is not a better reader. Two seams exist, both
// anchored in the handler's own Go types so neither can drift:
//
// - Register (register.go): a subsystem declares its binding structs for a
// route it already serves — no handler change, schema by reflection. A
// registration renders ONLY when the router carries the route, so the
// document still cannot disagree with the router; schemas are additive
// metadata on routes that exist.
// - zip's typed ops (zip.Get[In,Out]), which carry the In/Out types in the
// handler signature itself and also earn an MCP tool and a CLI command from
// the same registry entry (zip's third projection, zip/mcp.go). That is a
// per-handler refactor of business logic, and it composes with this:
// GetRoutes() already includes typed ops, so migration adds detail through
// the fold that is already running.
//
// # Chained handlers are not collisions
//
// Fiber MERGES byte-identical route patterns into ONE Route carrying both
// handlers chained, so a duplicate registration is invisible to an entry count
// and shows only as len(Handlers) > 1. That is true — but the converse does not
// hold, and this generator does NOT use that signal:
//
// app.Post("/v1/billing/recharge/run-all",
// commercemid.RequestContext(), commercemid.TokenRequired(),
// commercemid.PlatformOnly(), commercebilling.RunAutoRechargeAllOrgs)
//
// is ONE registration with FOUR handlers — three middleware and a terminal
// handler (apps/commerce.go:151). The whole /v1/store/* surface is the same
// shape. 34 live routes carry chained handlers and every one is legitimate. A
// merged duplicate and a middleware chain are INDISTINGUISHABLE through the
// public API, so "handlers > 1" cannot mean "collision" fleet-wide; treating it
// as one would refuse a spec for a healthy router.
// (clients/bots/routes_test.go asserts exactly that rule, and is right to: it is
// a local truth for the bots/visor/runtime surface, where nothing chains
// middleware. It is not a global one.)
//
// This costs the document nothing. Merged or chained, one pattern is one
// operation — which is what the spec emits either way. Detecting routing bugs is
// the bots guard's job, at the seam where the premise holds; the spec's only
// requirement is that (method, path) → operation stay injective, which From
// enforces via operationId uniqueness.
type Document struct {
OpenAPI string `json:"openapi"`
Info Info `json:"info"`
Servers []Server `json:"servers,omitempty"`
Tags []Tag `json:"tags,omitempty"`
Paths map[string]PathItem `json:"paths"`
Components *Components `json:"components,omitempty"`
}
// From builds the document from route data. No router, no I/O — its inputs are
// the routes, plus the package registry of declared bodies (Register), which is
// written only at init time and is therefore fixed by the time any document is
// built.
//
// It refuses on a duplicate operationId rather than emit a document a generator
// would mis-consume. That check subsumes the only ambiguity the spec can suffer:
// two routes sharing a (method, path) derive the same id, so an injective
// (method, path) → operation map is exactly what uniqueness buys. It refuses
// equally when two DIFFERENT Go types claim one component name — a silent merge
// would hand an SDK generator a lie.
func From(rs []Route, info Info, servers ...Server) (*Document, error) {
doc := &Document{
OpenAPI: "3.1.0",
Info: info,
Servers: servers,
Paths: map[string]PathItem{},
}
products := map[string]bool{}
comp := newComponents()
for _, r := range rs {
path, params := translate(r.Path)
op := &Operation{OperationID: zip.ID(r.Method, path)}
if p := Product(r.Path); p != "" {
op.Tags = []string{p}
products[p] = true
}
for _, name := range params {
op.Parameters = append(op.Parameters, Parameter{
Name: name, In: "path", Required: true, Schema: map[string]any{"type": "string"},
})
}
// Declared bodies attach ONLY here — to a route the router carries. A
// registration without a live route never renders, which is what keeps
// the registry unable to contradict the router.
if reg := registered(r.Method, r.Path); reg != nil {
if err := reg.apply(op, comp); err != nil {
return nil, err
}
}
// An UNTYPED route inside a GRAFTED app has its prose in neither place a
// host can reach: not in the typed registry (it is not typed) and not in
// [Describe] (that is this repo's seam, and the route is another repo's).
// It is in the doc comment on the handler, where it belongs, and zipdoc
// put it in zip's process-wide extraction — so ask there.
//
// Last, so nothing here overrides prose this app declared for itself: a
// host that has something to add about a route it mounts still wins, and
// the owning service's sentence is what fills the silence.
if op.Summary == "" && op.Description == "" {
if summary, description, ok := zip.Prose(r.Method, r.Path); ok {
op.Summary, op.Description = summary, description
}
}
if doc.Paths[path] == nil {
doc.Paths[path] = PathItem{}
}
doc.Paths[path][strings.ToLower(r.Method)] = op
}
if len(comp.schemas) > 0 {
doc.Components = &Components{Schemas: comp.schemas}
}
for p := range products {
doc.Tags = append(doc.Tags, Tag{Name: p})
}
sort.Slice(doc.Tags, func(i, j int) bool { return doc.Tags[i].Name < doc.Tags[j].Name })
if err := uniqueOperationIDs(doc); err != nil {
return nil, err
}
return doc, nil
}
// Registry is zip's typed-op projection, reduced to what the router cannot
// supply: the operations that carry schema and prose, keyed by the same
// "METHOD /templated/path" identity a Document is indexed by, and the schemas
// they $ref.
type Registry struct {
Ops map[string]*Operation
Schemas map[string]any
}
// Typed reads the typed-op registry off the app — the SAME value zip serves at
// /.well-known/openapi.json, so there is one generator for schemas and this is
// not it.
//
// It comes back through JSON rather than by walking zip's map[string]any because
// the map IS a JSON document; decoding it into the same Operation the router
// projection builds is what makes the two foldable at all, and it means a field
// zip adds later arrives here without a change (or is dropped honestly, if
// nothing here has a name for it).
func Typed(app *zip.App) (Registry, error) {
raw, err := json.Marshal(app.OpenAPISpec())
if err != nil {
return Registry{}, fmt.Errorf("typed registry: %w", err)
}
var spec struct {
Paths map[string]map[string]*Operation `json:"paths"`
Components Components `json:"components"`
}
if err := json.Unmarshal(raw, &spec); err != nil {
return Registry{}, fmt.Errorf("typed registry: %w", err)
}
reg := Registry{Ops: map[string]*Operation{}, Schemas: spec.Components.Schemas}
for path, item := range spec.Paths {
for method, op := range item {
reg.Ops[strings.ToUpper(method)+" "+path] = op
}
}
return reg, nil
}
// Compat marks an operation whose ADDRESS is a legacy spelling of another
// operation's: served so a consumer pinned to it does not break, and not part of
// the contract.
//
// The router cannot know this. `/v1/iam/get-users` and `/v1/iam/users` are two
// live routes with no relation the route table can see; only the code that
// registers them knows one replaced the other. So the SERVING code declares it —
// hanzoai/iam tags the fifty-one entity verbs it inherited (`get-users`,
// `add-application`, `set-preferred-mfa`) and the singular `application` address
// it had before the kind was pluralized — and the declaration rides the weave
// out to hanzoai/openapi, whose merge keeps those addresses out of the published
// document. Without it the customer surface carries two spellings of every one of
// those operations: two SDK methods, two docs entries, two `hanzo iam` commands.
//
// It is a SECOND tag, never the first: the product tag is the axis every
// generator files an operation under, and this is an orthogonal fact about the
// same operation.
const Compat = "compat"
// Products drops Compat from an operation's tag list.
//
// Compat is an orthogonal fact about ONE address, not a product. Everything that
// reads tags to answer "which products does this document publish" — the floor
// ratchet, the weave's ownership claims, the document's own tag list — has to
// ask this instead of iterating Tags, or `compat` arrives as a product with
// twenty-three operations in it and a doc-site heading nothing answers to.
func Products(tags []string) []string {
out := make([]string, 0, len(tags))
for _, t := range tags {
if t != Compat {
out = append(out, t)
}
}
return out
}
// Fold lays the registry's detail over the router's shape, in place. A typed op
// REPLACES the structural operation at its address — it is strictly richer,
// including the path parameters, which zip derives from the same pattern.
//
// Two things the fold keeps from the router, both because the router is the
// authority on them:
//
// - the product tag, which is the path's first /v1/ segment and nothing else.
// zip's per-op tags name a different axis, and the one that carries a fact
// the router cannot know survives beside it (Compat, below).
// - membership. A typed op with no live route is a contradiction — registering
// one registers a fiber route — so it means the two readings disagree about
// a path (a translation bug), and the honest answer is to refuse rather than
// to invent the operation or drop it silently.
func Fold(doc *Document, reg Registry) error {
for _, key := range sortedKeys(reg.Ops) {
op := reg.Ops[key]
method, path, _ := strings.Cut(key, " ")
shape := doc.Paths[path][strings.ToLower(method)]
if shape == nil {
return fmt.Errorf("typed op %q has no live route — the registry and the router disagree about its path", key)
}
legacy := slices.Contains(op.Tags, Compat)
op.Tags = shape.Tags
if legacy {
op.Tags = append(op.Tags, Compat)
}
doc.Paths[path][strings.ToLower(method)] = op
}
// MERGE, never replace: From may already have named schemas from Register.
// On a name both seams claim, the typed op wins — its schema is derived from
// the handler signature itself, the strongest evidence there is — and the
// override is deterministic (sorted key order above, single writer here).
if len(reg.Schemas) > 0 {
if doc.Components == nil {
doc.Components = &Components{Schemas: map[string]any{}}
}
for _, name := range sortedKeys(reg.Schemas) {
doc.Components.Schemas[name] = reg.Schemas[name]
}
}
return uniqueOperationIDs(doc)
}
// Spec reads the live router and every projection of it — the one call a caller
// wants.
//
// Three readings, in the order each earns: the router gives the total set of
// addresses, the typed registry gives detail for the ones that have any, and the
// relays give what is BEHIND the addresses that are doors rather than endpoints.
// Project runs last because a door is only a door once the router has been read,
// and because what it substitutes is already a finished document.
func Spec(app *zip.App, info Info, servers ...Server) (*Document, error) {
doc, err := From(Live(app), info, servers...)
if err != nil {
return nil, err
}
reg, err := Typed(app)
if err != nil {
return nil, err
}
if err := Fold(doc, reg); err != nil {
return nil, err
}
if err := Project(doc, relays()); err != nil {
return nil, err
}
// Audience is stamped LAST, on the finished set of addresses, because it is
// the only point at which every operation this app publishes exists at its
// published address: Fold replaces structural operations with typed ones and
// Project replaces doors with what is behind them, so a mark written before
// either would be thrown away by it. See openapi/public.go.
stamp(doc)
return doc, nil
}
// uniqueOperationIDs refuses a document a generator would mis-consume. It runs
// after BOTH projections because either can name an operation: From derives an
// id from method+path, Fold takes the registry's own (WithOperationID), and a
// clash between the two families is exactly as broken as one within either.
//
// The check subsumes the only ambiguity the document can suffer: two routes
// sharing a (method, path) derive the same id, so an injective
// (method, path) → operation map is what uniqueness buys.
func uniqueOperationIDs(d *Document) error {
seen := map[string]string{} // operationId → "METHOD path", for the clash message
for _, path := range sortedKeys(d.Paths) {
item := d.Paths[path]
for _, method := range sortedKeys(item) {
at := strings.ToUpper(method) + " " + path
if prev, dup := seen[item[method].OperationID]; dup {
return fmt.Errorf("operationId %q is claimed by both %q and %q — OpenAPI requires it unique",
item[method].OperationID, prev, at)
}
seen[item[method].OperationID] = at
}
}
return nil
}
// sortedKeys makes map iteration deterministic, so a clash reports the same pair
// every run.
func sortedKeys[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// translate rewrites a fiber pattern into an OpenAPI path template and returns
// the parameter names in path order.
//
// fiber :name → OpenAPI {name}. A fiber wildcard (* or +) has no OpenAPI
// equivalent; it becomes {wildcardN}, numbered in path order. fiber's own key
// for it is "*1", which is not a legal URI-template name, so it cannot be
// reused verbatim.
func translate(pattern string) (string, []string) {
segs := strings.Split(pattern, "/")
var params []string
stars := 0
for i, s := range segs {
switch {
case strings.HasPrefix(s, ":"):
name := strings.TrimSuffix(strings.TrimPrefix(s, ":"), "?")
segs[i] = "{" + name + "}"
params = append(params, name)
case s == "*" || s == "+":
stars++
name := fmt.Sprintf("wildcard%d", stars)
segs[i] = "{" + name + "}"
params = append(params, name)
}
}
return strings.Join(segs, "/"), params
}
// Mount serves the document at Path off app's OWN live router — the app it is
// registered on is the app it reads, so what it serves is that process's actual
// route table and nothing else.
//
// It is UNAUTHENTICATED, deliberately:
//
// - It is an API description of a public API, and it grants no capability.
// Every route it names stays individually auth-gated; reading the map does
// not open a door. Withholding it would be obscurity, not access control.
// - The `hanzo` CLI must build its command tree BEFORE a user logs in. Gating
// the spec would make `hanzo --help` require credentials.
// - It carries no schemas, no examples, no secrets — only addresses.
// - Enablement already scopes it: the document is generated from what THIS
// deployment mounted, so a deployment that does not enable admin does not
// list admin routes. The blast radius is the deployment's own surface.
//
// The cost accepted is path enumeration on a deployment that mounts admin. That
// is a recon convenience, not an authorization change. If a deployment ever
// needs it closed, the lever is a guard here — one line, one place.
//
// The document is built once, lazily, on first request: the route table is fixed
// after boot, and building lazily (rather than at Mount) means it includes every
// route — including this one, and any registered after Mount.
//
// The light HOST mounts no subsystem, so its live router is not the API — see
// [MountFleet], the other document source at this same one address.
func Mount(app *zip.App, info Info, servers ...Server) {
serve(app, func() (*Document, error) { return Spec(app, info, servers...) })
}
// serve registers Path and answers it with whatever doc produces, rendered ONCE
// on the first request.
//
// One registrar for both document sources, so the address, the status, the
// encoding and the failure mode are stated once and cannot drift between them.
//
// LAZY is load-bearing in both: it is what lets Mount's document contain the
// route this very call registers, and what keeps MountFleet's weave off the
// host's boot path.
//
// ONCE covers the bytes, not just the value, and that is not an optimization —
// it is the shape of what this is. The route table is fixed after boot, so the
// document is immutable and re-encoding it per request is work whose answer
// cannot change. It matters because the door is public and unauthenticated on
// the front-door router: the fleet document is megabytes, and re-marshalling it
// per request is an amplifier anyone can pull. Rendered, a repeat request is a
// memcpy and the Document itself is collectable.
//
// encoding/json rather than the app's own encoder, because these are the bytes
// openapi.yaml is rendered from (openapi/weave_test.go): the served document and
// the committed artifact are then the same bytes, not two encodings that agree.
// The document endpoint describes ITSELF, in an init rather than in serve, because
// serve runs once per document source (Mount and MountFleet) and Describe refuses a
// duplicate — a self-description that panicked the second time a process mounted
// would be worse than none.
//
// It is the one operation with no owning subsystem, so nothing else would ever
// declare it, and it was the last route in the fleet publishing an operationId and
// nothing else. A generated SDK offers it as a method; a spec-derived CLI offers it
// as a command. Both should be able to say what it is.
func init() {
Describe(Path, http.MethodGet,
"The API description this SDK was generated from",
"Serves the OpenAPI document for the routes this process actually answers — "+
"generated from the live router at request time, not from a checked-in file that "+
"can disagree with it.\n\n"+
"On an app it is that app's own surface; on the fleet's front door it is the woven "+
"document for every mounted app. Unauthenticated by design: a client has to be able "+
"to read the contract before it holds a credential, and the document grants nothing.\n\n"+
"Rendered once and served as bytes thereafter, so the route table's immutability is "+
"what makes a repeat request a memcpy rather than a re-encode of a megabyte document.")
}
func serve(app *zip.App, doc func() (*Document, error)) {
render := sync.OnceValues(func() ([]byte, error) {
d, err := doc()
if err != nil {
return nil, err
}
return json.Marshal(d)
})
app.Get(Path, func(c *zip.Ctx) error {
body, err := render()
if err != nil {
return zip.ErrInternal(err.Error())
}
c.SetHeader("Content-Type", "application/json")
return c.Bytes(200, body)
})
// The command projection reads the SAME render, so both addresses are two
// readings of one artifact. Here rather than in either mount, because both
// document sources go through serve and the fifth projection belongs to
// whichever one this deployment has (command.go).
serveCommands(app, render)
}
// Door reports whether path is one [serve] registers — the document and its
// command projection.
//
// The doors are the host's, and they are the only operations that belong to NO
// app: they are declared in this package rather than beside any subsystem, no
// manifest row names them, and EVERY deployment serves them whatever subset of
// the fleet it runs. Three gates need exactly that fact and each had written it
// as the one literal that was true when it was written:
//
// Complete skips a description no app can own
// cmd/cloud/openapi_test.go exempts them from a scoped deployment's surface
// manifest/openapi_test.go refuses an app row that claims one
//
// So it is stated once, beside the code that makes it true. The second door
// arrived and all three were wrong the same afternoon — the cost of a literal is
// that it is right until it isn't and says nothing when it stops.
func Door(path string) bool { return path == Path || path == CommandPath }