Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -365,3 +365,15 @@ SW.Bitween.NativeAdapters/JsonFieldMapper/Bitween-api.code-workspace
.vscode/
.postman/
postman/

# Admin UI build output (generated by ClientApp `yarn build` into wwwroot)
SW.Bitween.Web/wwwroot/index.html
SW.Bitween.Web/wwwroot/assets/
SW.Bitween.Web/wwwroot/brand/
SW.Bitween.Web/wwwroot/favicon.svg
SW.Bitween.Web/wwwroot/icons.svg

# Playwright MCP tool output and test-run artifacts (created at repo root when
# run from here, separate from ClientApp's own .gitignore'd copies)
/.playwright-mcp/
/test-results/
14 changes: 12 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ WORKDIR /app
EXPOSE 8080
EXPOSE 443

# Build the admin UI (SPA) in its own stage; output lands in SW.Bitween.Web/wwwroot
FROM node:22-alpine AS ui-build
WORKDIR /src/SW.Bitween.Web/ClientApp
COPY ["SW.Bitween.Web/ClientApp/package.json", "SW.Bitween.Web/ClientApp/yarn.lock", "./"]
RUN yarn install --frozen-lockfile
COPY ["SW.Bitween.Web/ClientApp/", "./"]
RUN yarn build

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["SW.Bitween.Web/SW.Bitween.Web.csproj", "SW.Bitween.Web/"]
Expand All @@ -21,9 +29,11 @@ WORKDIR "/src/SW.Bitween.Web"
RUN dotnet build "SW.Bitween.Web.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "SW.Bitween.Web.csproj" -c Release -o /app/publish
# UI is built in the ui-build stage; the SDK image has no node
RUN dotnet publish "SW.Bitween.Web.csproj" -c Release -o /app/publish -p:SkipClientBuild=true

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "SW.Bitween.Web.dll"]
COPY --from=ui-build /src/SW.Bitween.Web/wwwroot ./wwwroot
ENTRYPOINT ["dotnet", "SW.Bitween.Web.dll"]
2 changes: 1 addition & 1 deletion SW.Bitween.Api/Controllers/GatewayController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ private async Task<IActionResult> ProcessAsync([FromRoute] string gatewayApiName

var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();

var xchangeFile = new XchangeFile(json);
var xchangeFile = new XchangeFile(json, $"{gatewayApiName}.json");

var validatorProperties = subscription.ValidatorProperties.ToDictionary()
.Fill(partner, globalAdapterValuesSet);
Expand Down
54 changes: 51 additions & 3 deletions SW.Bitween.Api/Data/BitweenDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
modelBuilder.Entity<Document>(b =>
{
b.ToTable("Documents");
b.Property(p => p.Id).ValueGeneratedNever();
b.Property(p => p.Name).HasMaxLength(100).IsUnicode(false).IsRequired();
b.Property(p => p.Code).HasMaxLength(50).IsUnicode(false);
b.Property(p => p.BusMessageTypeName).IsUnicode(false).HasMaxLength(500);
b.Property(p => p.PromotedProperties).StoreAsJson();
b.Property(p => p.DisregardsUnfilteredMessages).IsRequired(false);
b.HasIndex(p => p.Name).IsUnique();
b.HasIndex(p => p.Code).IsUnique();
b.HasIndex(p => p.BusMessageTypeName).IsUnique();

b.HasMany<Subscription>().WithOne().HasForeignKey(p => p.DocumentId).OnDelete(DeleteBehavior.Restrict);
Expand Down Expand Up @@ -349,7 +350,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
b.HasIndex(p => p.Email).IsUnique();

b.Property(p => p.Email).IsUnicode(false).HasMaxLength(200);
b.Property(p => p.Phone).IsUnicode(false).HasMaxLength(20);
b.Property(p => p.Password).IsUnicode(false).HasMaxLength(500);
b.Property(p => p.DisplayName).IsRequired().HasMaxLength(200);

Expand Down Expand Up @@ -382,9 +382,57 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
b.Property(p => p.AccountId);
b.Property(p => p.LoginMethod).HasConversion<byte>();
});


modelBuilder.Entity<Role>(b =>
{
b.ToTable("Roles");
b.HasKey(p => p.Id);
b.Property(p => p.Id).ValueGeneratedOnAdd();
b.HasIndex(p => p.Name).IsUnique();

b.Property(p => p.Name).IsRequired().HasMaxLength(100);
b.Property(p => p.Description).HasMaxLength(500);
b.Property(p => p.Permissions).StoreAsJson();

b.HasData(SystemRoleSeed());
});

modelBuilder.Entity<AccountRoleLink>(b =>
{
b.ToTable("AccountRoles");
b.HasKey(p => new { p.AccountId, p.RoleId });
b.HasOne<Account>().WithMany().HasForeignKey(p => p.AccountId).OnDelete(DeleteBehavior.Cascade);
b.HasOne<Role>().WithMany().HasForeignKey(p => p.RoleId).OnDelete(DeleteBehavior.Cascade);
});

modelBuilder.Entity<Setting>(b =>
{
b.ToTable("Settings");
b.HasKey(p => p.Id);
// Id is the catalog key, e.g. "Theme.PrimaryColor". Value is left unbounded:
// it carries anything from a hex color to a license key or a page of blurb.
b.Property(p => p.Id).IsUnicode(false).HasMaxLength(200);
});

}

