Skip to content

Hooks

The contract iiiris maintains for the request-time customization seam — the internal/hook.Hook interface and its bundled engines. Operator how-to lives in docs/hooks.md; this spec is the durable "what the subsystem guarantees" reference a re-implementation must satisfy.

What it does

A Hook lets user-supplied logic adjust three points of an image/info request — where an identifier resolves, whether it's authorized, and whether it redirects — plus two optional capabilities: Presentation descriptor overrides and image overlays (watermarking). The default is a no-op, so the rest of the system calls through a hook unconditionally. Hooks are selected and configured per deployment; zero-config startup uses Noop.

Surface

Public Go API

type Hook interface {
    Resolve(ctx context.Context, identifier string) (ResolveResult, error)
    Authorize(ctx context.Context, req AuthRequest) (AuthDecision, error)
    Redirect(ctx context.Context, identifier string) (string, error)
}

type ResolveResult struct{ Backend, Key string }

type AuthRequest struct {
    Identifier string
    Path       string
    Header     http.Header // read-only by convention
}

type AuthDecision struct {
    Allow                                  bool
    Reason, Profile, Substitute, Redirect  string
}

Optional capabilities, detected by type assertion:

// presentation.Describer
Describe(ctx context.Context, identifier string) (Descriptor, bool, error)

// hook.Overlayer — composite an image overlay (watermark) onto image responses
Overlay(ctx context.Context, req OverlayRequest) (*OverlaySpec, error)

type OverlayRequest struct {
    Identifier               string
    Path                     string
    Header                   http.Header // read-only by convention
    OutputWidth, OutputHeight int        // final derivative dims (post region+size)
}

type OverlaySpec struct {
    Backend, Key, Version    string  // overlay image, via source.Source; Key required
    Target                   string  // "output" (default) | "source"
    Anchor                   string  // nine-position grid; "" = center
    Fit                      string  // "none"(default) | "shrink" | "scale" | "tile"
    Scale                    float64 // Fit=scale: overlay longest side / base longest side
    OffsetX, OffsetY         int     // inset from the anchor, in the target's pixel space
    Opacity                  int     // 0–100; 0 ⇒ default 100
}

