Hooks¶
Hooks are the customization seam between an incoming IIIF request and the
source backend. The internal/hook.Hook interface has three methods:
| Method | Purpose | Wired? |
|---|---|---|
Resolve(ctx, identifier) |
Map IIIF identifier → (backend, key) |
✓ consulted by handlers before source.Open |
Authorize(ctx, AuthRequest) |
Per-identifier auth gate | ✓ consulted by auth.RuleAuthorizer.applyHook after the rule-engine verdict; hook can only make a verdict more restrictive (cannot lift a rule-driven deny) |
Redirect(ctx, identifier) |
303 to a different URL | ✓ consulted at the top of each image / info handler, before auth and parameter parsing — a non-empty URL fires a 303 immediately |
AuthRequest carries the per-request inputs to Authorize:
{ Identifier, Path, Header }. Header is the request's http.Header
(read-only by convention), so a hook can make decisions on cookies or
bearer tokens. The js engine exposes it to scripts; webhook does not
forward it (yet).
The default hook.Noop returns zero values for all three, which the
handler maps to "use defaults": backend = sources.default, key = identifier.
Optional capability: Describe (Presentation derivation)¶
A hook may additionally implement presentation.Describer:
When a directory identifier is auto-derived into a manifest/collection (see
the Presentation API), iiiris layers descriptor overrides over the filesystem
conventions: a manifest.{yaml,yml,json} sidecar in the directory first, then
the hook's Describe result (authoritative — it wins over both conventions
and the sidecar). A descriptor can set the manifest/collection label, a
metadata block, and per-child (canvas / member) label overrides. The
capability is detected by type assertion, so a hook that doesn't implement it
simply contributes nothing. The js engine implements it (via a
describe() handler); noop / lookup / lua / webhook do not.
found=false means "no opinion for this identifier".
Optional capability: Overlay (watermarking)¶
A hook may additionally implement hook.Overlayer:
It decides, per image request, whether to composite an image overlay (a
watermark) onto the response and how to place it. The js engine
implements it via an overlay(identifier, ctx) handler; noop / lookup /
lua / webhook do not. Returning null (or not implementing the handler)
means "no overlay". The capability is opt-in: a deployment with no
overlay-capable hook does no overlay work and keeps the zero-touch cache hit.
The handler receives ctx = { path, headers, outputWidth, outputHeight } —
outputWidth/outputHeight are the final derivative dimensions, so a script
can suppress the overlay on small outputs (thumbnails). It returns a spec:
| Field | Values | Meaning |
|---|---|---|
backend, key |
source name + key | The overlay image, loaded via the same sources as resolve. key required; empty backend = default source. Any decodable image; alpha (transparent PNG) is honoured. |
version |
string | Optional cache-busting token. The cache keys on the image reference, not its bytes — bump version after swapping the file under the same key. |
target |
output (default), source |
output: drawn at a fixed pixel size on the final derivative. source: sized/placed in source space so it scales with the image. |
anchor |
topLeft…bottomRight, center (default) |
Nine-position grid. |
fit |
none (default), shrink, scale, tile |
none: native size. shrink: fit within the anchored cell, never upscaling. scale: longest side = scale × the base's longest side. tile: repeat across the whole image (requires target: output). |
scale |
number | Fraction for fit: scale (e.g. 0.25). |
offsetX, offsetY |
px | Inset from the anchor edge. Ignored for tile. |
opacity |
0–100 (default 100) | Overlay opacity. |
A target: output overlay is composited after rotation and the quality
colorspace conversion, so a fixed-size watermark sits upright on the final
output and a gray/bitonal request stays gray/bitonal. A target: source
overlay is sized/placed in source coordinates and mapped through the request's
crop + scale, so it scales with the image and rotates/mirrors with it (and is
cropped away if the requested region excludes it). The overlay decision is
folded into the render-cache key, so a watermarked render never collides with
an un-watermarked one (or with a different watermark). A hook error or an
unreadable overlay image fails closed (500) — iiiris never serves an
un-watermarked image for an overlay-keyed request.
Built-in hook impls¶
| Type | When to reach for it |
|---|---|
noop |
Default. Identifier is the source key; no auth or redirect logic. |
lookup |
Static template substitution (prefix/suffix/sharding). 90% of cases. |
js |
In-process scripted logic with branching, lookups, computed strings, header-aware auth, and descriptor overrides. The recommended scripting engine. Microseconds per call. |
lua |
Deprecated — removed in iiiris 1.0. The original scripting engine; migrate to js. |
webhook |
The hook needs to call your existing service / database / identity system. Network latency per call. |
noop¶
Identity. The IIIF identifier is passed straight to the configured default source as the lookup key. This is the default when no hook is configured.
lookup¶
Template-driven identifier-to-key translation, optionally pinned to a
specific backend. The template is Go's text/template; the data context is
hook.TemplateData:
| Field | Meaning |
|---|---|
{{.Identifier}} |
Raw IIIF identifier as the client sent it |
{{.IdentifierEscaped}} |
URL-path-escaped identifier (url.PathEscape) |
Config:
hook:
type: lookup
lookup:
backend: filesystem # or http / s3 — overrides sources.default
template: "production/{{.Identifier}}.tif"
Examples:
| Template | identifier "cat" resolves to |
|---|---|
"{{.Identifier}}" |
cat (identity, equivalent to noop) |
"production/{{.Identifier}}.tif" |
production/cat.tif |
"v1/{{.IdentifierEscaped}}" |
v1/cat (escapes spaces, slashes, etc.) |
"archive/{{.Identifier}}" (with id "photos/cat.jpg") |
archive/photos/cat.jpg |
The template parses once at startup; an unparseable template fails fast.
template: is required when type: lookup.
js¶
Embedded JavaScript (ES5.1 + most ES6, via goja — a pure-Go interpreter, no CGO). The script is compiled once and runs against a pool of pre-initialized runtimes — per-request overhead is microseconds. This is the recommended scripting engine.
The script exports a handler object. All methods are optional; a missing method is pass-through:
module.exports = {
resolve(id) {
// Identifier "legacy/123" → key "archive/123" on the s3 source.
if (id.indexOf("legacy/") === 0) {
return { backend: "s3", key: "archive/" + id.slice(7) };
}
return { backend: "filesystem", key: "production/" + id + ".tif" };
},
authorize(id, ctx) {
// ctx = { path, headers }. headers is a read-only object of request
// header name -> first value.
if (id.indexOf("private/") === 0 && !ctx.headers["Authorization"]) {
return { allow: false, reason: "private collection" };
}
return { allow: true };
},
redirect(id) {
// null = no redirect; a string = 303 to that URL.
return null;
},
describe(id) {
// Optional: override the derived Presentation manifest/collection.
// null = no opinion. See "Optional capability: Describe" above.
return { label: "Item " + id };
},
overlay(id, ctx) {
// Optional: composite a watermark onto image responses.
// ctx = { path, headers, outputWidth, outputHeight }.
// null = no overlay. See "Optional capability: Overlay" below.
if (ctx.outputWidth < 400) return null; // skip thumbnails
return { backend: "watermarks", key: "logo.png",
anchor: "bottomRight", fit: "shrink", opacity: 70 };
},
};
Config:
hook:
type: js
js:
path: /etc/iiiris/hooks.js # OR
script: |
module.exports = { resolve(id) { return { key: "production/" + id + ".tif" }; } };
watch_interval: 5s # hot-reload poll cadence (path mode only)
timeout: 1s # per-call execution budget (default 1s)
Sandbox. A bare runtime with only the JavaScript builtins (JSON,
Math, String, Array, Object, Date, RegExp, …). There is no
require, no Node API, no filesystem, no network, and no timers. The one
host affordance is log(level, msg, fields?) — it routes into the server
log (level is debug/info/warn/error; fields is an optional
object of structured attributes), tagged source=hook.js. There is no
print or console. Scripts are expected to be pure functions of their
inputs.
Execution budget. Each call is bounded by timeout (default 1s). A
script that exceeds it (e.g. an infinite loop) is interrupted and the call
fails. See Failure semantics below.
Concurrency. Runtimes are pooled and reused across requests; do not mutate module-level state from inside a handler (the change leaks into other requests).
Hot reload. Identical to lua: path-mode scripts are live-reloaded,
mtime re-checked at most once per watch_interval (default 5s); a changed
file is recompiled and the generation swapped atomically. A recompile
error is logged at WARN and the previous version stays in service. Set
watch_interval negative (e.g. -1ns) to disable. Inline scripts
(js.script) are never reloaded.
Failure semantics. When a handler throws, times out, or returns a malformed value:
| Handler | Failure result |
|---|---|
resolve |
Request fails (500). No fallback to defaults — never silently serve unintended content. |
authorize |
Deny (fail closed). |
redirect |
Request fails (500). |
describe |
Fail open — the override is ignored, the server-derived descriptor is used, and a WARN is logged. |
overlay |
Request fails (500), fail closed — a broken overlay must never serve an un-watermarked image. |
TypeScript. There is no TypeScript runtime — goja runs JavaScript.
To author hooks in TypeScript, compile to a single dependency-free .js
file (e.g. tsc --outFile hooks.js) and point js.path at the output.
lua¶
Deprecated. The Lua engine is superseded by the
jsengine and will be removed in iiiris 1.0. It still works for now; configuringhook.type: lualogs a one-line deprecation warning at startup. New hooks should usejs, and existing Lua hooks should migrate. See thejssection above for the equivalent surface.
Embedded Lua 5.1 (via gopher-lua). The
script is compiled once and runs against a pool of pre-initialized LStates
— per-request overhead is microseconds.
Three optional globals are honored. Missing globals are treated as pass-through:
function resolve(id)
-- Identifier "cat" → key "production/cat.tif" on the s3 source.
if string.find(id, "^legacy/") then
return { backend = "s3", key = "archive/" .. string.sub(id, 8) }
end
return { backend = "filesystem", key = "production/" .. id .. ".tif" }
end
function authorize(id, request_path)
if string.find(id, "^private/") then
return { allow = false, reason = "private collection" }
end
return { allow = true }
end
function redirect(id)
-- nil = no redirect; string = 303 to that URL.
return nil
end
Config:
hook:
type: lua
lua:
path: /etc/iiiris/hooks.lua # OR
script: |
function resolve(id) return { key = "production/" .. id .. ".tif" } end
watch_interval: 5s # hot-reload poll cadence (path mode only)
Sandbox. Only the base, string, table, and math standard
libraries are loaded. os, io, package, debug, coroutine and the
dynamic-load functions (loadstring, loadfile, dofile, load) are
unavailable. The intent is that scripts are pure functions of their inputs.
Concurrency. LStates are pooled and reused across requests; do not
mutate globals from inside a hook function (the change leaks into other
requests).
Hot reload. Path-mode scripts are live-reloaded. The file's mtime is
re-checked at most once per watch_interval (default 5s); a changed
file is recompiled and the script generation is swapped atomically — in-
flight requests finish on the previous version, subsequent requests use
the new one. A recompile error (syntax, sandbox-disallowed call, init
failure) is logged at WARN and the previous version stays in service,
so a bad edit does not break live traffic. Set watch_interval to a
negative value (e.g. -1ns) to disable hot reload entirely. Inline
scripts (lua.script) are baked into config and never reloaded.
webhook¶
Out-of-process: iiirisd POSTs a JSON envelope per Hook call to a URL the
operator runs. The hook can be written in any language. Adds 1–10 ms of
network latency per request and creates a hard dependency: if the webhook
service is down, every request fails.
Request body:
(request_path is set for authorize; omitted otherwise.)
Response body — all fields optional, only those relevant to method need
be set:
{
"backend": "filesystem",
"key": "production/cat.tif",
"allow": true,
"reason": "",
"redirect": ""
}
For authorize, an absent allow defaults to true (no opinion = allow).
Non-2xx upstream responses surface as errors; the request fails.
Config:
hook:
type: webhook
webhook:
url: https://iiiris-hook.example.internal/hook
timeout: 2s # default 2s when zero
auth_header: "Bearer xyz" # sent verbatim as Authorization
Cache-key invariant¶
The render and info caches key on r.URL.Path (i.e. the IIIF identifier as
the client sees it), not on the resolved backend key. This is the right
bound: two different IIIF identifiers that happen to map to the same backend
file should still have distinct cache entries (their info.json id fields
differ, and a client could cache them differently). The OriginCache, by
contrast, keys on <source-name>:<resolved-key> — so two identifiers that
resolve to the same origin object share the cached origin bytes.
Authorize vs. Redirect ordering¶
Both authorization hooks now run on every gated request, but at different points and with different semantics:
Redirectruns first, before auth and before parsing IIIF parameters. A non-empty URL produces an immediate 303 to that location and the request stops there. Use it for content relocation — "this identifier has moved" — regardless of who is asking.Authorizeruns after the rule engine has rendered an initial verdict from the YAMLauth.ruleslist. The hook can refine the verdict (add a substitute identifier, a redirect URL, or flip allow → deny) but cannot lift a rule-driven deny. Use it for per-request policy: time-of-day windows, IP allowlists richer than the kiosk one, or per-user lookups against an external service.
When both produce a redirect, Redirect wins because it fires first.
The auth-flow's own Decision.Redirect (driven by external SSO
flows) is a third, separate redirect path that runs only after auth
denies — it never collides with Hook.Redirect's unconditional
behavior.
Limitations¶
lookupis a single template — per-prefix routing logic should use thejsengine (it can branch on the identifier).- The
js(andlua) sandbox has no filesystem or network access. If you need either from the hook, usewebhook. webhookdoes not yet forward request headers (theAuthRequest.Headeradded forjs) in its JSON envelope.