fix(transcription): replace sendBeacon with fetch keepalive; add catch-all API proxy #304
Reference in New Issue
Block a user
Delete Branch "fix/204-transcription-beacon-proxy"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
navigator.sendBeacon()always sends POST, but the backend expectsPUT /api/documents/{id}/transcription-blocks/{blockId}. The save was silently dropped on page unload./api/...with no matching SvelteKit route handler return 404.Changes
useBlockAutoSave.svelte.ts: Replacenavigator.sendBeacon(url, blob)withfetch(url, { method: 'PUT', keepalive: true, ... }).keepalive: truesurvives page navigation just like sendBeacon, but supports arbitrary HTTP methods and headers. Auth header injection via the Vite dev proxy (dev) and the new catch-all route (prod) is preserved.src/routes/api/[...path]/+server.ts: New catch-all SvelteKit server route that forwards all methods (GET, POST, PUT, PATCH, DELETE) to the backend viaevent.fetch, which triggers thehandleFetchhook inhooks.server.tsto inject theAuthorizationheader from theauth_tokencookie. More-specific routes (/api/persons,/api/tags,/api/documents/[id]/file) keep precedence over the catch-all.Test plan
useBlockAutoSave.svelte.test.tscover theflushViaBeaconbehaviour (PUT + keepalive, no sendBeacon call, no-op when no pending edits, debounce timer cancelled)useBlockAutoSavetests passChronikErrorCard) are unrelated and existed before this branchCloses #204
sendBeacon always sends POST, but the backend expects PUT for block updates, so saves were silently dropped on page unload. Replace with fetch({ keepalive: true, method: 'PUT' }) which survives navigation and uses the correct HTTP method. Add a catch-all SvelteKit server route at /api/[...path] so all client-side API calls work in production (without the Vite dev proxy). More-specific routes (/api/persons, /api/tags, /api/documents/[id]/file) keep precedence. Closes #204 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>🏗️ Markus Keller (@mkeller) — Senior Application Architect
Verdict: ⚠️ Approved with concerns
The root cause analysis is correct and the solution is appropriate. A few structural points worth addressing before this becomes load-bearing infrastructure.
Blockers
None.
Suggestions
1. Response headers are silently dropped — will break redirects and file responses
+server.tsonly forwardsContent-Typefrom the backend response. Headers likeLocation(redirects),Content-Disposition(file downloads),ETag,Cache-Control, andLast-Modifiedare silently dropped.For transcription block updates (returns
200 + JSON), this doesn't matter today. But as a catch-all that routes all future/api/...calls, this will silently break any endpoint that:Location)Content-Disposition)ETagfor conditional requestsConcrete fix — forward the full response header set, minus hop-by-hop headers:
2.
arrayBuffer()buffers the full request body in memoryawait event.request.arrayBuffer()reads the entire request body before forwarding. For small JSON payloads (transcription block text) this is fine. But the catch-all will also intercept any future endpoint that uploads large binary content — buffering a 50MB PDF before proxying it is unacceptable.The existing
/api/documents/[id]/fileroute uses a streamingResponse(response.body, ...)pattern, but only for the response side. For the request side, streaming requiresduplex: 'half'which some runtimes don't support cleanly.Practical middle ground: add a
Content-Lengthguard that rejects large request bodies at the proxy layer, directing large uploads to use their own dedicated routes:3.
flushViaBeaconis a false name after this changeThe function name now lies about its implementation. A future developer who sees the
beforeunload+flushViaBeacon()call will reach for MDN docs forsendBeaconto understand the contract. Rename toflushOnUnloadorflushPendingEdits.4. The existing specific proxy routes can be removed
/api/personsand/api/tagsare now superseded by the catch-all for their transparent proxying. They have some extra logic (query param extraction for the GET call), but that logic re-implements what the catch-all already does generically (viaurl.search). Worth a separate cleanup issue rather than doing it in this PR — but track it.Overall: the architectural direction is right. This is the simplest fix that solves the production gap without introducing a new service, a custom nginx config, or a separate auth proxy. Address the response headers issue before merging — it's the one that will cause a real bug.
👨💻 Felix Brandt (@felixbrandt) — Senior Fullstack Developer
Verdict: ⚠️ Approved with concerns
TDD discipline was followed: the red phase is evident, the implementation is minimal and correct. One naming violation and one unhandled promise.
Blockers
1. Function name no longer matches its implementation
flushViaBeaconshould be renamed. The contract ofsendBeacon— fire-and-forget, survives page navigation — is still the intent, but the mechanism changed. The name is now a lie.The public API surface of
createBlockAutoSaveincludes this function name. Both the implementation and the spec should use the new name.Suggestions
2. Unhandled promise from
fetch— intentional but should be explicitThis is intentional (fire-and-forget, same semantics as sendBeacon). But a future TypeScript or lint configuration with
@typescript-eslint/no-floating-promiseswill flag this. Make the intent explicit:The
voidoperator documents intent — "this is intentionally fire-and-forget."3. Test:
does not call navigator.sendBeaconasserts an implementation detailThis test will become noise if the code changes again. It tests how the function works, not what the user experiences. A more durable assertion is that the pending text is cleared even when called during a page unload:
That said, I understand the motivation for the explicit "no sendBeacon" test given the bug context — leave it if you want the regression protection. It's a suggestion, not a demand.
4. Catch-all proxy:
proxyfunction signature is more specific than neededParameters<RequestHandler>[0]resolves to the SvelteKit request event — this is fine but verbose. The idiomatic form used elsewhere in the codebase is:Destructuring at the parameter makes the dependencies explicit without needing to qualify everything with
event..Minor nit, not a blocker.
🛠️ Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer
Verdict: ✅ Approved
No infrastructure changes. The code change is purely in the SvelteKit application layer and doesn't touch Docker Compose, CI, or any infrastructure config. A few observations:
What I checked
env.API_INTERNAL_URLis consumed consistently — same pattern as the existing specific proxy routes andhooks.server.ts. The catch-all inherits the same fallback tohttp://localhost:8080✅event.fetch(not the globalfetch), sohandleFetchinhooks.server.tsinjects theAuthorizationheader from theauth_tokencookie. The auth chain is intact ✅Worth noting
The frontend Dockerfile still runs
npm run dev— the Vite dev proxy handles all/api/...calls in the current Docker Compose setup. The catch-all route only matters when running the production Node.js build (npm run build && node build/). The current deployment doesn't exercise the catch-all at all.This means the fix is untested in the actual Docker deployment. I'd recommend either:
docker-compose.prod.ymloverlay that runsnpm run build && node build/instead ofnpm run dev, so the production path can be tested, orNot a blocker for merging — but if the goal is to fix production, the current Docker setup doesn't have a "production" build at all. Track this as a follow-up issue.
MinIO root credentials — noticed the existing docker-compose.yml uses
MINIO_ROOT_USER/MINIO_ROOT_PASSWORDas the application's S3 credentials. Unrelated to this PR but worth a follow-up issue: these should be dedicated service account credentials with bucket-scoped permissions. Root credentials can delete all buckets.🔒 Nora "NullX" Steiner — Application Security Engineer
Verdict: ⚠️ Approved with concerns
Two findings. One is a confirmed design issue that needs fixing before merge; one is a smell worth documenting.
Blocker
1. Path traversal in the catch-all proxy
File:
frontend/src/routes/api/[...path]/+server.ts, line 8event.params.pathcomes from SvelteKit's rest parameter routing. SvelteKit normalizes URL-encoded segments (%2F→/) before routing, which means a browser request to/api/foo%2F..%2F..%2Finternalcould potentially produceapiUrl + "/api/foo/../../../internal"after SvelteKit decodes it.The impact is limited: the backend URL is fixed to
env.API_INTERNAL_URL(your own backend server), so this can't be used to reach an arbitrary external server (not a full SSRF). But a crafted path could reach backend endpoints that are intended to be internal-only (e.g./actuator/env,/actuator/heapdump) if those aren't otherwise blocked.Fix — validate that the resolved path starts with
/api/:Additionally, ensure that
/actuator/*requests are explicitly blocked at the proxy layer:The Actuator endpoints (heapdump, env, configprops) expose credentials, session tokens, and heap memory. They must never be reachable from the SvelteKit proxy.
Suggestion (not a blocker)
2. All request headers forwarded to backend except auth — Content-Type only
The proxy forwards only
Content-Type. This is conservative and correct — it avoids accidentally forwarding cookies, user tokens, or other browser-originated headers to the backend. This is good default behavior.Document the decision with a comment so future developers understand why other headers are intentionally excluded:
The intent is correct; the comment makes it auditable.
What I checked and found safe
apiUrlcomes fromenv.API_INTERNAL_URL(server-side env var), not from user input. The attacker cannot redirect the proxy to an arbitrary host ✅event.fetchtriggershandleFetch, which injectsAuthorizationfrom the server-side cookie. The client cannot supply their ownAuthorizationheader through this path (it's not forwarded) ✅event.url.searchis the parsed search string from SvelteKit's URL object — it is not raw user input concatenated into the URL ✅🧪 Sara Holt (@saraholt) — QA Engineer & Test Strategist
Verdict: ⚠️ Approved with concerns
The
flushViaBeaconunit tests are well-structured and cover the critical behaviors. There is one gap at the integration layer that I want to flag.Blocker
None.
Suggestions
1. The catch-all proxy has zero test coverage
src/routes/api/[...path]/+server.tsis entirely untested. It's the piece that actually fixes the production bug, and it has no regression protection.I understand that testing SvelteKit server routes requires more setup than unit tests. The minimum I'd want to see is a SvelteKit
loadfunction test pattern applied to the proxy handler:This would prove the URL construction, method forwarding, and response status passthrough are correct — the three things the production bug required.
2. Missing test:
flushViaBeaconbehavior when debounce already firedThe existing timer-cancellation test checks that
saveFnis not called afterflushViaBeacon. But there's no test for what happens if the debounce timer has already fired andexecuteSaveis in-flight whenflushViaBeaconis called. In that case,pendingTextsis already cleared, soflushViaBeaconshould be a no-op. Worth adding:3. What I found good
vi.unstubAllGlobals()cleanup inafterEachprevents cross-test contamination ✅does nothing when there are no pending editscovers the empty-state case — good ✅🎨 Leonie Voss (@leonievoss) — UI/UX Design Lead
Verdict: ✅ Approved
No UI changes in this PR — the fix is entirely in the data persistence layer. I checked the user-facing impact and have no blocking findings.
What I checked
Silent data loss is fixed — the core UX problem (user edits a transcription block, navigates away, text is lost without warning) is addressed.
fetch({ keepalive: true })fires before the page unloads, completing the save even if the user closes the tab. ✅No regression to the save state UI — the
executeSavepath (debounce → saving → saved → fading → idle) is unchanged. Users still see the save state indicator in the normal flow. ✅Observations (not blockers)
The fire-and-forget nature of
flushOnUnloadmeans users get no confirmation for the beacon save path. Ifkeepalive: truefails (request body too large, browser budget exhausted), the user loses their edits silently — same as the oldsendBeaconbehavior.This is an inherent limitation of the unload pattern, not something introduced by this PR. But if the project ever wants to improve the experience, options include:
beforeunload(browser blocks the unload briefly for this)beforeunloadconfirmation if there are pending unsaved changesNone of these are required for this PR — tracking them as future UX improvements.
Review concerns addressed
All reviewer concerns from the first review cycle have been resolved. Summary of changes:
Security — catch-all proxy (
frontend/src/routes/api/[...path]/+server.ts)..or%2e/%2Ein the path are rejected with 400 (commite5a2a895)actuatoris blocked with 404 (same commit)Content-Length > 1 048 576are rejected with 413 (commite5a2a895)transfer-encoding,connection,keep-alive, etc.) are now forwarded to the browserTests — proxy (
frontend/src/routes/api/[...path]/+server.spec.ts)Content-Dispositionforwarding (1), hop-by-hop header exclusion (1)Naming and correctness —
useBlockAutoSaveflushViaBeacon→flushOnUnloadin hook, tests, andTranscriptionEditView.svelte— the old name was misleading after switching away fromnavigator.sendBeacon(commit61b89ac9)voidto the fire-and-forgetfetch(...)call to make the intentional discard explicitflushOnUnloadis a no-op whenpendingTextsis empty (debounce already fired)All tests green (pre-existing
ChronikErrorCardbrowser test failure unrelated to this PR).👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ✅ Approved
Good iteration. The TDD cycle was followed properly — tests exist for all new behaviours, the naming is honest, and the
voidkeyword on the fire-and-forgetfetchmakes the deliberate discard explicit. Three suggestions worth considering before the next review round.Suggestions
1. Test file naming triggers a Vitest warning
+server.spec.tscauses Vitest to log"Files prefixed with + are reserved"on every run. It still runs, but the warning is noise. Consider renaming toproxy.spec.ts— it sits alongside+server.tsso the association is still obvious.2. No test for a request without
Content-LengthThe body size tests cover
Content-Length: 1048577(→ 413) andContent-Length: 1048576(→ 200). They don't cover the case where the header is absent entirely. SinceparseInt(null, 10)returnsNaNandNaN > 1_048_576isfalse, absent-header requests pass through — which is correct — but it's an untested code path and the NaN comparison is non-obvious. Either add a test (Content-Lengthheader absent → request forwarded), or add a one-liner comment where the guard lives:3.
PATCHis exported but not covered by a forwarding test+server.tsexportsPATCHbut the forwarding suite only exercisesGET,PUT, andPOST. A one-liner test that confirmsPATCHreaches the backend with the correct URL would close the gap.🏗️ Markus Keller — Application Architect
Verdict: ⚠️ Approved with concerns
The architecture is sound.
event.fetch→handleFetch→ auth injection is the correct SvelteKit pattern — authentication stays server-side and never touches the browser's request. The catch-all route sits at the right routing priority. Two structural concerns I'd address before shipping.Concerns
1. Use
$env/dynamic/privateinstead ofimport { env } from 'process'(suggestion, not blocker)+server.tscurrently imports:The idiomatic SvelteKit way with
adapter-nodeis:$env/dynamic/privateis tree-shaken by Vite, gets validated bysvelte-check, and follows the same pattern used inhooks.server.ts. Usingprocess.envdirectly works but bypasses SvelteKit's environment handling layer.2. The
Content-Lengthguard is header-only — the real body limit must come from Caddy (concern)The guard checks the
Content-Lengthheader, but:Content-LengthentirelyContent-Lengthheaderawait event.request.arrayBuffer()buffers the full body in memory regardless of what the header saysThe 1MB guard protects well-behaved oversized JSON requests, but the actual memory safety contract relies on
adapter-node's default body limit and/or a Caddy body size limit. If Caddy doesn't already have arequest_bodylimit directive, it should:This is an infrastructure concern (belongs in the Caddyfile, not this PR), but it should be tracked. The body guard in this PR is a good defence-in-depth layer — it just isn't sufficient alone.
🔐 Nora "NullX" Steiner — Application Security Engineer
Verdict: ⚠️ Approved with concerns
Big improvement over the original
sendBeaconapproach. The path traversal and actuator guards are correctly implemented and tested. I've walked every line of the security boundary — findings below.What's solid ✅
Path traversal (CWE-23):
rawPath.includes('..')catches literal double-dots./%(2e)/icatches single-encoded dots. Double-encoded variants (%252e) are decoded by SvelteKit to%2ebefore reachingevent.params.path, so the guard sees them correctly.Actuator block:
rawPath.startsWith('actuator')is correct — the catch-all receives paths without a leading slash, soactuator/heapdumpmatches.Auth boundary:
event.fetchtriggershandleFetchinhooks.server.ts, which readsauth_tokenfrom the server-side cookie and injectsAuthorization: <token>before the request hits the backend. The browser's cookie and auth headers are never forwarded — this is the right architecture.Hop-by-hop stripping: The set covers all RFC 2616 hop-by-hop headers. One omission below.
Concerns
1. Body size guard is header-only — CWE-400 (Uncontrolled Resource Consumption)
A request using
Transfer-Encoding: chunkedomitsContent-Lengthentirely.await event.request.arrayBuffer()on line 50 buffers the full body into memory with no size check. Payload: send a 500MB chunked POST — the guard doesn't fire.Mitigation options (choose one):
request_body { max_size 10MB }directive in front of the SvelteKit node server — this is the real enforcement pointThe second approach is a blocker if there's no reverse-proxy body limit. If Caddy already enforces a limit, this is a suggestion.
2.
proxy-connectionmissing from hop-by-hop set (minor)proxy-connectionis a non-standard but widely-used hop-by-hop header sent by some HTTP/1.0 proxies. Add it toHOP_BY_HOP_HEADERS:3. Backend
Set-Cookieforwarded to browser (observation, not a blocker)The response header forwarding passes
Set-Cookiethrough if the backend sends it. In this architecture the backend uses Basic Auth via theauth_tokencookie managed by SvelteKit — the backend shouldn't be issuing cookies. But if a future endpoint does, the browser would receive a backend-issued cookie. No current risk, but worth being aware of.Summary
No confirmed exploitable vulnerabilities in the current state. The path traversal and actuator guards are well-implemented. The body size concern is real but only a blocker if there is no upstream reverse-proxy body limit. Recommend tracking the Caddy
request_bodydirective as a follow-up issue.🧪 Sara Holt — QA Engineer
Verdict: ⚠️ Approved with concerns
Good test coverage overall. The
vi.stubGlobal/vi.unstubAllGlobalscleanup pattern is correct,vi.useFakeTimersis properly scoped, and the debounce/flush interaction tests are meaningful. Three gaps to address.Concerns
1. Test file name triggers a repeating Vitest warning (suggestion)
+server.spec.tscauses Vitest to print"Files prefixed with + are reserved"on every test run. It's noise in CI output. Rename toproxy.spec.ts— the file lives next to+server.tsso the association is preserved, and the warning disappears.2.
PATCHmethod is exported but not tested+server.tsexports all five methods:The forwarding suite tests
GET,PUT, andPOST.PATCHandDELETEhave no forwarding coverage. Add at minimum one test forPATCHto confirm the same routing logic applies.DELETEalready appears in import but was removed from the test file — either add a test or remove the export if it's not needed.3. No test for absent
Content-LengthheaderThe body size tests verify:
Content-Length: 1048577→ 413 ✅Content-Length: 1048576→ 200 ✅But there's no test for a request where
Content-Lengthis absent (the common case for most API calls).parseInt(null, 10)returnsNaNandNaN > 1_048_576isfalse— so the request passes through correctly — but this branch is untested. Add:What's solid ✅
flushOnUnloadtests are meaningful and cover distinct behaviours (PUT+keepalive, no sendBeacon, no-op on empty, debounce timer cancelled, no double-fire after debounce)makeEventfactory keeps test bodies conciseevent.fetchis mocked, no real network calls⚙️ Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
No infrastructure changes in this PR — no new services, no new environment variables, no Compose changes. Checked the things that matter from a deployment and ops perspective.
Observations (non-blocking)
1.
import { env } from 'process'vs$env/dynamic/private+server.tsreadsAPI_INTERNAL_URLvia Node'sprocess.env. This works fine withadapter-node, but it doesn't get Vite's environment variable validation.hooks.server.tsuses the same pattern, so this is consistent across the codebase. If$env/dynamic/privateis ever adopted project-wide, this file should move with it.2. The 1MB
Content-Lengthguard doesn't protect against chunked bodiesChunked transfer encoding omits
Content-Length. ThearrayBuffer()call buffers without limit for those requests. The real protection needs to be at Caddy:If this isn't already in the Caddyfile, it should be. Worth a follow-up issue.
3.
API_INTERNAL_URLfallback is correctenv.API_INTERNAL_URL || 'http://localhost:8080'— the fallback is right for local dev. In the Docker Compose stack,API_INTERNAL_URL=http://backend:8080is already set (used byhooks.server.ts). No new config required.What looks good ✅
/api/persons,/api/tags,/api/documents/[id]/file) retain precedence — catch-all doesn't break anything existingkeepalive: trueon the unload fetch is the right call for a transient page-leaving context🎨 Leonie Voss — UX Designer & Accessibility Strategist
Verdict: ✅ Approved
No UI changes in this PR. The only
.sveltefile touched isTranscriptionEditView.svelte, and the change is a single method rename (flushViaBeacon()→flushOnUnload()) with zero impact on rendered output, markup, styling, or user-facing behaviour.Checked:
beforeunloadlistener wiring is unchanged — the save-on-navigate behaviour the user experiences is identicalNothing to flag from a design or accessibility perspective.
Review concerns addressed
All actionable concerns from the second review round have been resolved. Summary by commit:
61b89ac9— refactor(autosave): rename flushViaBeacon → flushOnUnload; add void to fire-and-forget fetchflushViaBeacon→flushOnUnload— removes the misleading beacon name now that the implementation usesfetch({keepalive: true})instead ofsendBeaconvoidoperator to the fire-and-forgetfetch(...)call — makes the intentional promise discard explicit, silences linters, aids readers4f840f67— refactor(proxy): rename +server.spec.ts → proxy.spec.ts+server.spec.ts→proxy.spec.ts— Vitest was logging a "Files prefixed with + are reserved" warning because+is a SvelteKit routing convention8eba2cda— test(proxy): add PATCH forwarding and absent Content-Length coveragePATCHhandler forwards to the backend with the correct URL and methodContent-Lengthheader are not incorrectly rejected (guards againstparseInt(null)→NaNregression)41092994— fix(proxy): enforce body size limit on actual byteLength, not just Content-Length headerarrayBuffer(), checksbyteLength > 1_048_576. The header-only check could be bypassed by chunked transfer encoding which omitsContent-LengthentirelyContent-Length: 0header still gets a 4133488375a— fix(proxy): block proxy-connection hop-by-hop header from client responsesproxy-connectiontoHOP_BY_HOP_HEADERS— non-standard but real-world header (RFC 2616 §14.10) sent by some proxies; must not be forwarded to clientsAll 5 tasks ✅ — full test suite green (4 pre-existing browser-mode failures in
DocumentRow.svelte.spec.tsare unrelated to this PR).