/// <summary>
/// The built-in roles. Their grants aren't stored — <see cref="Role.GetEffectivePermissions"/>
/// derives them from the catalog — so adding a permission never needs a data fix-up here.
/// </summary>
protected Role[] SystemRoleSeed() =>
[
new Role(Role.AdministratorId, "Administrator",
"Full access to everything, including members, roles and settings.")
{ CreatedOn = defaultCreatedOn.ToUniversalTime() },
new Role(Role.MemberId, "Member",
"Runs and configures integrations. Can't manage members, roles or settings.")
{ CreatedOn = defaultCreatedOn.ToUniversalTime() },
new Role(Role.ViewerId, "Viewer",
"Read-only access to integrations, exchanges and configuration.")
{ CreatedOn = defaultCreatedOn.ToUniversalTime() }
];

async public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
ChangeTracker.ApplyAuditValues(requestContext.GetNameIdentifier());
Expand Down
21 changes: 20 additions & 1 deletion SW.Bitween.Api/Domain/Accounts/Account.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ public Account(string displayName, string email, string password, AccountRole ro
}

public string Email { get; private set; }
public string Phone { get; private set; }
public string DisplayName { get; set; }

public AccountRole Role { get; private set; }
Expand Down Expand Up @@ -63,6 +62,26 @@ public void Update(string name, AccountRole role)
Role = role;
}

/// <summary>Self-service details. Deliberately cannot touch <see cref="Role"/>.</summary>
public void UpdateProfile(string name)
{
DisplayName = name;
}

/// <summary>
/// Legacy coarse role, kept in step with the account's roles for older API consumers.
/// Authorization itself reads the role assignments, not this.
/// </summary>
public void SetRole(AccountRole role)
{
Role = role;
}

public void SetDisabled(bool disabled)
{
Disabled = disabled;
}

public DateTime CreatedOn { get; set; }
public string CreatedBy { get; set; }
public DateTime? ModifiedOn { get; set; }
Expand Down
18 changes: 18 additions & 0 deletions SW.Bitween.Api/Domain/Accounts/AccountRoleLink.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace SW.Bitween.Domain.Accounts;

/// <summary>Which roles an account holds. Composite key — one row per account/role pair.</summary>
public class AccountRoleLink
{
private AccountRoleLink()
{
}

public AccountRoleLink(int accountId, int roleId)
{
AccountId = accountId;
RoleId = roleId;
}

public int AccountId { get; private set; }
public int RoleId { get; private set; }
}
80 changes: 80 additions & 0 deletions SW.Bitween.Api/Domain/Accounts/Role.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using System.Linq;
using SW.Bitween.Model;
using SW.PrimitiveTypes;

