httpx

package
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 3, 2026 License: Apache-2.0 Imports: 33 Imported by: 0

Documentation

Overview

Package httpx builds the single Huma API the application serves, records every operation the adapter mounts, and enforces that each one declares exactly one authorization: a permission, public, or any signed-in user.

The declaration

Register is the only way a module mounts a handler, and it takes an Auth alongside the operation, so "I forgot to say who may call this" is not a state a route can be in. The declaration is written into op.Extensions, which is the same object the OpenAPI document renders and ctx.Operation() returns at request time: the reviewer, the document and the middleware read one value.

Recording

huma adds an operation to the OpenAPI document only when it is not hidden, so walking the document would miss exactly the routes most likely to be forgotten. Everything, hidden or not, passes through the adapter's Handle, so this package records there instead — huma's own documentation routes included, declared Public where they are mounted. ValidateDeclarations reads that recording and kit/app refuses to start when it reports anything.

The huma.API and the adapter are both unexported fields, and neither has an accessor. That is not tidiness: a handler mounted straight on the adapter is recorded, so it passes the boot gate, and yet it is mounted below this package's middleware, so it resolves no tenant, opens no transaction and is never authorized. A door that satisfies the gate and skips the enforcement is worse than no gate, so there is no door. Everything mounts through Register.

The transaction

This is the one place a request obtains a tenant transaction. The middleware chain resolves the tenant from the request host and puts a pending transaction on the context; TxFrom opens it on the first query, so a request that never touches the database never opens one. A response of 400 or worse rolls it back, and the response itself is held until the commit succeeds.

Index

Constants

View Source
const AuthExtension = "x-platformkit-auth"

AuthExtension is the OpenAPI extension an operation's declaration is written to. It is a document key as well as a runtime one: the declaration a reviewer reads in /openapi.json is the value the middleware enforces, because Register writes it once and both sides read that same map.

View Source
const EventsExtension = "x-platformkit-events"

EventsExtension is the OpenAPI extension an operation lists the events its handler publishes under. kit/rest writes it when it mounts a Spec, and kit/app reads it back to check that some module declared each one — the same recording, the same object and the same gate as the authorization declaration, rather than a second channel to keep in step.

View Source
const (
	MaxBodyBytes = 1 << 20
)

The two body ceilings, and the reason there are two.

A request body with a schema is huma's business: it decodes one and it bounds one, at op.MaxBodyBytes, which is a megabyte unless a route says otherwise. A route that reads the request itself has no such bound, and there is exactly one of those — the file upload. A review trickled a body into it at a byte a second and nothing anywhere said stop.

So every request gets an http.MaxBytesReader before it reaches a handler: a megabyte, or MaxUpload plus an envelope for the route that streams. The envelope is a megabyte because a multipart form's part headers and boundaries are measured in hundreds of bytes and a round number nobody has to compute is worth more here than a tight one.

View Source
const RequestIDHeader = "X-Request-ID"

RequestIDHeader is the header a request id arrives in and leaves in.

View Source
const SessionCookie = "platformkit_session"

SessionCookie is the base name of the cookie a browser session travels in. It is here, and not in the auth module that mints it, because the kernel has to recognise it twice without knowing anything else about sessions: to refuse a cross-site write (csrf) and to decide that a request is worth authenticating at all.

View Source
const SignInExtension = "x-platformkit-signin"

SignInExtension is where an operation names the form an anonymous caller should be sent to instead of being refused.

It is an operation's own declaration rather than an option of the API, because "there is a login page and it is at /admin/login" is knowledge the module that serves that page has and the kernel does not. modules/admin writes it on every HTML page it mounts; nothing else writes it, and an application with no shell has no redirect and no line of configuration saying so.

View Source
const StreamedBodyExtension = "x-platformkit-streams-body"

StreamedBodyExtension marks an operation that reads the request body itself instead of declaring a schema for it. Such a route gets Options.MaxUpload plus an envelope rather than MaxBodyBytes; nothing else about it changes.

It is an extension on the operation and not a field on Options because the kernel must not know which module happens to own the route. See StreamedBody.

Variables

This section is empty.

Functions

func ConnFrom