A nil *OverlaySpec (or a hook that doesn't implement Overlayer) means "no overlay". OverlaySpec.CacheFragment() returns the canonical string the server folds into the render-cache key.

Constructors, all returning a Hook, wired by hook.Build(config.HookConfig):

  • NewLookup(backend, template string)
  • NewJS(scriptPath, scriptInline string, watchInterval, timeout time.Duration)
  • NewLua(scriptPath, scriptInline string, watchInterval time.Duration)
  • NewWebhook(url string, timeout time.Duration, authHeader string)

Engines

hook.type Engine Notes
"" / noop Noop Default. Pass-through resolve, allow-all auth, no redirect.
lookup Lookup Go text/template identifier→key, optional backend pin.
js JS Sandboxed JavaScript (goja, pure-Go). Recommended scripting engine.
lua Lua Sandboxed Lua 5.1 (gopher-lua). Deprecated — removed in iiiris 1.0.
webhook Webhook Out-of-process HTTP POST per call.

Config

config.HookConfig: type selector + per-engine blocks lookup, js, lua, webhook. The scripting blocks:

  • js.{path, script, watch_interval, timeout} — one of path/script required; watch_interval 0=default(5s)/<0=disabled; timeout 0=default(1s).
  • lua.{path, script, watch_interval} — same, no per-call timeout.

No environment-variable overrides for hook config.

Script ABI (js)

A js script assigns a handler object to module.exports. All methods optional; a missing or non-function property is pass-through.

Method Signature Returns
resolve (identifier) {backend, key} or null
authorize (identifier, ctx) where ctx = {path, headers} {allow, reason, profile, substitute, redirect} or null
redirect (identifier) URL string or null
describe (identifier) {label, metadata:[{label,value}], canvases:{<base>:{label}}} or null
overlay (identifier, ctx) where ctx = {path, headers, outputWidth, outputHeight} {backend, key, version, target, anchor, fit, scale, offsetX, offsetY, opacity} or null

Host surface injected into the runtime: log(level, msg, fields?) only.

Contracts

  • Zero-config default is Noop. No hook configured ⇒ pass-through; Resolve/Redirect zero values mean "use defaults" (backend = sources.default, key = identifier), Authorize allows.
  • Concurrency-safe. Every method may be called per request from many goroutines. Scripting engines pool per-generation VM/runtime states; scripts must be pure (no module-level mutation leaked across calls).
  • Resolve zero-value semantics. Empty Backend falls back to the default source; empty Key falls back to the raw identifier.
  • Authorize can only restrict. It runs after the rule engine and may flip allow→deny or add metadata (Profile/Substitute/Redirect); it cannot lift a rule-driven deny. A nil/empty decision is allow.
  • Redirect runs before auth. A non-empty URL produces an immediate 303, ahead of auth and IIIF parameter parsing. It wins over the auth-flow's own Decision.Redirect.
  • AuthRequest.Header is read-only. Impls must not mutate it. The js engine exposes it as a name→first-value object; webhook does not forward it.
  • Failure semantics (scripting engines). A throw, timeout, or malformed return is a server fault: Resolve/Redirect → 500 (no fallback to defaults); Authorize → deny (fail closed); Describe → fail open (override ignored, server-derived descriptor used, WARN logged). Timeouts are treated identically to errors.
  • Sandbox. js and lua have no filesystem, network, or process access. js: bare goja runtime, no require/Node/timers, only the log() host builtin. lua: only base/string/table/math, with loadstring/loadfile/dofile/load stripped.
  • Hot reload (path mode). A changed script file (mtime, checked at most once per watch_interval) is recompiled and the generation swapped atomically; in-flight calls finish on the old generation. A recompile/init failure is logged WARN and the previous generation stays live. Inline scripts are never reloaded.
  • Describe is opt-in. Only js implements presentation.Describer; hooks that don't are skipped in descriptor layering. found=false means "no opinion".
  • Overlay is opt-in and fail-closed. Only hooks implementing Overlayer overlay; a deployment without one does no overlay work and keeps the zero-touch cache hit. Overlay runs per image request (never on the info path), before the render-cache lookup so its decision keys the entry. A throw, timeout, malformed return, invalid enum, or unreadable overlay image is a server fault → 500 (fail closed — never serve an un-watermarked image for an overlay-keyed request). A null/absent return is "no overlay".
  • Overlay validation + defaults. Key is required; empty Backend ⇒ default source. Defaults: target=output, anchor=center, fit=none, opacity=100 (0 ⇒ 100). fit=tile requires target=output; fit=scale requires scale>0. Invalid target/ anchor/fit are malformed (fail closed); validation never coerces.
  • Overlay placement. target=output composites at pipeline stage 6 (after rotation + quality): fixed pixel size, upright, in the output colorspace. target=source composites before rotation, placement mapped through the request's region + size: it scales and rotates/mirrors with the image and is cropped away if the region excludes it. The image is loaded via source.Source ({backend, key}) like resolution; any decodable image is accepted and alpha is honoured.
  • Render-cache keying folds in the overlay. When (and only when) an overlay applies, an append-only hash of the resolved OverlaySpec (image reference + version, target, anchor, fit, scale, offsets, opacity) is appended to the render-cache key, composed alongside the auth tier. An un-overlaid request keeps the legacy path-keyed shape (no churn, no cacheKeyVersion bump); a distinct overlay never collides with a different overlay or an un-overlaid render. The hash keys the image reference, not its bytes — swap the file under the same key and bump version to invalidate. Resolving an overlay requires concrete output dimensions, so when an Overlayer is configured the server reads source dimensions before the cache lookup.
  • Cache keying is otherwise unaffected by hooks. Resolve/redirect don't change the key — render/info caches key on the IIIF URL path, not the resolved backend key (see docs/specs/scaffold.md); the overlay segment above is the one exception.
  • govips/prometheus isolation is preserved. The hook package depends on neither; the js engine's only new dependency is the pure-Go goja (no CGO), keeping the static/distroless build intact.

Out of scope

  • Lua header access / per-call timeoutlua is deprecated; it keeps its pre-existing surface (identifier + path, no timeout) until removal in 1.0. No new investment.
  • Webhook header forwarding — the AuthRequest.Header field is not forwarded in the webhook JSON envelope. Deferred to a follow-up.
  • Third-party package loading — no require/import of external modules at runtime. Author dependency-free scripts (bundle/compile ahead of time; e.g. TypeScript → single .js).
  • WASM / other engines — the JS engine is the chosen mainstream scripting surface; alternative engines (wazero/WASM, CGO V8/CPython) were rejected for the pure-Go, no-build-step posture.
  • Persistent cross-request state — scripts get per-call locals only; shared mutable state would break the purity + pooling invariant.
  • Overlay scope — image overlays only (no text/string rendering); one overlay per request (no list); plugin-only (no config-driven "apply to all" block); blend mode is over only. A full-resolution source composite (overlay drawn on source pixels before resize) and a dedicated decoded-overlay cache are not built — target=source maps through region+size on the already-resized image, and overlay bytes ride the existing OriginCache for http/s3.