From 3abdb82d0bf10aed57633321bf52669369327caa Mon Sep 17 00:00:00 2001 From: Musa Misto <64855513+MusaMisto@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:02:40 +0300 Subject: [PATCH 1/6] Chart: route / by default, alongside /api and /swagger The Ingress exposed only /api and /swagger, so the admin SPA at / stayed on whatever else served the host unless every deployment remembered to add it. A deployment that tried (`ingress.path=/api, /`) found that `ingress.path` is ignored whenever `ingress.paths` is set, which it always is by default. ingress.paths now defaults to [/api, /swagger, /], and ingress.path, the fallback for an empty list, is / too. Gateway mode already routes / and renders unchanged. --- charts/default/README.md | 7 ++++--- charts/default/values.yaml | 7 +++++-- docs/deployment.md | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/charts/default/README.md b/charts/default/README.md index f629a8c4..f01da25a 100644 --- a/charts/default/README.md +++ b/charts/default/README.md @@ -33,7 +33,7 @@ and automatically suppresses the `Ingress` — you do **not** need to also set ```sh helm install bitween ./charts/default \ --set ingress.hosts[0]=api.example.com -# paths default to /api and /swagger (ingress.paths) +# paths default to /api, /swagger and / (ingress.paths) ``` ### Gateway API @@ -64,7 +64,8 @@ concerns. | `ingress.enabled` | `true` | Render an `Ingress` (only when `gateway.enabled` is `false`). | | `ingress.annotations` | `{}` | Annotations applied to the `Ingress`. | | `ingress.hosts` | _(unset)_ | List of hostnames; one rule per host. | -| `ingress.paths` | `[/api, /swagger]` | Paths exposed per host (falls back to `ingress.path`). | +| `ingress.paths` | `[/api, /swagger, /]` | Paths exposed per host. `/` serves the admin UI. | +| `ingress.path` | `/` | Single path used only when `ingress.paths` is empty. | | `ingress.tls` | `[]` | List of `{ secretName, hosts }` TLS entries. | ### Gateway API (`HTTPRoute`) @@ -74,7 +75,7 @@ concerns. | `gateway.enabled` | `false` | Render an `HTTPRoute` instead of the `Ingress`. | | `gateway.parentRefs` | see `values.yaml` | `Gateway`/listener references the route attaches to (`name`, `namespace`, `sectionName`). | | `gateway.hostnames` | see `values.yaml` | Hostnames the route matches. | -| `gateway.routes` | `[{ path: "/", pathType: PathPrefix }]` | Path matches and backend refs. Each entry supports `path`, `pathType`, optional `timeout.{request,backendRequest}`, and `backendRef.{name,port,namespace,weight}`. `backendRef.name` defaults to the release fullname; `backendRef.port` defaults to `service.port`. To mirror the ingress, set routes for `/api` and `/swagger`. | +| `gateway.routes` | `[{ path: "/", pathType: PathPrefix }]` | Path matches and backend refs. Each entry supports `path`, `pathType`, optional `timeout.{request,backendRequest}`, and `backendRef.{name,port,namespace,weight}`. `backendRef.name` defaults to the release fullname; `backendRef.port` defaults to `service.port`. The default `/` already matches the ingress paths. | > The shipped `gateway` defaults reference Simplify9 cluster infrastructure > (`public-gateway` / `s9-dev-edge` / `*.sf9.io`) and a single `/` route. diff --git a/charts/default/values.yaml b/charts/default/values.yaml index 46861aa0..afd2b199 100644 --- a/charts/default/values.yaml +++ b/charts/default/values.yaml @@ -31,10 +31,13 @@ ingress: annotations: {} # nginx.ingress.kubernetes.io/rewrite-target: /$2 # kubernetes.io/tls-acme: "true" - path: /api + # /api is the API, /swagger its UI, and / the admin SPA served from this image's wwwroot. + # `path` is only the fallback for an empty `paths`, so it is the root as well. + path: / paths: - /api - /swagger + - / hosts: tls: [] # - secretName: chart-example-tls @@ -45,7 +48,7 @@ ingress: # Gateway-API-related unless gateway.enabled is set to true at deploy time. # When enabled, the Ingress above is suppressed and routing works exactly like # the Gateway API chart. Override parentRefs/hostnames/routes for your cluster -# (the routes below default to "/"; the ingress equivalents are /api and /swagger). +# (the routes below default to "/", which covers /api, /swagger and the admin UI). gateway: enabled: false parentRefs: diff --git a/docs/deployment.md b/docs/deployment.md index ad1e63ca..7ea047b7 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -45,7 +45,7 @@ Things to know about the chart: - The image tag is the chart version, so a chart must be packaged with the version of an image that exists. - The Service forwards port 80 to container port 8080. -- The Ingress exposes only `/api` and `/swagger` by default. Add `/` to `ingress.paths`, or route `/` through the Gateway API, to serve the admin UI and `/blank.html` from the same host. +- The Ingress exposes `/api`, `/swagger` and `/` by default, so the admin UI and `/blank.html` are served from the same host as the API. A deployment that overrides `ingress.paths` replaces the whole list, so it has to include `/` itself. - Probes are off by default. The template declares container port 80 while the app listens on 8080, so check the probe port before turning probes on. - The `rabbitmq` values are not used by any template. - `charts/default/README.md` explains the two routing modes. From bb504f72b4be9f71bc3abb6d5e14acee0a4b60d2 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 11 Oct 2026 14:32:11 +0300 Subject: [PATCH 2/6] Upgrade SW-Bus to 10.0.2 --- SW.Bitween.Api/SW.Bitween.Api.csproj | 4 ++-- .../SW.Bitween.IntegrationTests.csproj | 2 +- SW.Bitween.UnitTests/SW.Bitween.UnitTests.csproj | 2 +- SW.Bitween.Web/SW.Bitween.Web.csproj | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/SW.Bitween.Api/SW.Bitween.Api.csproj b/SW.Bitween.Api/SW.Bitween.Api.csproj index 8fb674a6..434c5478 100644 --- a/SW.Bitween.Api/SW.Bitween.Api.csproj +++ b/SW.Bitween.Api/SW.Bitween.Api.csproj @@ -37,8 +37,8 @@ them to ask the broker what exists; without this it would have to rebuild each of them from configuration and get them subtly wrong. Already in the app through SW.Bitween.Web — keep the version in step with it. --> - - + + diff --git a/SW.Bitween.IntegrationTests/SW.Bitween.IntegrationTests.csproj b/SW.Bitween.IntegrationTests/SW.Bitween.IntegrationTests.csproj index 4e8a148d..608d4464 100644 --- a/SW.Bitween.IntegrationTests/SW.Bitween.IntegrationTests.csproj +++ b/SW.Bitween.IntegrationTests/SW.Bitween.IntegrationTests.csproj @@ -46,7 +46,7 @@ - + diff --git a/SW.Bitween.UnitTests/SW.Bitween.UnitTests.csproj b/SW.Bitween.UnitTests/SW.Bitween.UnitTests.csproj index 0e57051a..a26f0ae9 100644 --- a/SW.Bitween.UnitTests/SW.Bitween.UnitTests.csproj +++ b/SW.Bitween.UnitTests/SW.Bitween.UnitTests.csproj @@ -19,7 +19,7 @@ - + diff --git a/SW.Bitween.Web/SW.Bitween.Web.csproj b/SW.Bitween.Web/SW.Bitween.Web.csproj index 37644e8b..991b165d 100644 --- a/SW.Bitween.Web/SW.Bitween.Web.csproj +++ b/SW.Bitween.Web/SW.Bitween.Web.csproj @@ -41,7 +41,7 @@ - + From deba30d1084691e3558810423397072eac92fb60 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 11 Oct 2026 14:48:05 +0300 Subject: [PATCH 3/6] Upgrade SW-Bus to 10.0.3 --- SW.Bitween.Api/SW.Bitween.Api.csproj | 4 ++-- .../SW.Bitween.IntegrationTests.csproj | 2 +- SW.Bitween.UnitTests/SW.Bitween.UnitTests.csproj | 2 +- SW.Bitween.Web/SW.Bitween.Web.csproj | 2 +- docs/configuration.md | 14 ++++++++++++++ 5 files changed, 19 insertions(+), 5 deletions(-) diff --git a/SW.Bitween.Api/SW.Bitween.Api.csproj b/SW.Bitween.Api/SW.Bitween.Api.csproj index 434c5478..cdb3243e 100644 --- a/SW.Bitween.Api/SW.Bitween.Api.csproj +++ b/SW.Bitween.Api/SW.Bitween.Api.csproj @@ -37,8 +37,8 @@ them to ask the broker what exists; without this it would have to rebuild each of them from configuration and get them subtly wrong. Already in the app through SW.Bitween.Web — keep the version in step with it. --> - - + + diff --git a/SW.Bitween.IntegrationTests/SW.Bitween.IntegrationTests.csproj b/SW.Bitween.IntegrationTests/SW.Bitween.IntegrationTests.csproj index 608d4464..2ee9f77c 100644 --- a/SW.Bitween.IntegrationTests/SW.Bitween.IntegrationTests.csproj +++ b/SW.Bitween.IntegrationTests/SW.Bitween.IntegrationTests.csproj @@ -46,7 +46,7 @@ - + diff --git a/SW.Bitween.UnitTests/SW.Bitween.UnitTests.csproj b/SW.Bitween.UnitTests/SW.Bitween.UnitTests.csproj index a26f0ae9..a3e0d7e7 100644 --- a/SW.Bitween.UnitTests/SW.Bitween.UnitTests.csproj +++ b/SW.Bitween.UnitTests/SW.Bitween.UnitTests.csproj @@ -19,7 +19,7 @@ - + diff --git a/SW.Bitween.Web/SW.Bitween.Web.csproj b/SW.Bitween.Web/SW.Bitween.Web.csproj index 991b165d..0a7e6b10 100644 --- a/SW.Bitween.Web/SW.Bitween.Web.csproj +++ b/SW.Bitween.Web/SW.Bitween.Web.csproj @@ -41,7 +41,7 @@ - + diff --git a/docs/configuration.md b/docs/configuration.md index 918038a5..129c2850 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -30,6 +30,20 @@ Any long random string will do — `openssl rand -base64 48`. Changing the key invalidates every token already issued, so everyone signs in again. That is the intended response to a key you no longer trust. +### Bus messages from other applications + +A message another application publishes to Bitween's bus can carry its user's token. Bitween checks +it with its own `Token` values, so a publisher signing with a different key fails every message it +sends: it goes straight to the queue's `.bad` queue and no exchange is created. Give both +applications the same `Token` values. Until they match, `Bus:OnInvalidUserToken` +(env: `Bus__OnInvalidUserToken`) can change what happens: + +| Value | Effect | +|---|---| +| `Fail` *(default)* | The message goes to the `.bad` queue without retrying. | +| `ContinueAnonymous` | The message is processed without a user, and a warning is logged. | +| `ContinueAnonymousSilently` | The same, without the warning. | + ## `Bitween` section | Key | Default | Description | From 36e261d2117ca83d6158b82ad7eedf6ecc4fe01a Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Mon, 12 Oct 2026 11:49:43 +0300 Subject: [PATCH 4/6] Exchanges: property filter matches any part of the picked property's value --- .../Resources/Xchanges/XchangeFilters.cs | 31 ++++++++ .../Tests/PromotedPropertySearchTests.cs | 73 +++++++++++++++++++ .../ClientApp/src/api/http/exchanges.ts | 12 ++- .../src/pages/exchanges/ExchangesPage.tsx | 45 +++++++----- docs/operations.md | 2 +- 5 files changed, 135 insertions(+), 28 deletions(-) create mode 100644 SW.Bitween.IntegrationTests/Tests/PromotedPropertySearchTests.cs diff --git a/SW.Bitween.Api/Resources/Xchanges/XchangeFilters.cs b/SW.Bitween.Api/Resources/Xchanges/XchangeFilters.cs index caaba804..862a84d0 100644 --- a/SW.Bitween.Api/Resources/Xchanges/XchangeFilters.cs +++ b/SW.Bitween.Api/Resources/Xchanges/XchangeFilters.cs @@ -168,6 +168,24 @@ internal static IQueryable ApplySpecialFilters(this IQueryable f.Field == "PromotedPropertyKey").ToList(); + var key = keyFilters.Select(f => f.Value?.ToString()).FirstOrDefault(v => !string.IsNullOrEmpty(v))?.ToLower(); + foreach (var keyFilter in keyFilters) + condition.Filters.Remove(keyFilter); + + // Stored as "key:value,key:value": the key comes first or after a comma, which keeps "id" + // from matching "orderId:". Asked of lower(properties_raw) as it is, not with a comma put in + // front: Postgres keeps statistics for that expression (its trigram index), and without them + // it guessed almost nothing matched, so a common key fetched and sorted every match rather + // than reading the newest 25 - 6s against 1M exchanges. + var keyTag = "," + key + ":"; + if (key != null) + query = query.Where(i => i.PromotedPropertiesRaw.ToLower().StartsWith(key + ":") + || i.PromotedPropertiesRaw.ToLower().Contains(keyTag)); + var propertiesFilters = condition.Filters .Where(f => f.Field == "PromotedPropertiesRaw").ToList(); foreach (var propertyFilter in propertiesFilters) @@ -179,6 +197,19 @@ internal static IQueryable ApplySpecialFilters(this IQueryable i.PromotedPropertiesRaw.ToLower().Contains(value)); + + // With a key, only inside that key's value: what follows ",key:" up to the next comma. + // A value that itself holds a comma is only searched up to it, since the stored text + // cannot tell that comma from the one before the next key. IndexOf rather than Contains + // for the same estimate reason as the key above: Postgres guesses a third of rows pass + // an unknown comparison but under 1% pass an unknown LIKE. + if (key != null) + query = from i in query + let rest = ("," + i.PromotedPropertiesRaw.ToLower()) + .Substring(("," + i.PromotedPropertiesRaw.ToLower()).IndexOf(keyTag) + keyTag.Length) + "," + where rest.Substring(0, rest.IndexOf(",")).IndexOf(value) >= 0 + select i; + condition.Filters.Remove(propertyFilter); } diff --git a/SW.Bitween.IntegrationTests/Tests/PromotedPropertySearchTests.cs b/SW.Bitween.IntegrationTests/Tests/PromotedPropertySearchTests.cs new file mode 100644 index 00000000..969f3156 --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/PromotedPropertySearchTests.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.Domain; +using SW.Bitween.IntegrationTests.Fixtures; +using SW.Bitween.Model; +using SW.PrimitiveTypes; +using Xunit; + +namespace SW.Bitween.IntegrationTests.Tests; + +/// +/// The Exchanges page's promoted property filter: a key picked from the list and text that has to +/// be found anywhere inside that key's value. +/// +[Collection("Bitween")] +public class PromotedPropertySearchTests(BitweenFixture fixture) +{ + [Fact] + public async Task A_picked_key_finds_part_of_its_own_value_and_nothing_in_other_keys() + { + await using var scope = fixture.CreateScope(); + scope.Superuser(); + var db = scope.ServiceProvider.GetRequiredService(); + + var document = new Document(null, $"Prop search {Guid.NewGuid():N}"[..24], DocumentFormat.Json); + document.SetDictionaries(new Dictionary + { ["orderNo"] = "$.o", ["customer"] = "$.c", ["refOrderNo"] = "$.r" }); + db.Add(document); + await db.SaveChangesAsync(); + + var service = scope.ServiceProvider.GetRequiredService(); + async Task Exchange(params (string Key, string Value)[] properties) + { + var xchange = await service.CreateXchange(document, null, new XchangeFile("{}")); + var filtered = new FilterResult(); + foreach (var (key, value) in properties) + filtered.Properties[key] = value; + db.Add(new XchangePromotedProperties(xchange.Id, filtered)); + await db.SaveChangesAsync(); + return xchange.Id; + } + + var a = await Exchange(("orderNo", "SO-1001-X"), ("customer", "Acme")); + // "1001" sits after orderNo here, in the next key's value. + var b = await Exchange(("orderNo", "PO-7"), ("customer", "Zed-1001")); + // A key whose name ends in the picked one. + var c = await Exchange(("refOrderNo", "R-1001")); + + await using var readScope = fixture.CreateScope(); + readScope.Superuser(); + async Task Found(params string[] filters) + { + var page = (SearchyResponse)await ActivatorUtilities + .CreateInstance(readScope.ServiceProvider) + .Handle(new SearchyRequest(filters.Append($"DocumentId:1:{document.Id}").ToArray()), false, null); + return page.Result.Select(r => r.Id).Order().ToArray(); + } + + string[] Ids(params string[] ids) => ids.Order().ToArray(); + + // The middle of the value, which a "orderNo:1001" term never found. + Assert.Equal(Ids(a), await Found("PromotedPropertyKey:1:orderNo", "PromotedPropertiesRaw:4:1001")); + Assert.Equal(Ids(a), await Found("PromotedPropertyKey:1:ORDERNO", "PromotedPropertiesRaw:4:01-x")); + Assert.Equal(Ids(b), await Found("PromotedPropertyKey:1:orderNo", "PromotedPropertiesRaw:4:7")); + // A key on its own: "carries this property at all". + Assert.Equal(Ids(a, b), await Found("PromotedPropertyKey:1:orderNo")); + // No key: anywhere, as before. + Assert.Equal(Ids(a, b, c), await Found("PromotedPropertiesRaw:4:1001")); + } +} diff --git a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts index 339b49ac..9bd99711 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts @@ -192,13 +192,11 @@ function buildExchangeFilters(query: ExchangeQuery): URLSearchParams { params.append("filter", `ReceiveAttemptId:1:${query.receiveAttemptId}`); if (query.correlationId?.trim()) params.append("filter", `CorrelationId:1:${query.correlationId.trim()}`); if (query.latest) params.append("filter", "LatestOnly:1:true"); - // PromotedPropertiesRaw is stored as "key:value,key:value", so prefixing the key turns - // the same substring match into a scoped one — no schema or endpoint change needed. - // Typing "merchant:Acme" into the value box has therefore always worked; the picker - // just makes it something you can find. - const propertyValue = query.property?.trim() ?? ""; - const propertyTerm = query.propertyKey ? `${query.propertyKey}:${propertyValue}` : propertyValue; - if (propertyTerm) params.append("filter", `PromotedPropertiesRaw:4:${propertyTerm}`); + // The key goes apart from the value so the server can look anywhere inside that key's value. + // Sent as one "key:value" term it only matched values starting with what was typed. + if (query.propertyKey) params.append("filter", `PromotedPropertyKey:1:${query.propertyKey}`); + const propertyValue = query.property?.trim(); + if (propertyValue) params.append("filter", `PromotedPropertiesRaw:4:${propertyValue}`); if (query.from) params.append("filter", `StartedOn:6:${query.from}`); if (query.to) params.append("filter", `StartedOn:8:${query.to}`); return params; diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx index dd670b7c..bf7c9ebb 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx @@ -343,7 +343,7 @@ export function ExchangesPage() { {/* — filters — */} -
+
setParam("informationTypeId", v || null)} options={infoTypes.map((t) => ({ value: String(t.id), label: t.name, code: t.code }))} /> + {/* Key and value share one cell spanning two columns, so the grid never wraps them onto + separate rows. Placed after the information type, which narrows the key list. */} +
+
+ setParam("propertyKey", v || null)} + /> +
+ e.target.value !== (query.property ?? "") && setParam("property", e.target.value || null)} + onKeyDown={(e) => e.key === "Enter" && setParam("property", e.currentTarget.value || null)} + /> +
e.key === "Enter" && setParam("correlationId", e.currentTarget.value || null)} /> -
- setParam("propertyKey", v || null)} - /> -
- e.target.value !== (query.property ?? "") && setParam("property", e.target.value || null)} - onKeyDown={(e) => e.key === "Enter" && setParam("property", e.currentTarget.value || null)} - />