func ConnFrom(ctx context.Context) (*db.Conn, bool)

ConnFrom is the application connection this request is served on.

It is the second half of SystemToken: db.RunSystem takes a connection and a capability, and a module holds neither until it is handed them. A module that has not taken a token can do nothing with this that it could not already do with the request's own transaction.

func CookieName

func CookieName(base string, secure bool) string

CookieName is the name a first-party cookie is set under, which depends on whether it will carry Secure.

__Host- is a rule the browser enforces: a cookie named with it is accepted only with Secure, Path=/ and no Domain, and a page on another host cannot set one the browser will send here. That last part is why it matters here — every tenant is reached at its own host, often as siblings under one registrable domain, so without the prefix a page at one customer's host can set platformkit_session for the parent domain and have it attached at every other customer's. It is dropped when the cookie is not Secure, because a browser refuses one of those over http://localhost; both names are recognised on the way in, and a deployment only ever presents one.

func HTML

func HTML[I any](api *API, op huma.Operation, auth Auth, handler func(context.Context, *I) (*Page, error))

HTML mounts one page, with the authorization it declares, exactly like every other operation in the application. Hidden keeps it out of the OpenAPI document — a page is not an API — and the kernel records it anyway, so the boot gate still sees it. See this package's comment.

The caller builds the operation, because what a page belongs to is the caller's to say: its tag, its summary, and the sign-in form an anonymous visitor is sent to (SignIn). What is not the caller's is the shape of the answer, which is why a SeeOther returned by the handler becomes the redirect here rather than in each of the seven screens that return one.

func HostOnly

func HostOnly(host string) string

HostOnly is the loader's key: the Host header without its port and without the brackets an IPv6 literal carries, lower-cased, without the trailing dot a fully qualified name may have. Normalising here means every TenantLoader is spared doing it, and doing it differently.

It is exported because the module that stores a host has to spell it the same way as the middleware that looks one up, and two normalisations that drift is a domain that resolves for nobody.

func LocalPath

func LocalPath(p string) bool

LocalPath reports whether p is a path on this site and nothing else: no scheme, no authority, a leading slash, no backslash.

It is the answer to "may I send a browser here", and there is one of it because there were two and one of them was wrong. The admin sign-in form checked a leading slash and a second character that was not a slash, so `next=/\evil.example` passed — and every browser resolves a slash followed by a backslash as an authority, so the review's login redirect left the site. modules/site had the correct rule for its navigation; this is that rule, in the kernel, and both callers read it.

url.Parse is what tells the rest apart: a path on this site parses to no scheme, no authority and a path that begins with a slash. The two prefix checks come first because the parse alone is not enough — url.Parse reports "//evil.example" as an empty host and a path, and a browser collapsing the slashes goes to evil.example — and the backslash is refused outright, because it has no meaning in a path and its only use here is to look like something else.

func Register

func Register[I, O any](api *API, op huma.Operation, auth Auth, handler func(context.Context, *I) (*O, error))

Register mounts an operation together with the authorization it declares. It is the only way a module registers a handler, and the declaration is a parameter rather than a field, so an operation cannot be written without one.

func RequestFrom

func RequestFrom(ctx context.Context) (*http.Request, bool)

RequestFrom is the request being served.

A handler wants it for the things huma's typed input cannot express and that are properties of the connection rather than of the operation: the host an absolute redirect has to be built for, and the address and user agent a session records so that a person can recognise it in a list. Reading the body through it is a mistake — huma has already decoded it — and reading the context off it is another, because this is the request as it was before the tenant, the transaction and the principal were put on it.

func SameSite

func SameSite(r *http.Request) bool

SameSite is the browser's own account of where a request came from: Sec-Fetch-Site, which a page cannot forge, and an Origin whose host is this one for the older clients that do not send it. A request that says neither is not a browser, and reads as same-site — see the middleware above for why that is the deliberate answer and not a hole.

It is exported for the one route the middleware cannot cover: a sign-in carries no session cookie, so nothing was attached on the caller's behalf and the middleware lets it through. That is right for the general rule and wrong for that route, because a cross-site sign-in mints a credential rather than spending one — the attacker signs the visitor into the attacker's own account and then reads what the visitor does in it. modules/auth asks this directly.

