Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
d7affec
Fix bad-result retries never matching
hamzahalq Aug 4, 2026
ae5bf12
Merge pull request #240 from simplify9/hamza/fix/badresult-retry-matc…
hamzahalq Aug 4, 2026
e04a65e
Enforce MaxAttemptsTotal across messages instead of per message
hamzahalq Aug 11, 2026
ca08886
Make budget claims atomic and keep failures visible
hamzahalq Aug 11, 2026
102c35e
Merge pull request #242 from simplify9/hamza/fix/retry-total-attempts…
hamzahalq Aug 11, 2026
fdf46fe
chore(dependabot): raise semver-patch cooldown to 5 days (#243)
MusaMisto Aug 11, 2026
77544aa
Add security headers and restrict CORS to configured origins
hamzahalq Aug 11, 2026
b5e7c92
Enforce a server-side password policy on account create and change-pa…
hamzahalq Aug 11, 2026
716e562
Apply security-audit remediations (accounts + responses)
hamzahalq Aug 13, 2026
f6cea04
Merge remote-tracking branch 'origin/releases/r8.0' into hamza/fix/se…
hamzahalq Aug 13, 2026
a14c058
Reject empty-credential logins and make failed-login lockout atomic
hamzahalq Aug 13, 2026
48a06f3
Apply Cache-Control no-store to structured +json responses
hamzahalq Aug 13, 2026
1f80f52
Merge pull request #244 from simplify9/hamza/fix/security-audit-r8
hamzahalq Aug 13, 2026
1fdac04
Alert when a retry group's shared budget runs out
hamzahalq Aug 18, 2026
0b9295d
List the failures behind a retry group's spent budget
hamzahalq Aug 18, 2026
651a69d
Fix budget alert lost after a failed send, and review findings
hamzahalq Aug 18, 2026
f78ea1d
Require TLS before SMTP auth, and clean up removed groups atomically
hamzahalq Aug 18, 2026
1e1d3a9
Keep alert handler secrets out of responses, and reject a password wi…
hamzahalq Aug 18, 2026
25fbc46
Merge pull request #252 from simplify9/hamza/feature/retry-budget-alerts
hamzahalq Aug 18, 2026
efed203
Report and reset retry budgets per subscription
hamzahalq Aug 19, 2026
d9f7c7c
Stop a failed scheduled retry from stalling the queue
hamzahalq Aug 19, 2026
6b28edc
Keep the retry budget honest about manual retries and recoveries
hamzahalq Aug 19, 2026
a173e19
Send alert email without a certificate revocation check
hamzahalq Aug 19, 2026
0c4ab3d
Release only budgets that have actually run out
hamzahalq Aug 19, 2026
dd4423f
Refuse a revoked certificate, tolerate one that cannot be checked
hamzahalq Aug 19, 2026
09aa340
Stop the retry job claiming it dropped a retry it may have created
hamzahalq Aug 19, 2026
7131283
Merge pull request #253 from simplify9/hamza/feature/retry-budget-fol…
hamzahalq Aug 19, 2026
2b28d08
Merge releases/r8.0 into v2
hamzahalq Aug 19, 2026
2b0cde0
feat: carry the security audit into the redesigned UI
hamzahalq Aug 20, 2026
f5582ad
test: give the retry tests the permissions the merge started requiring
hamzahalq Aug 20, 2026
372bd2c
feat: surface retry budgets, and whether the alert reached anyone
hamzahalq Aug 20, 2026
209b8ac
fix: size adapter fields by their container, not the viewport
hamzahalq Aug 20, 2026
67558bc
fix: refuse a bus gateway route as a response destination
hamzahalq Aug 23, 2026
a6ac083
fix: don't draw a response from an integration that delivers nothing
hamzahalq Aug 23, 2026
b40a221
fix: open a retry group from its row, and keep its table in the panel
hamzahalq Aug 23, 2026
884de1e
chore: name the combined integrations list "All integrations"
hamzahalq Aug 23, 2026
5e3751b
fix: keep gateway url names usable as a path segment
hamzahalq Aug 23, 2026
18eb793
fix: accept a bus message name no information type carries
hamzahalq Aug 23, 2026
38536e8
fix: stop the bus message picker latching into free text
hamzahalq Aug 23, 2026
ecd344b
fix: hold response message names to the rule information types alread…
hamzahalq Aug 23, 2026
8b311db
fix: state the bus message name rule the same way in both fields
hamzahalq Aug 23, 2026
c7fd011
fix: compare bus message names the way the bus does
hamzahalq Aug 23, 2026
3be227d
fix: send the gateway create pages back where they came from
hamzahalq Aug 23, 2026
0ff8c0e
fix: make the back arrow actually go back
hamzahalq Aug 23, 2026
65b51ba
fix: name an exchange by its id when nothing was promoted
hamzahalq Aug 23, 2026
51b2226
fix: keep promoted property values as the payload sent them
hamzahalq Aug 23, 2026
91975c8
feat: filter exchanges by one promoted property, not all of them
hamzahalq Aug 23, 2026
d78e8f2
chore: put the flow map after the gateways, notifiers after retry pol…
hamzahalq Aug 23, 2026
c6d4c16
feat: name a list by its count, with the names one click away
hamzahalq Aug 23, 2026
34933e7
feat: keep the name when there is one, count the noun when there are …
hamzahalq Aug 23, 2026
9eebcee
feat: turn a gateway off without deleting it
hamzahalq Aug 23, 2026
3fa06f3
fix: create an information type under the same rules update already e…
hamzahalq Aug 24, 2026
62590e3
fix: stop auto-matching integrations that already have their own trigger
hamzahalq Aug 24, 2026
1b1315f
fix: don't crash saving a notifier with no handler properties
hamzahalq Aug 24, 2026
eddd97f
feat: let notifiers be deleted
hamzahalq Aug 24, 2026
e430646
feat: retire feeding a response straight into another integration
hamzahalq Aug 24, 2026
ee487be
fix: name what's still using an integration, instead of a raw db error
hamzahalq Aug 24, 2026
d947314
feat: explain what a status badge means on hover
hamzahalq Aug 24, 2026
022644f
feat: page and filter every main table, and search gateway attachments
hamzahalq Aug 24, 2026
a0f5fe2
feat: define the integration a gateway attachment or route points at,…
hamzahalq Aug 24, 2026
31b8277
feat: give scheduled jobs their own run history, decoupled from Quartz
hamzahalq Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ updates:
timezone: "Asia/Amman"
cooldown:
default-days: 3
semver-patch-days: 1
semver-patch-days: 5
semver-minor-days: 3
semver-major-days: 7
open-pull-requests-limit: 10
Expand All @@ -36,7 +36,7 @@ updates:
timezone: "Asia/Amman"
cooldown:
default-days: 3
semver-patch-days: 1
semver-patch-days: 5
semver-minor-days: 3
semver-major-days: 7
open-pull-requests-limit: 5
Expand All @@ -54,7 +54,7 @@ updates:
timezone: "Asia/Amman"
cooldown:
default-days: 3
semver-patch-days: 1
semver-patch-days: 5
semver-minor-days: 3
semver-major-days: 7
open-pull-requests-limit: 5
Expand Down
12 changes: 12 additions & 0 deletions SW.Bitween.Api/Controllers/GatewayController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Linq;
using System.Net.Mime;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SW.Bitween.Domain;
Expand Down Expand Up @@ -56,6 +57,17 @@ private async Task<IActionResult> ProcessAsync([FromRoute] string gatewayApiName
if (apiGatewayPartner == null)
return Unauthorized();

// After authorisation on purpose: whether a gateway exists and is switched off is
// something only an attached partner should learn — checking it earlier would
// answer that for anyone who guessed the url.
//
// 503 rather than 404 because the url is right and the partner should keep it: a
// 404 reads as "wrong address" and sends someone hunting for a new one, where this
// is a gateway somebody switched off and will switch back on.
if (apiGateway.Inactive)
return StatusCode(StatusCodes.Status503ServiceUnavailable,
$"The '{apiGateway.Name}' gateway is currently deactivated.");

var subscription = await cache.SubscriptionByIdAsync(apiGatewayPartner.SubscriptionId);

if (subscription == null)
Expand Down
38 changes: 35 additions & 3 deletions SW.Bitween.Api/Data/BitweenDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
b.Property(p => p.Id).ValueGeneratedOnAdd();
b.Property(p => p.Name).IsRequired().HasMaxLength(200);
b.Property(p => p.Groups).StoreAsJson();
b.Property(p => p.AlertHandlerId).HasMaxLength(200).IsUnicode(false);
b.Property(p => p.AlertHandlerProperties).StoreAsJson();
});

