backend phase 0: foundation, cross-cutting seams & starter cleanup

Remove the Order demo (entity/feature/repo/config/gRPC/proto) and the three
pre-marketplace migrations; regenerate a fresh InitialBaseline migration.

Stand up the REST surface (PingController + System/Ping CQRS) proving the
Mediator -> behaviors -> OperationResult -> ApiResult envelope end to end.

Close wiring gaps: register LoggingBehavior (outermost) and add the built-in
rate limiter (per-IP global + otp/auth/sensitive policies), placed before
authentication.

Add current-user + audit plumbing: ICurrentUser (HttpContext + null impls),
rename BaseEntity audit fields to CreatedAt/ModifiedAt (DateTimeOffset) +
CreatedById/ModifiedById, stamped by a new AuditFieldInterceptor.

Introduce five cross-cutting seams (IDateTimeProvider, IFieldEncryptor,
ICacheService, IObjectStorage, INotificationDispatcher) with in-memory/local
mocks registered via AddCrossCuttingSeams.

Add Baya.Test.Foundation (encryptor, audit interceptor, ping handler) and
update docs, contracts (swagger.v1.json), handoff, report, and mocks registry.
This commit is contained in:
hamid
2026-06-30 22:48:41 +03:30
parent 53a40dc51d
commit 765cc632d5
75 changed files with 1539 additions and 1418 deletions
@@ -17,4 +17,7 @@
<ProjectReference Include="..\Baya.Infrastructure.Identity\Baya.Infrastructure.Identity.csproj" />
<ProjectReference Include="..\Baya.Infrastructure.Persistence\Baya.Infrastructure.Persistence.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
@@ -0,0 +1,63 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Local-disk implementation of <see cref="IObjectStorage"/> — the mock seam. Blobs are stored as
/// files under a configured scratch root, keyed by an opaque storage key. The real implementation
/// swaps to MinIO/S3/ArvanCloud (presigned URLs) behind the same interface.
/// </summary>
public sealed class LocalDiskObjectStorage : IObjectStorage
{
private readonly string _root;
public LocalDiskObjectStorage(IOptions<SeamOptions> options)
{
var configured = options.Value.ObjectStorage.RootPath;
_root = string.IsNullOrWhiteSpace(configured)
? Path.Combine(Path.GetTempPath(), "balinyaar-object-storage")
: configured;
Directory.CreateDirectory(_root);
}
public async ValueTask PutAsync(string key, Stream content, string contentType, CancellationToken cancellationToken = default)
{
var path = ResolvePath(key);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await using var file = File.Create(path);
await content.CopyToAsync(file, cancellationToken);
}
public ValueTask<Stream?> GetAsync(string key, CancellationToken cancellationToken = default)
{
var path = ResolvePath(key);
Stream? stream = File.Exists(path) ? File.OpenRead(path) : null;
return ValueTask.FromResult(stream);
}
public ValueTask DeleteAsync(string key, CancellationToken cancellationToken = default)
{
var path = ResolvePath(key);
if (File.Exists(path))
File.Delete(path);
return ValueTask.CompletedTask;
}
public string GetUrl(string key) => new Uri(Path.Combine(_root, SanitizeKey(key))).AbsoluteUri;
private string ResolvePath(string key) => Path.Combine(_root, SanitizeKey(key));
// Keep the key from escaping the storage root; treat '/' as a folder separator only.
private static string SanitizeKey(string key)
{
var normalized = key.Replace('\\', '/').TrimStart('/');
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries)
.Where(s => s != "." && s != "..");
return Path.Combine([.. segments]);
}
}
@@ -0,0 +1,22 @@
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// No-op implementation of <see cref="INotificationDispatcher"/> — the mock seam. It logs that a
/// notification would be sent (no PII in the log). The real in-app write lands in backend-phase-15,
/// with SMS/push channels added behind the same interface.
/// </summary>
public sealed class LogNotificationDispatcher(ILogger<LogNotificationDispatcher> logger) : INotificationDispatcher
{
public ValueTask DispatchAsync(Notification notification, CancellationToken cancellationToken = default)
{
logger.LogInformation(
"Notification suppressed (mock dispatcher): channel {Channel} to user {UserId}",
notification.Channel,
notification.RecipientUserId);
return ValueTask.CompletedTask;
}
}
@@ -0,0 +1,47 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Caching.Memory;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// In-process <see cref="IMemoryCache"/> implementation of <see cref="ICacheService"/> — the mock seam.
/// The real implementation swaps to Redis (StackExchange.Redis) while keeping the same key/TTL scheme.
/// </summary>
public sealed class MemoryCacheService(IMemoryCache cache) : ICacheService
{
public ValueTask<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
{
return ValueTask.FromResult(cache.TryGetValue(key, out T? value) ? value : default);
}
public ValueTask SetAsync<T>(string key, T value, TimeSpan? ttl = null, CancellationToken cancellationToken = default)
{
var options = new MemoryCacheEntryOptions();
if (ttl is { } expiry)
options.AbsoluteExpirationRelativeToNow = expiry;
cache.Set(key, value, options);
return ValueTask.CompletedTask;
}
public ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default)
{
cache.Remove(key);
return ValueTask.CompletedTask;
}
public async ValueTask<T> GetOrCreateAsync<T>(
string key,
Func<CancellationToken, ValueTask<T>> factory,
TimeSpan? ttl = null,
CancellationToken cancellationToken = default)
{
if (cache.TryGetValue(key, out T? cached) && cached is not null)
return cached;
var value = await factory(cancellationToken);
await SetAsync(key, value, ttl, cancellationToken);
return value;
}
}
@@ -0,0 +1,28 @@
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Options bound from the <c>Seams</c> configuration section. The mock seams read non-secret defaults
/// from here; production keys/paths come from environment variables or user-secrets, never committed.
/// </summary>
public sealed class SeamOptions
{
public const string SectionName = "Seams";
public FieldEncryptionOptions FieldEncryption { get; set; } = new();
public ObjectStorageOptions ObjectStorage { get; set; } = new();
}
public sealed class FieldEncryptionOptions
{
/// <summary>Base64 32-byte AES key. Local-dev default only; override per environment.</summary>
public string Key { get; set; } = string.Empty;
/// <summary>Base64 HMAC key used for deterministic lookup hashes.</summary>
public string HashKey { get; set; } = string.Empty;
}
public sealed class ObjectStorageOptions
{
/// <summary>Filesystem root the local-disk mock writes blobs under.</summary>
public string RootPath { get; set; } = string.Empty;
}
@@ -0,0 +1,86 @@
using System.Security.Cryptography;
using System.Text;
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Local symmetric-key implementation of <see cref="IFieldEncryptor"/> — AES-256-CBC with a random
/// per-value IV (prepended to the ciphertext) for at-rest reversibility, and a keyed HMAC-SHA256 for
/// deterministic lookup hashes. This is the mock seam: the real implementation swaps to a KMS / Key
/// Vault provider behind the same interface. Plaintext is never logged.
/// </summary>
public sealed class SymmetricFieldEncryptor : IFieldEncryptor
{
private readonly byte[] _key;
private readonly byte[] _hashKey;
public SymmetricFieldEncryptor(IOptions<SeamOptions> options)
{
var settings = options.Value.FieldEncryption;
// Derive a stable 32-byte AES key from whatever the operator configured (any length/format),
// so a human-friendly secret still yields a valid key. SHA-256 of the configured material.
_key = SHA256.HashData(Encoding.UTF8.GetBytes(Require(settings.Key, nameof(settings.Key))));
var hashMaterial = string.IsNullOrEmpty(settings.HashKey) ? settings.Key : settings.HashKey;
_hashKey = SHA256.HashData(Encoding.UTF8.GetBytes(Require(hashMaterial, nameof(settings.HashKey))));
}
public string Encrypt(string plaintext)
{
if (string.IsNullOrEmpty(plaintext))
return plaintext;
using var aes = Aes.Create();
aes.Key = _key;
aes.GenerateIV();
using var encryptor = aes.CreateEncryptor();
var plainBytes = Encoding.UTF8.GetBytes(plaintext);
var cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
var result = new byte[aes.IV.Length + cipherBytes.Length];
Buffer.BlockCopy(aes.IV, 0, result, 0, aes.IV.Length);
Buffer.BlockCopy(cipherBytes, 0, result, aes.IV.Length, cipherBytes.Length);
return Convert.ToBase64String(result);
}
public string Decrypt(string ciphertext)
{
if (string.IsNullOrEmpty(ciphertext))
return ciphertext;
var cipherWithIv = Convert.FromBase64String(ciphertext);
using var aes = Aes.Create();
aes.Key = _key;
var ivLength = aes.BlockSize / 8;
var iv = new byte[ivLength];
Buffer.BlockCopy(cipherWithIv, 0, iv, 0, ivLength);
aes.IV = iv;
using var decryptor = aes.CreateDecryptor();
var cipherBytes = decryptor.TransformFinalBlock(cipherWithIv, ivLength, cipherWithIv.Length - ivLength);
return Encoding.UTF8.GetString(cipherBytes);
}
public string Hash(string value)
{
if (string.IsNullOrEmpty(value))
return value;
using var hmac = new HMACSHA256(_hashKey);
var hashBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(value));
return Convert.ToHexString(hashBytes);
}
private static string Require(string value, string name) =>
string.IsNullOrWhiteSpace(value)
? throw new InvalidOperationException($"Seams:FieldEncryption:{name} must be configured.")
: value;
}
@@ -0,0 +1,9 @@
using Baya.Application.Contracts.Common;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>Real system clock. Tests substitute <see cref="IDateTimeProvider"/> to freeze time.</summary>
public sealed class SystemDateTimeProvider : IDateTimeProvider
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
@@ -0,0 +1,29 @@
using Baya.Application.Contracts.Common;
using Baya.Infrastructure.CrossCutting.Seams;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Baya.Infrastructure.CrossCutting.ServiceConfiguration;
public static class ServiceCollectionExtension
{
/// <summary>
/// Registers the cross-cutting seams (time, PII encryption, cache, object storage, notifications)
/// with their in-memory/local mock implementations. Swapping in a real provider later is a
/// registration change here — callers depend only on the Application contracts.
/// </summary>
public static IServiceCollection AddCrossCuttingSeams(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<SeamOptions>(configuration.GetSection(SeamOptions.SectionName));
services.AddMemoryCache();
services.AddSingleton<IDateTimeProvider, SystemDateTimeProvider>();
services.AddSingleton<IFieldEncryptor, SymmetricFieldEncryptor>();
services.AddSingleton<ICacheService, MemoryCacheService>();
services.AddSingleton<IObjectStorage, LocalDiskObjectStorage>();
services.AddScoped<INotificationDispatcher, LogNotificationDispatcher>();
return services;
}
}