func Script

func Script(ctx context.Context, js string) g.Node

Script is an inline <script> with this request's content security policy nonce on it. A page that must run something before the first paint uses it; every other script is a file under Static and needs nothing.

It exists because the alternative is remembering: the policy in headers.go allows an inline script only with the nonce, so a tag written without one is dropped by the browser and reported in a console nobody is reading. This is the shape that cannot be written wrong.

func SessionCookieOf

func SessionCookieOf(r *http.Request) (*http.Cookie, bool)

SessionCookieOf is the session cookie the request presents, under either name. It is exported because the auth module reads the one the kernel recognised: two spellings of "which cookie is the session" is a session one half of the program can see and the other cannot.

func SignIn

func SignIn(op *huma.Operation, path string)

SignIn declares that this operation is a page and names the sign-in form. It is written into op.Extensions, beside the authorization, so a reviewer reading /openapi.json sees both.

func StreamedBody

func StreamedBody() (string, any)

StreamedBody is what a route that reads its own request puts in its Extensions, beside EventsExtension. There is one caller, and there should stay one: a second route that reads its own body is a second route with no schema, no generated client and no bound but this one.

func TxFrom

func TxFrom(ctx context.Context) (db.Tx[db.Tenant], bool)

TxFrom returns the request's tenant transaction, opening it if this is the first query of the request. It reports false for a request that resolved to no tenant, and for one whose transaction could not be opened — the middleware logs that failure with its cause, and the handler's own error becomes the response.

Opening on demand rather than on arrival is what lets a liveness probe reach a tenant host while the database is down: a request that never queries never needs a database.

This is how a handler reaches the database: there is no other door, and a repository that takes db.Tx[db.Tenant] cannot be called without going through one.

func ValidPermission

func ValidPermission(token string) bool

ValidPermission reports whether token is a well-formed permission. kit/module checks a manifest's permission keys with it, so the grammar exists once.

func WithConn

func WithConn(ctx context.Context, c *db.Conn) context.Context

WithConn puts a connection on ctx. The transaction middleware calls it for every request; it is exported so that a test can put a service in the same position a request puts it in, rather than the service growing a second code path that exists to be testable.

Types

type API

type API struct {
	// contains filtered or unexported fields
}

API is the application's Huma API: the huma.API every registration goes through, plus the recording that makes boot-time validation possible.

func New

func New(cfg Options) (*API, *chi.Mux)

New builds the API and the router it is mounted on. The router is returned rather than hidden because static assets and the server itself need it; every route that is an API operation goes through Register.

func (*API) Declare

func (a *API) Declare(grants []tenancy.Grant)

Declare records every permission the composition defines. kit/app calls it once, from the manifests, before any module registers its routes.

It is here rather than in a module's Deps because the one module that needs the list — auth, which refuses a role naming a permission nobody defines — would otherwise be handed what every other module declares, and a module that knows the catalogue knows its neighbours. Module.Routes is given this API, which is the one moment the kernel has the whole list.

func (*API) Events

func (a *API) Events() []string

Events lists, once each, every event a recorded operation says it publishes.

func (*API) InvalidateHost

func (a *API) InvalidateHost(host string)

InvalidateHost forgets a cached resolution, so a rename or a removal takes effect now rather than within hostTTL. The tenant module calls it when it changes a host; nothing else has any reason to.

func (*API) Permissions

func (a *API) Permissions() []tenancy.Grant

Permissions is every permission the composition defines, in name order. A module validating a list somebody typed asks this, not its neighbours.

func (*API) Probes

func (a *API) Probes(h http.Handler, paths ...string)

Probes mounts handlers beside the API, on the router that carries neither the request middleware nor a transaction. kit/health is the only caller, and the two paths it names are /health and /ready.

A probe is not an operation. It has no tenant — an orchestrator reaches an instance at a pod address that names no site — no session, and nothing to declare. Going through the chain would make liveness depend on the one query every request makes before it is a request: the host lookup, which has a two second budget of its own and never hits the cache for a pod address, because only a successful resolution is cached. A liveness probe with a two second timeout therefore fired during a database outage and the kubelet restarted pods whose only problem was that their database was unreachable, which is the exact failure the probes exist to avoid.