namespace SW.Bitween.Domain.Accounts;

/// <summary>
/// A reusable set of permission keys. Members hold any number of roles and may do anything
/// any of their roles allows.
/// </summary>
public class Role : BaseEntity, IAudited
{
public const int AdministratorId = 1;
public const int MemberId = 2;
public const int ViewerId = 3;

/// <summary>The groups the built-in Member and Viewer roles reach; Administration stays admin-only.</summary>
private static readonly string[] NonAdminGroups = ["Operate", "Integrations", "Configuration"];

private Role()
{
}

/// <summary>Seeding path for the built-in roles — fixed Id, grants computed at runtime.</summary>
public Role(int id, string name, string description)
{
Id = id;
Name = name;
Description = description;
IsSystem = true;
}

public Role(string name, string description, IEnumerable<string> permissions)
{
Name = name;
Description = description;
Permissions = PermissionCatalog.Sanitize(permissions);
}

public string Name { get; private set; }
public string Description { get; private set; }

/// <summary>
/// Catalog keys, stored as one JSON column. Empty for built-in roles — see
/// <see cref="GetEffectivePermissions"/>.
/// </summary>
public List<string> Permissions { get; private set; } = [];

/// <summary>Built-in roles can be assigned, but never edited or deleted.</summary>
public bool IsSystem { get; private set; }

public void Update(string name, string description, IEnumerable<string> permissions)
{
Name = name;
Description = description;
Permissions = PermissionCatalog.Sanitize(permissions);
}

/// <summary>
/// What this role actually grants. Built-in roles derive their grants from the catalog at
/// runtime instead of storing them, so adding a permission to the catalog reaches
/// Administrators — and the right groups — without a migration or a data fix-up.
/// </summary>
public List<string> GetEffectivePermissions() => IsSystem ? SystemPermissions(Id) : Permissions;

public static List<string> SystemPermissions(int roleId) => roleId switch
{
AdministratorId => PermissionCatalog.AllKeys.ToList(),
MemberId => PermissionCatalog.InGroups(false, NonAdminGroups),
ViewerId => PermissionCatalog.InGroups(true, NonAdminGroups),
_ => []
};

public DateTime CreatedOn { get; set; }
public string CreatedBy { get; set; }
public DateTime? ModifiedOn { get; set; }
public string ModifiedBy { get; set; }
}
20 changes: 20 additions & 0 deletions SW.Bitween.Api/Domain/Document/Document.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,17 @@ public Document(int id, string name, DocumentFormat format)
DocumentFormat = format;
}

/// <summary>Production creation path — Id is database-generated. Code is optional.</summary>
public Document(string code, string name, DocumentFormat format)
{
Code = code;
Name = name ?? throw new ArgumentNullException(nameof(name));
PromotedProperties = new Dictionary<string, string>();
DocumentFormat = format;
}

public string Name { get; private set; }
public string Code { get; private set; }
public bool BusEnabled { get; set; }
public string BusMessageTypeName { get; set; }
public int DuplicateInterval { get; set; }
Expand All @@ -37,5 +47,15 @@ public void SetDictionaries(IReadOnlyDictionary<string, string> promotedProperti
{
PromotedProperties = promotedProperties;
}

public void SetName(string name)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
}

public void SetCode(string code)
{
Code = code;
}
}
}
26 changes: 26 additions & 0 deletions SW.Bitween.Api/Domain/Setting.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System;
using SW.PrimitiveTypes;

namespace SW.Bitween.Domain;

