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¶
Added¶
-
Published documentation site at https://docs.iiiris.org. The
docs/Markdown tree is rendered with MkDocs + Material and deployed to GitLab Pages by a newpagesCI job on every default-branch docs change and every release tag. Versioned with mike:devtracksmain, each releasevX.Y.Zpublishes versionX.Yaliasedlatest. Source docs are unchanged — a build hook importsREADME/CHANGELOG, injects the logo, and rewrites links. Seedocs/deployment.md"Documentation site" (includes the one-timeDOCS_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: trueand asentry.dsn(orIIIRIS_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 viasentry.capture.*. Events are attributed byenvironmentand buildrelease. Privacy-first by default —send_default_pii: falsestrips client IPs, cookies, auth headers, and URL query strings before any event leaves the process (opt in withsend_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 thesentry.*block orIIIRIS_SENTRY_*env vars. Seedocs/configuration.md. -
Distributed tracing via OpenTelemetry (off by default). With
tracing.enabled: trueiiiris emits a root span per request plus child spans across source open and cache get/put, exported over OTLP/gRPC totracing.otlp_endpoint— any collector (Tempo/Jaeger/…) or Sentry's own OTLP ingestion URL. Inbound W3Ctraceparentis honored (ParentBasedhead sampling attracing.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 ininternal/telemetry(root span via an opaque middleware, source/cache spans viaTraced*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 W3Ctraceparentheader on outbound HTTP source fetches so a trace spans iiiris → origin (S3 is out of scope — a terminal service). Configure via thetracing.*block orIIIRIS_TRACING_*env vars. Seedocs/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 acontent_state.store.write_tokenBearer token; refused when no token is configured) stores a state and returns its URI;GET /content-state/s/{id}serves it asapplication/ld+jsonwith CORS + ETag. Durable, single-node filesystem store (internal/annostore, non-evicting), shaped so a later W3C annotation store subsumes it. Enable viacontent_state.store.{enabled,dir,write_token}. Seedocs/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
/mcpoffering four tools —browse(list a collection),get_image_info(dimensions + capabilities),get_region(render a region, returned as an image the agent can see), andget_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 officialmodelcontextprotocol/go-sdk). Tools reuse the existing resolve/probe/render/list and Presentation serve/derive paths, soget_regionenforces the same limits as the Image API. MCP enforces the same IIIF Auth — a host presents its access token as aBearertoken (or cookie) on the/mcpconnection; gated identifiers are denied without it andbrowseomits them (no separate MCP credential, no extra config). Manifests are also exposed as MCP resources (aiiiris:///presentation/{identifier}template) for URI-addressable, host-readable access. Seedocs/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-contentparameter. 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/mintbuilds a content state (plus its encoded value and a ready-to-share/viewURL) from one of your Presentation identifiers, with an optionalcanvasandxywhregion.GET/POST /content-state/resolvedecodes and inspects a state.- New
internal/contentstatepackage: 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.
[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.metadataconfig field (IIIRIS_IMAGE_METADATA):strip(default) orpreserve. Also trims served bytes.
Changed¶
- BREAKING (default behaviour):
image.metadata: stripnormalizes derivatives. In the defaultstripmode 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.jsondimensions, 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.jsonreported 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 inimage.metadata: strip(the default), raw everywhere inpreserve.
[v0.7.0] - 2026-06-23¶
Added¶
- Plugin-driven image overlays / watermarking. A hook may implement
the optional
Overlayercapability (thejsengine via anoverlay(id, ctx)handler) to composite an image overlay onto image responses. Placement covers a nine-position anchor grid with inset offsets,fitmodes (none/shrink/scale/tile), andopacity. Two insertion points:target: outputdraws a fixed-size watermark on the final derivative (after rotation + thequalitycolorspace conversion, so it sits upright and a gray/bitonal request stays gray/bitonal);target: sourceplaces 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 viaiiiris_overlay_applied_total/iiiris_overlay_composite_seconds. Seedocs/hooks.md. - JavaScript hook engine (
hook.type: js), a pure-Go (goja) peer to the Lua engine and the recommended scripting surface. Scripts export amodule.exportsobject with optionalresolve/authorize/redirect/describehandlers;authorizereceives a{path, headers}context so hooks can decide on cookies / bearer tokens, anddescribemakes the JS engine the first hook to supply Presentation descriptor overrides. Sandboxed (norequire/Node/filesystem/network/ timers; a singlelog()builtin into the server log), pooled runtimes, atomic mtime hot-reload (js.path), and a per-calljs.timeout(default 1s). Config:hook.js.{path,script,watch_interval,timeout}. Seedocs/hooks.md. Hook.Authorizenow receives request headers (via anAuthRequeststruct), enabling header/cookie/token-aware authorization hooks.- Prometheus metrics exporting (opt-in via
metrics.enabled/IIIRIS_METRICS_ENABLED).GET /metricsserves Prometheus exposition format on the main listener, or on a dedicated listener viametrics.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). Seedocs/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) — seedocs/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 viarelease-cli— absent from our images and runner-version dependent, so v0.5.0'srelease-publishfailed (release-cli: command not found) and no Release object was created.release-publishnow 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. Seedocs/specs/releases.md. - Admin dashboard "requests served" counter always read 0. The
count was bound to a throwaway
Serverinstance 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/streamrequestsfield 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 (idin 3.0,@idin 2.1) is normalised to its canonical iiiris URL; nested values are preserved verbatim. Responses carryETag/ conditional GET → 304,application/ld+jsoncontent negotiation, and a dedicatedcaches.manifestslot. Enabled by default (presentation.enabled: true) as a stateless part of the zero-config path. Seedocs/presentation.md(guide) anddocs/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/ImageService2on 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 optionalpresentation.Describercapability. Malformed overrides are logged and ignored — derivation never fails because an override is bad. Seedocs/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 viago: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_viewersets 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 theiiif-viewer-e2eCI 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.Validatechecks 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 normalgo testrun). 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 theiiif-presentation-validateCI stage, mirroringiiif-auth-validate.
Changed¶
- docker compose now demos both the Image and Presentation APIs. The
stack bind-mounts a new
./examples/imagescorpus (a rootthumbnail.jp2plus alibrary/of object sub-directories —manuscript/with amanifest.yamlsidecar,plates/) as the IIIF source root, sodocker compose up --buildexercises 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.examplelist the URLs for both APIs. OverrideIIIRIS_LOCAL_IMAGESto serve your own directory as before. - docker compose Redis caching example.
docker-compose.redis.ymlis an opt-in overlay that adds aredisservice (bounded256mb+allkeys-lru) and routes the render / info / manifest caches through it viaexamples/config/redis-cache.yaml. The base stack stays heap-cached;docker compose -f docker-compose.yml -f docker-compose.redis.yml upenables it. Seedocs/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. Seedocs/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/briefs/RESOLUTION_AWARE_DECODE.md. image.max_concurrentnow 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). Setmax_concurrent: 0explicitly 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.jsononly needs the source's width and height; iiiris now parses those straight from the file header in pure Go (image.DecodeConfigfor JPEG/PNG/GIF, an IFD walk for TIFF, theihdrbox /SIZmarker 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
mmapthe file and let libvips fault in only the bytes it reads, instead ofio.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 previousio.ReadAllpath, so behaviour is unchanged where mmap doesn't apply. Seedocs/briefs/PARTIAL_SOURCE_IO.md.
Fixed¶
- info.json source probes are now concurrency-bounded. A burst of
caches-off
info.jsonrequests 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 byimage.max_concurrent, but the probe path was not). The probe now acquires a dedicated semaphore sized to the sameimage.max_concurrentvalue, 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
imageconfig fields —vips_concurrency(worker threads per libvips operation) andvips_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. Seedocs/configuration.md.
[v0.3.6] - 2026-06-04¶
Added¶
nonecache backend. Any cache slot (render,info,origin) acceptsbackend: 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. Seedocs/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_BENCHMARKS.md. release-imageno longer collides on the macOS runner keychain.docker loginon a Docker Desktop host force-enables thedesktop/osxkeychain credential helper even with an empty per-jobDOCKER_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 witherrSecDuplicateItem (-25299)(v0.3.5'srelease-image). The base job now skipsdocker loginentirely and writes the auth directly into the ephemeral per-jobconfig.json;buildx --pushauthenticates 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-validateandiiif-auth-validaterun concurrently in thevalidatestage and both boundiiirisdto:18180under--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 emptyservice[]and404s on every auth route.iiif-auth-validatenow 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-publishuses the modern glab image. The oldregistry.gitlab.com/gitlab-org/release-cli:latestno longer contains therelease-clibinary it used to (deprecated and removed upstream); the wrapper image's entrypoint still tries to invoke it and fails withrelease-cli: command not found. Switched toregistry.gitlab.com/gitlab-org/cli:v1.100.0, the glab CLI image GitLab now recommends for therelease:keyword. v0.3.3's release pipeline hit this after the credential-helper hang was unblocked.release-imagejobs no longer hang on the Docker credential helper..release-image-basenow isolates docker auth into a per-jobDOCKER_CONFIGdirectory instead of using the runner host's~/.docker/config.json. On macOS Docker Desktop the shared config defaults tocredsStore: 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 liketonistiigi/binfmt. v0.3.3's first release pipeline hit exactly this and therelease-image+release-image-debianjobs timed out at 60 minutes. The per-job config has nocredsStore, so docker writes plainauthsentries that work without UI coupling. Also retires thedocker logoutkeychain workaround since the system keychain isn't touched at all.release-binary-linux-amd64no longer silently builds a misnamed binary. The job'sdocker rundidn't pass-e CI_COMMIT_TAG, and the innerbash -c '...'uses single quotes — so${CI_COMMIT_TAG}inside the container expanded to an empty string. Thego buildsucceeded but wroteiiirisd--linux-amd64(empty version slot); the host-sidesha256sum "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_TAGthrough and usesset -euinside so an unset variable fails loud.release-binary-smokenow mirrors the release job's shell-expansion + filename path. Smoke writes its output to the sameiiirisd-${CI_COMMIT_TAG}-linux-amd64template (falling back tov0.0.0-smokeon non-tag pipelines) and runssha256sumagainst that name. Future env-passthrough regressions of the v0.3.2 shape will fire on push instead of at tag time.release-binary-linux-amd64actually produces a binary on apple-silicon runners. The job was running an arm64 container and invokingx86_64-linux-gnu-gccto cross-compile a CGO binary for linux/amd64, but the cross-compiler wasn't installed (the job's runtimeapt-get installwas 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/amd64container so the toolchain, libvips, and binary are all natively amd64 — the same Rosetta/binfmt pathdocker-build-distrolessandrelease-image*already use for buildx. Confirmed v0.3.1's release pipeline died on this; the fix unblocks future tag pushes.docker loginno 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 sharedbefore_scriptin.release-image-basenow runsdocker logout "$CI_REGISTRY" || truefirst — idempotent, no-op on linux runners with file-store credentials.
Internal¶
release-binary-smokejob runs on every pipeline. Same docker invocation asrelease-binary-linux-amd64, building to/tmpinside the container. Catches toolchain regressions (missing cross-compile path, missing CGO deps, missing--platformflag) 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:lateststays 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. Newiiiris-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=1no longer fail validation. Previously,release-binary-windows-amd64andrelease-binary-windows-msideclaredneeds:on the gatedbuild-windows-*jobs without marking them optional, so GitLab rejected the entire pipeline at creation time whenRUN_WINDOWSwas unset. The two re-export jobs now also gate onRUN_WINDOWS=1(skipping entirely when absent), andrelease-publishmarks its needs on themoptional: trueso 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. Perdocs/specs/releases.md"Windows artifact gating" the dead-links wart is retired whendocs/briefs/WINDOWS_EC2_RUNNER.mdlands.
Internal¶
- CI pipeline parallelism + interruptibility. Non-release jobs
(
lint,test,bench-smoke,docker-build,docker-build-distroless,iiif-validate,iiif-auth-validate) now declareneeds: [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: truecancels in-flight jobs from a superseded pipeline when a newer commit lands on the same branch.release-publishdeclares its dependencies withartifacts: false, skipping the ~280-390 MB download of release binaries/zip/MSI it never reads (therelease: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), andTHIRD_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/Dockerfileanddeploy/distroless/Dockerfile) nowCOPYtheLICENSE,NOTICE, andTHIRD_PARTY_LICENSES.mdinto the image at/usr/share/doc/iiiris/; the distroless variant additionally bundles a newLIBVIPS-SOURCE.md— the LGPL-2.1 §6(d) "info to relink" pointer documenting pinned libvips / OpenJPEG / libtiff versions, source URLs, and build flags. The staleorg.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¶
filesystemcache 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 aflockon<path>/.lock.GetandPutremain 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-versionsentinel is written; operator-dropped files are preserved.- GitLab Release description sourced from
CHANGELOG.md. Therelease-publishCI job now extracts the body of the## [vX.Y.Z]section fromCHANGELOG.md(heading stripped, leading/trailing blank lines trimmed) and writes it toRELEASE_NOTES.md, which therelease: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 bydocs/specs/releases.md"Release notes". scripts/cut-release.shautomates the pre-tag steps. Runscripts/cut-release.sh vX.Y.Zto validate state (version format, clean tree, onmain, tag doesn't already exist,[Unreleased]non-empty), flipCHANGELOG.md[Unreleased]→[vX.Y.Z] - YYYY-MM-DD, commit onrelease/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
.sha256file alongside, attached to the GitLab Release. Verify withsha256sum -c iiirisd-vX.Y.Z-<flavor>.sha256(Linux) orshasum -a 256 -c(macOS) from the directory containing both files.
Added¶
filesystemcache backend: distributed mode (Redis coordinator). Multi-replica iiirisd deployments sharing an NFS/EFS mount can now use thefilesystembackend by adding acoordinator.redisblock alongsidebackend: 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. Thecoordinator.redisblock uses the same shape as the existingredis:configs;backend: redisandcoordinator.redisare mutually exclusive. Seedocs/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 + everydocs/**/*.mdopen 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.svgso 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 viaprefers-color-scheme), with the SVG sources served atGET /logo.svgandGET /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
iiirisdas a Windows service that starts on boot. Enabled viamsiexec /i ... INSTALLSERVICE=1; an attended GUI checkbox is a future polish. The service runs asNT AUTHORITY\LocalSystemwithStart=auto; operators can swap to NetworkService or a domain account viaservices.msc. Windows Event Log integration ships in lockstep — the installer registers theiiirisEvent 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 beforeMajorUpgradewrites the new binary, thenStart="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=1gates 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. Seedocs/specs/windows-build.mdfor the full contract anddocs/deployment.mdfor operator walkthrough. - Windows MSI installer (binary-install mode). Tagged releases
now publish
iiirisd-vX.Y.Z-windows-amd64.msialongside the zip. Standard Windows wizard with per-user (%LocalAppData%\iiiris) or all-users (Program Files\iiiris) layout choice via WiX v5'sWixUI_Advanceddialog. First install drops a commentedconfig.yaml.exampleat%ProgramData%\iiiris\config\config.yaml; operator edits survive upgrades.MajorUpgradestops the prior install (if any), replaces the binary, and restarts; data dirs (config,cache,logs) are markedPermanent="yes"and preserved on uninstall — manual wipe of%ProgramData%\iiirisis 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. Newdeploy/installer/iiiris.wxs(hand- authored Package + Feature + standard directories + components), generateddeploy/installer/dlls.wxs(the 91-DLL inventory; freshness enforced at build time byscripts/wix-generate-dlls.ps1 -Mode Check), and CI jobsbuild-windows-msi+ tag-onlyrelease-binary-windows-msicomplete the pipeline. Optional Windows-service-install mode (ServiceInstall+ Event Log source registration) is the remaining Phase 3c work; for now, manualsc.exe create iiiris ...registers the installed binary as a service perdocs/deployment.md"Running as a Windows service (manual)". - Windows service-mode entrypoint and Event Log integration.
iiirisd.exenow detects Windows Service Control Manager context automatically (viagolang.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 gracefulhttp.Server.Shutdownflow the console path triggers on Ctrl+C, with the configuredserver.shutdown_timeoutdrain budget. Two newslog.Handlers ship for service-mode logging — a Windows Event Log writer (Phase 3c's WiX installer will register theiiirissource under HKLM; until then the handler returns nil at startup and falls back to file + stderr) and a size-based rolling log file vialumberjack.v2(100 MB per file, keep 5). The rolling file is also available in console mode via the newlog.fileconfig field (IIIRIS_LOG_FILE). Manual service registration viasc.exe create iiiris binPath=...works today; the full WiX MSI installer with optional service-install mode lands in vX.Y+1. Seedocs/deployment.md"Running as a Windows service (manual)". - Published Windows/amd64 binary. Tagged releases now publish
iiirisd-vX.Y.Z-windows-amd64.zipalongside the existing distroless + debian-slim container images and the linux/amd64 binary. The zip containsiiirisd.exeplus 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.exedirectly; 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. Newbuild-windows-amd64CI job on the GitLab SaaS Windows runner builds the binary with CGO via MSYS2 UCRT64; a newscripts/bundle-windows.ps1resolves the DLL closure and assembles the zip; per-format smoke (jpg / png / tif / webp) runs against the staged binary with MSYS2 stripped fromPATHso a missing bundled DLL surfaces as a startup failure rather than silently falling back to the build-host install. A new tag-onlyrelease-binary-windows-amd64re-exports the zip withexpire_in: neverfor the GitLab Release asset link. - Windows portability fixes (toward a published Windows build).
cmd/iiirisdnow selects shutdown signals via build-tagged helpers (SIGINT+SIGTERMon Unix;os.Interrupton Windows —SIGTERMdoes not exist on Windows).internal/source/filesystem.go'ssafePathrejects 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.gonotes the Windows rename sharing-violation edge forPut; the actual retry wrapper lands when needed. No behaviour change on Linux/macOS. - Per-IP concurrency cap. New
image.max_concurrent_per_ipcaps how many IIIF requests a single client IP may have in flight at once — the per-client analogue of the globalimage.max_concurrentguard, 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 get429 Too Many Requests. Applies to/iiif/...only —/health,/ready, and/adminare 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 requiresauth.trusted_proxiesso the real client IP is the key. General rate limiting / DDoS defence stays at the edge (reverse proxy / CDN) by design — seedocs/deployment.md. The limiter middleware is only wired into the chain when the cap is non-zero, so a disabled cap costs nothing. (auth.parseCIDRsexported asauth.ParseCIDRsto 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 perhook.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. Setwatch_intervalto 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 insideinfo.jsonfor 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 (builtinwith bcrypt/apr1 htpasswd, reverse-proxyheadertrust, signed-callbackexternal, first-classoidc). Seedocs/iiif-auth.mdand thedocs/specs/auth.mdspec. - 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 ownDecision.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,RenderCacheandInfoCachekeys 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). Thepublictier 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 acacheKeyVersionbump 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.Authorizecan additionally inject a per-request alternate identifier. access_service.templateoverride for the built-in access service. Operators supply a Gohtml/templatefile 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 indocs/iiif-auth.md.- IIIF Authentication API 1.0 dual-advertisement.
info.jsonnow 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, scalarerrorfield 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 indexdocs/examples/README.mdis a pick-list by pattern and by deployment shape. - IIIF Auth 2.0 conformance harness + CI stage. New
tools/iiif-auth-conformanceGo 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.mdanddocs/deployment.mdReleases 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. Seedocs/specs/windows-build.mdfor the full three-phase plan.docs/caches.md: new "Windows path-length" note under thefilesystemcache backend — the sharded layout adds ~70 characters topath:, so anchoringpath:near the volume root keeps the cache well under the 260-characterMAX_PATHceiling without any long-path-support flags.- New
docs/iiif-auth.mdcovering 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 theauth.Authorizer.Authorizestep and the tier-keyed cache lookup; package map addsinternal/auth/{service,htpasswd,session}.docs/configuration.md: fullauth: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/streamendpoint, 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 toadmin.md; documentsimage.max_concurrent_per_ip.docs/deployment.md: new "Rate limiting and abuse protection" section — the edge-first stance, an nginxlimit_reqexample, and the opt-inimage.max_concurrent_per_ipcap 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.mdfeature list gains the Auth 2.0 entry.- Release process spec.
docs/specs/releases.mddefines 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,latestsemantics, forward-only rollback, the cut-release flow, and what's deliberately out of scope (cosign, SBOMs, multi-registry mirroring, pre-release channels).CHANGELOG.mdheader,docs/specs/scaffold.md"Versioning + releases",docs/deployment.md"Releases",README.md"Where to read next", andCLAUDE.mddocumentation- 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 tovips_jp2kload_buffer. Encode is classic Part 1 only — OpenJPEG doesn't implement HTJ2K encoding upstream. Seedocs/specs/htj2k.md. testdata/sample.jphHTJ2K fixture (49 KiB synthetic gradient, generated via OpenJPH'sojph_compress; seetools/gen-htj2k-sample/README.md).- CORS on every IIIF response (
Access-Control-Allow-Origin: *). Thecorsadvertisement inextraFeaturesis now load-bearing. - JSON-LD content negotiation on
info.json—Accept: application/ld+jsonproducesapplication/ld+json;profile="… /context.json". ThejsonldMediaTypeadvertisement is now load-bearing. - Slash-collapse middleware — folds runs of
/in the request path before routing. Fixes 404s from//foo-style URLs (which the stdlibServeMuxwould 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-validatorregenerates.
Changed¶
- Default release image is now distroless —
:vX.Y.Zand:latestare built fromdeploy/distroless/Dockerfileongcr.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-debiantag) for operators who need shell tooling in the container or hit a distroless-specific issue. - CI
docker-build-distrolessis no longerallow_failure— distroless build failures now block merges. - CI
iiif-validateswitches to--version=2.0for v2 testing (the validator tags its tests2.0/2.1.1/3.0; the prior--version=2.1silently 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.jsonpreviously advertisedjsonldMediaTypeandcorsinextraFeaturesbut emitted neither header — both are now actually implemented.
Internal¶
- Test coverage push for
internal/source(18.6% → 85%),internal/config(0% → 100%), andinternal/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 onIIIRIS_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.