Skip to content

Commit cfe0070

Browse files
committed
Accept and echo Octi-Device-Capabilities header
Adds support for a new per-device capability tag set published by clients via the Octi-Device-Capabilities HTTP header. The server is a dumb pipe — it parses, validates (max 64 tags, 128 chars each, ASCII namespace:value shape), stores per device, and echoes back in the device-list response. No knowledge of tag semantics required. Honored on every state-updating authenticated request that already updates version/platform/label: register, heartbeat (via touchAuthenticatedDevice), WebSocket connect. Absent header = no update (matches the existing pattern for label/version/platform). Malformed headers are dropped (treated as absent) with a WARN log; entire set is rejected on any bad tag for wire-format consistency. Activates the client-side capability mechanism shipped in octi#309. Until clients send the header, this is a no-op; once they do, the data flows end-to-end. CORS allowlist updated so SPAs can send the header in preflight.
1 parent 602c5e8 commit cfe0070

12 files changed

Lines changed: 244 additions & 4 deletions

File tree

src/main/kotlin/eu/darken/octi/server/Server.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ class Server @Inject constructor(
9696
allowHeader("Octi-Device-Version")
9797
allowHeader("Octi-Device-Platform")
9898
allowHeader("Octi-Device-Label")
99+
allowHeader("Octi-Device-Capabilities")
99100
allowHeader("Upload-Offset")
100101
exposeHeader(HttpHeaders.ETag)
101102
exposeHeader(HttpHeaders.LastModified)

src/main/kotlin/eu/darken/octi/server/account/AccountRoute.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import eu.darken.octi.server.common.debug.logging.logTag
1010
import eu.darken.octi.server.common.debug.logging.shortId
1111
import eu.darken.octi.server.common.headerDeviceId
1212
import eu.darken.octi.server.common.normalizeLabel
13+
import eu.darken.octi.server.common.parseCapabilitiesHeader
1314
import eu.darken.octi.server.common.verifyCaller
1415
import eu.darken.octi.server.device.DeviceClientIdentityTracker
1516
import eu.darken.octi.server.device.DeviceLimitExceededException
@@ -96,6 +97,7 @@ class AccountRoute @Inject constructor(
9697
version = call.request.headers["Octi-Device-Version"] ?: call.request.headers["User-Agent"],
9798
platform = call.request.headers["Octi-Device-Platform"],
9899
label = normalizeLabel(call.request.headers["Octi-Device-Label"]),
100+
capabilities = parseCapabilitiesHeader(call.request.headers["Octi-Device-Capabilities"]),
99101
)
100102
} catch (e: DeviceLimitExceededException) {
101103
if (share != null) {

src/main/kotlin/eu/darken/octi/server/common/HttpExtensions.kt

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ import io.ktor.server.request.*
1515
import io.ktor.server.response.*
1616
import io.ktor.server.routing.*
1717
import io.ktor.util.*
18+
import kotlinx.serialization.SerializationException
19+
import kotlinx.serialization.json.Json
20+
import kotlinx.serialization.json.JsonArray
21+
import kotlinx.serialization.json.JsonPrimitive
1822
import java.time.Instant
1923
import java.util.*
2024

@@ -45,10 +49,62 @@ data class DeviceMetadataPatch(
4549
val version: String? = null,
4650
val platform: String? = null,
4751
val label: String? = null,
52+
val capabilities: Set<String>? = null,
4853
)
4954

5055
fun normalizeLabel(raw: String?): String? = raw?.trim()?.take(128)?.ifBlank { null }
5156

57+
private val capabilityParseJson = Json { ignoreUnknownKeys = true }
58+
59+
/**
60+
* Parses the `Octi-Device-Capabilities` HTTP header value into a validated [Set] of tag
61+
* strings. Mirrors the validation in the client-side `CapabilitiesCodec`: max [MAX_CAPABILITY_TAGS]
62+
* tags, max [MAX_CAPABILITY_TAG_LENGTH] chars each, ASCII `<namespace>:<value>` shape.
63+
*
64+
* Returns `null` if the header is absent, blank, malformed, or violates a limit — the device
65+
* is treated as not reporting capabilities. Drops the whole set on any bad tag (no partial
66+
* acceptance) so peers see a consistent "either valid or absent" wire contract.
67+
*/
68+
fun parseCapabilitiesHeader(raw: String?): Set<String>? {
69+
if (raw.isNullOrBlank()) return null
70+
if (raw.length > MAX_CAPABILITY_HEADER_LENGTH) {
71+
log(TAG, WARN) { "parseCapabilitiesHeader: header too long (${raw.length})" }
72+
return null
73+
}
74+
val element = try {
75+
capabilityParseJson.parseToJsonElement(raw)
76+
} catch (e: SerializationException) {
77+
log(TAG, WARN) { "parseCapabilitiesHeader: malformed JSON: ${e.message}" }
78+
return null
79+
}
80+
val array = element as? JsonArray ?: run {
81+
log(TAG, WARN) { "parseCapabilitiesHeader: not a JSON array" }
82+
return null
83+
}
84+
if (array.size > MAX_CAPABILITY_TAGS) {
85+
log(TAG, WARN) { "parseCapabilitiesHeader: too many tags (${array.size})" }
86+
return null
87+
}
88+
val result = LinkedHashSet<String>(array.size.coerceAtLeast(1))
89+
for (item in array) {
90+
val str = (item as? JsonPrimitive)?.takeIf { it.isString }?.content ?: run {
91+
log(TAG, WARN) { "parseCapabilitiesHeader: non-string element" }
92+
return null
93+
}
94+
if (str.length > MAX_CAPABILITY_TAG_LENGTH || !CAPABILITY_TAG_REGEX.matches(str)) {
95+
log(TAG, WARN) { "parseCapabilitiesHeader: invalid tag shape '$str'" }
96+
return null
97+
}
98+
result.add(str)
99+
}
100+
return result
101+
}
102+
103+
const val MAX_CAPABILITY_TAGS = 64
104+
const val MAX_CAPABILITY_TAG_LENGTH = 128
105+
const val MAX_CAPABILITY_HEADER_LENGTH = 4096
106+
val CAPABILITY_TAG_REGEX = Regex("""[a-z][a-z0-9]*:[A-Za-z0-9._\-]+""")
107+
52108
/**
53109
* Validates the auth headers and returns the device on success — no side effects.
54110
* Use [touchAuthenticatedDevice] to record lastSeen/IP after the per-account rate
@@ -110,6 +166,7 @@ suspend fun touchAuthenticatedDevice(
110166
metadata?.version?.let { v -> updated = updated.copy(version = v) }
111167
metadata?.platform?.let { p -> updated = updated.copy(platform = p) }
112168
metadata?.label?.let { l -> updated = updated.copy(label = l) }
169+
metadata?.capabilities?.let { c -> updated = updated.copy(capabilities = c) }
113170
updated
114171
}
115172
if (clientIp != null && ipTracker != null) {
@@ -191,6 +248,7 @@ suspend fun RoutingContext.verifyCaller(tag: String, deviceRepo: DeviceRepo): De
191248
version = call.request.header("Octi-Device-Version"),
192249
platform = call.request.header("Octi-Device-Platform"),
193250
label = normalizeLabel(call.request.header("Octi-Device-Label")),
251+
capabilities = parseCapabilitiesHeader(call.request.header("Octi-Device-Capabilities")),
194252
),
195253
)
196254
}

src/main/kotlin/eu/darken/octi/server/device/Device.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ data class Device(
4343
val label: String?
4444
get() = data.label
4545

46+
val capabilities: Set<String>?
47+
get() = data.capabilities
48+
4649
val addedAt: Instant
4750
get() = data.addedAt
4851

@@ -56,6 +59,13 @@ data class Device(
5659
val version: String? = null,
5760
val platform: String? = null,
5861
val label: String? = null,
62+
/**
63+
* Feature capability tags published by the device, format `<namespace>:<value>`
64+
* (e.g. `encryption:AES256_GCM_SIV`). Opaque to the server — stored and echoed
65+
* as-is. `null` = device hasn't reported capabilities; an empty set = device
66+
* explicitly reports no capabilities. See [eu.darken.octi.server.common.parseCapabilitiesHeader].
67+
*/
68+
val capabilities: Set<String>? = null,
5969
@Contextual val addedAt: Instant = Instant.now(),
6070
@Contextual val lastSeen: Instant = Instant.now(),
6171
) {

src/main/kotlin/eu/darken/octi/server/device/DeviceRepo.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,12 +141,14 @@ class DeviceRepo @Inject constructor(
141141
version: String?,
142142
platform: String? = null,
143143
label: String? = null,
144+
capabilities: Set<String>? = null,
144145
): Device {
145146
val data = Device.Data(
146147
id = deviceId,
147148
version = version,
148149
platform = platform,
149150
label = label,
151+
capabilities = capabilities,
150152
)
151153
val device = Device(
152154
data = data,

src/main/kotlin/eu/darken/octi/server/device/DeviceRoute.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ class DeviceRoute @Inject constructor(
7474
version = it.version,
7575
platform = it.platform,
7676
label = it.label,
77+
capabilities = it.capabilities,
7778
addedAt = it.addedAt,
7879
lastSeen = it.lastSeen,
7980
)

src/main/kotlin/eu/darken/octi/server/device/DevicesResponse.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ data class DevicesResponse(
1515
@SerialName("version") val version: String?,
1616
@SerialName("platform") val platform: String?,
1717
@SerialName("label") val label: String?,
18+
@SerialName("capabilities") val capabilities: Set<String>? = null,
1819
@Contextual @SerialName("addedAt") val addedAt: Instant,
1920
@Contextual @SerialName("lastSeen") val lastSeen: Instant,
2021
)
21-
}
22+
}

src/main/kotlin/eu/darken/octi/server/ws/WsRoute.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import eu.darken.octi.server.common.IpDeviceTracker
88
import eu.darken.octi.server.common.authenticateDevice
99
import eu.darken.octi.server.common.clientIp
1010
import eu.darken.octi.server.common.normalizeLabel
11+
import eu.darken.octi.server.common.parseCapabilitiesHeader
1112
import eu.darken.octi.server.common.touchAuthenticatedDevice
1213
import eu.darken.octi.server.common.debug.logging.Logging.Priority.INFO
1314
import eu.darken.octi.server.common.debug.logging.Logging.Priority.WARN
@@ -80,6 +81,7 @@ class WsRoute @Inject constructor(
8081
version = call.request.headers["Octi-Device-Version"],
8182
platform = call.request.headers["Octi-Device-Platform"],
8283
label = normalizeLabel(call.request.headers["Octi-Device-Label"]),
84+
capabilities = parseCapabilitiesHeader(call.request.headers["Octi-Device-Capabilities"]),
8385
),
8486
)
8587

src/test/kotlin/eu/darken/octi/TestRunnerExtensions.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ suspend fun TestEnvironment.createDeviceRaw(
5959
version: String? = null,
6060
platform: String? = null,
6161
label: String? = null,
62+
capabilities: String? = null,
6263
userAgent: String? = null,
6364
): HttpResponse = this.http.run {
6465
post {
@@ -70,6 +71,7 @@ suspend fun TestEnvironment.createDeviceRaw(
7071
if (version != null) headers.append("Octi-Device-Version", version)
7172
if (platform != null) headers.append("Octi-Device-Platform", platform)
7273
if (label != null) headers.append("Octi-Device-Label", label)
74+
if (capabilities != null) headers.append("Octi-Device-Capabilities", capabilities)
7375
if (userAgent != null) headers.set(HttpHeaders.UserAgent, userAgent)
7476
}
7577
}
@@ -80,9 +82,10 @@ suspend fun TestEnvironment.createDevice(
8082
version: String? = null,
8183
platform: String? = null,
8284
label: String? = null,
85+
capabilities: String? = null,
8386
userAgent: String? = null,
8487
): Credentials {
85-
val credentials = createDeviceRaw(deviceId, shareCode, version, platform, label, userAgent).asAuth()
88+
val credentials = createDeviceRaw(deviceId, shareCode, version, platform, label, capabilities, userAgent).asAuth()
8689
return Credentials(deviceId, credentials)
8790
}
8891

@@ -115,6 +118,7 @@ data class TestDevices(
115118
val version: String? = "ktor-client",
116119
val platform: String? = null,
117120
val label: String? = null,
121+
val capabilities: Set<String>? = null,
118122
val addedAt: String? = null,
119123
val lastSeen: String? = null,
120124
)

src/test/kotlin/eu/darken/octi/server/common/CorsFlowTest.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,12 +140,21 @@ class CorsFlowTest : TestRunner() {
140140
val response = http.options("/v1/account") {
141141
header(HttpHeaders.Origin, allowedOrigin)
142142
header(HttpHeaders.AccessControlRequestMethod, HttpMethod.Post.value)
143-
header(HttpHeaders.AccessControlRequestHeaders, "${HttpHeaders.Authorization},X-Device-ID,Octi-Device-Platform,Octi-Device-Label")
143+
header(
144+
HttpHeaders.AccessControlRequestHeaders,
145+
"${HttpHeaders.Authorization},X-Device-ID,Octi-Device-Platform,Octi-Device-Label,Octi-Device-Capabilities",
146+
)
144147
}
145148
response.status shouldBe HttpStatusCode.OK
146149
val allowed = response.headers[HttpHeaders.AccessControlAllowHeaders]?.lowercase() ?: ""
147150
// Ktor folds requested headers into the response — assert each one we plan to send
148-
listOf("authorization", "x-device-id", "octi-device-platform", "octi-device-label").forEach {
151+
listOf(
152+
"authorization",
153+
"x-device-id",
154+
"octi-device-platform",
155+
"octi-device-label",
156+
"octi-device-capabilities",
157+
).forEach {
149158
(it in allowed) shouldBe true
150159
}
151160
}

0 commit comments

Comments
 (0)