This is a narrow door and not a general "mount a handler" one, for the reason the package comment gives: a handler mounted below this package's middleware resolves no tenant, opens no transaction and is never authorized, so the only handlers that may take it are the ones that must answer without any of the three. Static is the other. Both are named for what they carry.

func (*API) Recorded

func (a *API) Recorded() []*huma.Operation

Recorded returns every operation the adapter has handled, in registration order, hidden ones included.

func (*API) RegisterResource

func (a *API) RegisterResource(r Resource)

RegisterResource records a resource, with this API's authorization wrapped around each of its five operations. kit/rest calls it from Spec.Mount, in the same breath as the routes, so a resource and its API cannot disagree about a permission or a path.

The wrapping is here rather than in kit/rest because this is where the Authorizer is: a Resource is the entity without its routes, and the routes are where the permission used to live. A hand-written page holds a Resource and calls List on it directly — the dashboard does — so a closure that did not ask would be a page that reads past the permission whenever whoever wrote it forgot to. Now forgetting is not available.

func (*API) Required

func (a *API) Required() []tenancy.Grant

Required lists, once each, every grant a recorded operation asks for, with the operator flag the route declared. kit/app checks it against the manifests, so a permission nobody defines — or one the two sides disagree about — fails startup rather than denying everyone forever or, worse, letting a customer's wildcard through the control plane.

func (*API) Resources

func (a *API) Resources() []Resource

Resources is every registered resource, in mount order. modules/admin reads it in Routes, which is why the shell is composed last: a module that mounts after it registers a resource no screen was generated for.

func (*API) Static

func (a *API) Static(prefix string, fsys fs.FS)

Static mounts a file tree beside the API, on the router that carries neither the request middleware nor the transaction. Static assets are not operations: there is no handler to authorize, no tenant transaction to open and nothing to declare, so they never appear in Recorded and never hold a response in memory waiting for a commit.

func (*API) SystemToken

func (a *API) SystemToken() tenancy.SystemToken

SystemToken is the capability that opens a cross-tenant transaction, handed to a module at the one moment it is being wired: Module.Routes is given this API, and a module that registers a control-plane route takes the token there.

It is a method rather than something kit/internal/syscap would mint for anybody, because the point is that the set of modules holding one is short and visible. `grep -rn 'SystemToken()' modules/` is that list, and there is no other door: nothing outside kit/ can construct or implement a token, so a module that wants to cross tenants has to write this call where a reviewer reading the manifest will see it. See docs/adr/0006.

A route that holds one still runs inside its own tenant's transaction, so it opens the system transaction on a detached context (db.Detached) — two transactions, and the control-plane one commits on its own.

func (*API) ValidateDeclarations

func (a *API) ValidateDeclarations() error

ValidateDeclarations names every operation that does not carry an authorization this package minted. kit/app calls it once every route is registered and refuses to serve when it returns anything; the request-time middleware denies the same operations, so this turns a 403 nobody notices into a startup failure someone has to fix.

type Auth

type Auth struct {
	// contains filtered or unexported fields
}

Auth is the authorization an operation declares. Its fields are unexported and its constructors are the only way to build a usable value, so "some operation declares an authorization I did not think of" is not expressible.

func OperatorPermission

func OperatorPermission(token string) Auth

OperatorPermission requires the caller to hold token, and requires the tenant the request resolved to be the operator's own.

It exists because the control plane is served at every tenant's host — an installation has no host of its own, only its customers' — so a permission alone does not guard it: a customer's administrator holds the wildcard in their own tenant, and that wildcard used to list, create and suspend the tenants beside them. The kernel refuses such a route on an ordinary tenant before it asks the Authorizer anything, and no wildcard satisfies one.

func Permission

func Permission(token string) Auth

Permission requires the caller to hold token, checked against Options.Authorize in the tenant the request resolved to.

An ill-formed token is a wiring mistake rather than a request-time condition, so it panics at the registration site instead of turning into a permission nobody can ever hold.

func Public

func Public() Auth

Public admits every caller, signed in or not. It is the declaration that has to be justified in review.

