From 463402f8f14055724fa30986713a33a2c6e1b408 Mon Sep 17 00:00:00 2001 From: Blake Regalia Date: Fri, 17 Jul 2026 01:28:30 -0700 Subject: [PATCH 01/11] fix: complete PrimitiveConstraint semantics per OMG schema (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schema defines 'value' as an array (or null), and commit 9e93353 made deserialization accept that shape — but execution still only honored the first array element, the null-value branch compared a List against JsonNull (dead code), a JSON null value was rejected outright, and every constraint added a required triple pattern so 'or' composites over different properties silently excluded elements missing either property. - '=' (and the schema's 'in', now accepted) is set membership over all values in the array - 'value': null and null array entries match elements lacking the property, via OPTIONAL + !bound() - every constraint pattern is OPTIONAL with a bound() guard, so or-composites over different properties work - property names are sanitized before becoming SPARQL variable names - POST/PUT queries missing select/where return 400 instead of a 500 NPE Adds QueryResultsTest — the first execution coverage of POST /query-results, GET /queries/{id}/results, and the constraint translation (equality, membership, @type, null, and/or composites). Co-Authored-By: Claude Fable 5 --- .../openmbee/flexo/sysmlv2/apis/QueryApi.kt | 95 +++++---- .../sysmlv2/models/PrimitiveConstraint.kt | 9 +- .../flexo/sysmlv2/QueryResultsTest.kt | 181 ++++++++++++++++++ 3 files changed, 241 insertions(+), 44 deletions(-) create mode 100644 src/test/kotlin/org/openmbee/flexo/sysmlv2/QueryResultsTest.kt diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/QueryApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/QueryApi.kt index 1d61f25..e1dfde3 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/QueryApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/QueryApi.kt @@ -39,59 +39,72 @@ import org.openmbee.flexo.sysmlv2.models.QueryRequest fun PrimitiveConstraint.toSparql(cb: ConstructBuilder): Expr { val p: Node - var pvar: String = property + // property names are client input; only variable-safe characters may + // appear in the SPARQL variable derived from them + var pvar: String = property.replace(Regex("[^A-Za-z0-9_]"), "_") when(property) { "@id" -> { p = SYSMLV2.prop("elementId").asNode(); pvar = "id" } "@type" -> { p = RDF.type.asNode(); pvar = "Metatype" } else -> { p = SYSMLV2.prop(property).asNode() } } - if (value.isEmpty()) { + // schema: value is null, or an array of at least one scalar/Identified/null; + // a null value (either form) matches elements lacking the property + val values = value ?: listOf(JsonNull) + if (values.isEmpty()) { throw BadRequestException("PrimitiveConstraint value must not be empty") } - val firstVal = value[0] - val v: Node = when(firstVal) { - is JsonObject -> { - if (firstVal.containsKey("@id")) { - SYSMLV2.element(firstVal.jsonObject["@id"]!!.jsonPrimitive.content).asNode() - } else { - throw BadRequestException("PrimitiveConstraint value object must contain @id") + // null stands for "property absent"; everything else becomes a comparable node + val nodes: List = values.map { el -> + when(el) { + is JsonObject -> { + if (el.containsKey("@id")) { + SYSMLV2.element(el["@id"]!!.jsonPrimitive.content).asNode() + } else { + throw BadRequestException("PrimitiveConstraint value object must contain @id") + } } - } - is JsonPrimitive -> { - if (firstVal == JsonNull) { - RDF.nil.asNode() - } else { - if (property == "@type") SYSMLV2.type(firstVal.content).asNode() else firstVal.toRdfLiteralNode() + is JsonPrimitive -> { + if (el == JsonNull) { + null + } else { + if (property == "@type") SYSMLV2.type(el.content).asNode() else el.toRdfLiteralNode() + } } + else -> throw BadRequestException("Unexpected JSON element type in PrimitiveConstraint value") } - is JsonArray -> throw BadRequestException("PrimitiveConstraint value cannot be array") - else -> throw BadRequestException("Unexpected JSON element type in PrimitiveConstraint value") - } - if (firstVal == JsonNull) { - cb.addOptional("?e", p, "?$pvar") - } else { - cb.addWhere("?e", p, "?$pvar") } + // the pattern must be optional so that (a) null values can match elements + // lacking the property and (b) an or-composite over different properties + // does not require all of them to be present; bound() guards below keep + // required-presence semantics for the individual comparisons + cb.addOptional("?e", p, "?$pvar") val ef = cb.exprFactory + val matchesAbsent = nodes.any { it == null } + val comparable = nodes.filterNotNull() val expr = when(operator) { - PrimitiveConstraint.Operator.Equal -> { - if (value == JsonNull) { - ef.or(ef.not(ef.bound("?$pvar")), ef.eq("?$pvar", v)) - } else { - ef.eq("?$pvar", v) - } + // per the OMG schema "=" takes an array; both it and "in" are set + // membership over the array's values + PrimitiveConstraint.Operator.Equal, PrimitiveConstraint.Operator.In -> { + val membership = comparable + .map { ef.eq("?$pvar", it) as Expr } + .reduceOrNull { acc, e -> ef.or(acc, e) } + ?.let { ef.and(ef.bound("?$pvar"), it) as Expr } + val absent = if (matchesAbsent) ef.not(ef.bound("?$pvar")) as Expr else null + listOfNotNull(absent, membership).reduce { acc, e -> ef.or(acc, e) } } - PrimitiveConstraint.Operator.Less_Than -> { - ef.lt("?$pvar", v) - } - PrimitiveConstraint.Operator.Less_Than_Equal -> { - ef.le("?$pvar", v) - } - PrimitiveConstraint.Operator.Greater_Than -> { - ef.gt("?$pvar", v) - } - PrimitiveConstraint.Operator.Greater_Than_Equal -> { - ef.ge("?$pvar", v) + else -> { + if (matchesAbsent || comparable.size != 1) { + throw BadRequestException("PrimitiveConstraint operator '${operator.value}' requires exactly one non-null value") + } + val v = comparable[0] + val cmp = when(operator) { + PrimitiveConstraint.Operator.Less_Than -> ef.lt("?$pvar", v) + PrimitiveConstraint.Operator.Less_Than_Equal -> ef.le("?$pvar", v) + PrimitiveConstraint.Operator.Greater_Than -> ef.gt("?$pvar", v) + PrimitiveConstraint.Operator.Greater_Than_Equal -> ef.ge("?$pvar", v) + else -> throw BadRequestException("Unsupported PrimitiveConstraint operator '${operator.value}'") + } + ef.and(ef.bound("?$pvar"), cmp) } } return if (inverse) ef.not(expr) else expr @@ -121,8 +134,8 @@ suspend fun RoutingContext.createOrUpdateQuery( val query = Query(atId = queryId, atType = Query.AtType.Query, owningProject = Identified(projectId), - select = queryRequest.select!!, - where = queryRequest.where!!) + select = queryRequest.select ?: throw BadRequestException("Query must have a select"), + where = queryRequest.where ?: throw BadRequestException("Query must have a where")) val queryString = Json.encodeToString(query) val queryUri = SYSMLV2.query(queryId) val insertBuilder = UpdateBuilder().addInsert(queryUri, DCTerms.description, queryString) diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/models/PrimitiveConstraint.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/models/PrimitiveConstraint.kt index c5d2b4b..02f165d 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/models/PrimitiveConstraint.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/models/PrimitiveConstraint.kt @@ -29,12 +29,13 @@ data class PrimitiveConstraint @OptIn(ExperimentalSerializationApi::class) const val inverse: kotlin.Boolean = false, val operator: PrimitiveConstraint.Operator, val property: kotlin.String, - val value: kotlin.collections.List, //can be Identified, boolean, string, number + // schema: array of Identified/boolean/string/number/null, or null + val value: kotlin.collections.List?, ) : Constraint() { /** * - * Values: Equal,Greater_Than,Less_Than + * Values: Equal,Greater_Than,Less_Than,In */ enum class Operator(val value: kotlin.String){ @SerialName("=") @@ -46,7 +47,9 @@ data class PrimitiveConstraint @OptIn(ExperimentalSerializationApi::class) const @SerialName("<=") Less_Than_Equal("<="), @SerialName(">=") - Greater_Than_Equal(">="); + Greater_Than_Equal(">="), + @SerialName("in") + In("in"); } } diff --git a/src/test/kotlin/org/openmbee/flexo/sysmlv2/QueryResultsTest.kt b/src/test/kotlin/org/openmbee/flexo/sysmlv2/QueryResultsTest.kt new file mode 100644 index 0000000..58f73a5 --- /dev/null +++ b/src/test/kotlin/org/openmbee/flexo/sysmlv2/QueryResultsTest.kt @@ -0,0 +1,181 @@ +package org.openmbee.flexo.sysmlv2 + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.testing.ApplicationTestBuilder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.openmbee.flexo.sysmlv2.util.* + +/** + * Execution tests for POST /projects/{id}/query-results and + * GET /projects/{id}/queries/{queryId}/results — i.e. the constraint→SPARQL + * translation in `toSparql`/`runQuery`, which query CRUD tests never touch. + */ +class QueryResultsTest : ProjectAny() { + init { + val alphaId = "5f7e1c1a-0000-4000-8000-000000000001" + val betaId = "5f7e1c1a-0000-4000-8000-000000000002" + val gammaId = "5f7e1c1a-0000-4000-8000-000000000003" + val docId = "5f7e1c1a-0000-4000-8000-000000000004" + + // two named PartDefinitions, one named PartUsage, and one + // Documentation element with no name at all + val seedChanges = """ + { + "change": [ + { + "@type": "DataVersion", + "identity": {"@id": "$alphaId"}, + "payload": {"@type": "PartDefinition", "name": "Alpha"} + }, + { + "@type": "DataVersion", + "identity": {"@id": "$betaId"}, + "payload": {"@type": "PartDefinition", "name": "Beta"} + }, + { + "@type": "DataVersion", + "identity": {"@id": "$gammaId"}, + "payload": {"@type": "PartUsage", "name": "Gamma"} + }, + { + "@type": "DataVersion", + "identity": {"@id": "$docId"}, + "payload": {"@type": "Documentation", "body": "docs"} + } + ] + } + """.trimIndent() + + suspend fun ApplicationTestBuilder.queryResultIds(whereJson: String): List { + val response = httpPost("/projects/$demoProjectId/query-results") { + setJsonBody("""{ "@type": "Query", "where": $whereJson }""") + } + response shouldHaveStatus HttpStatusCode.OK + return Json.parseToJsonElement(response.bodyAsText()).jsonArray + .map { it.jsonObject["@id"]!!.jsonPrimitive.content } + } + + "POST query-results - '=' with a single-element array matches by name" { + testApplication { + commitChanges(demoProjectId, seedChanges).atId() + val ids = queryResultIds( + """{"@type": "PrimitiveConstraint", "operator": "=", "property": "name", "value": ["Alpha"]}""" + ) + ids shouldContainExactlyInAnyOrder listOf(alphaId) + } + } + + "POST query-results - '=' with a multi-element array is set membership" { + testApplication { + commitChanges(demoProjectId, seedChanges).atId() + val ids = queryResultIds( + """{"@type": "PrimitiveConstraint", "operator": "=", "property": "name", "value": ["Alpha", "Beta"]}""" + ) + ids shouldContainExactlyInAnyOrder listOf(alphaId, betaId) + } + } + + "POST query-results - 'in' behaves like multi-value '='" { + testApplication { + commitChanges(demoProjectId, seedChanges).atId() + val ids = queryResultIds( + """{"@type": "PrimitiveConstraint", "operator": "in", "property": "name", "value": ["Beta", "Gamma"]}""" + ) + ids shouldContainExactlyInAnyOrder listOf(betaId, gammaId) + } + } + + "POST query-results - '=' on @type filters by metaclass" { + testApplication { + commitChanges(demoProjectId, seedChanges).atId() + val ids = queryResultIds( + """{"@type": "PrimitiveConstraint", "operator": "=", "property": "@type", "value": ["PartUsage"]}""" + ) + ids shouldContainExactlyInAnyOrder listOf(gammaId) + } + } + + "POST query-results - '=' null matches elements lacking the property" { + testApplication { + commitChanges(demoProjectId, seedChanges).atId() + val ids = queryResultIds( + """{"@type": "PrimitiveConstraint", "operator": "=", "property": "name", "value": null}""" + ) + ids shouldContainExactlyInAnyOrder listOf(docId) + } + } + + "POST query-results - 'or' composite over different properties matches both sides" { + testApplication { + commitChanges(demoProjectId, seedChanges).atId() + val ids = queryResultIds( + """ + { + "@type": "CompositeConstraint", + "operator": "or", + "constraint": [ + {"@type": "PrimitiveConstraint", "operator": "=", "property": "name", "value": ["Alpha"]}, + {"@type": "PrimitiveConstraint", "operator": "=", "property": "body", "value": ["docs"]} + ] + } + """.trimIndent() + ) + ids shouldContainExactlyInAnyOrder listOf(alphaId, docId) + } + } + + "POST query-results - 'and' composite intersects constraints" { + testApplication { + commitChanges(demoProjectId, seedChanges).atId() + val ids = queryResultIds( + """ + { + "@type": "CompositeConstraint", + "operator": "and", + "constraint": [ + {"@type": "PrimitiveConstraint", "operator": "=", "property": "@type", "value": ["PartDefinition"]}, + {"@type": "PrimitiveConstraint", "operator": "=", "property": "name", "value": ["Beta"]} + ] + } + """.trimIndent() + ) + ids shouldContainExactlyInAnyOrder listOf(betaId) + } + } + + "GET queries/{queryId}/results - runs a saved query" { + testApplication { + commitChanges(demoProjectId, seedChanges).atId() + val queryId = createQuery(demoProjectId, "name", "Gamma").atId() + + val response = httpGet("/projects/$demoProjectId/queries/$queryId/results") + response shouldHaveStatus HttpStatusCode.OK + val ids = Json.parseToJsonElement(response.bodyAsText()).jsonArray + .map { it.jsonObject["@id"]!!.jsonPrimitive.content } + ids shouldContainExactlyInAnyOrder listOf(gammaId) + } + } + + "POST queries - missing select is a 400, not a server error" { + testApplication { + val response = httpPost("/projects/$demoProjectId/queries") { + setJsonBody( + """ + { + "@type": "Query", + "where": {"@type": "PrimitiveConstraint", "operator": "=", "property": "name", "value": ["x"]} + } + """.trimIndent() + ) + } + response shouldHaveStatus HttpStatusCode.BadRequest + } + } + } +} From 72cf2582503da8090eb308a12a9ccbcacf5ded9b Mon Sep 17 00:00:00 2001 From: Blake Regalia Date: Fri, 17 Jul 2026 01:35:03 -0700 Subject: [PATCH 02/11] fix: stop silently dropping element data (#19) Two silent data-loss paths around array-valued properties: - extractModelElementToJson skipped any multi-valued sysml: property that had no json: annotation triple, so models ingested as raw RDF (rather than through this API's commit path, which writes the annotation) lost properties like featureChain from every /elements response with no error. Emit a best-effort array instead; when an annotation exists it remains authoritative, and the skip no longer depends on predicate iteration order. - a commit change whose identity @id disagreed with its payload @id was silently discarded while the commit still returned 200; now a 400. Adds ElementArrayTest: array round-trip through the API (order preserved via annotation), the annotation-less raw-RDF load path, and the identity/payload mismatch rejection. Co-Authored-By: Claude Fable 5 --- .../openmbee/flexo/sysmlv2/apis/CommitApi.kt | 6 +- .../openmbee/flexo/sysmlv2/apis/ElementApi.kt | 72 ++++++---- .../flexo/sysmlv2/ElementArrayTest.kt | 133 ++++++++++++++++++ 3 files changed, 181 insertions(+), 30 deletions(-) create mode 100644 src/test/kotlin/org/openmbee/flexo/sysmlv2/ElementArrayTest.kt diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/CommitApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/CommitApi.kt index 7d1cf56..9407594 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/CommitApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/CommitApi.kt @@ -162,8 +162,10 @@ fun Route.CommitApi() { val identityId = identity?.atId var payloadId = payload?.getOrDefault("@id", null)?.jsonPrimitive?.content if (identityId != null && payloadId != null && identityId != payloadId) { - //bad, log error? - continue + // silently skipping the change would report a successful + // commit while discarding data + throw InvalidSysmlSerializationError( + "identity @id '$identityId' does not match payload @id '$payloadId' at .change[$index]") } if (identityId == null && payloadId == null && payload != null) { payloadId = generateId() // generate an id diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ElementApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ElementApi.kt index b9e1c31..13a68a0 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ElementApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ElementApi.kt @@ -68,6 +68,30 @@ class InvalidTripleError( value: RDFNode ): Error("$message at <$subjectIri> <${predicate.uri}> ${value.stringify()}") +private fun FlexoModelHandler.jsonFromRdfNode(elementIri: String, predicate: Property, obj: RDFNode): JsonElement { + return if (obj == RDF.nil) { + JsonNull + } else if (obj.isResource) { + buildJsonObject { + put("@id", obj.asResource().uri.autoSuffix) + } + } else if (obj.isLiteral) { + val lit = obj.asLiteral() + + // depending on its datatype + when (lit.datatype.uri) { + XSD.xboolean.uri -> JsonPrimitive(lit.boolean) + XSD.integer.uri -> JsonPrimitive(lit.int) + XSD.decimal.uri, XSD.xdouble.uri -> JsonPrimitive(lit.float) + else -> JsonPrimitive(lit.string) + } + } + // invalid + else { + throw InvalidTripleError("Don't know what this is", elementIri, predicate, obj) + } +} + fun FlexoModelHandler.extractModelElementToJson(elementIri: String): JsonObject { // direct outgoing properties of element val out = indexOut(elementIri) @@ -76,13 +100,17 @@ fun FlexoModelHandler.extractModelElementToJson(elementIri: String): JsonObject val type = out[RDF.type].resource()?.uri?.autoSuffix val id = elementIri.urnSuffix + // properties whose authoritative (ordered) form is carried by a JSON + // array annotation; the plain sysml: triples for these are skipped + val annotatedKeys = out.keys + .filter { it.uri.startsWith(SYSMLV2.ANNOTATION_JSON) } + .map { it.uri.urnSuffix } + .toSet() + return buildJsonObject { put("@type", type) put("@id", id) - // keeps track of json array annotations, if we already deserialized an array, ignore any triple with the same property - val seenArrays = mutableListOf() - // outgoing properties out.forEach { (predicate, values) -> // extract the suffix name part @@ -92,29 +120,20 @@ fun FlexoModelHandler.extractModelElementToJson(elementIri: String): JsonObject val obj = values.elementAt(0) if(predicate.uri.startsWith(SYSMLV2.VOCABULARY)) { - // multiple values means it's an array, skip and prefer JSON annotation - // if we've already seen a JSON annotation with the same property key then ignore - if (values.size > 1 || seenArrays.contains(propertyKey)) return@forEach - if (obj == RDF.nil) { - put(propertyKey, JsonNull) - } else if (obj.isResource) { - put(propertyKey, buildJsonObject { - put("@id", obj.asResource().uri.autoSuffix) + // the JSON annotation carries this property's array form + if (annotatedKeys.contains(propertyKey)) return@forEach + if (values.size > 1) { + // multi-valued property without an annotation (e.g. data + // loaded as raw RDF rather than through this API): emit a + // best-effort array rather than dropping the property. + // RDF triples are unordered, so sort for determinism. + put(propertyKey, buildJsonArray { + values.map { jsonFromRdfNode(elementIri, predicate, it) } + .sortedBy { it.toString() } + .forEach { add(it) } }) - } else if (obj.isLiteral) { - val lit = obj.asLiteral() - - // depending on its datatype - when (lit.datatype.uri) { - XSD.xboolean.uri -> put(propertyKey, lit.boolean) - XSD.integer.uri -> put(propertyKey, lit.int) - XSD.decimal.uri, XSD.xdouble.uri -> put(propertyKey, lit.float) - else -> put(propertyKey, lit.string) - } - } - // invalid - else { - throw InvalidTripleError("Don't know what this is", elementIri, predicate, obj) + } else { + put(propertyKey, jsonFromRdfNode(elementIri, predicate, obj)) } } // annotations @@ -144,9 +163,6 @@ fun FlexoModelHandler.extractModelElementToJson(elementIri: String): JsonObject // add parsed element to JSON object put(propertyKey, jsonElement) - - // do not overwrite this property - seenArrays.add(propertyKey) } // something else else { diff --git a/src/test/kotlin/org/openmbee/flexo/sysmlv2/ElementArrayTest.kt b/src/test/kotlin/org/openmbee/flexo/sysmlv2/ElementArrayTest.kt new file mode 100644 index 0000000..c203b77 --- /dev/null +++ b/src/test/kotlin/org/openmbee/flexo/sysmlv2/ElementArrayTest.kt @@ -0,0 +1,133 @@ +package org.openmbee.flexo.sysmlv2 + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.shouldBe +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.openmbee.flexo.sysmlv2.util.* + +/** + * Round-trip coverage for array-valued element properties (e.g. + * featureChain) — the regression surface of issue #19, where multi-valued + * properties disappeared from /elements responses. + */ +class ElementArrayTest : ProjectAny() { + val chainAId = "6a0e2b3c-0000-4000-8000-00000000000a" + val chainBId = "6a0e2b3c-0000-4000-8000-00000000000b" + val flowId = "6a0e2b3c-0000-4000-8000-00000000000f" + + init { + "array-of-ref property committed via the API round-trips in order" { + testApplication { + val commitId = commitChanges( + demoProjectId, + """ + { + "change": [ + { + "@type": "DataVersion", + "identity": {"@id": "$chainAId"}, + "payload": {"@type": "Feature", "name": "chainA"} + }, + { + "@type": "DataVersion", + "identity": {"@id": "$chainBId"}, + "payload": {"@type": "Feature", "name": "chainB"} + }, + { + "@type": "DataVersion", + "identity": {"@id": "$flowId"}, + "payload": { + "@type": "FlowUsage", + "name": "flow1", + "featureChain": [{"@id": "$chainBId"}, {"@id": "$chainAId"}] + } + } + ] + } + """.trimIndent() + ).atId() + + val element = getElement(demoProjectId, commitId, flowId).bodyAsJsonObject() + val chain = element["featureChain"]!!.jsonArray + .map { it.jsonObject["@id"]!!.jsonPrimitive.content } + // the JSON annotation preserves the original array order + chain shouldBe listOf(chainBId, chainAId) + } + } + + "multi-valued property loaded as raw RDF still appears in /elements" { + testApplication { + // write element triples directly to layer1's graph endpoint — + // no json: annotation triples, as if the model had been + // ingested by another tool rather than through this API + val config = testEnv() + val layer1 = "${config.property("flexo.protocol").getString()}://" + + "${config.property("flexo.host").getString()}:" + + config.property("flexo.port").getString() + val org = config.property("flexo.org").getString() + val defaultBranchId = getProject(demoProjectId).bodyAsJsonObject() + .nestedAtId("defaultBranch") + HttpClient().use { client -> + val response = client.put( + "$layer1/orgs/$org/repos/$demoProjectId/branches/$defaultBranchId/graph") { + header(HttpHeaders.Authorization, authorization()) + header(HttpHeaders.ContentType, "text/turtle") + setBody( + """ + @prefix sysml: . + a sysml:FlowUsage ; + sysml:name "externalFlow" ; + sysml:featureChain , . + a sysml:Feature ; sysml:name "a" . + a sysml:Feature ; sysml:name "b" . + """.trimIndent() + ) + } + require(response.status.isSuccess()) { + "failed to load raw RDF into layer1: ${response.status} ${response.bodyAsText()}" + } + } + + // the load created a commit; find it through the API + val commits = Json.parseToJsonElement( + getCommits(demoProjectId).bodyAsText()).jsonArray + val commitId = commits.first().jsonObject["@id"]!!.jsonPrimitive.content + + val element = getElement(demoProjectId, commitId, flowId).bodyAsJsonObject() + // annotation-less multi-valued properties must not be dropped; + // order is best-effort since RDF triples are unordered + val chain = element["featureChain"]!!.jsonArray + .map { it.jsonObject["@id"]!!.jsonPrimitive.content } + chain shouldContainExactlyInAnyOrder listOf(chainAId, chainBId) + } + } + + "commit change whose identity and payload @id disagree is a 400" { + testApplication { + val response = commitChanges( + demoProjectId, + """ + { + "change": [ + { + "@type": "DataVersion", + "identity": {"@id": "$chainAId"}, + "payload": {"@type": "Feature", "@id": "$chainBId", "name": "mismatch"} + } + ] + } + """.trimIndent() + ) + response shouldHaveStatus HttpStatusCode.BadRequest + } + } + } +} From fcc42b433b829470f8f6f05e792194951a268798 Mon Sep 17 00:00:00 2001 From: Blake Regalia Date: Fri, 17 Jul 2026 01:39:01 -0700 Subject: [PATCH 03/11] fix: element deletion no longer leaves dangling references The commit handler collected deleted-element nodes into deleteIncoming but never used it: only the deleted element's own (outgoing) triples were removed, so every element that referenced it kept a dangling {"@id": } forever. The update now also deletes incoming link triples and the referencing property's json: array annotation (which cannot be rewritten in SPARQL; the surviving link triples render as a best-effort array via the annotation-less fallback path). Co-Authored-By: Claude Fable 5 --- .../openmbee/flexo/sysmlv2/apis/CommitApi.kt | 26 ++++- .../flexo/sysmlv2/DeleteElementTest.kt | 96 +++++++++++++++++++ 2 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 src/test/kotlin/org/openmbee/flexo/sysmlv2/DeleteElementTest.kt diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/CommitApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/CommitApi.kt index 9407594..af45f63 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/CommitApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/CommitApi.kt @@ -266,6 +266,27 @@ fun Route.CommitApi() { } } if (replace == null || replace != "true") { //regular update + // deleted elements must not leave dangling references: remove + // incoming link triples, and the json: array annotations of the + // referencing property (they cannot be rewritten in SPARQL; the + // remaining link triples render as a best-effort array instead) + val deleteIncomingUpdate = if (deleteIncoming.isEmpty()) "" else """ + delete { + ?ref_s ?ref_p ?deleted_n . + ?ref_s ?ref_ann ?ann_val . + } where { + values ?deleted_n { + ${deleteIncoming.joinToString("\n").reindent(5)} + } + ?ref_s ?ref_p ?deleted_n . + optional { + ?ref_s ?ref_ann ?ann_val . + filter(strstarts(str(?ref_ann), "${SYSMLV2.ANNOTATION_JSON}")) + filter(strafter(str(?ref_ann), "${SYSMLV2.ANNOTATION_JSON}") = strafter(str(?ref_p), "${SYSMLV2.VOCABULARY}")) + } + }; + """ + // build SPARQL UPDATE string var sparqlUpdateString = """ ${DEFAULT_PREFIX_MAPPING.nsPrefixMap.filter { (id, iri) -> @@ -279,14 +300,15 @@ fun Route.CommitApi() { } where { values ?element_n { ${values.joinToString("\n").reindent(5)} - } + } optional { ?element_n ?element_p ?element_o . } }; + $deleteIncomingUpdate insert data { ${inserts.joinToString("\n\n").reindent(4)} - } + } """ // trim indent for better inspectability diff --git a/src/test/kotlin/org/openmbee/flexo/sysmlv2/DeleteElementTest.kt b/src/test/kotlin/org/openmbee/flexo/sysmlv2/DeleteElementTest.kt new file mode 100644 index 0000000..578995c --- /dev/null +++ b/src/test/kotlin/org/openmbee/flexo/sysmlv2/DeleteElementTest.kt @@ -0,0 +1,96 @@ +package org.openmbee.flexo.sysmlv2 + +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldNotContain +import io.kotest.matchers.string.shouldContain as stringShouldContain +import io.kotest.matchers.string.shouldNotContain as stringShouldNotContain +import io.ktor.client.statement.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.openmbee.flexo.sysmlv2.util.* + +/** + * Deleting an element (null payload) must not leave dangling references + * in other elements — neither single-valued refs nor array annotations. + */ +class DeleteElementTest : ProjectAny() { + val targetId = "7b1f3c4d-0000-4000-8000-000000000001" + val keepId = "7b1f3c4d-0000-4000-8000-000000000002" + val singleRefId = "7b1f3c4d-0000-4000-8000-000000000003" + val arrayRefId = "7b1f3c4d-0000-4000-8000-000000000004" + + init { + "deleting an element removes it and all references to it" { + testApplication { + commitChanges( + demoProjectId, + """ + { + "change": [ + { + "@type": "DataVersion", + "identity": {"@id": "$targetId"}, + "payload": {"@type": "Feature", "name": "target"} + }, + { + "@type": "DataVersion", + "identity": {"@id": "$keepId"}, + "payload": {"@type": "Feature", "name": "keep"} + }, + { + "@type": "DataVersion", + "identity": {"@id": "$singleRefId"}, + "payload": {"@type": "PartUsage", "name": "singleRef", "owner": {"@id": "$targetId"}} + }, + { + "@type": "DataVersion", + "identity": {"@id": "$arrayRefId"}, + "payload": { + "@type": "FlowUsage", + "name": "arrayRef", + "featureChain": [{"@id": "$targetId"}, {"@id": "$keepId"}] + } + } + ] + } + """.trimIndent() + ).atId() + + val deleteCommitId = commitChanges( + demoProjectId, + """ + { + "change": [ + { + "@type": "DataVersion", + "identity": {"@id": "$targetId"}, + "payload": null + } + ] + } + """.trimIndent() + ).atId() + + val body = getElements(demoProjectId, deleteCommitId).bodyAsText() + val elements = Json.parseToJsonElement(body).jsonArray + val ids = elements.map { it.jsonObject["@id"]!!.jsonPrimitive.content } + + // the deleted element is gone, everything else survives + ids shouldNotContain targetId + ids shouldContain singleRefId + ids shouldContain arrayRefId + + // no element still references the deleted id anywhere + body stringShouldNotContain targetId + + // the array reference to a surviving element is intact + val arrayRef = elements.first { + it.jsonObject["@id"]!!.jsonPrimitive.content == arrayRefId + }.toString() + arrayRef stringShouldContain keepId + } + } + } +} From 3c0008dc631f1da68776013ec37b59fedc3c629e Mon Sep 17 00:00:00 2001 From: Blake Regalia Date: Fri, 17 Jul 2026 01:42:51 -0700 Subject: [PATCH 04/11] fix: forward() now actually propagates upstream response headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header-forwarding block inside the respondText configure lambda read from the outgoing (empty) content headers and discarded the headersOf() value it built — dead code, so ETag, Location, WWW-Authenticate, etc. from layer1 never reached clients (401s arrived without a challenge). Append upstream headers to the response before responding, excluding content headers (managed by respondText) and this server's Date/Server. Co-Authored-By: Claude Fable 5 --- .../org/openmbee/flexo/sysmlv2/Flexo.kt | 14 +++++---- .../flexo/sysmlv2/ForwardHeadersTest.kt | 30 +++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 src/test/kotlin/org/openmbee/flexo/sysmlv2/ForwardHeadersTest.kt diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/Flexo.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/Flexo.kt index 263eeec..a666ead 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/Flexo.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/Flexo.kt @@ -335,10 +335,14 @@ class FlexoModelHandlerWithFocalNode( suspend fun RoutingContext.forward(flexoResponse: FlexoResponse) { val response = flexoResponse.response - call.respondText(response.bodyAsText(), response.contentType(), response.status) { - // forward the response headers - headersOf(*response.headers.names().map { name -> - name to headers.getAll(name).orEmpty() - }.toTypedArray()) + // forward the upstream response headers (e.g. ETag, Location, + // WWW-Authenticate); content headers are managed by respondText and + // Date/Server belong to this server, not the upstream + response.headers.forEach { name, values -> + if (HttpHeaders.isUnsafe(name) + || name.equals(HttpHeaders.Date, ignoreCase = true) + || name.equals(HttpHeaders.Server, ignoreCase = true)) return@forEach + values.forEach { call.response.headers.append(name, it) } } + call.respondText(response.bodyAsText(), response.contentType(), response.status) } diff --git a/src/test/kotlin/org/openmbee/flexo/sysmlv2/ForwardHeadersTest.kt b/src/test/kotlin/org/openmbee/flexo/sysmlv2/ForwardHeadersTest.kt new file mode 100644 index 0000000..98476f6 --- /dev/null +++ b/src/test/kotlin/org/openmbee/flexo/sysmlv2/ForwardHeadersTest.kt @@ -0,0 +1,30 @@ +package org.openmbee.flexo.sysmlv2 + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.string.shouldContain +import io.ktor.client.request.* +import io.ktor.http.* +import org.openmbee.flexo.sysmlv2.util.* + +/** + * Failure responses from layer1 are forwarded verbatim — including + * response headers such as WWW-Authenticate, which clients need to + * renegotiate credentials. + */ +class ForwardHeadersTest : CommonSpec() { + init { + "forwarded 401 carries the upstream WWW-Authenticate challenge" { + testApplication { + val response = httpGet("/projects") { + headers.remove(HttpHeaders.Authorization) + header(HttpHeaders.Authorization, "Bearer garbage") + } + response shouldHaveStatus HttpStatusCode.Unauthorized + val challenge = response.headers[HttpHeaders.WWWAuthenticate] + challenge.shouldNotBeNull() + challenge shouldContain "Bearer" + } + } + } +} From 342ee2a5dfcd6a9bc814139afc3afb993bc9eaea Mon Sep 17 00:00:00 2001 From: Blake Regalia Date: Fri, 17 Jul 2026 01:47:35 -0700 Subject: [PATCH 05/11] fix: correct status codes on missing resources and malformed payloads - GET of a nonexistent element returned 200 with a fabricated {"@type": null} body; now 404 - POST /commits and query-results against a nonexistent project crashed on next()!! over an empty model (500); layer1 failures are now forwarded (404) and an empty repo result falls back safely - extra keys next to an @id reference threw a bare java.lang.Error (500); now InvalidSysmlSerializationError (400) - blank-node subjects in model graphs NPE-crashed every list endpoint (elements/roots/relationships/query results); they are now skipped Adds NegativePathTest covering the 404/400 surfaces. Co-Authored-By: Claude Fable 5 --- .../openmbee/flexo/sysmlv2/apis/CommitApi.kt | 16 +++- .../openmbee/flexo/sysmlv2/apis/ElementApi.kt | 14 ++- .../openmbee/flexo/sysmlv2/apis/QueryApi.kt | 16 +++- .../flexo/sysmlv2/apis/RelationshipApi.kt | 1 + .../flexo/sysmlv2/NegativePathTest.kt | 95 +++++++++++++++++++ 5 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 src/test/kotlin/org/openmbee/flexo/sysmlv2/NegativePathTest.kt diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/CommitApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/CommitApi.kt index af45f63..2d95a02 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/CommitApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/CommitApi.kt @@ -142,9 +142,18 @@ fun Route.CommitApi() { val projectResponse = flexoRequestGet { orgPath("/repos/$projectId") } + // nonexistent/unauthorized project: forward instead of crashing + // on an empty model below + if (projectResponse.isFailure()) { + return@post forward(projectResponse) + } projectResponse.parseModel { - val outgoing = model.listSubjectsWithProperty(RDF.type, MMS.Repo).next()!!.outgoing() - branchId = outgoing[SYSMLV2.DEFAULT_BRANCH_ID]?.literal()?: "master" + val repos = model.listSubjectsWithProperty(RDF.type, MMS.Repo) + if (repos.hasNext()) { + branchId = repos.next().outgoing()[SYSMLV2.DEFAULT_BRANCH_ID]?.literal()?: "master" + } else { + branchId = "master" + } } } // each change (DataVersionRequest) @@ -244,7 +253,8 @@ fun Route.CommitApi() { // @id reference if(valueObj.containsKey("@id")) { if(valueObj.keys.size > 1) { - throw Error("Unexpected extra keys at .${key}") + // client-input problem: must map to a 400, not a 500 + throw InvalidSysmlSerializationError("Unexpected extra keys at .change[$index].$key") } add(SYSMLV2.prop(key) to setOf(SYSMLV2.element(valueObj["@id"]!!.jsonPrimitive.content).asNode())) } diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ElementApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ElementApi.kt index 13a68a0..70373cd 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ElementApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ElementApi.kt @@ -11,6 +11,7 @@ */ package org.openmbee.flexo.sysmlv2.apis +import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.resources.* import io.ktor.server.response.* @@ -194,10 +195,12 @@ fun Route.ElementApi() { } // parse the response model, extract the target element to JSON, and reply to client - call.respond(flexoResponse.parseModel { - // extract the target model element to JSON - extractModelElementToJson(elementIri) - }) + val element = flexoResponse.parseModel { + // an empty result means no such element at this commit + if (!model.listStatements(model.getResource(elementIri), null, null as RDFNode?).hasNext()) null + else extractModelElementToJson(elementIri) + } ?: return@get call.respond(HttpStatusCode.NotFound) + call.respond(element) } // get multiple elements @@ -217,6 +220,7 @@ fun Route.ElementApi() { val result = buildJsonArray { flexoResponse.parseModel { for(subject in model.listSubjects()) { + if (subject.isAnon) continue add(extractModelElementToJson(subject.uri)) } } @@ -254,6 +258,7 @@ fun Route.ElementApi() { val result = buildJsonArray { flexoResponse.parseModel { for(subject in model.listSubjects()) { + if (subject.isAnon) continue add(extractModelElementToJson(subject.uri)) } } @@ -297,6 +302,7 @@ fun Route.ElementApi() { val result = buildJsonArray { flexoResponse.parseModel { for(subject in model.listSubjects()) { + if (subject.isAnon) continue add(extractModelElementToJson(subject.uri)) } } diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/QueryApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/QueryApi.kt index e1dfde3..36cbb2a 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/QueryApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/QueryApi.kt @@ -160,9 +160,15 @@ suspend fun RoutingContext.runQuery( val projectResponse = flexoRequestGet { orgPath("/repos/$projectId") } + // nonexistent/unauthorized project: let the caller forward the failure + if (projectResponse.isFailure()) { + return projectResponse + } projectResponse.parseModel { - val outgoing = model.listSubjectsWithProperty(RDF.type, MMS.Repo).next()!!.outgoing() - branchId = outgoing[SYSMLV2.DEFAULT_BRANCH_ID]?.literal() ?: "master" + val repos = model.listSubjectsWithProperty(RDF.type, MMS.Repo) + if (repos.hasNext()) { + branchId = repos.next().outgoing()[SYSMLV2.DEFAULT_BRANCH_ID]?.literal() ?: "master" + } } } val cb = ConstructBuilder().addConstruct("?e", "?p", "?o") @@ -303,7 +309,8 @@ fun Route.QueryApi() { // parse the response model, extract the elements to JSON, and reply to client call.respond(buildJsonArray { flexoResponse.parseModel { - for (subject in model.listSubjects()) { + for(subject in model.listSubjects()) { + if (subject.isAnon) continue add(extractModelElementToJson(subject.uri)) } } @@ -323,7 +330,8 @@ fun Route.QueryApi() { // parse the response model, extract the elements to JSON, and reply to client call.respond(buildJsonArray { flexoResponse.parseModel { - for (subject in model.listSubjects()) { + for(subject in model.listSubjects()) { + if (subject.isAnon) continue add(extractModelElementToJson(subject.uri)) } } diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/RelationshipApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/RelationshipApi.kt index a3773a8..e5d8b29 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/RelationshipApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/RelationshipApi.kt @@ -72,6 +72,7 @@ fun Route.RelationshipApi() { val result = buildJsonArray { flexoResponse.parseModel { for(subject in model.listSubjects()) { + if (subject.isAnon) continue add(extractModelElementToJson(subject.uri)) } } diff --git a/src/test/kotlin/org/openmbee/flexo/sysmlv2/NegativePathTest.kt b/src/test/kotlin/org/openmbee/flexo/sysmlv2/NegativePathTest.kt new file mode 100644 index 0000000..5c9a5cd --- /dev/null +++ b/src/test/kotlin/org/openmbee/flexo/sysmlv2/NegativePathTest.kt @@ -0,0 +1,95 @@ +package org.openmbee.flexo.sysmlv2 + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.ktor.http.* +import org.openmbee.flexo.sysmlv2.util.* + +/** + * Requests that reference missing resources or carry malformed payloads + * must produce client-error status codes, not 500s or fabricated 200s. + */ +class NegativePathTest : ProjectAny() { + val unknownId = "00000000-dead-4000-8000-000000000000" + + init { + "GET nonexistent element is a 404, not a fabricated 200" { + testApplication { + val commitId = commitChanges( + demoProjectId, + """ + { + "change": [ + { + "@type": "DataVersion", + "identity": {"@id": "11111111-1111-4111-8111-111111111111"}, + "payload": {"@type": "PartDefinition", "name": "exists"} + } + ] + } + """.trimIndent() + ).atId() + + getElement(demoProjectId, commitId, unknownId) shouldHaveStatus HttpStatusCode.NotFound + } + } + + "POST commit to nonexistent project is a 404, not a 500" { + testApplication { + val response = commitChanges( + unknownId, + """ + { + "change": [ + { + "@type": "DataVersion", + "identity": {"@id": "11111111-1111-4111-8111-111111111111"}, + "payload": {"@type": "PartDefinition", "name": "orphan"} + } + ] + } + """.trimIndent() + ) + response shouldHaveStatus HttpStatusCode.NotFound + } + } + + "POST query-results to nonexistent project is a 404, not a 500" { + testApplication { + val response = httpPost("/projects/$unknownId/query-results") { + setJsonBody( + """ + { + "@type": "Query", + "where": {"@type": "PrimitiveConstraint", "operator": "=", "property": "name", "value": ["x"]} + } + """.trimIndent() + ) + } + response shouldHaveStatus HttpStatusCode.NotFound + } + } + + "commit payload with extra keys next to an @id reference is a 400" { + testApplication { + val response = commitChanges( + demoProjectId, + """ + { + "change": [ + { + "@type": "DataVersion", + "identity": {"@id": "11111111-1111-4111-8111-111111111111"}, + "payload": { + "@type": "PartUsage", + "owner": {"@id": "$unknownId", "bogus": true} + } + } + ] + } + """.trimIndent() + ) + response shouldHaveStatus HttpStatusCode.BadRequest + } + } + } +} From 91c26d78600f56b5863d887ba61af27ca0991ef7 Mon Sep 17 00:00:00 2001 From: Blake Regalia Date: Fri, 17 Jul 2026 01:52:20 -0700 Subject: [PATCH 06/11] fix: soft-deleted resources 404 on single GET; POST project conflict is 409 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletion only set a urn:sysmlv2:deleted flag that list endpoints filtered — GET-by-id still returned deleted projects/branches/tags with 200. Single-resource GETs now apply the same filter. POST /projects with an existing @id silently overwrote the project via the underlying PUT (replacing title/description/defaultBranch); it now returns 409 Conflict. Co-Authored-By: Claude Fable 5 --- .../openmbee/flexo/sysmlv2/apis/BranchApi.kt | 5 +- .../openmbee/flexo/sysmlv2/apis/ProjectApi.kt | 12 +++- .../org/openmbee/flexo/sysmlv2/apis/TagApi.kt | 5 +- .../openmbee/flexo/sysmlv2/SoftDeleteTest.kt | 70 +++++++++++++++++++ 4 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 src/test/kotlin/org/openmbee/flexo/sysmlv2/SoftDeleteTest.kt diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/BranchApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/BranchApi.kt index 216da0f..7bf8f11 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/BranchApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/BranchApi.kt @@ -111,7 +111,10 @@ fun Route.BranchApi() { } // parse the response model, convert it to JSON, and reply to client val branch = flexoResponse.parseModel { - model.listSubjectsWithProperty(RDF.type, MMS.Branch).mapWith { + // soft-deleted branches are invisible + model.listSubjectsWithProperty(RDF.type, MMS.Branch).filterDrop { + it.hasProperty(SYSMLV2.DELETED) + }.mapWith { branchFromResponse(it.outgoing(), path.projectId, it.uri.uriSuffix) }.toList().filterNotNull().firstOrNull() } ?: return@get call.respond(HttpStatusCode.NotFound) diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ProjectApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ProjectApi.kt index 0deeb75..f1d0b93 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ProjectApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ProjectApi.kt @@ -133,7 +133,8 @@ fun Route.ProjectApi() { return@get forward(flexoResponse) } val project = flexoResponse.findFirstByType(MMS.Repo) { - projectFromResponse(it) + // soft-deleted projects are invisible + if (it[SYSMLV2.DELETED] != null) null else projectFromResponse(it) } ?: return@get call.respond(HttpStatusCode.NotFound) call.respond(project) } @@ -165,6 +166,15 @@ fun Route.ProjectApi() { projectRequest.defaultBranch?.atId?.let { requireValidId(it, "defaultBranch.@id") } // use provided project ID or generate one if not provided val projectId = projectRequest.atId ?: generateId() + // POST must not silently overwrite an existing project + if (projectRequest.atId != null) { + val existing = flexoRequestGet { + orgPath("/repos/${projectId}") + } + if (!existing.isFailure()) { + return@post call.respond(HttpStatusCode.Conflict) + } + } val flexoResponse = createOrUpdateProject(projectId, projectRequest, true) if (flexoResponse.isFailure()) { return@post forward(flexoResponse) diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/TagApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/TagApi.kt index 7c6635e..9d809f6 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/TagApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/TagApi.kt @@ -89,7 +89,10 @@ fun Route.TagApi() { return@get forward(flexoResponse) } val tags = flexoResponse.parseModel { - model.listSubjectsWithProperty(RDF.type, MMS.Lock).mapWith { + // soft-deleted tags are invisible + model.listSubjectsWithProperty(RDF.type, MMS.Lock).filterDrop { + it.hasProperty(SYSMLV2.DELETED) + }.mapWith { tagFromResponse(it.outgoing(), path.projectId, it.uri.uriSuffix) }.toList().filterNotNull() } diff --git a/src/test/kotlin/org/openmbee/flexo/sysmlv2/SoftDeleteTest.kt b/src/test/kotlin/org/openmbee/flexo/sysmlv2/SoftDeleteTest.kt new file mode 100644 index 0000000..1f1b474 --- /dev/null +++ b/src/test/kotlin/org/openmbee/flexo/sysmlv2/SoftDeleteTest.kt @@ -0,0 +1,70 @@ +package org.openmbee.flexo.sysmlv2 + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.ktor.http.* +import org.openmbee.flexo.sysmlv2.util.* + +/** + * Soft-deleted resources must be invisible through single-resource GETs, + * not just filtered from list responses; and POST must not silently + * overwrite an existing resource. + */ +class SoftDeleteTest : ProjectAny() { + val elementChange = """ + { + "change": [ + { + "@type": "DataVersion", + "identity": {"@id": "22222222-2222-4222-8222-222222222222"}, + "payload": {"@type": "PartDefinition", "name": "anything"} + } + ] + } + """.trimIndent() + + init { + "GET a soft-deleted project by id is a 404" { + testApplication { + deleteProject(demoProjectId) shouldHaveStatus HttpStatusCode.OK + getProject(demoProjectId) shouldHaveStatus HttpStatusCode.NotFound + } + } + + "GET a soft-deleted branch by id is a 404" { + testApplication { + val commitId = commitChanges(demoProjectId, elementChange).atId() + val branchId = createBranch(demoProjectId, commitId, "doomed").atId() + + deleteBranch(demoProjectId, branchId) shouldHaveStatus HttpStatusCode.OK + getBranch(demoProjectId, branchId) shouldHaveStatus HttpStatusCode.NotFound + } + } + + "GET a soft-deleted tag by id is a 404" { + testApplication { + val commitId = commitChanges(demoProjectId, elementChange).atId() + val tagId = createTag(demoProjectId, commitId, "v-doomed").atId() + + deleteTag(demoProjectId, tagId) shouldHaveStatus HttpStatusCode.OK + getTag(demoProjectId, tagId) shouldHaveStatus HttpStatusCode.NotFound + } + } + + "POST /projects with an existing @id is a 409, not a silent overwrite" { + testApplication { + val response = httpPost("/projects") { + setJsonBody( + """ + { + "@type": "Project", + "@id": "$demoProjectId", + "name": "usurper" + } + """.trimIndent() + ) + } + response shouldHaveStatus HttpStatusCode.Conflict + } + } + } +} From dd9432e12e9ad40923f467bbfbd3b8c5116bbefb Mon Sep 17 00:00:00 2001 From: Blake Regalia Date: Fri, 17 Jul 2026 01:56:47 -0700 Subject: [PATCH 07/11] fix: escape carriage returns in RDF literals; validate element ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A payload string containing \r produced an illegal SPARQL string literal and failed the whole commit with an opaque upstream parse error. Element @ids (subject and references) were interpolated into IRI positions unvalidated — hostile or merely malformed ids now get a 400 up front, and an array member without @id no longer NPEs into a 500. Co-Authored-By: Claude Fable 5 --- .../org/openmbee/flexo/sysmlv2/Flexo.kt | 1 + .../openmbee/flexo/sysmlv2/apis/CommitApi.kt | 16 +++- .../openmbee/flexo/sysmlv2/EscapingTest.kt | 81 +++++++++++++++++++ 3 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 src/test/kotlin/org/openmbee/flexo/sysmlv2/EscapingTest.kt diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/Flexo.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/Flexo.kt index a666ead..340017d 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/Flexo.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/Flexo.kt @@ -35,6 +35,7 @@ fun escapeRdfDoubleQuotedLiteralContents(contents: String): String { .replace("\\", "\\\\") .replace("\"", "\\\"") .replace("\n", "\\n") + .replace("\r", "\\r") } private val RDF_IRI_ESCAPE_REGEX = "([\\x00-\\x20<>\"{}|^`\\\\]|%(?![0-9A-F][0-9A-F]))".toRegex() diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/CommitApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/CommitApi.kt index 2d95a02..cec5999 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/CommitApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/CommitApi.kt @@ -180,8 +180,11 @@ fun Route.CommitApi() { payloadId = generateId() // generate an id } - // subject node, target element - val elementNode: Node = SYSMLV2.element(identityId ?: payloadId!!).asNode() + // subject node, target element; the id is interpolated into + // SPARQL, so it must be validated + val elementId = identityId ?: payloadId!! + requireValidId(elementId, ".change[$index] element @id") + val elementNode: Node = SYSMLV2.element(elementId).asNode() // transform payload into property pairs mutableListOf>>().apply { @@ -230,7 +233,10 @@ fun Route.CommitApi() { if(value[0] is JsonObject) { // create additional triples to link the elements add(SYSMLV2.prop(key) to value.jsonArray.map { - SYSMLV2.element(it.jsonObject["@id"]!!.jsonPrimitive.content).asNode() + val refId = it.jsonObject["@id"]?.jsonPrimitive?.content + ?: throw InvalidSysmlSerializationError("Missing @id at .change[$index].$key") + requireValidId(refId, ".change[$index].$key.@id") + SYSMLV2.element(refId).asNode() }.toSet()) } // and first element is a primitive @@ -256,7 +262,9 @@ fun Route.CommitApi() { // client-input problem: must map to a 400, not a 500 throw InvalidSysmlSerializationError("Unexpected extra keys at .change[$index].$key") } - add(SYSMLV2.prop(key) to setOf(SYSMLV2.element(valueObj["@id"]!!.jsonPrimitive.content).asNode())) + val refId = valueObj["@id"]!!.jsonPrimitive.content + requireValidId(refId, ".change[$index].$key.@id") + add(SYSMLV2.prop(key) to setOf(SYSMLV2.element(refId).asNode())) } else { throw InvalidSysmlSerializationError("Unexpected JSON object at .change[$index].$key == ${Json.encodeToString(value)}") diff --git a/src/test/kotlin/org/openmbee/flexo/sysmlv2/EscapingTest.kt b/src/test/kotlin/org/openmbee/flexo/sysmlv2/EscapingTest.kt new file mode 100644 index 0000000..86c08f2 --- /dev/null +++ b/src/test/kotlin/org/openmbee/flexo/sysmlv2/EscapingTest.kt @@ -0,0 +1,81 @@ +package org.openmbee.flexo.sysmlv2 + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.matchers.shouldBe +import io.ktor.http.* +import kotlinx.serialization.json.jsonPrimitive +import org.openmbee.flexo.sysmlv2.util.* + +/** + * Client-supplied strings are interpolated into SPARQL updates: literal + * contents must be escaped (including carriage returns, which previously + * produced an illegal SPARQL string and failed the whole commit), and + * element ids must be rejected before reaching an IRI position. + */ +class EscapingTest : ProjectAny() { + val elementId = "33333333-3333-4333-8333-333333333333" + + init { + "element name with control and quote characters round-trips" { + testApplication { + val commitId = commitChanges( + demoProjectId, + """ + { + "change": [ + { + "@type": "DataVersion", + "identity": {"@id": "$elementId"}, + "payload": {"@type": "PartDefinition", "name": "line1\r\nline2 \"quoted\" back\\slash"} + } + ] + } + """.trimIndent() + ).atId() + + val element = getElement(demoProjectId, commitId, elementId).bodyAsJsonObject() + element["name"]!!.jsonPrimitive.content shouldBe "line1\r\nline2 \"quoted\" back\\slash" + } + } + + "commit with an invalid element @id is a 400" { + testApplication { + val response = commitChanges( + demoProjectId, + """ + { + "change": [ + { + "@type": "DataVersion", + "identity": {"@id": "../not/a/valid#id"}, + "payload": {"@type": "PartDefinition", "name": "evil"} + } + ] + } + """.trimIndent() + ) + response shouldHaveStatus HttpStatusCode.BadRequest + } + } + + "commit with an invalid referenced @id is a 400" { + testApplication { + val response = commitChanges( + demoProjectId, + """ + { + "change": [ + { + "@type": "DataVersion", + "identity": {"@id": "$elementId"}, + "payload": {"@type": "PartUsage", "owner": {"@id": "urn:evil> . Date: Fri, 17 Jul 2026 02:01:28 -0700 Subject: [PATCH 08/11] fix: stop fabricating created/name when upstream data is malformed (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branch/tag/project builders defaulted a missing dct:title to "" and a missing mms:created to OffsetDateTime.now(), so broken upstream data (e.g. the GraphDB wildcard-CONSTRUCT bug behind issue #20) surfaced as plausible-looking invented values instead of an observable failure. Malformed resources are now omitted (404 on single GET) rather than silently patched up. Adds BranchListPairingTest asserting each branch's name stays paired with its own @id in the list response — the exact regression surface reported in #20. Co-Authored-By: Claude Fable 5 --- .../openmbee/flexo/sysmlv2/apis/BranchApi.kt | 9 ++- .../openmbee/flexo/sysmlv2/apis/ProjectApi.kt | 12 ++-- .../org/openmbee/flexo/sysmlv2/apis/TagApi.kt | 9 ++- .../flexo/sysmlv2/BranchListPairingTest.kt | 55 +++++++++++++++++++ 4 files changed, 69 insertions(+), 16 deletions(-) create mode 100644 src/test/kotlin/org/openmbee/flexo/sysmlv2/BranchListPairingTest.kt diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/BranchApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/BranchApi.kt index 7bf8f11..9e49148 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/BranchApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/BranchApi.kt @@ -39,16 +39,15 @@ fun FlexoModelHandler.branchFromResponse( ): Branch? { val commitSuffix = outgoing[MMS.commit]?.resource()?.uri?.uriSuffix ?: return null val commit = Identified(commitSuffix) + // never fabricate created/name for malformed upstream data — invented + // values mask triplestore bugs (see issue #20) return Branch( atId = branchId, atType = Branch.AtType.Branch, - created = OffsetDateTime.parse( - outgoing[MMS.created]?.literal() - ?: OffsetDateTime.now().toString() - ), + created = OffsetDateTime.parse(outgoing[MMS.created]?.literal() ?: return null), owningProject = Identified(projectId), referencedCommit = commit, - name = outgoing [DCTerms.title]?.literal() ?: "", + name = outgoing[DCTerms.title]?.literal() ?: return null, head = commit ) } diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ProjectApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ProjectApi.kt index f1d0b93..88305c1 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ProjectApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/ProjectApi.kt @@ -33,16 +33,16 @@ fun projectFromResponse( outgoing: Map>, projectId: String = outgoing[MMS.id]?.literal() ?: error("project missing mms:id"), branchId: String = outgoing[SYSMLV2.DEFAULT_BRANCH_ID]?.literal() ?: error("project missing default branch id") -): Project { +): Project? { + // never fabricate created/name for malformed upstream data — invented + // values mask triplestore bugs (see issue #20) return Project( atId = projectId, atType = Project.AtType.Project, - created = OffsetDateTime.parse( - outgoing[MMS.created]?.literal() - ?: OffsetDateTime.now().toString()), + created = OffsetDateTime.parse(outgoing[MMS.created]?.literal() ?: return null), defaultBranch = Identified(branchId), description = outgoing[DCTerms.description]?.literal()?: "", - name = outgoing[DCTerms.title]?.literal()?: "" + name = outgoing[DCTerms.title]?.literal() ?: return null ) } @@ -155,7 +155,7 @@ fun Route.ProjectApi() { it.hasProperty(SYSMLV2.DELETED) }.mapWith { projectFromResponse(it.outgoing()) - }.toList() + }.toList().filterNotNull() }) } diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/TagApi.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/TagApi.kt index 9d809f6..dfa6072 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/TagApi.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/apis/TagApi.kt @@ -39,17 +39,16 @@ fun FlexoModelHandler.tagFromResponse( ): Tag? { val commitSuffix = outgoing[MMS.commit]?.resource()?.uri?.uriSuffix ?: return null val commit = Identified(commitSuffix) + // never fabricate created/name for malformed upstream data — invented + // values mask triplestore bugs (see issue #20) return Tag( atId = tagId, atType = Tag.AtType.Tag, - created = OffsetDateTime.parse( - outgoing[MMS.created]?.literal() - ?: OffsetDateTime.now().toString() - ), + created = OffsetDateTime.parse(outgoing[MMS.created]?.literal() ?: return null), owningProject = Identified(projectId), referencedCommit = commit, taggedCommit = commit, - name = outgoing[DCTerms.title]?.literal() ?: "", + name = outgoing[DCTerms.title]?.literal() ?: return null, ) } fun Route.TagApi() { diff --git a/src/test/kotlin/org/openmbee/flexo/sysmlv2/BranchListPairingTest.kt b/src/test/kotlin/org/openmbee/flexo/sysmlv2/BranchListPairingTest.kt new file mode 100644 index 0000000..2bdf772 --- /dev/null +++ b/src/test/kotlin/org/openmbee/flexo/sysmlv2/BranchListPairingTest.kt @@ -0,0 +1,55 @@ +package org.openmbee.flexo.sysmlv2 + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.matchers.maps.shouldContainAll +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.openmbee.flexo.sysmlv2.util.* + +/** + * Regression surface for issue #20: the branch list endpoint must keep + * each branch's name associated with its own @id (reported shuffled on + * GraphDB deployments — the wildcard-CONSTRUCT bug lives upstream in + * layer1, but this pairing must hold against any conformant store). + */ +class BranchListPairingTest : ProjectAny() { + val elementChange = """ + { + "change": [ + { + "@type": "DataVersion", + "identity": {"@id": "44444444-4444-4444-8444-444444444444"}, + "payload": {"@type": "PartDefinition", "name": "anything"} + } + ] + } + """.trimIndent() + + init { + "GET branches pairs each name with its own @id" { + testApplication { + val commitId = commitChanges(demoProjectId, elementChange).atId() + val idAlpha = createBranch(demoProjectId, commitId, "alpha").atId() + val idBravo = createBranch(demoProjectId, commitId, "bravo").atId() + val idCharlie = createBranch(demoProjectId, commitId, "charlie").atId() + + val response = getBranches(demoProjectId) + response shouldHaveStatus HttpStatusCode.OK + val byId = Json.parseToJsonElement(response.bodyAsText()).jsonArray + .associate { + it.jsonObject["@id"]!!.jsonPrimitive.content to + it.jsonObject["name"]!!.jsonPrimitive.content + } + byId shouldContainAll mapOf( + idAlpha to "alpha", + idBravo to "bravo", + idCharlie to "charlie", + ) + } + } + } +} From 633257ed688528e7b83b18dcc6c573f74306f4b6 Mon Sep 17 00:00:00 2001 From: Blake Regalia Date: Fri, 17 Jul 2026 02:01:56 -0700 Subject: [PATCH 09/11] docs: fix invalid cp command in README Co-Authored-By: Claude Fable 5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5335526..3726731 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ JDK 21 for development or running without docker ## Run ``` -cp src/main/resources/application.conf.example to src/main/resources/application.conf +cp src/main/resources/application.conf.example src/main/resources/application.conf ./gradlew run ``` From 84851ff8c6e6a342631f8ce16ff39ffe68495144 Mon Sep 17 00:00:00 2001 From: Blake Regalia Date: Fri, 17 Jul 2026 02:05:30 -0700 Subject: [PATCH 10/11] build: bump minor/patch dependency versions Ktor 3.4.3 -> 3.5.1, Jena 6.0.0 -> 6.1.0, Kotest 6.1.11 -> 6.2.2, logback 1.5.18 -> 1.5.38, kotlinx-coroutines-test 1.10.2 -> 1.11.0, java-jwt 4.5.0 -> 4.6.0, JaCoCo 0.8.12 -> 0.8.15. All API-compatible; full suite green. Major migrations (Gradle 9, Kotlin 2.4, Sonar plugin 7, JUnit 6) deliberately deferred to a dedicated change. Co-Authored-By: Claude Fable 5 --- build.gradle.kts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index d25f262..12b6083 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,7 +11,7 @@ repositories { } jacoco { - toolVersion = "0.8.12" + toolVersion = "0.8.15" } plugins { @@ -34,7 +34,7 @@ sonar { dependencies { implementation(kotlin("stdlib")) - val ktorVersion = "3.4.3" + val ktorVersion = "3.5.1" implementation("io.ktor:ktor-client-core:$ktorVersion") implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") implementation("io.ktor:ktor-client-cio:$ktorVersion") @@ -58,7 +58,7 @@ dependencies { implementation("io.ktor:ktor-server-netty:$ktorVersion") implementation("io.ktor:ktor-server-resources:$ktorVersion") implementation("io.ktor:ktor-server-status-pages:$ktorVersion") - val kotestVersion = "6.1.11" + val kotestVersion = "6.2.2" testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") testImplementation("io.kotest:kotest-assertions-core:$kotestVersion") testImplementation("io.kotest:kotest-assertions-json-jvm:$kotestVersion") @@ -67,17 +67,17 @@ dependencies { testImplementation("io.ktor:ktor-server-test-host:$ktorVersion") - implementation("ch.qos.logback:logback-classic:1.5.18") + implementation("ch.qos.logback:logback-classic:1.5.38") val junitVersion = "5.13.1" testImplementation("org.junit.jupiter:junit-jupiter-engine:$junitVersion") - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0") // sign JWTs locally in tests with the same shared secret as the // layer1-service container (see src/test/resources/test.env) - testImplementation("com.auth0:java-jwt:4.5.0") + testImplementation("com.auth0:java-jwt:4.6.0") - val jenaVersion = "6.0.0" + val jenaVersion = "6.1.0" implementation("org.apache.jena:jena-arq:${jenaVersion}") implementation("org.apache.jena:jena-querybuilder:${jenaVersion}") // CommonSpec.beforeEach uses RDFConnection to reload cluster.trig via GSP From 3e64639836fe6517743c941267cfe587ab7d1146 Mon Sep 17 00:00:00 2001 From: Blake Regalia Date: Fri, 17 Jul 2026 02:10:10 -0700 Subject: [PATCH 11/11] fix: make CORS origins configurable; no credentials with any-origin CORS was anyHost() with allowCredentials = true (flagged @TODO), letting any website make credentialed cross-origin requests that replay a logged-in user's Authorization header. Origins are now configurable via flexo.corsAllowedOrigins / FLEXO_CORS_ALLOWED_ORIGINS (space-separated); when unset the API still answers any origin, but credentialed cross-origin requests are disabled. Co-Authored-By: Claude Fable 5 --- .../org/openmbee/flexo/sysmlv2/AppMain.kt | 25 ++++++++++++++++--- src/main/resources/application.conf.example | 4 +++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/org/openmbee/flexo/sysmlv2/AppMain.kt b/src/main/kotlin/org/openmbee/flexo/sysmlv2/AppMain.kt index 92d885f..482f3c5 100644 --- a/src/main/kotlin/org/openmbee/flexo/sysmlv2/AppMain.kt +++ b/src/main/kotlin/org/openmbee/flexo/sysmlv2/AppMain.kt @@ -60,7 +60,10 @@ fun Application.module() { //register(ContentType.Application.Json, GsonConverter()) } install(CORS) { - allowCredentials = true + // credentials (the Authorization header) may only be shared with + // configured origins — anyHost()+allowCredentials would let any + // website replay a logged-in user's session + allowCredentials = GlobalFlexoConfig.corsAllowedOrigins.isNotEmpty() allowHeader(HttpHeaders.Authorization) allowHeader(HttpHeaders.ContentType) @@ -86,7 +89,16 @@ fun Application.module() { exposeHeader(HttpHeaders.Link) exposeHeader("Flexo-Mms-Layer-1") - anyHost() // @TODO: make configuration + if (GlobalFlexoConfig.corsAllowedOrigins.isEmpty()) { + anyHost() + } else { + GlobalFlexoConfig.corsAllowedOrigins.forEach { origin -> + val scheme = origin.substringBefore("://", "") + val host = origin.substringAfter("://") + if (scheme.isEmpty()) allowHost(host, listOf("http", "https")) + else allowHost(host, listOf(scheme)) + } + } } install(AutoHeadResponse) // see https://ktor.io/docs/autoheadresponse.html install(Compression, ApplicationCompressionConfiguration()) // see https://ktor.io/docs/compression.html @@ -139,7 +151,10 @@ data class FlexoConfig( val org: String, val defaultTimeout: Long, val auth: String, - val basePath: String + val basePath: String, + // origins allowed for CORS ("scheme://host[:port]", space-separated); + // empty means any host, which browsers only honor without credentials + val corsAllowedOrigins: List = emptyList() ) /** @@ -154,5 +169,7 @@ val Application.flexoConfig: FlexoConfig val defaultTimeout = property("flexo.defaultTimeout")?.getString()?.toLong() ?: 60L val auth = property("flexo.auth")?.getString() ?: "" val basePath = property("flexo.basePath")?.getString() ?: "" - return FlexoConfig(protocol, host, port, org, defaultTimeout, auth, basePath) + val corsAllowedOrigins = property("flexo.corsAllowedOrigins")?.getString() + ?.split(" ")?.filter { it.isNotBlank() } ?: emptyList() + return FlexoConfig(protocol, host, port, org, defaultTimeout, auth, basePath, corsAllowedOrigins) } diff --git a/src/main/resources/application.conf.example b/src/main/resources/application.conf.example index 740990d..3b059db 100644 --- a/src/main/resources/application.conf.example +++ b/src/main/resources/application.conf.example @@ -27,4 +27,8 @@ flexo { auth = ${?FLEXO_AUTH} basePath = "" basePath = ${?BASEPATH} + # space-separated origins allowed for CORS, e.g. "https://app.example.org http://localhost:3000"; + # empty allows any origin but disables credentialed (Authorization) cross-origin requests + corsAllowedOrigins = "" + corsAllowedOrigins = ${?FLEXO_CORS_ALLOWED_ORIGINS} }