From 45e19a0f09280da45f663de38466efb2f8affe4f Mon Sep 17 00:00:00 2001 From: darken Date: Wed, 20 May 2026 21:56:50 +0200 Subject: [PATCH] Expose X-Modified-At via CORS for module reads ModuleRoute already sets the header on GET responses (and ModuleFlowTest pins its presence). Cross-origin browsers can't read it without Access-Control-Expose-Headers, so octi-web's cross-origin fetch saw it as null. octi-web's multi-connector merge orders peer data by newest X-Modified-At per (deviceId, moduleId). Without this exposure, the merge falls back to deterministic-tiebreak in production while still working in dev (where same-origin or localhost relaxes the restriction). Sister web change: d4rken-org/octi-web#21. --- src/main/kotlin/eu/darken/octi/server/Server.kt | 7 +++++++ .../kotlin/eu/darken/octi/server/common/CorsFlowTest.kt | 1 + 2 files changed, 8 insertions(+) diff --git a/src/main/kotlin/eu/darken/octi/server/Server.kt b/src/main/kotlin/eu/darken/octi/server/Server.kt index 121ad77..99a0cd7 100644 --- a/src/main/kotlin/eu/darken/octi/server/Server.kt +++ b/src/main/kotlin/eu/darken/octi/server/Server.kt @@ -100,6 +100,13 @@ class Server @Inject constructor( allowHeader("Upload-Offset") exposeHeader(HttpHeaders.ETag) exposeHeader(HttpHeaders.LastModified) + // ModuleRoute sets X-Modified-At with the payload's server-side + // modification timestamp. Browsers can only read non-safelisted + // response headers cross-origin when they're listed here. + // octi-web's multi-connector merge uses it to order data when the + // same peer device is reachable via two connectors — newest + // X-Modified-At per (deviceId, moduleId) wins. + exposeHeader("X-Modified-At") exposeHeader(HttpHeaders.ContentRange) exposeHeader(HttpHeaders.AcceptRanges) exposeHeader(HttpHeaders.RetryAfter) diff --git a/src/test/kotlin/eu/darken/octi/server/common/CorsFlowTest.kt b/src/test/kotlin/eu/darken/octi/server/common/CorsFlowTest.kt index 084109d..af64607 100644 --- a/src/test/kotlin/eu/darken/octi/server/common/CorsFlowTest.kt +++ b/src/test/kotlin/eu/darken/octi/server/common/CorsFlowTest.kt @@ -128,6 +128,7 @@ class CorsFlowTest : TestRunner() { "upload-expires", "upload-state", "x-blob-id", + "x-modified-at", ) withClue("Access-Control-Expose-Headers='$exposed' missing: ${expected.filterNot { it in exposed }}") { expected.forEach { (it in exposed) shouldBe true }