The ocr oracle demanded six pangram words when ocr-clean.png prints five; the ocr-pdf oracle expected a searchable PDF when the tool's contract is text extraction; media-30s.mp4 was eight seconds long. Fix all three, regenerate tool-contract.json from current schemas, record the new fixture in the manifest, and add a QA_OUT_DIR override so parallel machines stop clobbering each other's lane output. Fixes#677.
Docker-in-WSL2 stacks can be reachable from every other device while localhost times out on the Windows host itself, even with mirrored networking. Tell Windows users what to expect and how to reach the app. Fixes#676.
A retried compose up after a failed first start can leave the app container detached from the compose network; the resulting Postgres EAI_AGAIN crash-loop reads as a database problem and never self-heals. New Troubleshooting section with the force-recreate fix. Fixes#675.
SD1.5 inpainting is OOM-killed at the stock 6g compose limit on CPU hosts and completes at 8g. Read the cgroup limit and refuse hq up front with an actionable message; GPU hosts and unlimited containers are untouched. Fixes#670.
The generated v2.2.0 changelog entry had no explicit heading ids, so its auto new-features slug collided with v2.0.0's explicit anchor and vitepress refused to build. Version-scoped ids for every v2.2.0 section; the release commit's [skip ci] had kept the breakage dormant.
The frame loop spent 2-19s per frame in CPU pymatting while the CUDA session sat idle; a 30-frame GIF took 353s on a 4070. Animations skip alpha matting now (stills keep it), the session device is logged, and the CUDA-to-CPU session fallback says why. Same fixture finishes in 46s with every frame on the GPU. Fixes#668.
The arm64 transcription bundle baked huggingface_hub 1.22.0 while inpaint-hq's transformers needs hub <1.0; last-writer-wins in the shared venv made the hq install fail on arm64. Constrain hub at build time, pin it in transcription's package list, and lock both invariants with manifest unit tests. The arm64 transcription bundle still needs a rebuild and republish to ship the fix. Fixes#669.
A fresh GPU deployment has working passthrough but no torch/ONNX runtime until a bundle installs, so the dispatcher reports gpu=false and the boot banner said no GPU was detected. Add the hardware-present state, driven by the same /dev/nvidia0 check the instance census uses. Fixes#673.
app_version was attached only to feedback events; every other allowlist stripped it, so the install base could not be segmented by release. One property on the once-per-boot census event covers it. Fixes#674.
caire's -width/-height/-blur/-sobel flags are integer-only; a schema-valid float crashed the binary and surfaced as a corrupt-file 422. Reject fractional values at validation time with a settings-shaped 400. Fixes#672.
trim-audio stream-copies, and a window below one frame ships a container with zero audio frames as a 200 success. Floor the window at 0.1s in the schema, and refuse after endS clamps to the real duration if the window drops under the floor. Fixes#671.
Second failed dispatch (verify-ocr, now fixed in #666) again left a
chore(release): 2.2.0 commit. Undo its artifacts so the re-dispatch regenerates
them once: restore .release-notes.md to root, drop the 2.2.0 entries from
CHANGELOG.md and the docs changelog. Tag and draft deleted, so the fresh run
re-tags on this commit, which includes the #666 fix the previous tag lacked.
Non-releasable type so the re-run stays 2.2.0.
Fourth latent bug in the OCR publish chain (#649/#519 added it, never ran).
verify-ocr's trust-identity step read OCR_RUNTIME_INDEX_KEY_ID from the image env,
which the official image leaves unset by design (operator-override path;
runtime-index.ts reads the baked file when env is unset). It compared "" to
snapotter-ocr-2026-07 and failed a correct image. Now verifies the baked
/app/docker/ocr-runtime-trust.json, proven against the built release image.
A full audit of the remaining chain (verify, sign, verify-signed, publish)
reproduced the sign+verify path end-to-end inside the image and confirmed nothing
else breaks for v2.2.0. One latent non-ASCII canonicalization mismatch filed as
#667 (cannot fire on v2.2.0). Non-releasable type so the re-dispatch re-runs 2.2.0.
The first v2.2.0 dispatch failed at archive-security after semantic-release had
already committed its release artifacts (chore(release): 2.2.0). Undo those so
the re-dispatch regenerates them once instead of duplicating the CHANGELOG
section: restore .release-notes.md to the root, and drop the 2.2.0 entries from
CHANGELOG.md and the docs changelog. The tag and draft release were deleted, so
the fresh run re-tags on this commit, which includes the #665 fixes the previous
tag lacked. Non-releasable type so the re-run stays 2.2.0.
Two latent bugs the v2.2.0 release run surfaced, both added by #649 and never run
in a real release (v2.1.0 had no archive-security job).
archive-security asserted tsx at the workspace root, but tsx is a prod dependency
of apps/api, so pnpm places its bin at apps/api/node_modules/.bin/tsx, where the
Docker CMD runs it. The root path never existed and failed the extract step on
both arches. Fixed to the apps/api path, proven against the real prebuilt-amd64
artifact.
The blocking Trivy scans would then have failed on CVE-2026-56852,
golang.org/x/text v0.38.0 -> v0.39.0, the only fixed CRITICAL/HIGH in the image,
compiled into caire and pdfcpu. Pinned to v0.39.0 in both go-tools modules,
verified building in the golang:1.25.12 toolchain with -mod=readonly and linking
v0.39.0, with a clean Trivy rescan.
Guards added for both the tsx path and the x/text pin. Non-releasable type so a
re-dispatch re-runs 2.2.0.
Two release-pipeline defects found while pre-flighting 2.2.0, plus the image
hardening that the second one exposed.
The release job would have died immediately after pushing the v2.2.0 tag.
draftRelease was turned on in #649 and never executed, and GitHub's
/releases/tags/{tag} endpoint does not return draft releases, so all nine tag
lookups in release.yml would have 404'd against the draft semantic-release had
just created. Verified against this repo with a throwaway draft: the tag
endpoint 404s while gh release view reads it and /releases/{id} returns the same
REST shape. Every site now resolves the numeric id first, so existing jq
expressions are untouched.
The unfixed-vulnerability gate was measuring almost nothing. The blocking Trivy
steps run ignore-unfixed, and trivy-unfixed-gate.mjs was meant to cover the
remainder but defaults to CRITICAL with neither call site passing --severity. An
unfixed HIGH was gated by nothing, and the arm64 image carried 79 of them while
the summary read clean.
Rather than document 79 findings, the image lost what it did not need:
libde265 1.1.1 and libheif 1.23.1 are now built from source (the old libheif pin
was itself affected by CVE-2026-3950, and Debian's libde265 1.0.11 was the
decoder every .heic upload actually reached), and xvfb, wget and openssh-client
are purged. 15 CVEs left the image outright and the HIGH gap fell to 65, each
now carrying a rationale verified against the running container.
curl gets its own section: bookworm-backports has a fixed 8.14.1, so claiming no
fix was available would have been false. It is recorded as a declined fix.
Verified on both architectures: gate exits 0, the source-built libde265 is the
one libheif links, and HEIC, RAW, ImageMagick, Sharp AVIF and headless chromium
all still work after the purge.
Bumps every version surface to 2.2.0, fixes a latent version-coupling bug in the
OCR runtime tests, and stops an absent GPU runner from silently stalling a
release.
Version surfaces: scripts/sync-version.sh covers the 11 workspaces, APP_VERSION,
and the docs release commands across all locales. Root package.json plus the
three surfaces the script never reaches are done by hand: the DOCKERHUB.md banner
and tag table, the docker-tags.md pinning table in 21 locales, and the example
runtimeVersion in tools/image/ocr.md in 21 locales. The release-notes archive step
is deliberately not pre-run, so the notes text stays editable until the release.
Latent bug: runtime-state rejects any runtime whose compatibility.snapotterVersion
is not exactly APP_VERSION, and five fixtures pinned the literal 2.1.0. Since
semantic-release rewrites APP_VERSION on every release, the first PR after any
bump would have gone red for a reason nobody would trace to the release. The
fixtures now derive from APP_VERSION.
GPU runner: sign-ocr-index needs verify-ocr-nvidia on self-hosted hardware, and
the gated manifest job needs ai-bundles, so a missing runner queued instead of
failing and produced no image tags. preflight-gpu-runner claims the same labels
with no dependencies, so it is scheduled first and validates the GPU before the
90-minute build. An API preflight is impossible because listing self-hosted
runners needs Administration:read, which GITHUB_TOKEN cannot hold, so RELEASE.md
carries the maintainer-side check.
A release-readiness QA pass over the whole product. The commits split into
defects a user would hit and gates that were reporting green while measuring
nothing.
## Fixes that change behaviour
Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so
request.ip came from a client-set header and a forged X-Forwarded-For got past
the login limiter. The default is now a private-network trust list.
A transient Postgres outage stranded in-flight jobs, leaving finished output on
disk with no row pointing at it. A reconciler now resolves those rows and adopts
the bytes rather than dropping the work.
A Redis connection that moved to a new address wedged every read-blocked
consumer, so completions stopped signalling while health still answered 200.
Socket timeouts plus subscriber pings recover it.
Installing more than one AI bundle left the shared venv multi-versioned and
silently broke three tools. The installer now reconciles distributions to one
version each.
Converting an image to JXL at quality 1 through 4 returned a 500, because
libjxl 0.7 rejects the distance those values compute. The quality is floored at
what the encoder honours. A missing ffmpeg was also reported to the user as a
corrupt upload; it now says the engine is unavailable.
RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at
0.22.2, and the release scan was split so it can fail on an unfixed critical
instead of hiding it behind ignore-unfixed.
## Gates that could not fail
Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs
build; coverage discarded its whole report on any failing test; the lint gate
skipped root tests, scripts, and two workspaces; and several generated matrices
counted a host missing ffmpeg as a passing tool. Each now measures what it
claims.
Full evidence and the outstanding release items are tracked locally and are not
part of this branch.
The comment added in #652 was wrong. It said SYNC_WAIT_MS=0 drives every tool through its 202 path; it does the opposite. BullMQ arms its timer under `if (ttl)`, so 0 is falsy, waitUntilFinished waits forever and every route answers 200, matching the repo-wide convention that 0 means unlimited. A small positive value such as 1 is what forces the async path.
That error mattered: the local validation claimed for #652 exercised the synchronous path throughout and never reached settleAsyncFallback. Redone with SYNC_WAIT_MS=1, it surfaced six specs whose status gate accepts 202 but whose else branch then demands an error body a 202 never carries, a latent flake on a slow runner.
Each now settles the job first. Under a forced 1ms window the 'expected undefined to be defined' failures drop from 55 to 0, and all 1183 tests still pass on the normal 30s window. Per-shard totals unchanged at 9903 tests, 9435 passed, 468 skipped.
Specs asserting a bare 200 without listing 202 are deliberately untouched: they own the synchronous contract.
A 202 means the sync window expired while the job was still running. Tests treated it as a terminal pass: `if (isAsyncFallback(res)) return;` checked the envelope and returned, asserting nothing about the outcome and leaving the job running into the next test, which is the leak cancelAcceptedJobAndWait exists to prevent.
Because the window only expires under load, coverage tracked runner load. On CI 44 tests took this path and verified nothing; the same tests on a dev machine asserted in full (one measured 6.4s locally against 31s on CI).
settleAsyncFallback waits for a terminal state and asserts the job finished, and that a failure carries a message rather than being a crash. A clean failure stays valid, since the exotic-format fixtures are meant to be rejected. All 82 call sites moved over.
per-fork-env no longer floors SYNC_WAIT_MS, so forcing it to 0 drives every request through its 202 path. 570 tests were validated that way and matched their normal-window results exactly.
The 29-34s band dropped from 44 tests (23.4% of test time) to 6 (3.0%). Total test time rose 5.8% and CI wall went 12.8 to 13.1 min: the forks were doing real work during that wait, so this buys determinism, not speed. Per-shard totals unchanged at 9903 tests, 9435 passed, 468 skipped.
Vitest shards by file and runs a file's tests serially in one fork, so a single spec set the floor for the whole Integration job no matter how many shards or forks it got. Cost-aware sharding (#650) balanced the shards but could not get under that floor.
Split the three specs that exceeded it: format-matrix-comprehensive (1365s) into 4 by describe, format-matrix (1130s) into 4 with Cross-format matrix striped over FORMAT_SAMPLES, and format-matrix-generated (779s) into 3 striped over TOOLS. Largest spec is now 370s. Each preamble moved verbatim into a sibling .shared.ts exposing setupMatrixApp().
Integration shards went from 20m59s/17m55s/16m33s/9m19s to 11m44s/12m31s/10m37s/11m1s.
Coverage checked, not assumed: the set of test names collected by vitest list is byte-identical across the split, 2151 before and 2151 after. Per-shard totals matched the baseline exactly at 9903 tests, 9435 passed, 468 skipped.
Vitest's BaseSequencer sorts specs by SHA1 of their path and slices an equal number of files per shard, which is blind to how long each one takes. That dropped the four costliest generated matrix specs into a single shard: 24m47s against shard 1's 3m47s.
Partition by measured cost instead, greedy longest-processing-time-first. CI wall goes 25 min to 21.3 min. The remaining ceiling is that `format-matrix-comprehensive` and `format-matrix` are each one indivisible file, and tests within a file run sequentially in a single fork.
Coverage is unchanged by construction. The partition is total and disjoint, guarded over the real spec list for shard counts 1 through 8. Per-shard totals on the PR run matched the baseline exactly: 297 files, 9903 tests, 9435 passed, 468 skipped.
Build listed six test jobs in `needs` but only reads `needs.changes.outputs.code`, in its `if`. The rest were ordering, not data.
That queued a 55s job behind the slowest integration shard, so it added its full runtime to the tail of every green run. Build and the test jobs are independent required checks, so a green Build still can't merge past a red shard.
Three defects from #645, all of which let the server report something
that was not true.
Tool access was enforced per route, so it drifted. createToolRoute calls
requireToolAccess and the factory tools were fine, but all 45
hand-written routes had to remember the same call and none of them did.
A role without tools:use could run image-to-pdf, svg-to-raster,
erase-object, favicon, qr-generate, upscale, sign-pdf and the rest. The
issue described this as affecting two routes; it was every one of them.
The check now lives in a single preHandler keyed off the tool the router
matched, so it covers sub-paths (/batch, /info, /preview, /analyze,
/inspect) and any route added later without that route opting in. Ids no
tool claims stay unresolved, which keeps an unknown or misfiled tool a
404 rather than telling an unauthorized caller which ids exist.
Resolution reads request.routeOptions.url, the pattern the app itself
registered, rather than parsing request.url a second time. find-my-way
decodes before matching, so an independent parse disagrees with the
router and the router wins: `/api/v1/tools/image/%66avicon` ran favicon
while the gate saw no tool at all. Absolute-form request targets slipped
it the same way. Taking the router's own answer removes the disagreement.
A ZIP stream that failed after the 200 headers were out called
reply.raw.end(). On a chunked response that is indistinguishable from
success, so a client kept an archive with no central directory believing
it whole. Worse, a source stream that errored had no listener: the
request hung until it timed out and the error surfaced as unhandled. A
poisoned-storage probe reproduced both. The socket is destroyed instead,
and every source stream is listened to. svg-to-raster additionally ran
its append loop past the hijack with no try/catch, where a throw leaves
Fastify logging and walking away with the socket neither ended nor
destroyed. pdf-to-image is fixed alongside the other two: it shipped in
#643 with the destroy half but not the listener, so it hung the same way.
A zero-byte upload was dropped during parsing. The client pairs results
with its own file list by index, so every later result shifted onto the
wrong file: one document's output was presented as another's, under
another's name, while the file that actually converted was marked "not
found in batch results". Empty parts now keep their slot and fail in
place with a reason.
Two tests in adversarial-extended.test.ts asserted the old zero-byte
behavior, including a comment that batch "silently skips zero-byte
parts". They now pin the replacement: still rejected, nothing processed,
but the caller is told which files were empty instead of being told it
sent none.
A guard walks the whole catalog and fails if any of the 241 tools answers
anything but 403 for a role without tools:use, so a tool cannot escape
the gate by being registered in a shape nobody thought to sample.
Fixes#645
Type a printable character on the landing homepage or the app's home
dashboard and it lands in the search box, provided the box is on screen
and nothing else holds focus. Mod+K keeps working unchanged.
The parts that are easy to get wrong live in
packages/shared/src/search/type-to-search.ts so the two surfaces cannot
drift. isTypeToSearchKey decides whether a keystroke is text.
isSearchBoxTypeable decides whether the box is reachable, via one
elementFromPoint hit test at its center, which folds off-screen,
covered-by-a-modal and hidden into a single check that leans on no one's
aria markup. It fails closed where there is no layout engine, so jsdom
tests that mount the search bar do not blow up on it.
Modifier handling reads getModifierState("AltGraph") rather than
inferring AltGr from ctrl+alt. That inference reads correctly on Windows
and is backwards on macOS, where Option alone types accented characters
and ctrl+alt is a shortcut prefix, VoiceOver's included.
Focus is claimed before the keystroke is committed. Browsers silently
refuse focus inside inert or visibility:hidden subtrees, and without the
check an entire query drains into a box the user cannot see.
Scope comes from where the hook is mounted rather than a route check that
could rot, so tool pages, the editor, Files and Automate get nothing. No
new i18n strings, and no new analytics event, since
ANALYTICS_EVENTS.SEARCH already fires off the same state change.
Verified: 44 new unit tests, full unit suite 7557 passed, landing
homepage 24/24, home-page 19/19, gui-keyboard 41/41, typecheck and lint
clean, all 18 CI checks green.
Upload two PDFs to pdf-to-jpg and it answered `Tool "pdf-to-jpg" not
found`. pdf-to-jpg, pdf-to-png and pdf-to-tiff share
registerPdfToImageRoute, which registered a single-file endpoint and
nothing else, so the shared preset settings component's 2+-file
submission fell through to the generic `:section/:toolId/batch` route,
whose registry lookup misses every tool outside
createToolRoute/registerToolProcessFn.
Mirror of #627, different fix. image-to-pdf is many-to-one, so #633 sent
every file in one request. This direction is one-to-many: separate PDFs
want separate conversions, which is what /batch is for. The route now
serves its own /batch, the shape svg-to-raster already uses, and the
literal path beats the generic parametric one.
One PDF fans out to many page images, so a per-file result is a ZIP, same
as the single-file route. A batch returns a ZIP of per-document ZIPs in
upload order, keyed by X-File-Results so each result pairs with the file
it came from. A document that is unreadable, locked, empty, short of the
requested page range, or carrying no pages at all fails alone; 422 with a
reason per file when none survive.
That literal path also shadows the generic route's requireToolAccess
call, which would have turned a 403 into a converted ZIP for roles
without tools:use. All four endpoints in this file now gate.
Four ways the batch path could have reported something untrue are closed
with it: a storage fault blamed on the document (statusCode-carrying
errors now reach the error handler, the rest are logged before being
reduced to a generic message), per-file reasons stranded in a field
parseApiError never reads, a zero-byte upload dropped so that later
results landed on the wrong file, and a mid-stream failure ended cleanly
enough to pass for success (the socket is destroyed instead).
Page rendering and ZIP assembly are shared helpers now, createUniqueNamer
moves to lib/filename.ts next to its two existing copies, and
tool-route-drift fails if any batch-dispatched preset loses its /batch
route. Follow-up for the same defects in the sibling custom routes: #645.
Fixes#632
Adds /api/status to the landing Pages worker, HEAD-probing demo.snapotter.com and
docs.snapotter.com with a 2 second per-attempt deadline and one retry. snapotter.com is
not probed; the worker answering the request is the proof it is up.
The footer badge ships grey in the static HTML and only upgrades once the route answers.
A rejected fetch, a non-ok response, an unparseable body, and an unrecognized verdict all
leave it grey, so it never claims green on its own.
Color lives in the dot, never the label: `--color-success` scores 4.498:1 against the
footer's `--color-background-alt`, just under AA.
Four labels across 21 locales.
Unit Tests went red on main after #639 with one failure: the Escape test saw a
fully rendered dialog but zero apiPut calls, so the handler never ran. Escape is
the only control in that file wired through a document listener in a passive
effect; every other control is an onClick prop attached at commit, which is why
the sibling close-button test passed in the same run.
Could not reproduce it: 50 isolated and whole-file runs stayed green, and a probe
counting document keydown registrations shows the listener is already attached
when findByText resolves locally, so the window only opens under CI contention.
Rather than bet on an unconfirmed mechanism, settle effects and retry the
dispatch until the dismissal lands. handleDismiss guards on `busy` and the
settings write is idempotent, so repeats are harmless.
Still non-vacuous: pointing the handler at a key that never fires fails the test
in about a second. 30 consecutive whole-file runs clean, full unit suite 7,474
passing.
Testimonial wall: new landing section between Feature Highlights and Pricing.
Two rows drifting in opposite directions, CSS-only to keep the zero-runtime
rule. 22 quotes, all verbatim and traceable to a public URL or a
feedback_submitted event. In-app quotes ship unattributed because the feedback
dialog only ever promised "You can contact me about this feedback". Marquee
traps documented in the CSS: a track gap also sits between the last original
and the first clone, so the -50% translate jumped half a gap per loop; and
under dir="rtl" the flex track drifted itself off-screen while "@amn-96"
bidi-reordered to "amn-96@".
Landing stats: DOCKER_FALLBACK read 104,000 against a real 233,057, but the
stale constant was the symptom. Both fetchers swallowed failures in a bare
catch, so a degraded build never announced itself. That warning then exposed
the real bug: getStarCount runs from Navbar and TrustSignals on all 798 pages,
firing ~800 unauthenticated GitHub calls per build and 403ing partway through,
so early pages carried the live count and later pages the fallback. Both
fetchers now memoize the promise.
Onboarding survey: the shipped gate has no activity condition, so it fires on
first admin login; 1,105 of 1,287 surveyed instances never processed a file.
The opaque fixed inset-0 aria-modal with a focus trap and no Escape becomes a
corner card at 12% of the screen, Escape closes, and the optional questions
stay collapsed until the one required answer. Its title was an h1, which
RouteAnnouncer focuses and announces on every route change, so navigating
anywhere announced the survey instead of the page. Now an h2.
Coverage 83.6 to 87.36% lines, 81.63 to 84.14% branches. Mutation testing across five packages: image-engine 85, media-engine 92, doc-engine 87, shared+enterprise 86, apps/api security and jobs slice. Runs all five lanes weekly. Fixes the silently-broken mutation CI (babel pin), a redact-pdf envelope-shape test bug, an untested enterprise license valid-signature path, and an audit test that only exercised a hand-copied reproduction. Test and config only, no product code changes beyond the babel pin and one test-only oidc export. Full suite: 16,712 pass, 0 fail.
Follow-up to #625. The chart renders under the repo heading in the README, so the snapotter-hq/SnapOtter title baked into the SVG was redundant. Keep only the Star history heading; repo name stays in the aria-label for screen readers.
GitHub restricted the stargazers API to a repo's own admins/collaborators (June 2026), so star-history.com's shared token pool 503s and the README chart went blank. Generate the chart from our own stargazer timeline instead (default GITHUB_TOKEN has access), publish it to the star-history branch, and embed it by raw URL. Weekly workflow keeps it fresh.
Follow-up to a full re-audit of the 2.0 tree. Most prior findings were already
fixed; this closes the ones that were not:
- SAML assertion replay: validateInResponseTo ifPresent plus a Redis-backed
CacheProvider, so a captured signed assertion cannot be replayed. ifPresent
keeps IdP-initiated SSO working.
- MFA login challenge burned after 5 wrong TOTP codes.
- api_keys.key_prefix indexed; the per-request lookup was a full table scan.
- MAX_AI_JOBS_PER_USER caps a user's in-flight single-file AI jobs (the AI pool
runs at concurrency 1). Batch and pipeline AI stay uncapped.
- MAX_WORKSPACE_SIZE_GB enforced instead of being dead config.
- SUBPROCESS_MEMORY_LIMIT_MB (default off) for the native media and doc engines;
not applied to the AI sidecar.
- SVG sanitizer closes unquoted and whitespace-prefixed javascript: hrefs and
the animateTransform/animateMotion/handler/mpath elements.
- Windows-style paths stripped from error output to match the Sentry scrubber.
- Postgres and Redis compose services get cap_drop plus pids_limit and cpus.
- .env.example ships MAX_SVG_SIZE_MB=50 (0 disabled the cap).
Adds security-focused unit and integration tests. typecheck, biome, and the
full unit and integration suites pass.
Defers the onboarding usage survey to the instance's first successful processing (the worker writes a one-time onboarding.firstProcessedAt marker and the overlay gates on it), so it reaches engaged users instead of first-landing visitors.
Replaces the two questions telemetry already answers (modality preference from tool_used, install method from instance_started) with what it can't infer: prior tool, self-host motivation, and discovery source.
Adds feedback_prompt_shown and feedback_prompt_dismissed on all five feedback surfaces (usage survey, per-job prompt, admin install card, global nav dialog, search-miss) so skip and completion rates are measurable, not just submissions. New survey strings translated into all 20 non-English locales.
Five fixes to the PostHog event stream, from an audit of what we actually collect versus what's flowing in. Each one is test-first.
## What changed
**Silenced the `$opt_in` noise.** `initAnalytics` called `opt_in_capturing()` on every page load to clear a stale opt-out flag, and posthog-js emits an `$opt_in` event on every call. That was 10k+ events a month (up to 55 per user) carrying no signal: analytics is on by default with an admin opt-out, so there is no per-user consent to record. Both call sites now pass `captureEventName: false`.
**Split the onboarding survey out of `feedback_submitted`.** The onboarding usage survey rode the same event as real feedback, so about 93% of "feedback" was actually onboarding profiling. It now emits `onboarding_survey_submitted`, so feedback metrics mean feedback again.
**Set `pipeline_executed.file_count`.** It was declared in the properties interface but never populated. A pure `pipelineExecutedProps` helper now derives it (batch size for a batch run, else 1) and is shared by the success and failure paths, which also drops a duplicated payload.
**Tracked OIDC login failures.** All six OIDC callback failure branches bumped the Prometheus counter and wrote an audit log but never emitted `auth_login_failed`. A `recordOidcFailure` helper mirrors the password path.
**Added `TELEMETRY.md`.** A contributor-facing event dictionary: every event, its properties, where it fires, and the privacy invariants, with the allowlists as source of truth. A drift test fails if any `ANALYTICS_EVENTS` value goes undocumented.
I left the published telemetry guide (`apps/docs/guide/telemetry.md`) alone. It is high-level and still accurate, and editing it would pull in the 21-locale stale-gate for no gain.
## Verification
- Unit (63 tests): `analytics-events`, `telemetry-doc-drift`, `api/analytics`, `web/analytics`, `worker.behavior`
- Integration (41 tests): `oidc-auth`, `feedback`
- Full typecheck across all 9 workspaces
- Biome clean on the changed files
All green locally.
AI sidecar failures reached Sentry as 'Error: Error': the scrubber type-onlys plain Errors and the tool wrappers threw them from result.error. The bridge now exports toSidecarError(), wrapping the sidecar reason in a SafeError (memory-allocation text classifies as operational, the rest as bug); all 14 wrappers use it, plus the dispatcher crash/stdin/spawn rejection paths and parseStdoutJson. toBgRemovalError from #535 delegates to the shared helper.
On the web side, DOMExceptions report their specific name via err.name, so the NATIVE_ERRORS allowlist dropped the whole family's browser-authored messages. It now carries the full WebIDL DOMException name table; messages still pass through url/path redaction.
Bridge-mocking test files switched to importOriginal passthrough mocks.
Stackless uncaught errors reached Sentry as a bare Error with no frames and collapsed into one ungroupable issue. beforeSend now fingerprints frameless events by safe identity (name, code, one-way hash of the message) so distinct crashes separate without leaking PII. Only frameless events are touched; an upstream fingerprint is never overridden.
BullMQ raises UnrecoverableError when a job loses its lock (a stall), e.g. a heavy upscale under memory pressure. We never throw it ourselves, so classifyError now treats it as operational (one warning per hour) instead of a bug. ReplyError stays a bug.
RealESRGAN's enhance() and rembg's remove() run in one opaque call, so the
progress bar froze at 30% for the whole inference. Add a time-based
heartbeat that advances the bar in a background thread while the model runs
and stops when it returns, so the bar moves instead of freezing. Verified
end to end on a GPU box (forced CPU): a 55s upscale emitted 26 steady ticks
then completed; background removal too.
Fixes#591
Document that a response-buffering reverse proxy is the usual cause of a
self-hosted download that starts but never finishes, point at the
X-Accel-Buffering: no safety net (#604), and call out downloads alongside
SSE in the nginx and Caddy examples.
Refs #590
Add -d snapotter to the guide's Compose healthcheck examples so they match
the shipped compose fix (#595), and note "change this" next to the default
POSTGRES_PASSWORD. English guide only; locale docs regenerate through the
i18n pipeline.
Refs #592
Add a one-line notice to the upscale and background-removal settings that
heavy AI runs much slower without a GPU and a large image can take minutes,
so a CPU-only self-hoster isn't caught off guard by a slow run. Translated
into all 21 locales.
Refs #591
Send X-Accel-Buffering: no on file download responses so nginx and
compatible reverse proxies stream them through instead of buffering, the
usual cause of a self-hosted download that "starts but never finishes".
The app already delivers exactly Content-Length bytes; a new real-socket
test proves it for both the collated PDF and the multi-file ZIP.
Refs #590
Adds a Rounded Crop image tool for logo, favicon, and app-icon work. It masks the framed square to a rounded rectangle (with a corner-radius control) or an iOS-style squircle, reusing circle-crop's zoom/offset framing, border ring, background fill, and output-size options. Includes translations across all 21 locales.
Closes#601
When a PDF has no text layer (scanned or image-only), pdf-to-text now
returns a 422 that points at the OCR tool instead of a silent empty file,
and text downloads carry charset=utf-8 so UTF-8 Arabic renders correctly
when the .txt is viewed inline.
Fixes#589
The library file-storage helpers joined FILES_STORAGE_PATH with a database stored_name and never checked containment, so a crafted name could read or delete files outside the storage root after a malicious 1.x SQLite import (which copies stored_name verbatim). Add assertSafeStoredName() and apply it in every helper that resolves a stored name to a path, matching the containment guard object-storage already uses.
Reported by Alpesh Bhagwatkar.
Replace the job timeout message that hardcoded "background-removal" for
every tool with a tool-agnostic one that sets the CPU-vs-GPU expectation,
the usual reason heavy AI times out on modest hardware. The client-side
SSE stall message gets the same treatment. Both stay under friendlyError's
280-char limit so the guidance reaches the user instead of collapsing to a
generic "Processing failed".
Refs #591
The docs sitemap listed .html URLs that Cloudflare Pages 308-redirects to
their clean form, while every page's canonical/hreflang already pointed at
the clean URL. Google indexed the redirecting .html variant and picked its
own canonical (GSC "Duplicate, Google chose different canonical"), and burned
crawl budget re-fetching redirecting URLs. Setting VitePress cleanUrls:true
makes the sitemap and internal links extension-less so they match the
canonicals. CF Pages already serves the clean URL at 200 and 308s the .html
form, so no hosting change is needed and legacy .html hits consolidate.
Also mark demo.snapotter.com noindex: it mirrors the real app under the same
domain property, so its routes were being crawled and indexed as thin,
duplicate pages.
The job cancel endpoint authenticated the caller but never checked that the job belonged to them, so any authenticated user could cancel another user's job by ID. Load the job's owner and allow cancellation only for the owner or a caller with files:all; return 404 for missing and non-owned jobs alike. Extract the route into a shared registerJobRoutes() so the ownership check is covered by tests.
Reported by Alpesh Bhagwatkar.
Empty the hardcoded page-range default in remove/split/extract PDF tools (remove-pages defaulted to "2,4-6", out of range for any PDF under 6 pages) and disable submit until a range is entered. Reject password-protected PDFs up front for PDF-only tools with guidance to unlock first, instead of failing cryptically in the qpdf worker. Adds integration + e2e coverage.
Pin the compose Postgres healthchecks to POSTGRES_DB (pg_isready was
defaulting to the username, silently reporting healthy while spamming
FATAL logs when USER and DB differ), and make docker/wait-for-postgres.mjs
log the target host and error code instead of a silent retry loop. Adds a
change-me note next to the default password in README and Docker Hub.
Refs #592
Add -movflags +faststart for mp4/mov/m4v output from stabilize-video so
the stabilized result streams and previews progressively instead of
appearing broken or corrupted (moov atom was landing after mdat).
Fixes#588
Surface how to verify NVIDIA CUDA acceleration and recover when AI tools fall back to CPU despite --gpus all. Adds a Verify GPU acceleration section to the deployment guide (check logs, reinstall the affected bundle to restore the GPU ONNX Runtime build) and a pointer from the getting-started NVIDIA tip. Addresses #490.
Two OCR-install fixes surfaced while verifying the accurate-OCR (v3 runtime) path end to end:
- Installer timeout must be a safe integer, not a performance.now() float. With the default INSTALL_MAX_MS this failed every accurate-OCR install via the app right after the download (masked by the unpublished runtime; CI drives install_runtime.py directly so it never surfaced). Fixed via remainingInstallerTimeoutMs().
- Classify an absent or forbidden runtime index (401/403/404/410) as OcrRuntimeNotPublishedError with a clear "Fast OCR still works" message instead of a raw HTTP 404, without retrying.
Refs #552
Closes#578. Rewrites the user file library save-mode description in the English database.md and architecture.md guides (independent-new by default, parent-linked on overwrite) and updates all 20 translated copies of each, with i18n_source_hash re-stamped so the parity gate stays green.
Closes#565. Wires the fileId/saveMode pair into the ocr, erase-object, remove-background, background-replace, and blur-background submitters so the library save-mode selector works for them; remove-background's two-phase effects route now auto-saves the final composite instead of the transparent intermediate.
Removes the temporary 2.0 launch banner and README note, and refreshes the social/OG card to the current landing hero (synced to landing/web/docs og-image.png).
Adds an opt-in High Quality mode to the Object Eraser, backed by a new inpaint-hq feature bundle (Stable Diffusion 1.5 inpainting via diffusers). The default fast LaMa path is unchanged. Both arch archives are published to deepsafe/feature-bundles and the manifest carries their real sha256/sizes.
Verified end to end: a fresh container pulls the bundle from HuggingFace, checksum-verifies it, extracts torch/diffusers plus the fp16 model, and the HQ sidecar erases a large object with a plausible fill.
Refs #141
Exports a pure buildRedirects() from generate-redirects.mjs and adds a unit test asserting the committed apps/landing/public/_redirects matches it, so tool additions can't silently leave the generated redirects stale (see #573).
Fixes#568. Adds a real --color-ring token (ink orange #A85518 light / #F0A766 dark) and sweeps all 57 focus-indicator occurrences onto it: soft opacity rings blended to 1.2-1.7:1, border-only indicators sat at 2.6-3.0:1, and four focus:ring-ring sites referenced a token that never existed. Sponsor button keeps pink via pink-700; range sliders and the file list gain their missing keyboard indicators; landing skip link and form borders hardened; the palette contrast guard pins the ring at 3:1 in both themes.
Fixes#557. Vivid fill, ink label: brand #E07832 stays on fills while primary-foreground flips to #1A1814 (5.83:1); new theme-aware ink tokens carry orange, destructive, and success text roles; opacity-modified text purged; landing, demo, and the docs fund button retuned. Guarded by a CSS-parsing unit contrast test, rebuilt axe baselines with zero contrast entries, a new landing axe smoke, and fully regenerated darwin visual baselines.
Resyncs the generated _redirects with the tool catalog; it drifted when remove-gif-background was added without rerunning scripts/generate-redirects.mjs. Landing-only PR, admin-merged past the path-skipped required checks.
The landing Playwright suite ran in no CI workflow, so six specs had drifted red on main. Five subpages navigation tests asserted bare paths while the site emits trailing-slash URLs (format: directory), and one asserted a localized tool-detail page that is English-only by design. Fix the assertions and rewrite the tool test to the real invariant, then add a test-e2e-landing job gated on a new landing path filter so the suite runs on landing-relevant PRs and can't silently rot again.
The custom nav cluster (theme toggle + Fund + GitHub Star) rendered inline at
every width, overriding VitePress's responsive collapse: a horizontal scrollbar
at 768-959px and off-screen clipping of the buttons on 1280-1366px laptops.
Show the custom cluster only at >=1440px where it fits, defer to VitePress's
native nav below that, anchor the flyout menu to the start edge in RTL, and drop
the redundant "Home" nav link so the nav fits at 768px.
Closes#556
Anchor the Product menu panel below the navbar's bottom border line by bumping its top padding (pt-3 to pt-8). The panel was anchored to the button, landing 10px above the line, so the full-width navbar gradient line cut across the top of the open dropdown.
Editing a file from the library used to silently supersede it: the worker auto-saved every result as a new version and the leaf-only listing hid the original, which read as a destructive overwrite. Tool pages now show a per-edit choice for library-sourced files. The default saves the result as an independent new file and keeps the original; picking overwrite keeps the old superseding-version behavior.
The client sends a saveMode multipart field next to fileId, validated with a 400 on unknown values, and autoSaveToLibrary branches on it. Every hand-written route that honors fileId parses the field the same way as the factory. The review panel shows where an auto-saved result went instead of offering a second, duplicate save. Tools whose route or submitter ignores fileId keep the selector hidden via a shared unsupported-tools set, and the choice resets to the non-destructive default whenever a new file is staged.
Closes#495
The docs e2e suite clicked navbar and sidebar controls before Vue hydrated the
multi-locale bundle, so the clicks were swallowed. That raced the Pagefind
search open (filed as #551), the appearance toggle, and the homepage and
sidebar navigation tests. The old search tests also matched an input
placeholder the config overrides, so they failed against a working build.
Add a waitForHydration helper (gates on #app.__vue_app__, set inside Vue's
app.mount()) and an openDocsSearch helper, and route the affected tests through
them. Search itself was never broken; this change is test-only. Docs e2e suite
is green (43/43).
Closes#551
Astro's getRelativeLocaleUrl lowercases the locale segment by default, so landing links and hreflang for zh-CN, zh-TW, and pt-BR were emitted lowercase and 404 on case-sensitive Cloudflare Pages. Pin the casing at the localizeHref chokepoint with normalizeLocale: false, add an e2e hreflang casing guard, and add a deploy-time check that blocks the build if any lowercased locale path leaks into the output.
Closes#554
Removes the client-side api.github.com fetch from the landing navbar and the docs theme; both now render a build-time star count (docs via a new VitePress data loader). deploy-docs.yml gets GITHUB_TOKEN plus a daily refresh cron, mirroring deploy-landing.yml. Fixes#555.
The Convert Audio tool promised configurable bitrate, sample rate, and channel count, but only format and bitrate were exposed. Adds an optional sampleRate setting (8000 to 96000 Hz, omitted = preserve source) wired through the Zod schema, the FFmpeg -ar flag, the standalone settings panel, and the pipeline builder controls.
Impossible combinations fail loudly instead of degrading silently: MP3 + 96000 Hz is rejected (libmp3lame caps at 48 kHz), and MP3 bitrates above the encoder ceiling at low rates (64 kbps at 8 kHz, 160 kbps at 16/22.05 kHz) are rejected rather than clamped. The UI offers only legal combinations and sanitizes stored pipeline settings on load.
Docs updated in English plus all 20 localized pages with refreshed i18n_source_hash stamps; two new UI strings added to all 21 locales.
Fixes#558
Tool-detail pages (/tools/<section>/<tool>/) and the /self-hosted pages are
built only in English, with no per-locale route, so a locale-prefixed link
404s in the static build. Add an enOnlyHref() helper and use it for those
links in Footer, Navbar, HeroSearch, and ToolGrid so localized pages point at
the English pages that actually exist. Adds an e2e guard asserting localized
pages emit un-prefixed URLs for those routes.
On mobile browsers 100vh is the tall viewport (URL bar retracted), so the
full-height shells (h-screen + overflow-hidden) rendered their bottom strip
below the visible area with no way to scroll to it. After an upload the tool
page's bottom control is the "Process" peek bar, so it landed off-screen on
every tool.
Switch the fixed-height shells from h-screen (100vh) to h-dvh (100dvh), which
tracks the visible viewport. AppLayout covers every tool page; the same sweep
covers the editor, login, change-password, 404, and the error/loading screens.
On desktop dvh equals vh, so desktop is unchanged. Adds a source-level guard
(tests/unit/web/app-shell-viewport.test.ts) since headless Chromium has no URL
bar and cannot reproduce the bug.
Independent reviewer agents checked all 20 locales of guide/low-resource plus the two cross-link paragraphs against the English source. 26 fixes across 12 locales (fr calques, tr/ru terminology drift, a zh-CN crop-vs-trim meaning error, a zh-TW garden-path sentence, ko particles, de collocations, es calques, small id/vi/th/sv fixes); 8 locales clean. Parity check and VitePress build pass.
Admin merge: docs-only PR, path-filtered required contexts never report (#420 precedent).
Comprehensive telemetry quality improvements across Sentry and PostHog, grounded in an audit of the live data plus current best-practice research.
Sentry: job_id/instance_id tags, operational fingerprinting, PII-safe settings context on bug events, web tag population + extension-noise filtering, an early-crash buffer, http status/method kept on breadcrumbs, and a gated-off-by-default performance-tracing re-enable (tracesSampler that zeroes db/redis/queue-poll root spans + drops the Redis integration) with worker job spans and canonical-host cron monitors.
PostHog: history_change SPA pageviews, instance_id super property for fleet rollups, enriched tool_used (formats, byte sizes, is_batch, execution_hint, real error_kind taxonomy), the previously-dead result_saved/batch_processed/ai_bundle_prompted events fired, search click-through, editor + Automate authoring + auth instrumentation, a before_send PII boundary, and minimal opt-in landing-site pageviews.
Localize the docs homepage (DocsHome.vue), top nav, and sidebar structural labels into all 20 non-English locales; individual tool names stay English by design. Also derive the homepage tool-count chips from the shared catalog via toolSection() so they no longer drift.
New guide/low-resource page: what runs well on 2 GB machines, a Raspberry Pi / old laptop Compose walkthrough with tuned caps, the env-var knobs that matter on small hardware, and what to skip. Linked from getting-started, the deployment hardware section, and the sidebar. Translated into all 20 non-English locales via the i18n batch pipeline; parity check and VitePress build pass.
Admin merge: docs-only PR, the path-filtered required integration contexts never report (#420 precedent).
Closes#497
A genuine pdf.js load failure (corrupt or password-protected file) was swallowed by the same catch that silences teardown rejections, leaving a blank canvas that looks like it is still loading. The two cases are now distinguished by the load effect's cancelled flag, and a real failure renders a clear message pointing at Unlock PDF for encrypted files. New loadFailed string in all 21 locales.
Item 4 of #478.
Both PyPI onnxruntime flavors unpack into the same site-packages directory, so a bundle carrying the CPU build (transcription, via faster-whisper) overwrote the GPU build's native libraries during install while the stale onnxruntime_gpu dist-info kept claiming otherwise. Every ONNX-backed tool then silently ran on CPU.
The installer now reconciles the flavor before the venv merge and the GPU build always wins, in both install orders; reinstalling any GPU bundle repairs a previously clobbered venv. gpu.py's warning now says exactly that. Build-side, build-bundle.sh gains the same reconcile and verify-bundle-compatibility.sh layers bundles through the real installer merge and asserts a single flavor.
Verified live on an RTX 4070 against the published bundles: reproduced the clobber with the stock installer, then confirmed both the prevention and repair paths with the patched one.
Fixes#490
The password policy toggles (uppercase, lowercase, digit, special) can all be switched off in Settings -> Security, but the minimum-length input clamped at 4, so homelab admins couldn't deliberately allow short passwords. The API never enforced a floor; only the UI did. Lower the input floor to 1 and pin it with a test.
Closes#136
Defer the overall deadline until the first fetch is dispatched (the deferred-deadline pattern the sibling 20ms test already uses), so a loaded CI runner cannot abort before fetch and randomly fail Unit Tests.
Briefly retry Postgres and Redis connectivity at boot (waitForService, DB_STARTUP_TIMEOUT_MS) so an ordered-but-not-yet-ready dependency recovers cleanly instead of crash-looping.
removeBackground failures wrap in a SafeError so the specific reason survives the Sentry scrubber; the OOM lighter-model fallback and bridge SafeError passthrough are preserved.
Every worker tool error now carries an input_format tag (file extension only, never the filename) for triage, derived once at the worker error path and added to the scrubber allowlist.
Postgres auth (28xxx), permission (42501), resource (class 53), and operator-intervention (class 57) failures now classify as operational via a cause-chain walk, not bug. pg query bugs (e.g. 42601) stay bugs.
Route both Python exit paths through pythonExitError so the reason survives the scrubber; OOM/segfault stay operational and keep "out of memory" for the lighter-model fallback.
Wrap convert and gif-tools process functions so a Sharp .toBuffer() failure carries an authored SafeError title (and the original as cause) rather than a scrubbed "Error: Error".
Route every doc_* helper JSON.parse through a guarded helper; non-JSON stdout now yields a diagnosable SafeError with the raw output in the cause instead of a bare SyntaxError.
Fixes#533, found while working on #529/#531.
OIDC and SAML logins hard-blocked on the MFA policy with zero check of whether the user actually enrolled TOTP, and no challenge step at all. Once an admin turned on an MFA-required policy, every SSO user was permanently locked out regardless of enrollment status.
- Extract the post-auth MFA decision (challenge / enrollment-required / proceed) into a shared, unit-tested function so OIDC and SAML can't independently diverge again
- An already-enrolled user now gets a real challenge (reusing the existing, auth-method-agnostic MFA completion flow) instead of being blocked
- An unenrolled user under a required policy gets a distinct, correctly mapped error instead of the old generic one
- Fix a real fail-open regression caught in review: a transient DB error during the enrollment-status check could have silently skipped MFA entirely for an enrolled user; now it fails closed and logs
- Strip the one-time challenge token from the URL after consuming it
Fixes#529 (opened investigating #515).
Setting MFA policy to "required"/"admins only" saved regardless of whether the mfa enterprise feature was licensed, and there was no enrollment UI at all, so any instance that flipped the toggle locked every unenrolled user out with no way back in. The login page and Settings save also both collapsed the resulting error into a generic message, hiding the real reason.
- Reject saving mfaPolicy to admins_only/required server-side unless mfa is licensed
- Surface the specific server error on login and on a failed settings save instead of a generic fallback
- Add a self-service two-factor authentication enrollment flow (QR code, manual entry, recovery codes, verify, disable) so a licensed admin can actually satisfy the policy before it's enforced
- Fix a pending-enrollment dead end, silent error swallowing in verify/disable, and a silent clipboard-copy failure on the recovery codes screen
- Add the integration test that actually proves the fix: a real login attempt returns 403 MFA_ENROLLMENT_REQUIRED
Add a proportion chip row (Free, Original, 1:1, 4:3, 3:2, 16:9, 3:4, 9:16) to the Resize tool's Custom tab. Picking a ratio locks width and height so editing one recomputes the other, and prefills the largest box of that ratio that fits the source so it never upscales. Free stays the default, preserving existing behavior. Replaces the previously non-functional lock-aspect button. Frontend only, no backend or schema change; adds strings to all 21 locales.
Switch the footer LiftOff badge image to lift-off.sh's hosted webp so their launch-verification step passes. Removes the unused self-hosted copy; the do-follow link is unchanged.
Target-size compression had only a coarse DPI lever, so it undershot badly (a 350KB target could land at 216KB) and silently missed unreachable targets. Adds JPEG quality as a second lever (forced re-encode so it bites on JPEG scans), folds both into one monotonic quality axis that target-size binary-searches, reports targetMet honestly in the panel across 21 locales, and flips the tool to async for the extra passes. Quality-mode output sizes shift intentionally (slider now drives JPEG quality at full resolution in its top half).
Renames 18 ambiguous or hard-to-search tool names so image tools self-qualify like the other modalities ("Compress" becomes "Compress Image"), and cleans up a few awkward names. Propagated across search (constants.ts), display (en.ts + 20 locales), the OpenAPI base spec + 20 locale specs, and the docs tool-page headings in 21 languages. Removes the duplicate "Normalize Audio" summary shared by the video and audio endpoints. Tool ids and routes are unchanged, so no API paths or bookmarks break.