modelBuilder.Entity<DelayedRetry>(b =>
Expand All @@ -237,10 +239,36 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
b.HasKey(p => p.Id);
b.Property(p => p.Id).IsUnicode(false).HasMaxLength(50);
b.Property(p => p.On);
b.Property(p => p.GroupAttemptCounts).StoreAsJson();
b.HasIndex(p => p.On);
});

modelBuilder.Entity<ReceiveAttempt>(b =>
{
b.ToTable("ReceiveAttempts");
b.Property(p => p.Id).ValueGeneratedOnAdd();
b.Property(p => p.ErrorMessage).HasMaxLength(4000);
b.Property(p => p.ExchangeIds).IsSeparatorDelimited();
b.HasIndex(p => new { p.SubscriptionId, p.StartedOn });
});

modelBuilder.Entity<RetryGroupUsage>(b =>
{
b.ToTable("RetryGroupUsages");
b.HasKey(p => new { p.SubscriptionId, p.GroupId });
b.Property(p => p.AttemptsUsed);
b.Property(p => p.LastAttemptOn);
b.Property(p => p.ExhaustedNotifiedOn);
});

modelBuilder.Entity<RetryAlertOverride>(b =>
{
b.ToTable("RetryAlertOverrides");
b.HasKey(p => new { p.SubscriptionId, p.GroupId });
b.Property(p => p.AlertMode).HasConversion<byte>();
b.Property(p => p.AlertHandlerId).HasMaxLength(200).IsUnicode(false);
b.Property(p => p.AlertHandlerProperties).StoreAsJson();
});