func SignedIn

func SignedIn() Auth

SignedIn admits any caller carrying a principal for the resolved tenant, whatever that principal may do. It is for operations about the caller themselves, where there is no resource to name a permission on.

func (Auth) Declared

func (a Auth) Declared() bool

Declared reports whether a came from one of the four constructors. The zero Auth is not a declaration: Go lets any package write httpx.Auth{}, and an empty struct must never read as "public".

It is exported because kit/rest asks it about an optional field — a command that names no Auth takes its Spec's — and that question is the same one this package asks before it mounts anything.

func (Auth) MarshalJSON

func (a Auth) MarshalJSON() ([]byte, error)

MarshalJSON writes the declaration into the OpenAPI document as {"kind":"permission|public|signed_in","permission":"..."}. huma renders the YAML spec by converting the JSON one, so this is the only encoder needed.

type Authorizer

type Authorizer interface {
	Allowed(ctx context.Context, tenant tenancy.Tenant, grant tenancy.Grant) (bool, error)
}

Authorizer decides whether the caller of a request may exercise a grant in a tenant. The auth module implements it; a test implements it in three lines. It is the only thing this package knows about roles. It runs inside the request, so an implementation that needs the tenant's own rows reaches them with TxFrom.

The grant carries the operator flag as well as the permission, and the implementation is held to it: a wildcard must not satisfy an operator grant, because "everything in my tenant" is not "everything to every tenant". This package has already refused such a grant on a tenant that is not the operator's, so the question left is whether the caller's roles name it.

type Options

type Options struct {
	// PublicHost is the host the application believes it is reached at. It
	// names the server in the OpenAPI document; the tenant of a request always
	// comes from that request's own Host header, never from this.
	PublicHost string

	// Docs serves /openapi.json, /openapi.yaml and /docs. They are public and
	// unauthenticated by construction, and they publish every route and every
	// permission the application has, which is a map worth having before an
	// attack and worth withholding during one. The reference app turns them on;
	// a deployment that would rather not can turn them off. The JSON Schema
	// route stays either way, because response bodies link to it.
	Docs bool

	// Tenants maps a request host to a tenant.
	Tenants TenantLoader

	// Conn is the application connection every request transaction opens on.
	Conn *db.Conn

	// Authorize answers the permission questions the declarations ask.
	Authorize Authorizer

	// Authenticate recognises the caller. It runs after the host has resolved
	// to a tenant and is handed that request's own transaction, so a session
	// lookup is an ordinary tenant-scoped query under row-level security: the
	// auth module neither resolves the host a second time nor asks for a
	// capability to read across tenants, because a credential belonging to
	// somebody else's tenant is a row it cannot see.
	//
	// It reports false for an anonymous caller, which is not an error — a
	// Public operation serves them. An error is an outage: the request is a 500
	// and the reason is logged, because a session store that cannot be reached
	// must not read as "you are not signed in".
	Authenticate func(ctx context.Context, tx db.Tx[db.Tenant], r *http.Request) (tenancy.Principal, bool, error)

	// Log receives the reason behind every denial and every rolled-back
	// transaction. Defaults to slog.Default().
	Log *slog.Logger

	// MaxUpload is the largest file this deployment accepts, and it bounds the
	// body of the one kind of route that reads its own request rather than
	// declaring a schema for it — see StreamedBody. The envelope a multipart
	// form wraps the file in is allowed for on top of it.
	//
	// Zero means MaxBodyBytes, which is what every other route gets: a
	// deployment that mounts no streaming route needs no larger number, and a
	// test that mounts one is not testing the ceiling.
	MaxUpload int64
}

Options are the collaborators main chooses for the HTTP layer. Every field except Log, PublicHost and Docs is required: an API missing one of them could only fail closed on every request, which is worse than failing at New.

type Page

type Page struct {
	Status      int
	ContentType string `header:"Content-Type"`
	Location    string `header:"Location"`
	HXRedirect  string `header:"HX-Redirect"`
	Body        []byte
}

Page is an HTML response. huma writes a []byte body verbatim, so this is the whole of "serve HTML" — no second router, no second middleware chain, and the same recording, the same authorization declaration and the same transaction as every JSON route in the application.

