modelhooks

package
v1.4.0 Latest Latest
Warning

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

Go to latest
Published: Aug 15, 2026 License: MIT Imports: 17 Imported by: 0

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

View Source
const (
	EventBeforeChange = "beforeChange"
	EventAfterChange  = "afterChange"
	EventBeforeDelete = "beforeDelete"
	EventAfterDelete  = "afterDelete"
)

Event names, matching what the CLI writes into the manifest.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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

func Middleware(opts Options) func(http.Handler) http.Handler

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.

func (*Callback) Handler

func (c *Callback) Handler() http.Handler

Handler serves the callback. Mounted at PreviousPathPrefix on the outer mux.

func (*Callback) Path

func (c *Callback) Path(op Operation, table, filter string) string

Path returns the callback path for one request's table and filter, or "" when there is nothing to fetch — an insert has no prior rows, so the context type omits `previous` entirely.

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

type ClaimsFunc func(*http.Request) *Claims

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 Doer

type Doer interface {
	Do(req *http.Request) (*http.Response, error)
}

Doer is the HTTP surface the dispatcher needs, so tests can supply their own.

type HookConfigEntry

type HookConfigEntry struct {
	Function      string
	TimeoutMs     int
	OnUnavailable string
}

HookConfigEntry is one hook's configuration.

type HookConfigView

type HookConfigView struct {
	TimeoutMs     int
	OnUnavailable string
}

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.

const (
	OpInsert Operation = "insert"
	OpUpdate Operation = "update"
	OpDelete Operation = "delete"
)

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 — the hook did not answer. The per-hook policy decides.
	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.

func Classify

func Classify(req *http.Request, hooks map[string]proxy.TableHooks) Target

Classify decides which hooks, if any, a request implies, from a manifest's hook map.

A thin adapter over classifyViews: the matching rules live in one place, and this package stays indifferent to whether the map arrived from a manifest file or a control plane.

func (Target) HasWork

func (t Target) HasWork() bool

HasWork reports whether anything needs calling.

type UpstreamResolver

type UpstreamResolver func(req *http.Request, function string) (string, error)

UpstreamResolver returns the URL to POST a named function to. Wired to the server's existing functions resolution rather than duplicating it.

Jump to

Keyboard shortcuts

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