modelBuilder.Entity<Xchange>(b =>
{
b.ToTable("Xchanges");
Expand All @@ -253,7 +281,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
b.Property(p => p.HandlerId).HasMaxLength(200).IsUnicode(false);
b.Property(p => p.HandlerProperties).StoreAsJson();
b.Property(p => p.MapperProperties).StoreAsJson();
b.Property(p => p.GroupAttemptCounts).StoreAsJson();
b.Property(p => p.InputContentType).IsUnicode(false).HasMaxLength(200);
b.Property(p => p.ResponseMessageTypeName).IsUnicode(false).HasMaxLength(500);

Expand Down Expand Up @@ -284,6 +311,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
b.Property(p => p.ResponseName).HasMaxLength(200);
b.Property(p => p.ResponseContentType).IsUnicode(false).HasMaxLength(200);
b.Property(p => p.OutputContentType).IsUnicode(false).HasMaxLength(200);
b.Property(p => p.RetryBlockedReason).HasMaxLength(500);
b.Property(p => p.RetryGroupId);
b.Property(p => p.AttemptNumber);
b.HasIndex(p => p.RetryGroupId);


b.HasOne<Xchange>().WithOne().HasForeignKey<XchangeResult>(p => p.Id).OnDelete(DeleteBehavior.Cascade);
Expand Down Expand Up @@ -368,7 +399,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
Disabled = false,
Password = defaultPasswordHash,
Deleted = false,
Role = AccountRole.Admin
Role = AccountRole.Admin,
FailedLoginCount = 0
});
});