func Document

func Document(node g.Node, status int) (*Page, error)

Document renders a node as a whole HTML document: the doctype, and then the node. Without the doctype a browser parses the page in quirks mode, which is a stylesheet that behaves differently for a reason nobody will find.

func Fragment

func Fragment(node g.Node, status int) (*Page, error)

Fragment renders a node as itself, with no doctype: a response htmx swaps into a page that is already open.

It is the same content type, because a fragment is HTML. What differs is that the browser is not being asked to make a document out of it, and a doctype in the middle of a page is what a browser does the strangest things with. The admin shell swaps whole pages and has no use for it; a storefront that replaces a cart badge does, which is why it is here rather than there — the copy that had to exist somewhere is this one.

type Resource

type Resource struct {
	Module, Entity, Path string
	// Read and Write are the permissions the Spec declared. A screen carries
	// the same ones, so a person who cannot use the API cannot use the screen.
	Read, Write string
	// OperatorWrite says Write is an operator permission: the rows are the
	// installation's, every tenant reads them, and only the operator's own
	// tenant writes them. It has to be carried here as well as on the routes,
	// because the closures below are a door of their own — a screen guarded by
	// the bare permission would let a customer's wildcard write through the
	// form what the API had just refused. See docs/adr/0008.
	OperatorWrite bool
	// Immutable are the fields a command owns, shown read-only in a form.
	Immutable []string
	Schema    crud.Schema

	List   func(ctx context.Context, q crud.Query) ([]map[string]any, int64, error)
	Get    func(ctx context.Context, id uuid.UUID) (map[string]any, error)
	Create func(ctx context.Context, values map[string]any) (map[string]any, error)
	Update func(ctx context.Context, id uuid.UUID, values map[string]any) (map[string]any, error)
	Delete func(ctx context.Context, id uuid.UUID) error
	// contains filtered or unexported fields
}

Resource is one entity as a screen sees it: what it is called, where its API lives, which permissions guard it, what shape it has, and the five operations bound to its type.

The operations are closures because generics do not survive the trip: a screen knows a resource by name and cannot name its Go type, so the type is closed over at registration instead. Each runs inside the request's own transaction, which it takes from the context — a screen holds no capability the route beside it does not.

Rows are maps because that is what a schema-driven screen renders: a column is a field name from Schema, and a value is whatever the entity's own JSON says it is. There is no second serialization; it is encoding/json, once.

func (Resource) Readable

func (r Resource) Readable(ctx context.Context) bool

Readable reports whether the caller in ctx holds this resource's Read permission. A page that lists resources — the dashboard — asks it before it renders a card, so a person is not shown a count of something they may not look at. It is the same question the closures ask; this is only the form that answers without producing an error to swallow.

func (Resource) Writable

func (r Resource) Writable(ctx context.Context) bool

Writable reports whether the caller in ctx holds this resource's Write permission, in a tenant that may exercise it.

func (Resource) WriteAuth

func (r Resource) WriteAuth() Auth

WriteAuth is the declaration a page that mounts a write route carries, so a screen and the API it stands in front of cannot disagree about which kind of permission this is.

type SeeOther

type SeeOther string

SeeOther is a handler saying the answer is somewhere else: the row a write just created, or the list a delete just changed.

It is an error because "not this page" is a return a handler already has: keeping it there is what lets every page have one signature.

func (SeeOther) Error

func (s SeeOther) Error() string

type TenantLoader

type TenantLoader interface {
	ByHost(ctx context.Context, tx db.Tx[db.System], host string) (tenancy.Tenant, error)
}

TenantLoader maps an incoming host to a tenant. The tenant module implements it in E2; it returns tenancy.ErrNoSuchHost when there is simply no site at the host, and any other error when it could not tell.

It takes a db.Tx[db.System] because the answer is a query and the row it looks for belongs to no tenant yet — the request has not resolved one. This package mints the capability, since no module can, and passes the transaction in: an implementation cannot open a cross-tenant transaction of its own, and the one it is handed lasts only for the call.

It is declared here rather than in kit/tenancy because kit/db imports kit/tenancy; this package already imports both.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL