Skip to content

Changelog

All notable changes to iiiris are documented here.

The format follows Keep a Changelog, and this project adheres to Semantic Versioning — see docs/specs/releases.md for the release contract (versioning rules, artifact set, cut-release flow).

Unreleased

[v0.12.1] - 2026-08-25

Changed

  • The distroless release image now builds libvips 8.18.2 (was 8.16.0).

This is the performance half of the pyramid fix under Fixed below. On libvips through 8.16 the correctness guard falls back to a full decode for pyramidal TIFF and JP2 full-region downscales — right answer, but shrink-on-load is lost. Measured on identical iiiris code, cold renders of a 9021x7122 master:

source libvips 8.16 libvips 8.18.2
JP2 5.4 s 0.24–0.48 s
pyramidal TIFF 1.7–1.9 s 0.06–0.14 s
plain JPEG 0.43 s 0.43 s (unchanged)

Plain JPEG is unchanged because it never used the broken path — it shrinks via JPEG DCT.

The debian fallback image is unaffected by this bump: it links Debian's libvips (8.14.1), so pyramid downscales there stay correct-but-slower via the fallback until Debian ships a newer libvips.

Operators building the image themselves should note the mirror-build-deps job needs re-running to refresh the source-tarball mirror for the new version.

  • auth.session.ttl is renamed auth.session.sweep_interval. The old name sat one character away from auth.session_ttl — a session lifetime — while meaning a sweep cadence, so setting the wrong one was silent and easy.

Not a breaking change: ttl is still honoured, with a startup warning. Nothing read it before, so an operator who set it has been getting no sweeping at all; honouring it now finally does what they asked for, and failing their startup over a field that was inert would be gratuitous. Zero means the default (10m); a negative value disables sweeping.

Removed

  • auth.session.max_bytes is gone. It had no consumer — a byte cap copied from the cache config's shape and never implemented — and it was not documented under auth.session either.

It was never going to be implemented. A byte cap implies eviction on overflow, and evicting a session logs out a signed-in user, where evicting a cache entry merely costs a re-render. Capacity-based eviction on an auth store turns memory pressure into arbitrary, silent auth failures — and in the session-flood case it would drop legitimate sessions preferentially. Session stores are bounded by time: entries expire, and auth.session.sweep_interval now prunes them.

Nothing changes for anyone. The field did nothing, and because the YAML loader is not strict, a config that still carries it keeps parsing and keeps doing nothing. No migration, no version bump.

Fixed

  • Pyramidal TIFF and JP2 sources no longer serve a tiny pyramid level in place of the requested downscale. Affected every IIIF size whose dimensions must be derived — !w,h, w,, ,h, pct:n — on full-region requests. A 9021×7122 master answering full/!1024,1024 returned a 70×55 image: HTTP 200, image/jpeg, a valid JPEG, simply the wrong resolution. Explicit w,h and max were correct, as were all region (deep-zoom tile) requests, which is why it went unnoticed.

This shipped in both release images. The cause is libvips' buffer thumbnail picking the smallest pyramid level (confirmed 8.14.1, as in the debian image, and 8.16.0, as in distroless; 8.18.2 is correct) — and since SizeDown only ever shrinks, that level is returned untouched. The file-based thumbnail is correct at the same version, but iiiris must use the buffer path because sources can be HTTP or S3.

iiiris now verifies the loader's result against the requested downscale and falls back to a full decode when it doesn't match — the same trust-but-verify the arbitrary-region path has always applied via cleanReduction. On affected libvips versions this costs pyramid full-region downscales their shrink-on-load, so they decode fully: correct output for more work. On a correct libvips nothing changes.

Anyone serving JP2 or pyramidal TIFF masters from a released image should upgrade; viewers request !w,h and w, constantly, so the wrong image was being served on ordinary use.

  • Expired auth sessions are now actually pruned. session.Store.Sweep has been part of the interface and implemented by all four backends since sessions shipped — and nothing ever called it. The heap and filesystem stores expire lazily on read, so a session is only reclaimed if something looks up that exact key again; an abandoned session never is, and its record survives for the life of the process. iiirisd now runs a sweeper against the store on a configurable cadence.

Affects deployments with auth profiles configured. The growth is proportional to sessions that expire without being touched again, so it is slow rather than dramatic — but unbounded. Redis and S3 are unaffected in practice (their Sweep is a no-op: Redis expires natively, S3 defers to bucket lifecycle rules).

[v0.12.0] - 2026-08-22

Removed — BREAKING

  • The lua hook engine is removed. It has been documented as deprecated since the js engine landed, and hook.type: lua logged a warning at startup. It is now gone.

Who this affects: operators running hook.type: lua. Nobody else — the default is noop, and lookup, js and webhook are untouched.

What happens if you upgrade without migrating: iiirisd refuses to start, with an error naming the migration. That is deliberate rather than a warning-and-fall-back-to-noop: a Lua hook could implement authorize, so starting without it would open access that was meant to be gated — or, via resolve, serve the wrong bytes. Failing loudly is the only safe way to be wrong here. A leftover lua: block in your YAML is harmless and ignored; only type: lua fails.

Migration: port the script to the js engine and set hook.type: js. The surface is the same — the three globals become module exports and the returned tables become objects. A construct-by-construct table and a worked example are in docs/hooks.md. js additionally offers a per-call timeout and a describe() handler that Lua never had. If a script genuinely cannot be ported, the webhook engine runs your logic out-of-process in any language.

Version impact: this is a compatibility break, so the next release is a minor bump (0.12.0) per docs/specs/releases.md "Versioning", not a patch.

github.com/yuin/gopher-lua is no longer a direct dependency. It remains in the module graph as an indirect dependency of miniredis, which is test-only.

[v0.11.4] - 2026-08-22

Fixed

  • Replicas sharing a filesystem cache no longer wipe each other's entries on a simultaneous cold start. The on-disk format migration decides whether a root holds a pre-v2 cache by scanning it for hash files — and an entry another process wrote moments ago looks exactly like a legacy leftover. The scan ran outside the root's lock, so several iiiris attaching to the same fresh cache directory all saw "no version sentinel", and a slower one's scan deleted entries a faster one had already written. The victim's Put returned success and its own read-back missed.

This is the documented multi-replica configuration (several iiiris sharing one cache directory, local or Redis-coordinated) doing an ordinary simultaneous start. Impact was bounded — it only applied on first attach to a root with no sentinel, and the cache self-heals by re-rendering — but entries were being destroyed, not just missed. The check-scan-write sequence now runs under the root's flock in both modes, so a later process sees the sentinel the first wrote and returns early.

Surfaced as an intermittent Get w1-k0: cache: miss in TestFilesystemConcurrentWritesNoCorruption, which was a real defect reporting itself, not a flaky test.

Added

  • Render-queue pressure on the admin dashboard — how many calls are blocked waiting for an image.max_concurrent slot, plus the wait quantiles. This is the number that separates "renders are slow" from "renders are queued": a slow decode and a fast decode waiting behind three others show identical latency, and the fixes point in opposite directions. Only genuinely blocked callers count, so an unconstrained server reads zero instead of looking backed up.
  • Per-source Open outcome counts (opens / not found / errors) in the sources table, and how long ago source health was last probed. not found stays a separate column rather than feeding an error rate — asking for an identifier that doesn't exist is a client error, and counting it as source unhealthiness would make a healthy server look broken under scanning traffic.
  • A 24-hour auth allowed/denied summary on the dashboard, linking through to /admin/auth for the detail.
  • The admin dashboard shows cache hit rate per tier. Hits and misses were already measured — cache.OpObserver has fed them to Prometheus all along — but displayed nowhere, so the cache view reported footprint without effectiveness. Both views now hang off the same observer hook and cannot drift. Always on, independent of whether metrics are enabled: the operator without Prometheus is the one who needs the number. Lookups (hits + misses) are the only denominator; a tier nobody has queried reads , not 0%.
  • In-flight requests and request rate on the dashboard. In-flight is the one gauge here that goes down as well as up, which is what makes it worth showing: rising in-flight against flat throughput is requests piling up behind a slow upstream or a saturated render semaphore, and no cumulative counter reveals that. Rate is derived client-side from the counter delta, so the server retains no window.
  • A recent-errors panel — the last 50 responses with a 4xx or 5xx status, answering the question the per-route error count used to raise and leave hanging. Rows carry the route class, never the raw request path: a IIIF path embeds the identifier, which can be a signed URL or carry a token.
  • The dashboard names its own instance (hostname + pid) and states which numbers are per-process. Behind a load balancer, two refreshes can hit different replicas; without the label the counters look like they jumped backwards. Cross-replica aggregation stays Prometheus's job.

Changed

  • The admin SSE stream builds one snapshot per tick instead of one per connection. Each open dashboard used to run its own ticker, and every tick copied and sorted up to 1024 latency samples per route and queried every cache — so ten tabs did that work ten times a second to produce ten identical frames. One process-wide loop now fans a single snapshot out to every subscriber, making the cost O(1) in open dashboards, and it runs only while someone is watching. Concurrent stream clients are capped at 32 (503 beyond it), and a slow client drops frames rather than stalling delivery for everyone else.

[v0.11.3] - 2026-08-21