Expand Down
18 changes: 18 additions & 0 deletions SW.Bitween.Api/Domain/Accounts/Account.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,24 @@ public Account(string displayName, string email, string password, AccountRole ro

public string Password { get; set; }

public int FailedLoginCount { get; private set; }
public DateTime? LockoutEnd { get; private set; }

public bool IsLockedOut(DateTime nowUtc) => LockoutEnd.HasValue && LockoutEnd.Value > nowUtc;

public void RegisterSuccessfulLogin()
{
FailedLoginCount = 0;
LockoutEnd = null;
}

// Admin action: clear a lockout before it expires.
public void Unlock()
{
FailedLoginCount = 0;
LockoutEnd = null;
}


public bool AddEmailLoginMethod(string email, string password)
{
Expand Down
2 changes: 0 additions & 2 deletions SW.Bitween.Api/Domain/DelayedRetry.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
using System;
using System.Collections.Generic;
using SW.PrimitiveTypes;

namespace SW.Bitween.Domain;
// Id should be the same for xchangeId when retry happens the record is deleted
public class DelayedRetry : BaseEntity<string>
{
public DateTime On { get; set; }
public Dictionary<string, int> GroupAttemptCounts { get; set; } = new();
}
7 changes: 7 additions & 0 deletions SW.Bitween.Api/Domain/Gateway/ApiGateway.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ public class ApiGateway : BaseEntity,IAudited
{
public string Name { get; set; }
public string UrlName { get; set; }

/// <summary>
/// Turns the gateway off without deleting it. Deleting is the only alternative today,
/// and it takes the partner attachments with it — so a gateway that needs stopping for
/// an afternoon gets rebuilt by hand afterwards, or left running.
/// </summary>
public bool Inactive { get; set; }
public ICollection<ApiGatewayPartner> Partners { get; set; }
public DateTime CreatedOn { get; set; }
public string CreatedBy { get; set; }
Expand Down
6 changes: 6 additions & 0 deletions SW.Bitween.Api/Domain/Gateway/BusGateway.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ public class BusGateway : BaseEntity, IAudited
{
public string Name { get; set; }
public int DocumentId { get; set; }

/// <summary>
/// Turns the gateway off without deleting it — its routes stop being offered the
/// message. See <see cref="ApiGateway.Inactive"/>.
/// </summary>
public bool Inactive { get; set; }
public ICollection<BusGatewayRoute> Routes { get; set; }
public DateTime CreatedOn { get; set; }
public string CreatedBy { get; set; }
Expand Down
20 changes: 20 additions & 0 deletions SW.Bitween.Api/Domain/ReceiveAttempt.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System;
using SW.Bitween.Model;
using SW.PrimitiveTypes;

namespace SW.Bitween.Domain;

/// <summary>
/// One execution of a Receiving subscription's receive step, written by <c>ReceivingJob</c>
/// itself right where it already catches the receiver's own failures — kept independent of
/// Quartz's own run history and unaffected by how Quartz treats a thrown exception.
/// </summary>
public class ReceiveAttempt : BaseEntity
{
public int SubscriptionId { get; set; }
public DateTime StartedOn { get; set; }
public DateTime FinishedOn { get; set; }
public ReceiveOutcome Outcome { get; set; }
public string ErrorMessage { get; set; }
public string[] ExchangeIds { get; set; } = Array.Empty<string>();
}
35 changes: 35 additions & 0 deletions SW.Bitween.Api/Domain/RetryAlertOverride.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
using SW.Bitween.Model;

namespace SW.Bitween.Domain;

/// <summary>
/// The most specific level of the retry-alert hierarchy: where one subscription's failures in one
/// retry group should be alerted, overriding whatever the group or the policy says.
/// </summary>
/// <remarks>
/// Deliberately its own table rather than columns on <see cref="RetryGroupUsage"/>. Usage rows are
/// deleted by <c>RetryPolicies/resetusage</c>, so config stored there would be silently discarded
/// every time someone cleared a spent budget.
/// </remarks>
public class RetryAlertOverride
{
/// <summary>The subscription this override applies to.</summary>
public int SubscriptionId { get; set; }

/// <summary><c>RetryGroup.Id</c>, which survives policy edits, so the override does too.</summary>
public Guid GroupId { get; set; }

/// <summary>
/// Whether this level sends, stays silent, or defers upward. A row whose mode is
/// <see cref="RetryAlertMode.Inherit"/> is equivalent to having no row at all.
/// </summary>
public RetryAlertMode AlertMode { get; set; }

/// <summary>Adapter that delivers the alert. Required when <see cref="AlertMode"/> is Send.</summary>
public string AlertHandlerId { get; set; }

/// <summary>That adapter's own settings — api key, recipients, subject.</summary>
public IReadOnlyDictionary<string, string> AlertHandlerProperties { get; set; }
}
42 changes: 42 additions & 0 deletions SW.Bitween.Api/Domain/RetryBudgetExhaustedEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System;
using SW.PrimitiveTypes;

namespace SW.Bitween.Domain;

/// <summary>
/// Raised the one time a retry group's <c>MaxAttemptsTotal</c> runs out for a subscription, so the
/// configured alert handler can be told that failures matching that group have stopped being retried.
/// </summary>
/// <remarks>
/// <para>
/// Deliberately not an <c>IHasWorkGroup</c> event: it publishes under its own type name and is picked
/// up by a dedicated <c>IConsume&lt;RetryBudgetExhaustedEvent&gt;</c> consumer with its own queue. A
/// slow or broken alert handler therefore cannot delay or fail the ordinary notifier path, which
/// shares the work group's result queue.
/// </para>
/// <para>
/// Carried on <see cref="XchangeResult"/> rather than published directly, so it only reaches the bus
/// once the failure it describes has actually been committed.
/// </para>
/// </remarks>
public class RetryBudgetExhaustedEvent : BaseDomainEvent
{
/// <summary>The failure that found the budget empty.</summary>
public string XchangeId { get; set; }

public int SubscriptionId { get; set; }

public Guid GroupId { get; set; }

/// <summary>The group's name as it was when the budget ran out, in case it is later renamed.</summary>
public string GroupName { get; set; }

/// <summary>
/// The ceiling that was reached. Not paired with an "used" count, because at exhaustion the two
/// are the same number — except when the ceiling was lowered below what had already been spent,
/// where the ceiling is still the meaningful figure.
/// </summary>
public int MaxAttemptsTotal { get; set; }

public DateTime OccurredOn { get; set; }
}
41 changes: 41 additions & 0 deletions SW.Bitween.Api/Domain/RetryGroupUsage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System;

namespace SW.Bitween.Domain;

/// <summary>
/// Running total of the retries one retry group has spent for one integration, backing
/// <c>RetryBudget.MaxAttemptsTotal</c>. That cap is shared by every message hitting the
/// group, so it cannot be tracked on an individual xchange.
/// </summary>
/// <remarks>
/// Once <see cref="AttemptsUsed"/> reaches the group's <c>MaxAttemptsTotal</c> the group stops
/// retrying for that integration until this row is cleared. A row that has reached the cap is cleared
/// by the integration's next success — the only signal that the downstream it was failing against has
/// recovered — or by one of the reset endpoints. A row still below the cap is left alone by a success:
/// the cap is there for a downstream that fails some messages and succeeds others, which is exactly
/// when crediting it back would stop it ever being reached.
/// </remarks>
public class RetryGroupUsage
{
/// <summary>The integration whose budget this is. A shared policy gives each one its own total.</summary>
public int SubscriptionId { get; set; }

/// <summary><c>RetryGroup.Id</c>, which survives policy edits, so the total does too.</summary>
public Guid GroupId { get; set; }

public int AttemptsUsed { get; set; }

/// <summary>When the last attempt was claimed — the only clue left once a group is exhausted.</summary>
public DateTime LastAttemptOn { get; set; }

/// <summary>
/// When the exhaustion alert for this integration and group was claimed, or <c>null</c> while
/// the budget still has room.
/// </summary>
/// <remarks>
/// Claiming this is what makes the alert fire exactly once: every failure after the budget runs
/// out would otherwise raise another one. Reset deletes the whole row, which re-arms the alert
/// along with the budget.
/// </remarks>
public DateTime? ExhaustedNotifiedOn { get; set; }
}
9 changes: 9 additions & 0 deletions SW.Bitween.Api/Domain/RetryPolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ public class RetryPolicy : BaseEntity, IAudited, IRetryPolicy
{
public string Name { get; set; }
public List<RetryGroup> Groups { get; set; } = [];

/// <summary>
/// Default destination for "retry budget exhausted" alerts, used by every group that does not
/// override it. Null means no alert unless a group or a subscription+group override defines one.
/// </summary>
public string AlertHandlerId { get; set; }

/// <summary>That adapter's own settings — api key, recipients, subject.</summary>
public IReadOnlyDictionary<string, string> AlertHandlerProperties { get; set; }
public DateTime CreatedOn { get; set; }
public string CreatedBy { get; set; }
public DateTime? ModifiedOn { get; set; }
Expand Down
Loading