Documentation
¶
Overview ¶
Package modelhooks runs a project's schema-declared lifecycle hooks on the REST write path.
A hook is an edge function called by this server before or after a write, so it can validate, enrich, reject, or react. Separate from internal/hooks, which is GoTrue's *auth* hooks — same word, different feature, and merging them would confuse both.
What a hook is not: a security boundary. It fires for writes through this API, so direct SQL, seeds and migrations bypass it. Invariants belong in RLS.
Note what does *not* bypass it: `service_role`. That key changes what Postgres permits, not whether this middleware runs, so a service-role write over HTTP fires hooks like any other — which is why a hook writing to its own table would call itself forever without the depth guard.
Index ¶
- Constants
- func Middleware(opts Options) func(http.Handler) http.Handler
- func ViewsFromManifest(hooks map[string]proxy.TableHooks) map[string]TableHooksView
- type Callback
- type Claims
- type ClaimsFunc
- type Dispatcher
- type Doer
- type HookConfigEntry
- type HookConfigView
- type HooksFunc
- type Operation
- type Options
- type Outcome
- type OutcomeKind
- type TableHooksView
- type Target
- type UpstreamResolver
Constants ¶
const ( EventBeforeChange = "beforeChange" EventAfterChange = "afterChange" EventBeforeDelete = "beforeDelete" EventAfterDelete = "afterDelete" )
Event names, matching what the CLI writes into the manifest.
const DefaultPreviousLimit = 100
DefaultPreviousLimit caps how many rows a hook can pull back.
A hook on an unfiltered `PATCH` would otherwise be handed the table. Over the cap the answer carries `truncated: true` so the hook can refuse rather than act on a prefix it did not know was a prefix.
const DefaultTimeout = 2 * time.Second
DefaultTimeout is used when a hook declares none. Well below the edge-function ceiling, so a hung hook fails fast instead of occupying an invocation slot for ten seconds.
const HookDepthHeader = "X-Supatype-Hook-Depth"
HookDepthHeader carries how many hooks deep a write already is.
A hook receives the service-role key and can write through the API. If it writes to a table that declares the hook it is running as, that write re-enters this middleware and calls the same hook again — the classic trigger loop, except each hop is a fresh HTTP request holding a connection and a function slot. Nothing stops it: `service_role` changes what Postgres permits, not whether this middleware runs.
So the chain counts itself. The server stamps this header on every hook invocation, and the worker re-emits it on stack-bound requests a handler makes, so the count survives the hop through code we do not control.
const HooksRoutePrefix = "hooks/"
HooksRoutePrefix is the namespace the functions worker serves hooks under, and the one the public functions path refuses. Exported so the mount and the worker cannot drift apart on it.
const MaxBodyBytes int64 = 1 << 20 // 1 MiB
MaxBodyBytes caps what will be buffered to show a hook.
A before hook cannot be called without the body, so an oversized body is refused rather than waved through: skipping the hook would mean a write the schema said to validate arriving unvalidated, with nothing in the response to say so.
const MaxHookDepth = 4
MaxHookDepth is how deep a chain may go before a write is refused.
Not 1: fanning out to another table's hook is legitimate and useful — a `beforeChange` on `posts` writing an `audit_log` row whose own hook ships it somewhere is two levels and entirely reasonable. A runaway loop, by contrast, passes any small number immediately, so the limit only needs to be low enough to fail fast.
const PreviousPathPrefix = "/hooks/v1/previous/"
PreviousPathPrefix is where the callback is mounted. Sent to a hook as a **path**, not a URL: the server has no portable way to know its own in-network address, while the worker is already told how to reach the stack (`SUPATYPE_INTERNAL_URL`). The generated adapter joins the two.
Variables ¶
This section is empty.
Functions ¶
func Middleware ¶
Middleware runs a table's declared hooks around a write.
Mounted inside the response cache, so a cached read never reaches it. A request with no hook work is handed straight through with its body untouched — that is the overwhelmingly common case and it must stay free.
func ViewsFromManifest ¶
func ViewsFromManifest(hooks map[string]proxy.TableHooks) map[string]TableHooksView
ViewsFromManifest adapts a manifest's hook map into the view types this package works in.
Exported so the mount can build a HooksFunc without the middleware depending on how the map was delivered — a manifest file today, possibly a control-plane push later.
Types ¶
type Callback ¶
type Callback struct {
// contains filtered or unexported fields
}
Callback mints and serves the `previous()` endpoint.
Reads run as the **service role**, so a hook sees the rows as stored — RLS bypassed, field masking not applied. That is deliberate: a hook validating against a masked column would otherwise compare with `NULL` and pass. It is also not a privilege the hook lacks, since the worker already holds the service-role key; the endpoint is a convenience, not an escalation.
The token is what keeps it from being a general "read any rows matching any filter" surface: it pins the table and the filter to one in-flight request and expires with it.
func NewCallback ¶
func NewCallback( restBase func(*http.Request) string, schemaFor func(*http.Request) string, serviceRoleKey string, client Doer, ) (*Callback, error)
NewCallback builds the endpoint. The signing key is generated here and never leaves the process, so tokens are useless to anything but this server, and useless at all after a restart.
type Claims ¶
type Claims struct {
Sub string `json:"sub"`
Role string `json:"role"`
Email string `json:"email,omitempty"`
}
Claims is the caller identity a hook payload carries.
type ClaimsFunc ¶
ClaimsFunc reads the verified caller identity from a request, or nil for an anonymous one.
func ClaimsFromBearer ¶
func ClaimsFromBearer(jwtSecret string) ClaimsFunc
ClaimsFromBearer builds a ClaimsFunc that reads the caller from the request's bearer token.
Verified with the same secret PostgREST validates against, so a hook is never told about an identity the database will not honour. On any failure — no token, a bad signature, the project's anon key rather than a user token — the hook sees `null`, which is what "an anonymous caller" means. Guessing would be worse than saying nothing: a hook that trusts `user.sub` to decide something should be handed a verified subject or none at all.
A token with no `sub` is anonymous by this definition. The anon and service-role keys carry a role and no subject, so they arrive here as null rather than as a user with an empty id.
type Dispatcher ¶
type Dispatcher struct {
// contains filtered or unexported fields
}
Dispatcher calls hook functions and turns their answers into outcomes.
Deliberately **not** `internal/hooks/hookshttp`: that dispatcher retries, which is right for an auth webhook and wrong here. A retry before a write multiplies the latency the caller waits, and re-invokes a handler that may already have acted. It also folds every non-2xx into an error, which would lose the distinction this design turns on — a 4xx is the hook saying no, a 5xx is the hook being broken.
func NewDispatcher ¶
func NewDispatcher(client Doer, secret string) *Dispatcher
NewDispatcher builds a dispatcher. A nil client means the default HTTP client.
func (*Dispatcher) Call ¶
func (d *Dispatcher) Call( ctx context.Context, url string, event string, cfg HookConfigView, payload []byte, depth int, ) Outcome
Call invokes one hook and classifies the answer.
The **status is the outcome**, which is why this reads as a switch on it rather than a hunt through a body envelope: the transport already has a vocabulary for "no", and a hook written in another language should not have to learn ours.
type HookConfigEntry ¶
HookConfigEntry is one hook's configuration.
type HookConfigView ¶
type HookConfigView struct {
TimeoutMs int
}
HookConfigView is the part of a hook's config the dispatcher needs.
func (HookConfigView) RejectsWhenUnavailable ¶
func (h HookConfigView) RejectsWhenUnavailable(event string) bool
RejectsWhenUnavailable reports whether an unreachable hook should fail the write.
The default is decided by the CLI and written into the manifest, so both sides agree. An empty value here means the manifest predates that, and the safe reading for a *before* hook is to reject: a validation hook that stopped running must not quietly pass writes through.
func (HookConfigView) Timeout ¶
func (h HookConfigView) Timeout() time.Duration
Timeout is the configured timeout, or the default.
type HooksFunc ¶
type HooksFunc func(*http.Request) map[string]TableHooksView
HooksFunc returns the current hook map — read per request, since the manifest is hot-reloaded.
type Operation ¶
type Operation string
Operation is the write a request performs, as a hook payload reports it.
type Options ¶
type Options struct {
Dispatcher *Dispatcher
Hooks HooksFunc
ResolveURL UpstreamResolver
Claims ClaimsFunc
RequestID func(*http.Request) string
MaxBodyBytes int64
// Callback mints the `previous()` path. Optional: without it the context simply has no
// `previous`, which the generated types already model as absent rather than as a broken call.
Callback *Callback
}
Options configures Middleware.
type Outcome ¶
type Outcome struct {
Kind OutcomeKind
Status int
Body []byte
// Reason explains an OutcomeUnavailable, for the log line. Never sent to the caller: it may name
// internal hosts, and a caller cannot act on it.
Reason string
}
Outcome is the result of calling one hook.
type OutcomeKind ¶
type OutcomeKind int
OutcomeKind is what the server should do next.
const ( // OutcomeProceed — carry on with the request unchanged. OutcomeProceed OutcomeKind = iota // OutcomeReplace — carry on, using Body as the request body. OutcomeReplace // OutcomeReject — the hook said no. Status and Body go to the caller. OutcomeReject OutcomeUnavailable )
type TableHooksView ¶
type TableHooksView map[string]HookConfigEntry
TableHooksView is one table's hooks, decoupled from the manifest type so this package does not depend on how they were delivered.
type Target ¶
type Target struct {
Table string
Operation Operation
// Before is the hook to call before the write, if the table declares one.
Before *HookConfigEntry
// After is the hook to call once the write has succeeded, if the table declares one.
After *HookConfigEntry
// BeforeEvent and AfterEvent name the events, so a dispatcher can set the header without
// re-deriving them from the operation.
BeforeEvent string
AfterEvent string
}
Target is the hook work a single request implies.