/// <summary>
/// One instance-wide setting, keyed by the setting's catalog key (e.g. <c>Theme.PrimaryColor</c>).
/// This table is the single source of truth: configuration seeds a key once — on the first boot
/// after that key exists — and is ignored for it from then on. Every catalog key normally has a
/// row; "reset to default" rewrites the row with the product default rather than removing it.
/// <para>
/// Deliberately a plain key/value store: the definition of a key (label, section, type,
/// whether it's a secret) lives in <see cref="Services.SettingsCatalog"/>, so adding a
/// setting never needs a migration or a data fix-up. A secret's value is encrypted before it
/// gets here — see <see cref="Services.SettingsProtector"/>.
/// </para>
/// </summary>
public class Setting : BaseEntity<string>, IAudited
{
public string Value { get; set; }

public DateTime CreatedOn { get; set; }
public string CreatedBy { get; set; }
public DateTime? ModifiedOn { get; set; }
public string ModifiedBy { get; set; }
}
6 changes: 5 additions & 1 deletion SW.Bitween.Api/Domain/Subscription/Schedule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ public override int GetHashCode()

public DateTime Next(DateTime? currentDate = null)
{
var utcNow = currentDate == null ? DateTime.UtcNow : currentDate.Value;
// On.Hours/On.Minutes are UTC wall-clock time — the Quartz cron trigger that
// actually fires this schedule is explicitly pinned to TimeZoneInfo.Utc
// (see SW.Scheduler's WithCronSchedule(...).InTimeZone(TimeZoneInfo.Utc) call
// sites), so this must agree rather than each side defaulting independently.
var utcNow = currentDate ?? DateTime.UtcNow;
DateTime nextSelectedDate;

switch (Recurrence)
Expand Down
7 changes: 4 additions & 3 deletions SW.Bitween.Api/Domain/Xchange/Xchange.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,16 @@ public Xchange(Xchange xchange, XchangeFile file, IWorkGroup workGroup, IReadOnl
}

//retry with reset subscription properties
public Xchange(Subscription subscription, Xchange xchange, XchangeFile file, IReadOnlyDictionary<string, int> groupAttemptCounts = null) :
public Xchange(Subscription subscription, Xchange xchange, XchangeFile file, Partner gatewayPartner = null,
GlobalAdapterValuesSet[] globalAdapterValuesSets = null, IReadOnlyDictionary<string, int> groupAttemptCounts = null) :
this(xchange.DocumentId, subscription.WorkGroup, file, xchange.References)
{
SubscriptionId = xchange.SubscriptionId;
PartnerId = xchange.PartnerId ?? subscription.PartnerId;
MapperId = subscription.MapperId;
HandlerId = subscription.HandlerId;
MapperProperties = subscription.MapperProperties;
HandlerProperties = subscription.HandlerProperties;
MapperProperties = (subscription.MapperProperties ?? new Dictionary<string, string>()).ToDictionary().Fill(gatewayPartner, globalAdapterValuesSets);
HandlerProperties = (subscription.HandlerProperties ?? new Dictionary<string, string>()).ToDictionary().Fill(gatewayPartner, globalAdapterValuesSets);
ResponseSubscriptionId = subscription.ResponseSubscriptionId;
RetryFor = xchange.Id;
CorrelationId = xchange.CorrelationId;
Expand Down
4 changes: 0 additions & 4 deletions SW.Bitween.Api/Extensions/AccountExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,6 @@ private static ClaimsIdentity CreateClaimsIdentity(this Account account, LoginMe
case LoginMethod.EmailAndPassword:
claims.Add(new Claim(ClaimTypes.Name, account.Email));
break;
case LoginMethod.PhoneAndOtp:
claims.Add(new Claim(ClaimTypes.Name, account.Phone));
break;
case LoginMethod.ApiKey:
claims.Add(new Claim(ClaimTypes.Name, account.Id.ToString()));
break;
Expand All @@ -91,7 +88,6 @@ private static ClaimsIdentity CreateClaimsIdentity(this Account account, LoginMe
}

if (account.Email != null) claims.Add(new Claim(ClaimTypes.Email, account.Email));
if (account.Phone != null) claims.Add(new Claim(ClaimTypes.MobilePhone, account.Phone));


return new ClaimsIdentity(claims, "Bitween");
Expand Down
Loading