Added

  • The Windows MSI can register the iiiris service again. msiexec /i … ALLUSERS=2 MSIINSTALLPERUSER= INSTALLSERVICE=1 installs and starts an auto-start iiiris service running as LocalSystem against %ProgramData%\iiiris\config\config.yaml; upgrades stop it, replace the binary and start it again without the property being re-passed; uninstall stops and removes it. Service registration was dropped in v0.11.0 (it had never worked in any shipped MSI — see that release's entry) and this is the correct restoration.

The mechanism, since it is unusual: MSI applies a ServiceInstall row only when its component's key path is the service executable, and iiirisd.exe has to install unconditionally, so the component cannot also carry the "is a service wanted?" decision. The rows therefore sit on the iiirisd.exe component and the four service standard actions (StopServices, DeleteServices, InstallServices, StartServices) are conditioned instead. That is also what makes a per-user install degrade rather than fail — a non-elevated install cannot write to the SCM, so the actions do not run there. A HKLM\SOFTWARE\iiiris\ServiceInstalled marker records the choice so upgrade and uninstall behave without the operator re-passing INSTALLSERVICE=1.

Nothing changes for an install that does not ask for a service, and the sc.exe route stays supported for zip installs. Full contract in docs/specs/windows-build.md "Service mode"; operator walkthrough in docs/deployment.md "Service mode". - scripts/smoke-msi.ps1 covers both install shapes. A binary-only install (asserting no service is registered) and a service install that checks the service is running, auto-start, LocalSystem, pointed at the installed binary, and actually serving /health and an IIIF render over SCM — plus an install-over-install and the uninstall teardown.

Fixed

  • The docs site no longer loses a push race with itself. Merging a release MR and pushing its tag fire two pipelines seconds apart, and both deploy documentation — one as dev, one as the release version — to the same gh-pages version store. The loser was rejected with (fetch first), which failed the v0.11.2 pages job. scripts/docs-deploy.sh now re-fetches the store and replays the deploy; the two write different versions, so the second one simply appends.

Security

  • /admin/config no longer renders configuration secrets in the clear. The read-only effective-configuration YAML below the editor marshalled the live config directly, exposing every secret-bearing field — Sentry DSN, metrics password, Content State write token, the webhook Authorization header, the auth-session Redis password, the Redis and coordinator-Redis passwords of all four cache tiers, and inline auth users' password hashes — to anyone holding admin credentials, and to anyone looking at a screenshot or a shared screen. The view now goes through config.Redacted(), the same masking iiirisd -print-config uses. Redaction is display-only: saving an edit still writes the real values back to the -config file.

[v0.11.2] - 2026-08-20

Fixed

  • Container image pushes no longer fail with blob unknown to registry. This blocked the v0.11.1 release: three failures pushing the debian image, a different blob digest each time, and then the same error from ci-image pushing to an entirely different repository. The common factor was buildx uploading blobs — plain docker push and imagetools create kept working against the same registry throughout, and GitLab declared no incident. Recent buildx attaches provenance/SBOM attestation manifests to --push by default and GitLab's registry mishandles them; disabling them fixes the push.
  • The debian-slim image is built and published like the distroless one. Native amd64 and arm64 builds, plain docker push, then imagetools create — the path the distroless images already used successfully — instead of a single multi-arch buildx --push. The arm64 half is no longer QEMU-emulated, and both images now publish through one code path rather than two. Release-image jobs also retry on script failure, so a registry hiccup can no longer leave a tag published without its Release entry.
  • Release image builds no longer depend on reaching GitHub from the runner. Retrying the libvips tarball fetch (shipped in v0.11.1) was not enough: on arm64 runners the tarball's redirect target refuses to connect at all — four attempts, four connect timeouts, twice in a row on consecutive releases — while a git clone of github.com in the same build succeeds. The image build now fetches libvips and libtiff from a mirror in this project's own generic package registry, falling back upstream when the mirror lacks a version, and forces IPv4 (the failures were connect timeouts with no reply, the signature of an unroutable AAAA record). A mirror-build-deps job populates the mirror, automatically on main whenever the pinned versions change.

[v0.11.1] - 2026-08-19

Changed

  • The Windows MSI is a guaranteed release artefact. build-windows-msi and release-binary-windows-msi are no longer allow_failure on release tags: a broken installer now fails the release instead of quietly publishing a Release entry without one, which is how the MSI went three releases producing no file at all.

Fixed

  • Transient network failures no longer break a release. The v0.11.0 pipeline failed when GitHub did not answer within curl's five-minute timeout while an image build fetched the libvips tarball; because that job blocks the release, release-publish was skipped and the tag sat with no Release entry until the job was retried by hand. Tarball fetches in the image builds now pass --retry 3 (curl treats an operation timeout as transient), the openjpeg clone retries in a loop, and CI defaults to retrying infrastructure-class failures — runner, scheduler and API errors, but deliberately not script failures, so a real test failure still reports once rather than three times.

[v0.11.0] - 2026-08-19

Fixed

  • The Windows MSI installer now builds and installs. build-windows-msi had failed on every release it ran (v0.9.0, v0.9.1, v0.10.0) without ever producing an MSI; because the job is allow_failure, nothing surfaced it. Ten separate defects, each masking the next:
  • The job looked for a staging directory that no job published — only the zip is an artifact — so it never got past its first step. It now expands the zip.
  • wix build failed WIX0091: WiX v5's <Package> authors ALLUSERS itself, so the hand-written property was a duplicate. Replaced with Scope="perUserOrMachine".
  • The MSI ProductVersion was passed the raw tag (v0.10.0); MSI versions must be numeric.
  • deploy/installer/dlls.wxs was genuinely stale (MSYS2 moved libopenjph 0.27 → 0.31), and its staleness gate could never pass anyway: it sorted culture-aware while the committed file is ordinal, and compared against a string missing the trailing newline Set-Content writes.
  • The package was built 32-bit (wix build defaults to -arch x86), so an x64 payload installed into Program Files (x86) with HKLM writes redirected to Wow6432Node.
  • WixUI_Advanced hardcodes the all-users location to [ProgramFilesFolder] regardless of package platform, in the execute sequence, so even /qn installs landed in the 32-bit tree.
  • Silent all-users installs need MSIINSTALLPERUSER= (empty). ALLUSERS=2 alone installs per-user — WiX's dual-purpose authoring follows Microsoft's single-package recipe, which makes per-user the package default. Documented in docs/deployment.md; the attended wizard is unaffected.

Changed

  • The MSI no longer registers a Windows service, and INSTALLSERVICE=1 is no longer accepted. MSI applies a ServiceInstall row only when its component's key path is the service executable; iiirisd.exe belongs to the always-installed feature while the service must stay optional, and the synthetic registry key path used instead made MSI skip the row silently — so no shipped MSI ever created a service. Nothing regresses in practice. Register the service with sc.exe against the installed binary, as docs/deployment.md describes; an all-users install still registers the Event Log source (now on every all-users install, not only alongside a service) so a hand-registered service logs to the Event Log. Reworking the component layout so the MSI can own the service again is tracked as issue #7.

[v0.10.0] - 2026-08-18

Added

  • iiirisd -version, -check-config, and -print-config. Three one-shot flags that report and exit without binding a listener:
  • -version prints the build identity, including the libvips version the binary is actually linked against — resolved differently in every shipping form (distroless, debian-slim, Windows DLL bundle, local build) and otherwise unanswerable from outside the process.
  • -check-config loads and validates the configuration and exits 2 if it wouldn't start, for use as a deploy preflight (ExecStartPre in the systemd unit in docs/deployment.md). It shares its load-and- validate path with startup, so a passing check can't drift from a passing start; it does not build subsystems, so cache/auth/hook wiring errors still surface at startup.
  • -print-config prints the effective configuration — defaults, YAML, and IIIRIS_* overrides merged — as YAML, with every secret masked (sentry.dsn, metrics.password, content_state.store.write_token, hook.webhook.auth_header, every redis.password, and inline auth.profiles.*.users hashes).

Additive: zero-config startup and every existing invocation are unchanged. The governing rule — flags describe the binary, never the configuration — and the full contract are in docs/specs/cli.md, the surface 1.0 freezes.

Documentation

  • Docs realigned with what actually ships. A sweep of the surfaces that drifted during v0.9.0 / v0.9.1:
  • The Windows story now describes GitLab's SaaS Windows pool rather than the retired ephemeral-EC2 orchestrator. README.md and docs/deployment.md no longer claim pre-built Windows artefacts are absent from GitLab Releases — the amd64 zip has been a release asset since v0.9.1 — and docs/specs/releases.md replaces its RUN_WINDOWS=1 gating contract (a variable CI no longer reads) with the real one: the zip is guaranteed, the MSI is best-effort. Its artifact table also gained the linux/arm64 binary that shipped in v0.9.0.
  • docs/ROADMAP.md marks Content State (Phase 2) shipped, adds the MCP server to platform glue, and states what is genuinely outstanding on the 1.0 Windows gate.
  • Three briefs retired per the brief lifecycle: METADATA-PRIVACY.md and DOCS.md (fully shipped — their open follow-ups carried into CANTALOUPE_PARITY.md and docs/deployment.md respectively) and WINDOWS_EC2_RUNNER.md (a design that was never adopted).

[v0.9.1] - 2026-07-16

Fixed

  • Release pipeline: a Windows-MSI or slow arm64-image build no longer blocks a release. Two independent CI gaps surfaced on the first RUN_WINDOWS=1 + multi-arch release tag (v0.9.0), which left that tag's pipeline unable to publish a complete release:
  • release-binary-windows-msi re-exported the MSI but was not allow_failure, so when the (allow_failure) MSI build produced no installer, this job hard-failed and blocked release-publish. It is now allow_failure and skips gracefully when no MSI is present — the Windows zip, Linux binaries, and container images publish regardless. (The MSI installer itself remains best-effort while the SaaS Windows path proves out.)
  • The native arm64 distroless image build compiles the full libvips stack from source with a cold cache and overran the default 1h job timeout; the .release-image-native template timeout is raised so it can complete. (Follow-up tracked in the template comment: warm the arm64 layer cache so the build drops back to minutes.)

This supersedes v0.9.0, whose tag pipeline failed to publish a complete release; v0.9.1 re-releases the same features (see the v0.9.0 section below).

[v0.9.0] - 2026-07-16

Added

  • Render-quality controls: sharpening + supersampling (both off by default — every default preserves today's exact output and performance). Four new image: fields, each with an IIIRIS_* override:
  • image.sharpen (0.01.0, 0 = off) applies an unsharp mask to downscaled renders, restoring the acutance a clean reduce discards. 0.5 reproduces libvips' own default strength. image.sharpen_sigma and image.sharpen_x1 are advanced knobs (0 = libvips default). Sharpening runs only when neither axis was enlarged and at least one shrank — max, 1:1 crops, upscales, and distorting forced sizes are left alone — and never touches overlays (which would acquire halos) or pixels that are still CMYK when it runs (which would need a lossy CMYK→LAB→CMYK round-trip; a full-region downscale of a CMYK master decodes to sRGB on the way and is sharpened there, safely).
  • image.supersample (1 = off, or 2 / 4) sets the minimum linear headroom the resolution-aware decode must leave above the output. Without it, an exact power-of-two request lands the decode on the target, the residual Lanczos resize becomes a no-op, and the served pixels are raw DCT / pyramid-level output — visibly soft. Supersampling guarantees a real resample runs. It changes pixels, never dimensions: output W×H is identical at 1 | 2 | 4, so info.json never misreports what the server produces. Cost on the bundled sample (M1 Max, approximate): ~1.6 ms → ~4.7 ms for a full-region thumbnail and ~1.0 ms → ~2.6 ms for a tile, going from ss=1 to ss=4.

Both are restart-only, and changing either rekeys the render cache (an append-only |q=<hash> segment) rather than serving stale renders — no flush needed, and a default deployment keeps its existing keys and evicts nothing. Out-of-range values fail fast at boot. See docs/configuration.md "Render quality" and docs/specs/render-quality.md.

  • Colour policy, decoupled from metadata privacy. New image.color field (IIIRIS_IMAGE_COLOR): srgb (normalize everything — including wide-gamut RGB — to sRGB) or respect-icc (leave an RGB source's embedded profile untouched; CMYK still always converts to sRGB, since no mainstream browser renders a CMYK JPEG). Leaving it unset derives the policy from image.metadata (stripsrgb, preserverespect-icc), so an upgrade that never sets image.color keeps its existing behaviour except the CMYK fix below. Colour and metadata are now independent axes: metadata: strip + color: respect-icc strips GPS/EXIF while keeping a wide-gamut profile, a combination that was inexpressible before. Restart-only; the effective policy folds into the render-quality cache salt (respect-icc makes it non-empty, srgb is inert). See docs/configuration.md "Colour policy" and docs/specs/color-policy.md.

  • Linux arm64 release binary. Releases now publish a native iiirisd-<version>-linux-arm64 binary (with SHA256) alongside the amd64 one, built on a native arm64 CI runner. The container images were already multi-arch (linux/amd64 + linux/arm64); the standalone binary closes the gap for arm64 hosts that run iiirisd directly. See docs/deployment.md.

  • Published documentation site at https://docs.iiiris.org. The docs/ Markdown tree is rendered with MkDocs + Material and deployed to GitLab Pages by a new pages CI job on every default-branch docs change and every release tag. Versioned with mike: dev tracks main, each release vX.Y.Z publishes version X.Y aliased latest. Source docs are unchanged — a build hook imports README/CHANGELOG, injects the logo, and rewrites links. See docs/deployment.md "Documentation site" (includes the one-time DOCS_DEPLOY_TOKEN + DNS setup). Docs-only changes still run no MR pipeline; publishing happens post-merge.

  • Error tracking via Sentry (off by default). iiiris can report errors to a Sentry project through the official Go SDK: set sentry.enabled: true and a sentry.dsn (or IIIRIS_SENTRY_DSN) to capture recovered panics (with stack traces), 5xx responses, and internal source / decode / cache errors (reported at the error site, tagged by route + status), each toggleable via sentry.capture.*. Events are attributed by environment and build release. Privacy-first by defaultsend_default_pii: false strips client IPs, cookies, auth headers, and URL query strings before any event leaves the process (opt in with send_default_pii: true). A single new consumer package (internal/telemetry) owns the SDK; the request path carries no instrumentation when disabled, and events are flushed on graceful shutdown. Configure via the sentry.* block or IIIRIS_SENTRY_* env vars. See docs/configuration.md.

  • Distributed tracing via OpenTelemetry (off by default). With tracing.enabled: true iiiris emits a root span per request plus child spans across source open and cache get/put, exported over OTLP/gRPC to tracing.otlp_endpoint — any collector (Tempo/Jaeger/…) or Sentry's own OTLP ingestion URL. Inbound W3C traceparent is honored (ParentBased head sampling at tracing.sample_rate), and when Sentry error tracking is also on, captured errors are stamped with the active trace ID so an error links to its trace. All OpenTelemetry lives in internal/telemetry (root span via an opaque middleware, source/cache spans via Traced* wrappers), so the server, source, and cache packages stay SDK-free; the request path is untouched when disabled. Optional upstream propagation (tracing.propagate_upstream, off by default) injects the W3C traceparent header on outbound HTTP source fetches so a trace spans iiiris → origin (S3 is out of scope — a terminal service). Configure via the tracing.* block or IIIRIS_TRACING_* env vars. See docs/configuration.md.

  • Content State bookmark store (Form-B hosting) (roadmap Phase 2b, off by default). iiiris can host content states at stable, dereferenceable URIs so a state is shared as a short link rather than a long encoded blob: POST /content-state (gated by a content_state.store.write_token Bearer token; refused when no token is configured) stores a state and returns its URI; GET /content-state/s/{id} serves it as application/ld+json with CORS + ETag. Durable, single-node filesystem store (internal/annostore, non-evicting), shaped so a later W3C annotation store subsumes it. Enable via content_state.store.{enabled,dir,write_token}. See docs/content-state.md.

  • Model Context Protocol (MCP) server — core (roadmap 2.0-era platform glue). iiiris can expose its read-only IIIF capabilities to AI agents over MCP: a Streamable HTTP endpoint at /mcp offering four tools — browse (list a collection), get_image_info (dimensions + capabilities), get_region (render a region, returned as an image the agent can see), and get_manifest (the IIIF Presentation document for an identifier — stored or derived). Any MCP host connects by URL (Claude Desktop/Code, or the Claude API remote-MCP connector). Off by default (mcp.enabled: false) — a new protocol surface and dependency (the official modelcontextprotocol/go-sdk). Tools reuse the existing resolve/probe/render/list and Presentation serve/derive paths, so get_region enforces the same limits as the Image API. MCP enforces the same IIIF Auth — a host presents its access token as a Bearer token (or cookie) on the /mcp connection; gated identifiers are denied without it and browse omits them (no separate MCP credential, no extra config). Manifests are also exposed as MCP resources (a iiiris:///presentation/{identifier} template) for URI-addressable, host-readable access. See docs/mcp.md.

  • IIIF Content State API 1.0 — stateless core (roadmap Phase 2a). iiiris now speaks the shareable "open the viewer exactly here" format:

  • The bundled viewers read the iiif-content parameter. A /view/?iiif-content=<state> link (or /view/{id}?iiif-content=…) opens Mirador or Universal Viewer at the encoded Manifest, Canvas, or Canvas region — Mirador is pointed at the target canvas server-side, UV reads the state natively.
  • GET /content-state/mint builds a content state (plus its encoded value and a ready-to-share /view URL) from one of your Presentation identifiers, with an optional canvas and xywh region.
  • GET/POST /content-state/resolve decodes and inspects a state.
  • New internal/contentstate package: the content-state-encoding codec (round-trippable base64url ∘ encodeURIComponent) plus parse/build/validate.

Stateless and on by default (mirrors Presentation); disable with content_state.enabled: false. Endpoints sit under /content-state/ (outside /iiif/) since the spec defines no server API. The off-by-default bookmark store (Form-B hosting) is a later slice. See docs/content-state.md.

Changed

  • BREAKING (default behaviour, metadata: preserve deployments only): image.metadata: preserve now converts CMYK sources to sRGB. Previously, colour normalization ran only in metadata: strip mode, so a preserve-mode deployment served CMYK JPEGs verbatim — garbled in every browser. With colour policy decoupled from metadata (see image.color above), the preserve derivation is now color: respect-icc, which still always converts CMYK to sRGB; RGB and grayscale sources are unaffected and keep their original profile exactly as before. Migration: none required beyond reading this entry — the render-quality cache salt folds in the effective colour policy, so preserve-mode deployments rekey automatically on upgrade and any stale CMYK-passthrough entries a warm cache holds self-heal through normal re-rendering. No cache flush is needed. Per docs/specs/releases.md Versioning, this breaking change requires a MINOR version bump (pre-1.0: 0.X.0) at the next release cut.

  • CI now runs entirely on GitLab SaaS hosted runners (Linux amd64 + arm64, Windows amd64). No self-hosted runner is required to build, test, validate, or release iiiris; the ephemeral-EC2 Windows build orchestrator is retired. No change to what the pipeline tests or ships. Contributors need nothing new — the toolchain still comes from deploy/ci/Dockerfile. See docs/specs/ci-runners.md.

[v0.8.0] - 2026-07-03

Added

  • Metadata privacy — derivatives now strip embedded EXIF, XMP, and IPTC metadata (camera model, serial, GPS coordinates, captions, keywords) by default, so sensitive source metadata no longer ships in served images. Controlled by the new image.metadata config field (IIIRIS_IMAGE_METADATA): strip (default) or preserve. Also trims served bytes.

Changed

  • BREAKING (default behaviour): image.metadata: strip normalizes derivatives. In the default strip mode iiiris now, in addition to removing EXIF/XMP/IPTC:
  • Normalizes colour to sRGB (colour) / sGray (grayscale, not promoted to RGB), converting CMYK, and embeds a ~0.5 KB micro ICC profile — colour renders correctly in every viewer, including wide-gamut/P3 displays.
  • Bakes EXIF orientation into the pixels (autorotate) and reports oriented info.json dimensions, so images display upright regardless of viewer EXIF support.

This changes served pixels and info.json width/height for orientation-tagged and non-sRGB originals, and reverses the previous "raw dimensions are canonical / no autorotate" behaviour. Migration: set image.metadata: preserve to restore byte-for-byte prior behaviour (no stripping, no colour/orientation normalization, raw dimensions). When upgrading with the default, flush the render and info caches so stale pre-normalization entries are not served (see docs/caches.md).

Fixed

  • EXIF-oriented images downscaled inconsistently. A full-region downscale of an EXIF-oriented source used the shrink-on-load thumbnail path, which auto-rotated the result, while full-size renders and info.json reported the raw (un-rotated) frame — so a viewer scaling or tiling an oriented image got mismatched geometry. Orientation is now handled consistently across all decode paths: upright everywhere in image.metadata: strip (the default), raw everywhere in preserve.

[v0.7.0] - 2026-06-23

Added

  • Plugin-driven image overlays / watermarking. A hook may implement the optional Overlayer capability (the js engine via an overlay(id, ctx) handler) to composite an image overlay onto image responses. Placement covers a nine-position anchor grid with inset offsets, fit modes (none/shrink/scale/tile), and opacity. Two insertion points: target: output draws a fixed-size watermark on the final derivative (after rotation + the quality colorspace conversion, so it sits upright and a gray/bitonal request stays gray/bitonal); target: source places it in source space so it scales and rotates with the image. The overlay image is loaded via the configured sources (filesystem/http/s3) and the decision is folded into the render-cache key (append-only — no churn for un-overlaid requests). Errors and unreadable overlay images fail closed (500). Opt-in: a deployment with no overlay-capable hook does no overlay work. Observed via iiiris_overlay_applied_total / iiiris_overlay_composite_seconds. See docs/hooks.md.
  • JavaScript hook engine (hook.type: js), a pure-Go (goja) peer to the Lua engine and the recommended scripting surface. Scripts export a module.exports object with optional resolve / authorize / redirect / describe handlers; authorize receives a {path, headers} context so hooks can decide on cookies / bearer tokens, and describe makes the JS engine the first hook to supply Presentation descriptor overrides. Sandboxed (no require/Node/filesystem/network/ timers; a single log() builtin into the server log), pooled runtimes, atomic mtime hot-reload (js.path), and a per-call js.timeout (default 1s). Config: hook.js.{path,script,watch_interval,timeout}. See docs/hooks.md.
  • Hook.Authorize now receives request headers (via an AuthRequest struct), enabling header/cookie/token-aware authorization hooks.
  • Prometheus metrics exporting (opt-in via metrics.enabled / IIIRIS_METRICS_ENABLED). GET /metrics serves Prometheus exposition format on the main listener, or on a dedicated listener via metrics.addr; optional HTTP Basic auth (metrics.username / metrics.password). Families: HTTP requests/duration/bytes by route class, cache ops (hit/miss/put/delete/error) + entry/byte footprint per cache role, source open counts + latency, pipeline render duration by decode path (thumbnail/reduced/full) + concurrency-slot queue wait, auth decisions by profile, per-source health, build info, and the standard Go/process collectors. In-process counters scraped per replica — no shared backend required; zero-config deployments are unchanged (off by default). See docs/deployment.md "Prometheus metrics".

Deprecated

  • The Lua hook engine (hook.type: lua) is deprecated and will be removed in iiiris 1.0. It still works; configuring it logs a one-line startup warning. Migrate to the JavaScript engine (hook.type: js) — see docs/hooks.md.

[v0.6.0] - 2026-06-10

Fixed

  • GitLab Releases are created reliably, with publicly-downloadable binaries. The release pipeline relied on the release: keyword, which the runner executes via release-cli — absent from our images and runner-version dependent, so v0.5.0's release-publish failed (release-cli: command not found) and no Release object was created. release-publish now creates the Release via the Releases API (scripts/gitlab-release.sh) and uploads the binaries to the generic package registry, linking those instead of members-only job artifacts. Result: release downloads are anonymously downloadable on the public project regardless of CI visibility, while CI logs/artifacts stay members-only. v0.5.0's Release + package were backfilled manually. See docs/specs/releases.md.
  • Admin dashboard "requests served" counter always read 0. The count was bound to a throwaway Server instance built during the two-phase startup wiring (admin handler needs the server; the server needs the admin handler), while requests were served by the second, live instance. The dashboard headline and the /admin/stats/stream requests field now read the live counter, matching the per-route totals. Per-route stats were unaffected.

Added

  • IIIF Presentation API — serve stored manifests/collections (roadmap Phase 1a). New routes GET /iiif/presentation/3/{id} and /iiif/presentation/2/{id} serve stored Presentation documents resolved through the same hook + source path as image identifiers — a manifest is a JSON document in a backing source. The served document's top-level id (id in 3.0, @id in 2.1) is normalised to its canonical iiiris URL; nested values are preserved verbatim. Responses carry ETag / conditional GET → 304, application/ld+json content negotiation, and a dedicated caches.manifest slot. Enabled by default (presentation.enabled: true) as a stateless part of the zero-config path. See docs/presentation.md (guide) and docs/specs/presentation.md (contract).
  • IIIF Presentation API — auto-derive manifests/collections (roadmap Phase 1b). An identifier that resolves to a directory now derives a document on the fly: a Manifest (one canvas per image file, dimensions from a header probe, each canvas painted by an ImageService3 / ImageService2 on this same server, plus a confined-size thumbnail) when the directory holds images, or a Collection (one member per sub-directory, classified Manifest vs Collection one level deep) when it holds sub-directories. Both API versions. Phase 1b derives from filesystem conventions (directory name → label, filenames → canvas labels, listing order → sequence). Non-image files are skipped; an empty directory is 404.
  • IIIF Presentation API — descriptor layering for derivation. Derived manifests/collections now layer overrides over the conventions: a manifest.{yaml,yml,json} sidecar in the directory (label, metadata block, per-canvas label overrides), then a hook-supplied descriptor (authoritative — wins over sidecar and conventions) for hooks that implement the optional presentation.Describer capability. Malformed overrides are logged and ignored — derivation never fails because an override is bad. See docs/hooks.md.
  • IIIF Presentation API — bundled viewer at /view/{id}. A per-manifest viewer route renders a IIIF viewer pointed at the manifest for that identifier. Mirador is the default and is self-hosted — embedded via go:embed, served at /static/viewer/, referenced root-relative so it loads over the page's own scheme — so rendering works fully offline with no CDN dependency. Universal Viewer is available with ?viewer=uv, loaded from a version-pinned CDN (UV 4.x's ~10–21 MB runtime asset tree is too large to embed); presentation.default_viewer sets the default. Completes the zero-config "folder → manifest → viewer" path. Verified two ways: a Go integration test asserts the self-hosted asset serves and the default page references no CDN (Tier 1), and a build-tagged headless-Chrome test (viewer_e2e, wired as the iiif-viewer-e2e CI stage) drives a real browser and confirms Mirador renders by the IIIF traffic it generates with no JS exceptions (Tier 2).
  • Mixed content: the manifest + image-service URLs use the request scheme, so a TLS-terminating proxy must send X-Forwarded-Proto: https.
  • IIIF Presentation API — structural conformance validation. presentation.Validate checks a manifest/collection against the required structure of its version (3.0 + 2.1): @context, id/@id, type discriminators, required label, and — for manifests — positive canvas dimensions and the annotation/image nesting. Every document iiiris derives is asserted valid by the test suite (a conformance gate that rides the normal go test run). An opt-in serve-time mode (presentation.validate: true) logs structural problems on served/derived documents at WARN without rejecting them (serve-as-is; operators own correctness).
  • IIIF Presentation API — CI conformance harness. A project-local harness (tools/iiif-presentation-conformance) drives a live iiirisd and asserts derived manifests/collections (v3 + v2) are structurally conformant and that each canvas paints an Image API service on the same server. Wired as the iiif-presentation-validate CI stage, mirroring iiif-auth-validate.

Changed

  • docker compose now demos both the Image and Presentation APIs. The stack bind-mounts a new ./examples/images corpus (a root thumbnail.jp2 plus a library/ of object sub-directories — manuscript/ with a manifest.yaml sidecar, plates/) as the IIIF source root, so docker compose up --build exercises Image API derivatives, an auto-derived collection + manifests, and the bundled viewer out of the box. The compose header, README quick-start, docs/deployment.md, and .env.example list the URLs for both APIs. Override IIIRIS_LOCAL_IMAGES to serve your own directory as before.
  • docker compose Redis caching example. docker-compose.redis.yml is an opt-in overlay that adds a redis service (bounded 256mb + allkeys-lru) and routes the render / info / manifest caches through it via examples/config/redis-cache.yaml. The base stack stays heap-cached; docker compose -f docker-compose.yml -f docker-compose.redis.yml up enables it. See docs/deployment.md.

[v0.5.0] - 2026-06-07

Changed

  • JPEG output is now baseline, not progressive. iiiris was emitting progressive (interlaced) JPEG by accident — govips' export default — which does multiple entropy-coding passes and is ~3× slower to encode than baseline (measured on a 1024 px tile), for ~10% smaller files. Baseline is the right trade for per-request tile/derivative serving and matches conventional IIIF servers. This roughly triples JPEG encode throughput on render-bound workloads. Operators who want progressive (incremental browser rendering, smaller files) set the new image.jpeg_progressive: true. See docs/configuration.md.
  • Resolution-aware decode. A request that downsizes now decodes only the resolution the output needs — JPEG DCT scaling, JP2 / pyramidal-TIFF reduction levels — instead of decoding the full source then resizing. This covers both full-region downscales (thumbnails / scaled derivatives) and arbitrary-region downscales (deep-zoom tiles, where the crop is remapped onto the reduced grid). Large masters render far faster and with far less memory; output is equivalent within resampling tolerance, and any source whose codec doesn't cleanly reduce falls back to the previous full-decode path. See docs/specs/image-decode.md.
  • image.max_concurrent now defaults to the host CPU count (was unbounded). Renders are CPU-bound, so matching the core count uses the whole machine while bounding peak memory under a request flood (each in-flight decode holds its working set). Set max_concurrent: 0 explicitly to restore unbounded behaviour, or raise it above the core count for I/O-bound sources where cores would otherwise idle. Zero-config startup is unaffected.
  • info.json reads dimensions from the header, not a full decoder. Building an info.json only needs the source's width and height; iiiris now parses those straight from the file header in pure Go (image.DecodeConfig for JPEG/PNG/GIF, an IFD walk for TIFF, the ihdr box / SIZ marker for JP2/HTJ2K) instead of constructing a libvips image to read two integers — which was genuinely expensive for JP2 (OpenJPEG header parsing). Unrecognised formats or unparseable headers fall back to the previous libvips probe, and the parsed dimensions are asserted to match libvips exactly (iiiris applies no EXIF autorotate, so raw header dimensions are canonical). Combined with the mmap change, a dimension probe now touches only the header pages.
  • Heap cache LRU is now O(1) per operation. The in-memory cache tracked recency with a slice that was linear-scanned and re-sliced on every hit under the global lock; it now uses a doubly-linked list with per-entry element pointers, so touch/evict are constant-time and a cache hit holds the lock for O(1) regardless of how many entries are cached. Same LRU semantics; behaviour is unchanged.
  • Filesystem sources are memory-mapped, not fully buffered. Image renders and info.json probes over a filesystem source now mmap the file and let libvips fault in only the bytes it reads, instead of io.ReadAll-ing the whole compressed file into the heap. For a large tiled master a one-tile request (or a dimension probe) no longer allocates the entire file — resident memory becomes independent of source size (measured ~175 MB → ~41 MB on a 134 MB tiled TIFF) and the per-request allocation/GC cost drops. Network sources (HTTP/S3), the origin-cache wrapper, raw J2K codestreams, and non-Unix platforms keep the previous io.ReadAll path, so behaviour is unchanged where mmap doesn't apply. See docs/specs/image-decode.md.

Fixed

  • info.json source probes are now concurrency-bounded. A burst of caches-off info.json requests previously had no in-flight limit on the source probe, so each request could hold a whole-source buffer in memory at once — enough to OOM the server under load (the render path was already bounded by image.max_concurrent, but the probe path was not). The probe now acquires a dedicated semaphore sized to the same image.max_concurrent value, bounding the number of in-flight source buffers. No new config; renders and info probes use separate gates so neither starves the other.

Added

  • libvips memory/concurrency limits. Two new image config fields — vips_concurrency (worker threads per libvips operation) and vips_max_cache_mem (operation-cache cap, bytes) — let operators tune or bound libvips memory, which previously wasn't possible (iiiris started libvips with no configurable limits). Both default to 0 = the libvips default (concurrency 1, 50 MiB cache), so existing behaviour is unchanged. Env overrides: IIIRIS_VIPS_CONCURRENCY, IIIRIS_VIPS_MAX_CACHE_MEM. See docs/configuration.md.

[v0.3.6] - 2026-06-04

Added

  • none cache backend. Any cache slot (render, info, origin) accepts backend: none, which disables it — every lookup misses and every store is discarded. Lets a deployment turn a tier fully off without a nil-cache code path; used by the new comparative benchmark harness to isolate the render pipeline from caching effects. See docs/caches.md.

Internal

  • Comparative benchmark harness (tools/bench-compare + bench/). Drives the same IIIF workload (info / full-scaled / tiled across JPEG, HTJ2K, and a large master) against iiiris and Cantaloupe one at a time under identical Docker resource limits and matched output, sampling throughput, latency percentiles, and CPU/RSS for caches-off and caches-on passes, and emitting a Markdown + JSON comparison. Local for tuning, Terraform-provisioned AWS host for citable numbers; deliberately not wired into CI. Design: docs/briefs/CANTALOUPE_PARITY.md.
  • release-image no longer collides on the macOS runner keychain. docker login on a Docker Desktop host force-enables the desktop/osxkeychain credential helper even with an empty per-job DOCKER_CONFIG, so the two concurrent release-image jobs both wrote the registry credential to the host-global login keychain and raced — one won, the other failed with errSecDuplicateItem (-25299) (v0.3.5's release-image). The base job now skips docker login entirely and writes the auth directly into the ephemeral per-job config.json; buildx --push authenticates from there and never touches the keychain (also avoiding the UI-coupled helper's hang on public pulls).
  • Conformance jobs no longer race for a shared port. iiif-validate and iiif-auth-validate run concurrently in the validate stage and both bound iiirisd to :18180 under --network host, so whichever lost the bind race silently probed the other job's (auth-less) server — surfacing as 8/8 spurious auth-conformance failures with empty service[] and 404s on every auth route. iiif-auth-validate now owns :18280; both jobs gained a pre-flight port check and a bind-failure (kill -0) guard so any future collision fails loudly instead of validating the wrong process.

[v0.3.4] - 2026-06-01

Fixed

  • release-publish uses the modern glab image. The old registry.gitlab.com/gitlab-org/release-cli:latest no longer contains the release-cli binary it used to (deprecated and removed upstream); the wrapper image's entrypoint still tries to invoke it and fails with release-cli: command not found. Switched to registry.gitlab.com/gitlab-org/cli:v1.100.0, the glab CLI image GitLab now recommends for the release: keyword. v0.3.3's release pipeline hit this after the credential-helper hang was unblocked.
  • release-image jobs no longer hang on the Docker credential helper. .release-image-base now isolates docker auth into a per-job DOCKER_CONFIG directory instead of using the runner host's ~/.docker/config.json. On macOS Docker Desktop the shared config defaults to credsStore: desktop, which talks to a UI-coupled helper that hangs indefinitely when called from launchd-managed gitlab-runner (no UI session) — even for public pulls like tonistiigi/binfmt. v0.3.3's first release pipeline hit exactly this and the release-image + release-image-debian jobs timed out at 60 minutes. The per-job config has no credsStore, so docker writes plain auths entries that work without UI coupling. Also retires the docker logout keychain workaround since the system keychain isn't touched at all.
  • release-binary-linux-amd64 no longer silently builds a misnamed binary. The job's docker run didn't pass -e CI_COMMIT_TAG, and the inner bash -c '...' uses single quotes — so ${CI_COMMIT_TAG} inside the container expanded to an empty string. The go build succeeded but wrote iiirisd--linux-amd64 (empty version slot); the host-side sha256sum "iiirisd-vX.Y.Z-linux-amd64" then looked for a file that didn't exist and failed. v0.3.2's release pipeline broke on this. Now passes -e CI_COMMIT_TAG through and uses set -eu inside so an unset variable fails loud.
  • release-binary-smoke now mirrors the release job's shell-expansion + filename path. Smoke writes its output to the same iiirisd-${CI_COMMIT_TAG}-linux-amd64 template (falling back to v0.0.0-smoke on non-tag pipelines) and runs sha256sum against that name. Future env-passthrough regressions of the v0.3.2 shape will fire on push instead of at tag time.
  • release-binary-linux-amd64 actually produces a binary on apple-silicon runners. The job was running an arm64 container and invoking x86_64-linux-gnu-gcc to cross-compile a CGO binary for linux/amd64, but the cross-compiler wasn't installed (the job's runtime apt-get install was a no-op since the container runs as the unprivileged runner user), and even if it had been, the linker would have failed because the image only carries arm64 libvips/glib/openjpeg shared objects. The job now runs the build inside a --platform linux/amd64 container so the toolchain, libvips, and binary are all natively amd64 — the same Rosetta/binfmt path docker-build-distroless and release-image* already use for buildx. Confirmed v0.3.1's release pipeline died on this; the fix unblocks future tag pushes.
  • docker login no longer fails on macOS-runner re-runs. Docker Desktop's default credential helper writes to the system keychain, and a prior login left an entry the next login couldn't overwrite (errSecDuplicateItem -25299). The shared before_script in .release-image-base now runs docker logout "$CI_REGISTRY" || true first — idempotent, no-op on linux runners with file-store credentials.

Internal

  • release-binary-smoke job runs on every pipeline. Same docker invocation as release-binary-linux-amd64, building to /tmp inside the container. Catches toolchain regressions (missing cross-compile path, missing CGO deps, missing --platform flag) on push instead of at tag time — important because the spec's forward-only rollback means a bad tag is a tombstone.
  • CI builder image split into host-arch + amd64 variants. iiiris-ci:latest stays host-arch for jobs that build and run iiirisd (lint, test, bench, iiif-validate, iiif-auth-validate); govips's CGO bindings into libvips segfault under Rosetta, so these jobs need native arch. New iiiris-ci-amd64:latest, built via --platform linux/amd64, is used only by release-binary- jobs which cross-build a linux/amd64 binary that leaves the container as an artifact. The amd64 image is never used to run* iiirisd, side-stepping the Rosetta+CGO crash.

[v0.3.1] - 2026-05-27

Fixed

  • Tag pipelines without RUN_WINDOWS=1 no longer fail validation. Previously, release-binary-windows-amd64 and release-binary-windows-msi declared needs: on the gated build-windows-* jobs without marking them optional, so GitLab rejected the entire pipeline at creation time when RUN_WINDOWS was unset. The two re-export jobs now also gate on RUN_WINDOWS=1 (skipping entirely when absent), and release-publish marks its needs on them optional: true so it remains valid in their absence. Windows asset links in the GitLab Release entry are still hardcoded — they resolve to 404 on non-Windows releases. Per docs/specs/releases.md "Windows artifacts" the dead-links wart is retired when the Windows CI path lands. (It did, differently: the EC2-orchestrator design that entry refers to was dropped for GitLab's SaaS Windows pool — see docs/specs/ci-runners.md.)

Internal

  • CI pipeline parallelism + interruptibility. Non-release jobs (lint, test, bench-smoke, docker-build, docker-build-distroless, iiif-validate, iiif-auth-validate) now declare needs: [ci-image] so they start as soon as the CI builder image is ready instead of waiting for stage boundaries — combined with the runner's bumped concurrency they run in parallel rather than serializing. default: interruptible: true cancels in-flight jobs from a superseded pipeline when a newer commit lands on the same branch. release-publish declares its dependencies with artifacts: false, skipping the ~280-390 MB download of release binaries/zip/MSI it never reads (the release: block links to them by URL).

[v0.3.0] - 2026-05-27

Licensing

  • Relicensed from MIT to the Apache License 2.0. Previously released versions (≤ 0.2.0) stay under MIT; the relicense applies from this point forward. Apache-2.0 adds an explicit patent grant (with patent- retaliation clause), explicit NOTICE-preservation obligations for upstream Apache-2.0 dependencies, and matches the IIIF ecosystem's predominant outbound license (the spec itself, IIIF-Java, IIIF Presentation libraries, Mirador) as well as the licenses of the largest dependencies (AWS SDK, go-oidc, go-jose). Compatible with every direct and indirect dep (MIT / BSD-2 / BSD-3 / Apache-2.0) and with the LGPL-2.1-or-later runtime dependency on libvips. New LICENSE (verbatim canonical Apache-2.0 text), NOTICE (project copyright + reproduced upstream NOTICE blocks for the AWS SDK, smithy-go, and go-oidc — Apache-2.0 §4 requires keeping these intact), and THIRD_PARTY_LICENSES.md (full per-module inventory covering Go deps, the vendored OpenSeadragon, and the linked system libraries). README gains a License section.
  • Release-image compliance bundle. Both Dockerfiles (deploy/Dockerfile and deploy/distroless/Dockerfile) now COPY the LICENSE, NOTICE, and THIRD_PARTY_LICENSES.md into the image at /usr/share/doc/iiiris/; the distroless variant additionally bundles a new LIBVIPS-SOURCE.md — the LGPL-2.1 §6(d) "info to relink" pointer documenting pinned libvips / OpenJPEG / libtiff versions, source URLs, and build flags. The stale org.opencontainers.image.licenses="MIT" OCI labels on both images are corrected to "Apache-2.0". A deployed binary now points at its own license terms and (for the static distroless build) its own relink recipe — closing the LGPL §6 loop at runtime as well as in the source tree.

Changed

  • filesystem cache backend: concurrent-process safety. Replaces the single-process-only implementation. Multiple iiirisd processes on the same host may now share one cache directory; eviction is serialized across processes via a flock on <path>/.lock. Get and Put remain lock-free; only the eviction critical section takes the lock. Cap enforcement is best-effort across processes (typical overshoot <1% of cap). On first attach to a directory containing pre-FS_CACHE2 cache files, the legacy entries are wiped and a .iiiris-cache-version sentinel is written; operator-dropped files are preserved.
  • GitLab Release description sourced from CHANGELOG.md. The release-publish CI job now extracts the body of the ## [vX.Y.Z] section from CHANGELOG.md (heading stripped, leading/trailing blank lines trimmed) and writes it to RELEASE_NOTES.md, which the release: block uses as the GitLab Release description. The tag annotation message (git tag -s -m "...") is prepended as a one-line supplemental summary. CHANGELOG.md is now the single source of truth for release content — no more duplicating between the tag annotation and the CHANGELOG section. Contract owned by docs/specs/releases.md "Release notes".
  • scripts/cut-release.sh automates the pre-tag steps. Run scripts/cut-release.sh vX.Y.Z to validate state (version format, clean tree, on main, tag doesn't already exist, [Unreleased] non-empty), flip CHANGELOG.md [Unreleased][vX.Y.Z] - YYYY-MM-DD, commit on release/vX.Y.Z, and push the branch. The maintainer opens the MR, merges, then signs and pushes the tag themselves (the tag is the explicit "ship this" gesture). A companion project skill at .claude/skills/cut-release/ walks maintainers through the same flow from Claude Code.
  • SHA256 checksums for every binary release artifact. The Linux amd64 binary, Windows amd64 zip, and Windows amd64 MSI each ship with a .sha256 file alongside, attached to the GitLab Release. Verify with sha256sum -c iiirisd-vX.Y.Z-<flavor>.sha256 (Linux) or shasum -a 256 -c (macOS) from the directory containing both files.

Added

  • filesystem cache backend: distributed mode (Redis coordinator). Multi-replica iiirisd deployments sharing an NFS/EFS mount can now use the filesystem backend by adding a coordinator.redis block alongside backend: filesystem. The files live on disk as in local mode; the index (entries → sizes, LRU order, total bytes) moves to Redis where it stays consistent across replicas. Eviction is serialized by a TTL'd Redis lease; startup runs a reconciliation pass under the lease to align the index with disk state. The coordinator.redis block uses the same shape as the existing redis: configs; backend: redis and coordinator.redis are mutually exclusive. See docs/caches.md § Distributed mode (Redis coordinator).

Added

  • Project logo across the server, admin UI, and docs. Vector wordmark + tile-pyramid mark in logos/ (light, dark, square icon, 32 px favicon — all SVG). README + every docs/**/*.md open with the mark above the first heading. The admin UI sidebar now leads with the icon mark next to "iiiris admin", and the layout serves /admin/static/favicon.svg so tabs get the iiiris mark. New public-facing routes on the IIIF server itself: GET / returns a minimal landing page rendering the wordmark (auto-dark via prefers-color-scheme), with the SVG sources served at GET /logo.svg and GET /favicon.svg. All assets are embedded into the binary — no extra files on disk to manage.
  • Windows MSI installer service-install mode. The Windows MSI (Phase 3b) now optionally registers iiirisd as a Windows service that starts on boot. Enabled via msiexec /i ... INSTALLSERVICE=1; an attended GUI checkbox is a future polish. The service runs as NT AUTHORITY\LocalSystem with Start=auto; operators can swap to NetworkService or a domain account via services.msc. Windows Event Log integration ships in lockstep — the installer registers the iiiris Event Log source under HKLM so structured logs appear under Event Viewer → Applications and Services Logs → iiiris, alongside the rolling file at %ProgramData%\iiiris\logs\iiirisd.log. Per-user installs cannot register HKLM keys, so service mode is auto-disabled there (the brief's "disable + explain in dialog" decision; the feature simply doesn't apply rather than producing a cryptic install error). Upgrade flow handles the binary-replace dance: ServiceControl Stop="both" stops the old service before MajorUpgrade writes the new binary, then Start="install" starts it. Uninstall removes the service, deregisters the Event Log source, and preserves %ProgramData%\iiiris\{config,cache, logs}\ per the Permanent="yes" contract. Start Menu shortcuts land alongside (console-launch hidden in service mode; admin URL + uninstall always present). The MSI is self-signed in CI (SIGN_MSI=1 gates the sign step against a PFX in CI secrets); SmartScreen shows "Unknown publisher" on first run. A future EV/OV cert procurement replaces the PFX without code change. See docs/specs/windows-build.md for the full contract and docs/deployment.md for operator walkthrough.
  • Windows MSI installer (binary-install mode). Tagged releases now publish iiirisd-vX.Y.Z-windows-amd64.msi alongside the zip. Standard Windows wizard with per-user (%LocalAppData%\iiiris) or all-users (Program Files\iiiris) layout choice via WiX v5's WixUI_Advanced dialog. First install drops a commented config.yaml.example at %ProgramData%\iiiris\config\config.yaml; operator edits survive upgrades. MajorUpgrade stops the prior install (if any), replaces the binary, and restarts; data dirs (config, cache, logs) are marked Permanent="yes" and preserved on uninstall — manual wipe of %ProgramData%\iiiris is the documented clean-slate path. MSI is self-signed so SmartScreen shows "Unknown publisher" on first run; the CI wiring is stubbed for a future EV/OV cert flip. New deploy/installer/iiiris.wxs (hand- authored Package + Feature + standard directories + components), generated deploy/installer/dlls.wxs (the 91-DLL inventory; freshness enforced at build time by scripts/wix-generate-dlls.ps1 -Mode Check), and CI jobs build-windows-msi + tag-only release-binary-windows-msi complete the pipeline. Optional Windows-service-install mode (ServiceInstall + Event Log source registration) is the remaining Phase 3c work; for now, manual sc.exe create iiiris ... registers the installed binary as a service per docs/deployment.md "Running as a Windows service (manual)".
  • Windows service-mode entrypoint and Event Log integration. iiirisd.exe now detects Windows Service Control Manager context automatically (via golang.org/x/sys/windows/svc.IsWindowsService) and behaves as an SCM-managed service when launched by it, otherwise running as a console process. The service path translates SCM Stop / Shutdown control events into the same graceful http.Server.Shutdown flow the console path triggers on Ctrl+C, with the configured server.shutdown_timeout drain budget. Two new slog.Handlers ship for service-mode logging — a Windows Event Log writer (Phase 3c's WiX installer will register the iiiris source under HKLM; until then the handler returns nil at startup and falls back to file + stderr) and a size-based rolling log file via lumberjack.v2 (100 MB per file, keep 5). The rolling file is also available in console mode via the new log.file config field (IIIRIS_LOG_FILE). Manual service registration via sc.exe create iiiris binPath=... works today; the full WiX MSI installer with optional service-install mode lands in vX.Y+1. See docs/deployment.md "Running as a Windows service (manual)".
  • Published Windows/amd64 binary. Tagged releases now publish iiirisd-vX.Y.Z-windows-amd64.zip alongside the existing distroless + debian-slim container images and the linux/amd64 binary. The zip contains iiirisd.exe plus its MSYS2 UCRT64 runtime DLLs (libvips, libjpeg, libtiff, libwebp, libpng, libopenjp2, gcc runtime + transitive deps — ~90 DLLs total) and the compliance bundle (LICENSE, NOTICE, THIRD_PARTY_LICENSES.md). Extract anywhere and run the .exe directly; no MSYS2 install on the target host. Linkage is dynamic, so LGPL §6(d) is satisfied trivially by drop-in replacement of the libvips DLL. New build-windows-amd64 CI job on the GitLab SaaS Windows runner builds the binary with CGO via MSYS2 UCRT64; a new scripts/bundle-windows.ps1 resolves the DLL closure and assembles the zip; per-format smoke (jpg / png / tif / webp) runs against the staged binary with MSYS2 stripped from PATH so a missing bundled DLL surfaces as a startup failure rather than silently falling back to the build-host install. A new tag-only release-binary-windows-amd64 re-exports the zip with expire_in: never for the GitLab Release asset link.
  • Windows portability fixes (toward a published Windows build). cmd/iiirisd now selects shutdown signals via build-tagged helpers (SIGINT + SIGTERM on Unix; os.Interrupt on Windows — SIGTERM does not exist on Windows). internal/source/filesystem.go's safePath rejects NUL bytes, any : (catches Windows drive-letter injection and NTFS alternate data streams), and reserved DOS device names (CON, PRN, AUX, NUL, COM0-9, LPT0-9) in any path component — rejected universally so Linux-authored collections stay portable to Windows. internal/cache/filesystem.go notes the Windows rename sharing-violation edge for Put; the actual retry wrapper lands when needed. No behaviour change on Linux/macOS.
  • Per-IP concurrency cap. New image.max_concurrent_per_ip caps how many IIIF requests a single client IP may have in flight at once — the per-client analogue of the global image.max_concurrent guard, so one client can't occupy every render slot. It is a concurrency cap, not a rate limiter (steady traffic is never rejected); over-cap requests get 429 Too Many Requests. Applies to /iiif/... only — /health, /ready, and /admin are never throttled. Ships disabled (0): unlike the global guard, a per-IP cap can reject legitimate traffic when users share one egress IP (a NAT'd reading room), so it is opt-in, and behind a proxy it requires auth.trusted_proxies so the real client IP is the key. General rate limiting / DDoS defence stays at the edge (reverse proxy / CDN) by design — see docs/deployment.md. The limiter middleware is only wired into the chain when the cap is non-zero, so a disabled cap costs nothing. (auth.parseCIDRs exported as auth.ParseCIDRs to back the trusted-proxy parse.)
  • Lua hook hot-reload. Path-mode Lua scripts (hook.lua.path) are now live-reloaded: the file's mtime is re-checked at most once per hook.lua.watch_interval (default 5s), and a changed file is recompiled and the script generation is swapped atomically. In- flight calls finish on the previous version, subsequent calls use the new one. Recompile failures (syntax errors, sandbox-disallowed calls, init failures) are logged at WARN and the previous version stays in service — a bad edit cannot break live traffic. Inline scripts (hook.lua.script) are baked into config and never reloaded. Set watch_interval to a negative duration to disable hot reload entirely.
  • IIIF Authorization Flow API 2.0 — all four services (AuthProbeService2, AuthAccessService2, AuthAccessTokenService2, AuthLogoutService2) mounted under /iiif/auth/... and advertised inside info.json for gated identifiers (v2 and v3). The four interaction patterns (clickthrough, active, kiosk, external) are all implemented end-to-end, with four pluggable access-service backends (builtin with bcrypt/apr1 htpasswd, reverse-proxy header trust, signed-callback external, first-class oidc). See docs/iiif-auth.md and the docs/specs/auth.md spec.
  • Hook.Authorize is now wired through to the request path via RuleAuthorizer. Hook decisions can refine a rule-engine verdict (substitute identifier, redirect, profile override) but cannot lift a rule-driven deny to an allow — preventing a default-allow hook from unintentionally opening an auth gate set in YAML.
  • Hook.Redirect is now wired and consulted at the top of every image and info.json request, before auth and parameter parsing. A non-empty URL produces an immediate 303; hook errors surface as 500. Use case: content-relocation (this identifier has moved), independent of who is asking. Distinct from the auth flow's own Decision.Redirect (which runs after auth denies, e.g. to bounce a denied user to an external SSO).
  • Tier-keyed render and info caches. When auth: is configured, RenderCache and InfoCache keys are salted with one of three tiers — public, full, substitute — so a denied-substitute response can't collide with an authorized-full response (or vice versa). The public tier preserves the pre-auth key shape, so introducing auth to a deployment doesn't churn unrelated cache entries. Tier vocabulary is capped at three values; expansion is gated on a cacheKeyVersion bump that forces clean eviction.
  • Per-profile substitute behavior. substitute.max_size (an IIIF size string such as !400,400) degrades the response on deny in place; Hook.Authorize can additionally inject a per-request alternate identifier.
  • access_service.template override for the built-in access service. Operators supply a Go html/template file path; the file is parsed at startup (build error if missing/unparseable) and executed in place of the default login page. Restart required after edits. Template data contract documented in docs/iiif-auth.md.
  • IIIF Authentication API 1.0 dual-advertisement. info.json now carries both the 2.0 service tree and a 1.0 access service block side-by-side (modern viewers follow 2.0; older 1.0-only viewers still find a working path). The smaller 1.0 surface (/iiif/auth/v1/login/{profile}, /iiif/auth/v1/token, /iiif/auth/v1/logout) is mounted alongside the 2.0 routes. The login and logout handlers are reused verbatim — the cookie they manage is identical between versions; only the advertised service tree and the token-response shape (no @context / type, scalar error field on failure) differ. Header-backend profiles continue to advertise the 2.0 probe service only — 1.0 has no probe-only mode, so a 1.0 advertisement without an interactive access service would be ambiguous and is omitted.
  • Real-IP extraction (internal/auth/realip.go) with trusted-proxy CIDR allowlist (auth.trusted_proxies), used by the kiosk-pattern IP allowlist matcher.
  • Sample auth configs under docs/examples/: one self-contained YAML per pattern × backend combination (clickthrough, active with htpasswd file, active with inline users, kiosk, header-trust, external HMAC callback, OIDC). Each is heavily commented for copy-and-modify; the index docs/examples/README.md is a pick-list by pattern and by deployment shape.
  • IIIF Auth 2.0 conformance harness + CI stage. New tools/iiif-auth-conformance Go binary drives the spec-defined Auth 2.0 endpoints (probe, access service, access-token, logout) and asserts the response shapes (@context, type, status, payload fields per §3–§7 of the spec). The harness is -pattern-aware: runs nine checks for each interactive pattern (clickthrough, active, kiosk — they share the spec sequence, differing only in login mechanism) and a shorter four-check sequence for the header backend (no cookie flow, probe-only advertisement). external + oidc backends are out of scope (need a real upstream).

The CI job iiif-auth-validate builds iiirisd, starts it with a multi-profile sample config that defines one profile per pattern, runs the harness once per pattern, and captures the combined iiif-auth-validation.log as a 1-week artifact. The stage is load-bearing — a regression in any of the four patterns fails the pipeline. When a community-blessed Auth 2.0 validator emerges, swap the binary out of the CI job; posture stays the same. - /admin/auth operator page surfaces the IIIF Auth subsystem read-only: configured profiles + rules tables, active session count with per-profile breakdown (when the session backend supports it — heap and filesystem do; s3 reports the total only), and a bounded ring buffer of the most-recent 256 allow/deny decisions (latest-first, with a 24h tally). Nav-bar link added. - Live admin dashboard. The status dashboard updates in place via a Server-Sent Events stream (GET /admin/stats/stream) instead of manual refresh: uptime, request count, cache stats, and per-route metrics refresh once per second; source health (a real network probe on S3) every tenth tick (~10s). Each route row gains a client-side p95 sparkline over the last 60 streamed samples, and a live/reconnecting indicator tracks the EventSource connection. The stream path is excluded from request counting and per-route stats (classifyRoute returns "") so its long-lived connection doesn't pollute the admin route's latency quantiles; statusWriter grew a Flush pass-through so streaming works through the stats middleware. The admin UI is also restyled — sidebar navigation, a single embedded stylesheet (internal/admin/static/style.css) replacing per-template inline CSS, and status badges across the source / cache / auth tables. - Admin config editor. /admin/config gains a structured form over a curated subset of fields (server address + timeouts, log level/format, image pipeline limits). A save validates every field, writes the whole config back to the -config file atomically (temp file + rename, as normalized YAML — fields outside the subset are preserved verbatim), and hot-applies the three fields that can change without a restart: log.level (via a slog.LevelVar), image.max_pixel_area (the source-area guard) and image.max_concurrent (the pipeline's concurrency semaphore, now resizable in place). The image output caps and tile_size stay restart-only on purpose — each is paired with an info.json advertisement, and hot-reloading one side would make info.json lie; a save reports which changed fields need a restart. The editor is read-only without -config (no file to persist to), and individual fields lock when shadowed by an env var. New config.LoadFileOnly (file overlay without env overrides) backs the round-trip; observability.NewLogger now returns its *slog.LevelVar. - auth.DecisionLog — bounded ring buffer (256 most-recent events) of authorization decisions. RuleAuthorizer.Recorder is the hook; cmd/iiirisd/main.go wires it whenever the auth subsystem is enabled. - Optional session.Counter capability (mirrors source.AsLister's discovery pattern). Implemented by HeapStore and FilesystemStore with full per-profile breakdown; S3Store implements it via ListObjectsV2 totals only — a per-profile count would require one GetObject per session. - Four session-store backends. - heap (default): in-memory; sessions vanish on restart. - filesystem (auth.session.backend: filesystem, path: /var/lib/iiiris/sessions): JSON files sharded by SHA-256 prefix; atomic writes; lazy + Sweep-driven expiry; sessions survive restart. - s3 (auth.session.backend: s3, s3.bucket: ..., s3.prefix: ...): JSON objects under <prefix>/sessions/ and <prefix>/tokens/ keyed by sha256. Long-term cleanup is the operator's job via S3 lifecycle policy (Sweep is a no-op, matching the cache backend's posture); expired records are still dropped on read by GetSession/GetToken. Cascade on DeleteSession lists the tokens prefix and removes any token whose SessionID matches. - redis (auth.session.backend: redis, redis.addr: ...): JSON-encoded records under a configurable prefix with native Redis TTL eviction. Bound tokens tracked in a Redis SET per session so DeleteSession cascades atomically in one round trip (SMEMBERS + pipelined DEL) instead of the linear scan the filesystem and S3 backends use. First backend that supports multi-replica iiirisd — Redis's single-key atomic operations prevent the CreateToken / DeleteSession race that the other persistent backends can't avoid.

Opaque 32-byte / base64url tokens minted via crypto/rand. - Redis cache backend for RenderCache, InfoCache, and OriginCache (caches.<slot>.backend: redis, redis.addr: ...). Eviction is the operator's job via Redis MAXMEMORY + an eviction policy (allkeys-lru typical); optional ttl: attaches a per-entry TTL via SET EX. Binary-safe value encoding (2-byte content-type length prefix + content-type + body) so image bytes round-trip exactly without base64 / JSON overhead. Stats reports entry count via prefix SCAN; byte total is not tracked. PurgeAll walks the prefix with SCAN + DEL in batches. Session cookies use Secure + HttpOnly + SameSite=None; the cookie Path is auto-derived from X-Forwarded-Prefix when iiiris sits behind a sub-path proxy.

Documentation

  • README.md and docs/deployment.md Releases section advertise the new Windows/amd64 zip alongside the existing Linux binary and container images. The Windows (local build) subsection now points readers at the published zip as an alternative to building from source; "What's not yet shipped" retires the Windows-binary line (it shipped) and instead names the remaining gap (the WiX MSI installer with optional service registration, planned for a later phase).
  • docs/deployment.md: new "Windows (local build)" subsection covering the MSYS2 UCRT64 install path (choco install msys2, choco install golang, the pacman package set for libvips + gcc + pkg-config + openjpeg2), the UCRT64-shell prerequisite, make build, and the dynamic-CGO linkage with ~90 MSYS2 DLLs. Pinned versions validated by a Phase 1 spike on the GitLab SaaS Windows runner: Go 1.25.0, gcc 16.1.0, libvips 8.18.2. The two previous "cross-compiling CGO + libvips is impractical, no Windows artifact" sentences are retired in favour of a documented local-build path; a published Windows-amd64 zip remains planned for a subsequent release. See docs/specs/windows-build.md for the full three-phase plan.
  • docs/caches.md: new "Windows path-length" note under the filesystem cache backend — the sharded layout adds ~70 characters to path:, so anchoring path: near the volume root keeps the cache well under the 260-character MAX_PATH ceiling without any long-path-support flags.
  • New docs/iiif-auth.md covering the four services, four patterns, four access-service backends, YAML schema, validator posture, and migration notes from Auth 1.0.
  • docs/architecture.md: request flow now shows the auth.Authorizer.Authorize step and the tier-keyed cache lookup; package map adds internal/auth/{service,htpasswd,session}.
  • docs/configuration.md: full auth: schema documented; validation rules for the new fields listed.
  • docs/admin.md: clarifies that /admin/* HTTP Basic is separate from the public-facing IIIF auth services; cache key examples updated for the tiered shape; documents the live SSE dashboard, the /admin/stats/stream endpoint, and the per-route p95 sparkline; new "Configuration editor" section covering the curated field subset, the live-vs-restart split, and the save semantics.
  • docs/configuration.md: notes the admin config editor as a runtime path for changing a curated subset of fields, with a cross-reference to admin.md; documents image.max_concurrent_per_ip.
  • docs/deployment.md: new "Rate limiting and abuse protection" section — the edge-first stance, an nginx limit_req example, and the opt-in image.max_concurrent_per_ip cap with its trusted-proxy and shared-IP caveats. Also: the "Local build" + "Container" sections are replaced by a unified "Build variants" matrix (local binary / release binary / debian-slim image / distroless image) with linkage and license-implication call-outs per variant; a new "Redistribution compliance" matrix lists exactly which files to ship for each artifact; and a new "Running from a binary" section walks through the non-Docker deployment path (apt/brew install, the CI-published linux/amd64 release binary, a minimal systemd unit).
  • docs/caches.md: new "Auth-tier keying" section.
  • docs/iiif-compliance.md: new "IIIF Authorization Flow conformance" section.
  • README.md feature list gains the Auth 2.0 entry.
  • Release process spec. docs/specs/releases.md defines the release contract end-to-end: signed-tag trigger, pre-1.0 SemVer rules (breaking = IIIF API surface, config schema/env vars, or CLI flags), ad-hoc cadence, the fixed artifact set, latest semantics, forward-only rollback, the cut-release flow, and what's deliberately out of scope (cosign, SBOMs, multi-registry mirroring, pre-release channels). CHANGELOG.md header, docs/specs/scaffold.md "Versioning + releases", docs/deployment.md "Releases", README.md "Where to read next", and CLAUDE.md documentation- trigger table now cross-reference it.

0.2.0 - 2026-05-17

Added

  • HTJ2K (JPEG 2000 Part 15) decode via OpenJPEG ≥ 2.5.0. Sources in either form — boxed JP2/JPH or raw codestream (the default output of OpenJPH's ojph_compress) — flow through the IIIF pipeline. iiiris wraps raw codestreams in a minimal JP2 envelope on the fly so govips's format gate routes them to vips_jp2kload_buffer. Encode is classic Part 1 only — OpenJPEG doesn't implement HTJ2K encoding upstream. See docs/specs/htj2k.md.
  • testdata/sample.jph HTJ2K fixture (49 KiB synthetic gradient, generated via OpenJPH's ojph_compress; see tools/gen-htj2k-sample/README.md).
  • CORS on every IIIF response (Access-Control-Allow-Origin: *). The cors advertisement in extraFeatures is now load-bearing.
  • JSON-LD content negotiation on info.jsonAccept: application/ld+json produces application/ld+json;profile="… /context.json". The jsonldMediaType advertisement is now load-bearing.
  • Slash-collapse middleware — folds runs of / in the request path before routing. Fixes 404s from //foo-style URLs (which the stdlib ServeMux would otherwise 301-redirect, and many IIIF clients don't follow).
  • Validator fixture testdata/validator-squares.jpg (1000×1000 colored-grid JPEG matching the iiif-validator's hardcoded palette). Required by id_squares / size_wc / size_wh / size_ch / region_pixels. make testdata-validator regenerates.

Changed

  • Default release image is now distroless:vX.Y.Z and :latest are built from deploy/distroless/Dockerfile on gcr.io/distroless/static-debian12:nonroot. Static-linked libvips
  • OpenJPEG (with HTJ2K decode) + transitive image libs, no shell, no package manager. ~46 MB compressed. The Debian-slim image is retained as :vX.Y.Z-debian (no :latest-debian tag) for operators who need shell tooling in the container or hit a distroless-specific issue.
  • CI docker-build-distroless is no longer allow_failure — distroless build failures now block merges.
  • CI iiif-validate switches to --version=2.0 for v2 testing (the validator tags its tests 2.0 / 2.1.1 / 3.0; the prior --version=2.1 silently matched zero tests). After this change: v3 → 3 failures (all known upstream Python bugs / spec disagreements), v2 → 21 tests run with 2 failures (same upstream bugs).
  • CLAUDE.md "Documentation maintenance" rule expanded so a feature-list or release-tag-scheme change triggers a README.md update, and any user-shipping change triggers a CHANGELOG.md update (with "don't leave WIP entries past shipping" called out explicitly).

Fixed

  • info.json previously advertised jsonldMediaType and cors in extraFeatures but emitted neither header — both are now actually implemented.

Internal

  • Test coverage push for internal/source (18.6% → 85%), internal/config (0% → 100%), and internal/cache (56.5% → 84%). S3 backends in both packages refactored to take a small unexported API interface so unit tests can inject a fake — no minio sidecar or AWS calls in CI. Project-wide coverage ~54% → ~78%.

0.1.0 - 2026-05-11

First public iteration. Pre-1.0; the IIIF surface and most caching/source behavior is stable, but some edges (Lua hot-reload, IIIF validator in CI, Hook auth/redirect handler wiring) are still in flight.

Added

  • IIIF Image API 3.0 (Level 2) and 2.x parsers + endpoints, with multi-segment identifiers (paths with slashes).
  • libvips image pipeline via govips: full region / size / rotation / quality (incl. true bitonal) / format coverage.
  • Three caches (RenderCache, InfoCache, OriginCache) × three backends (heap, filesystem, S3 with multipart streaming). Filesystem cache has size-bounded LRU eviction.
  • Three source backends: filesystem, HTTP, S3 (S3 supports listing).
  • Hook engines: noop, lookup (text/template), lua (sandboxed gopher-lua), webhook (HTTP POST per call).
  • Operator admin UI under /admin/: status, config, cache management (purge / purge-all), file browser, OpenSeadragon viewer (vendored). HTTP Basic auth gated on IIIRIS_ADMIN_USER / IIIRIS_ADMIN_PASS.
  • Conditional GET (If-None-Match / If-Modified-Since → 304).
  • Zero-config startup (binary works with no flags, no file, no env vars).
  • Multi-arch container image (linux/amd64 + linux/arm64) and a Linux binary on tagged releases via GitLab CI.