Compare commits

...

13 Commits

Author SHA1 Message Date
Mathias Beaulieu-Duncan 07a7a683b7 feat(altcha): IAltchaDifficultyAdvisor for per-request PoW complexity
Publish NuGets / build (release) Successful in 39s
Adds an abstraction over the CreateChallengeRequest.complexity field
(already present in the proto since the original altcha module landed),
letting applications scale PoW difficulty per request based on actor
signals — repeat-offender counters, threat-intel headers, reputation
scores — without leaking those concerns into the gRPC provider.

  - new IAltchaDifficultyAdvisor in Svrnty.CQRS.Altcha.Abstractions:
    Task<uint?> GetComplexityAsync(...). null means "use the upstream
    service's configured default."

  - NullAltchaDifficultyAdvisor in Svrnty.CQRS.Altcha is the no-op
    fallback registered by AddSvrntyAltcha() via TryAddSingleton, so
    applications can replace it without ordering constraints.

  - AltchaGrpcChallengeProvider now resolves the advisor and sets
    CreateChallengeRequest.Complexity when the advisor returns a value.
    The Altcha server clamps to its configured min/max, so callers
    don't need to enforce bounds here.

No breaking changes to existing consumers — the no-op default keeps
behaviour identical when no advisor is registered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:09:00 -04:00
Mathias Beaulieu-Duncan ede9548cba test(altcha): runtime validation of check pipeline in Svrnty.Sample
Adds a [Altcha]-decorated ProtectedActionCommand with IHasAltchaSolution
and a StubAltchaVerifier that treats the literal "valid-solution" as
passing PoW. Exercises both the HTTP MinimalApi and gRPC pipelines
without requiring an external altcha service.

Validated 4 scenarios on each transport (8 total, all pass):

  HTTP /api/command/protectedAction          POST 6001    gRPC :6000
  -------------------------------------------------------------------
  no AltchaSolution                          401          Unauthenticated
  AltchaSolution = "wrong"                   401          Unauthenticated
  AltchaSolution = "valid-solution"          200 result   OK + result
  addUser (no [Altcha])                      200 result   OK + result

The last row confirms backward compatibility: a request type that
isn't decorated with [Altcha] bypasses the check entirely — the
AltchaAuthorizationCheck self-applies and no-ops, and any consumer
that doesn't call AddSvrntyAltcha() sees zero behavior change.

Generated CommandServiceImpl.g.cs verified to include the
ICommandAuthorizationCheck loop after validation, before handler
invocation, with the materialized command instance in ctx.Command.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:46:10 -04:00
Mathias Beaulieu-Duncan 891894d136 feat(altcha): add Svrnty.CQRS.Altcha.MinimalApi challenge endpoint
Single helper extension: MapSvrntyAltchaChallenge() exposes
GET /api/altcha/challenge (configurable prefix) that fetches a fresh
challenge from IAltchaChallengeProvider and projects it onto the
JSON shape the altcha widget v3 expects from its challengeurl —
{ algorithm, challenge, salt, signature, maxnumber } in lowercase.

AllowAnonymous on purpose: the whole point is gating mutations from
unauthenticated callers, so the challenge endpoint must be reachable
without credentials.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:26:41 -04:00
Mathias Beaulieu-Duncan 4446288bb6 feat(altcha): add Svrnty.CQRS.Altcha.Grpc with default verifier + proto
The default transport for IAltchaVerifier / IAltchaChallengeProvider —
calls a self-hosted altcha service over gRPC.

Wire contract
- Protos/altcha.proto defines svrnty.cqrs.altcha.v1.AltchaService with
  CreateChallenge + VerifyChallenge RPCs. Shipped in this package as
  source-of-truth; Go (and other) implementations vendor a copy.
- Challenge.challenge_hash is named (not "challenge") to avoid a C#
  property/class name collision; the MinimalApi widget JSON remaps.

Runtime
- AltchaGrpcVerifier maps RpcException → AltchaVerifyResult.Fail with
  a diagnostic reason ("verify-timeout", "service-unavailable", etc.)
  so the auth check surfaces a clean Unauthorized without leaking
  transport detail.
- AltchaGrpcChallengeProvider lets create-challenge failures bubble
  (challenge endpoint should 5xx if altcha is down — clients retry).
- AltchaGrpcOptions.TokenProvider hook for consumer-supplied HMAC
  service-token minting (plan-b will plug in ServiceTokenIssuer).
- AddGrpcClient<AltchaServiceClient> registered with HttpClientFactory.

AddSvrntyAltchaGrpcVerifier(Action<...>) and overload binding from
IConfiguration cover both wiring styles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:25:59 -04:00
Mathias Beaulieu-Duncan 69e29d4f6d feat(altcha): add Svrnty.CQRS.Altcha core check + DI
The Altcha authorization check, plugged into the
ICommandAuthorizationCheck / IQueryAuthorizationCheck seam.

Behavior
- Self-applies: returns Allowed for any request whose type isn't
  decorated with [Altcha]. No-op for the 99% of endpoints that don't
  need PoW.
- Reads ctx.Items["mobile_attested"] for Phase 3 bypass when the
  attribute's AllowMobileAttestationBypass is true.
- Pulls the solution off the request via IHasAltchaSolution and
  delegates verification to IAltchaVerifier (resolved per-call from
  the request scope, so any verifier lifetime works).
- Stashes a diagnostic reason in ctx.Items["altcha_reason"]
  (missing / misconfigured / invalid / replayed / expired / etc.)
  for downstream middleware to surface in error responses.
- Singleton itself — stateless; one instance shared via factory
  registrations under both check interfaces.

AddSvrntyAltcha() registers the check. The verifier is provided by
a transport-specific module (e.g. Svrnty.CQRS.Altcha.Grpc, next).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:24:02 -04:00
Mathias Beaulieu-Duncan 118d12a3db feat(altcha): add Svrnty.CQRS.Altcha.Abstractions package
Abstractions for the Altcha-based proof-of-work module:
- AltchaAttribute (AllowMobileAttestationBypass param)
- IHasAltchaSolution — marker interface for request POCOs carrying
  the widget's solution payload over HTTP/gRPC transports
- IAltchaVerifier / IAltchaChallengeProvider — transport-agnostic
  interfaces; default gRPC implementations ship in Svrnty.CQRS.Altcha.Grpc
- IMobileAttestationProvider — Phase 3 placeholder; concrete impls
  stamp ctx.Items["mobile_attested"] for the Altcha check to read as
  a bypass when AllowMobileAttestationBypass is true
- AltchaChallenge / AltchaVerifyResult DTOs

Lean dependencies — only references Svrnty.CQRS.Abstractions for the
auth-check pipeline types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:22:33 -04:00
Mathias Beaulieu-Duncan 86d87424ab feat(security): add ICommandAuthorizationCheck/IQueryAuthorizationCheck seam
Introduces a non-breaking, multi-instance authorization-check pipeline
that runs alongside the existing single-instance auth services.

Motivation
- Cross-cutting checks (proof-of-work, mobile attestation, rate-limit
  gates, IP allow-lists) don't belong in consumer auth services — they
  ship from framework modules and self-apply via attributes.
- The existing ICommandAuthorizationService takes only a Type; checks
  need the request *instance* to read payload fields (e.g. an Altcha
  solution carried on the command).

Shape
- New abstractions: ICommandAuthorizationCheck, IQueryAuthorizationCheck,
  CommandAuthorizationCheckContext, QueryAuthorizationCheckContext.
- Context carries (Type, Instance, IServiceProvider, Items dict). The
  Items dict lets sibling checks signal one another — e.g. a future
  mobile-attestation check stamps "mobile_attested" for the Altcha
  check to read as a bypass.
- AND semantics: framework resolves IEnumerable<…Check>, runs each in
  registration order, first non-Allowed short-circuits.
- Wired into MinimalApi (commands + queries, POST + GET) and the
  Svrnty.CQRS.Grpc.Generators source generator (commands, queries,
  dynamic queries). In all paths the checks run AFTER the instance
  is materialized and validated, BEFORE handler invocation.

Backward compatibility
- No registered checks = today's behavior exactly.
- ICommandAuthorizationService / IQueryAuthorizationService signatures
  unchanged; consumers' existing auth services keep working untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:21:20 -04:00
Mathias Beaulieu-Duncan a05ebad7fc Fix CS8601 in generated proto→command list mappings
Publish NuGets / build (release) Successful in 28s
Generated CommandServiceImpl.g.cs had warnings like:
    Slug = request.Slug?.ToList(),   // CS8601 if Slug is non-nullable List<T>

The ?. was over-defensive: proto3 repeated fields are emitted as
RepeatedField<T> in C# and are NEVER null. The conditional access
made the result List<T>? which then triggered CS8601 when assigned
to a non-nullable target on the command POCO.

Dropped ?. in 4 emission sites in GrpcGenerator.cs covering:
- Top-level primitive list mapping (line 872)
- Top-level Guid list mapping (line 861)
- Nested primitive list mapping in NestedPropertyAssignment (line 1083)
- Complex list .Select chain in GenerateComplexListMapping (line 974,
  conditional: kept ?. for value-type collections where source.Items is
  read off a possibly-null wrapper message)

Real fix in the generator instead of CS8601 NoWarn suppression in
consumer csprojs. Consumers can drop the suppression after bumping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:42:17 -04:00
Mathias Beaulieu-Duncan ee3ad866d9 Use InvariantCulture for decimal.Parse in generated gRPC mappers
Generated code was using locale-dependent parsing for decimal values.
On systems with comma decimal separator (e.g., French locale), parsing
"0.95" would throw FormatException because the system expected "0,95".

Switched all 4 decimal.Parse() call sites in the generated proto→domain
mappers to pass System.Globalization.CultureInfo.InvariantCulture for
consistent behavior across locales.

Inspired by JP's commit 599204d on feat/grpc-generator-improvements
(applied manually since cherry-pick had heavy context conflicts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:33:27 -04:00
mathias 55f1324286 Merge pull request 'feat/claude-code-harness' (#2) from feat/claude-code-harness into main
Publish NuGets / build (release) Successful in 34s
Reviewed-on: #2
2026-03-12 06:44:11 -04:00
Mathias Beaulieu-Duncan b34bf874b4 Remove Claude harness — replaced by claude-cqrs-plugin
The in-repo .claude/ harness (rules, skills, settings) is superseded by
the standalone claude-cqrs-plugin which provides the same guidance as a
reusable plugin across all Svrnty.CQRS projects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 06:42:50 -04:00
Mathias Beaulieu-Duncan c6de10b98b Move UseSvrntyCqrs() from MinimalApi to core Svrnty.CQRS package
gRPC-only projects couldn't call app.UseSvrntyCqrs() without adding the
MinimalApi package. The method only calls ExecuteMappingCallbacks() which
is already in core — it had no MinimalApi dependency. Adds ASP.NET Core
FrameworkReference to the core package.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 06:38:26 -04:00
Mathias Beaulieu-Duncan 3945c1a158 Add project-init agent for scaffolding new CQRS projects
Scaffolds a complete Svrnty.CQRS project from a natural language
description — creates solution, web project, DAL with PostgreSQL,
entities, Program.cs, first feature, proto file, and .editorconfig.
Defaults to gRPC-only; MinimalApi added only on request.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 06:38:26 -04:00
46 changed files with 1534 additions and 866 deletions
-1
View File
@@ -1 +0,0 @@
1.0.0
-100
View File
@@ -1,100 +0,0 @@
---
paths:
- "*Command*.cs"
- "*Query*.cs"
- "*Handler*.cs"
- "**/Program.cs"
- "**/ServiceCollectionExtensions.cs"
---
# Commands & Queries
## Commands Are Domain Actions, Not CRUD
Commands express **user intent**. Name them as the business action being performed.
- `PlaceOrderCommand` not `CreateOrderCommand`
- `ApproveExpenseCommand` not `UpdateExpenseCommand`
- `DeactivateAccountCommand` not `DeleteAccountCommand`
One CRUD "Update" often becomes multiple distinct commands, each with its own validation and side effects.
## File Structure
Command, handler, and validator live in the **same file**, organized by feature:
```csharp
// File: Features/Orders/PlaceOrderCommand.cs
public record PlaceOrderCommand
{
public string CustomerId { get; set; } = string.Empty;
public List<OrderItem> Items { get; set; } = [];
}
public class PlaceOrderCommandValidator : AbstractValidator<PlaceOrderCommand>
{
public PlaceOrderCommandValidator()
{
RuleFor(x => x.CustomerId).NotEmpty();
RuleFor(x => x.Items).NotEmpty();
}
}
public class PlaceOrderCommandHandler : ICommandHandler<PlaceOrderCommand, int>
{
public Task<int> HandleAsync(PlaceOrderCommand command, CancellationToken cancellationToken = default)
{
// implementation
}
}
```
## Handler Interfaces
```csharp
// Command with no result
ICommandHandler<TCommand>
Task HandleAsync(TCommand command, CancellationToken cancellationToken = default)
// Command with result
ICommandHandler<TCommand, TResult>
Task<TResult> HandleAsync(TCommand command, CancellationToken cancellationToken = default)
// Query (always returns result) — only for single-entity lookups or non-queryable data
IQueryHandler<TQuery, TResult>
Task<TResult> HandleAsync(TQuery query, CancellationToken cancellationToken = default)
```
## Registration
```csharp
// Command without result
services.AddCommand<DeactivateAccountCommand, DeactivateAccountCommandHandler>();
// Command with result
services.AddCommand<PlaceOrderCommand, int, PlaceOrderCommandHandler>();
// Command with result + validator (from Svrnty.CQRS.FluentValidation)
services.AddCommand<PlaceOrderCommand, int, PlaceOrderCommandHandler, PlaceOrderCommandValidator>();
// Regular query — ONLY for single-entity lookups or non-queryable results
services.AddQuery<FetchOrderByIdQuery, Order, FetchOrderByIdQueryHandler>();
```
## When to Use Regular IQueryHandler vs Dynamic Query
**Use `IQueryHandler`** (rare):
- Single entity by ID: `FetchOrderByIdQuery`
- Non-entity results: `GetDashboardStatsQuery`
- Complex aggregation not expressible as IQueryable
**Use Dynamic Query** (default — see dynamic-query.md rule):
- Any list/collection query
- Anything that needs pagination, filtering, or sorting
## Rules
- Always use `CancellationToken` — never omit it
- Commands/queries are `record` types with `{ get; set; }` properties
- Default string properties to `string.Empty`, collections to `[]`
- Naming: endpoint name is auto-derived by stripping `Command`/`Query` suffix and converting to lowerCamelCase
-97
View File
@@ -1,97 +0,0 @@
---
paths:
- "*QueryableProvider*.cs"
- "*AlterQueryable*.cs"
- "*DynamicQuery*.cs"
- "*Interceptor*.cs"
---
# Dynamic Queries — The Default Query Pattern
**99% of queries should be dynamic queries.** They provide pagination, filtering, sorting, grouping, and aggregation for free.
## The Standard Pattern: IQueryableProviderOverride
```csharp
// File: Features/Orders/OrderQueryableProvider.cs
public class OrderQueryableProvider : IQueryableProviderOverride<Order>
{
private readonly AppDbContext _db;
public OrderQueryableProvider(AppDbContext db) => _db = db;
public Task<IQueryable<Order>> GetQueryableAsync(object query, CancellationToken ct = default)
=> Task.FromResult(_db.Orders.AsQueryable());
}
```
Registration — one line:
```csharp
builder.Services.AddDynamicQueryWithProvider<Order, OrderQueryableProvider>();
```
This automatically creates endpoints with full filtering, sorting, and pagination support.
## Required Dependencies
These must be registered before `AddSvrntyCqrs`:
```csharp
builder.Services.AddTransient<IAsyncQueryableService, SimpleAsyncQueryableService>();
builder.Services.AddTransient<IQueryHandlerAsync, QueryHandlerAsync>();
```
## Alter Queryable Services (Security Filters, Tenant Isolation)
Use `IAlterQueryableService` to modify the queryable before execution — for security filters, tenant isolation, default ordering, etc.
```csharp
public class OrderTenantFilter : IAlterQueryableService<Order, Order>
{
private readonly ITenantContext _tenant;
public OrderTenantFilter(ITenantContext tenant) => _tenant = tenant;
public Task<IQueryable<Order>> AlterQueryableAsync(
IQueryable<Order> query,
IDynamicQuery dynamicQuery,
CancellationToken ct = default)
{
return Task.FromResult(query.Where(o => o.TenantId == _tenant.Id));
}
}
```
Registration:
```csharp
builder.Services.AddAlterQueryable<Order, Order, OrderTenantFilter>();
```
## Interceptors
Up to 5 interceptors per query type. These modify the PoweredSoft DynamicQuery criteria at query build time.
```csharp
builder.Services.AddDynamicQueryInterceptor<Order, Order, OrderInterceptor>();
```
## Source to Destination Mapping
When the entity type differs from the DTO:
```csharp
// Provider returns the source entity queryable
public class OrderQueryableProvider : IQueryableProviderOverride<Order> { ... }
// Registration maps source -> destination
builder.Services.AddDynamicQueryWithProvider<Order, OrderDto, OrderQueryableProvider>();
```
## Key Interfaces
```csharp
IQueryableProvider<TSource>
Task<IQueryable<TSource>> GetQueryableAsync(object query, CancellationToken ct)
IQueryableProviderOverride<TSource> : IQueryableProvider<TSource>
// Marker interface — same method, signals override registration
IAlterQueryableService<TSource, TDestination>
Task<IQueryable<TSource>> AlterQueryableAsync(IQueryable<TSource> query, IDynamicQuery dynamicQuery, CancellationToken ct)
```
-109
View File
@@ -1,109 +0,0 @@
---
paths:
- "**/*.proto"
- "**/Protos/**"
- "*Grpc*"
---
# gRPC Integration
## Proto File Conventions
Proto files live in `Protos/` directory. The source generator (`Svrnty.CQRS.Grpc.Generators`) auto-generates service implementations at compile time.
### Service Structure
```protobuf
service CommandService {
rpc PlaceOrder (PlaceOrderCommandRequest) returns (PlaceOrderCommandResponse);
}
service QueryService {
rpc FetchUserById (FetchUserByIdQueryRequest) returns (FetchUserByIdQueryResponse);
}
service DynamicQueryService {
rpc QueryOrders (DynamicQueryOrdersRequest) returns (DynamicQueryOrdersResponse);
}
```
### Naming Rules
- RPC method name: command/query name without the `Command`/`Query` suffix
- Request message: `{Name}CommandRequest` or `{Name}QueryRequest`
- Response message: `{Name}CommandResponse` or `{Name}QueryResponse`
- Dynamic query request: `DynamicQuery{PluralEntity}Request`
- Dynamic query response: `DynamicQuery{PluralEntity}Response`
- Fields use `snake_case` (proto convention), mapped case-insensitively to C# `PascalCase` properties
### Field Mapping
C# property names must match proto field names (case-insensitive):
| C# Property | Proto Field |
|---------------|-----------------|
| `UserId` | `user_id` |
| `Name` | `name` |
| `EmailAddress`| `email_address` |
| `OrderItems` | `order_items` |
### Type Mapping
| C# | Proto |
|-------------|-------------|
| `int` | `int32` |
| `long` | `int64` |
| `string` | `string` |
| `bool` | `bool` |
| `double` | `double` |
| `float` | `float` |
| `List<T>` | `repeated T`|
| complex type| `message` |
### Dynamic Query Messages (standard, reuse across entities)
```protobuf
message DynamicQueryFilter {
string path = 1;
int32 type = 2; // PoweredSoft.DynamicQuery.Core.FilterType
string value = 3;
repeated DynamicQueryFilter and = 4;
repeated DynamicQueryFilter or = 5;
}
message DynamicQuerySort {
string path = 1;
bool ascending = 2;
}
message DynamicQueryGroup {
string path = 1;
}
message DynamicQueryAggregate {
string path = 1;
int32 type = 2; // PoweredSoft.DynamicQuery.Core.AggregateType
}
```
## Source Generator Behavior
The generator in `Svrnty.CQRS.Grpc.Generators` auto-creates:
- `CommandServiceImpl` — implements `CommandService.CommandServiceBase`
- `QueryServiceImpl` — implements `QueryService.QueryServiceBase`
- `DynamicQueryServiceImpl` — implements `DynamicQueryService.DynamicQueryServiceBase`
- `GrpcServiceRegistration` — auto-registration code
Generated implementations handle:
1. Request-to-POCO property mapping
2. Validator invocation (if registered) with Google Rich Error Model errors
3. Handler invocation with proper `CancellationToken`
4. DI scope management via `IServiceScopeFactory`
## Validation in gRPC
Validation errors return `google.rpc.Status` with `BadRequest` detail containing `FieldViolations`. This is automatic — do not manually validate in handlers.
## Registration
```csharp
builder.Services.AddSvrntyCqrs(cqrs =>
{
cqrs.AddGrpc(grpc => grpc.EnableReflection()); // reflection for grpcurl/grpcui
});
```
-60
View File
@@ -1,60 +0,0 @@
---
paths:
- "*Validator*.cs"
- "*Validation*"
---
# FluentValidation Integration
## Validator Pattern
Validators live in the **same file** as their command/query, named `{CommandName}Validator`:
```csharp
public class PlaceOrderCommandValidator : AbstractValidator<PlaceOrderCommand>
{
public PlaceOrderCommandValidator()
{
RuleFor(x => x.CustomerId)
.NotEmpty()
.WithMessage("Customer is required");
RuleFor(x => x.Items)
.NotEmpty()
.WithMessage("At least one item is required");
}
}
```
## Registration
Validators are registered alongside their handler using the FluentValidation overloads:
```csharp
// Command without result + validator
services.AddCommand<DeactivateAccountCommand, DeactivateAccountCommandHandler, DeactivateAccountCommandValidator>();
// Command with result + validator
services.AddCommand<PlaceOrderCommand, int, PlaceOrderCommandHandler, PlaceOrderCommandValidator>();
// Query + validator
services.AddQuery<FetchOrderByIdQuery, Order, FetchOrderByIdQueryHandler, FetchOrderByIdQueryValidator>();
```
These come from `Svrnty.CQRS.FluentValidation.ServiceCollectionExtensions` — they call the base `AddCommand`/`AddQuery` then register `IValidator<T>`.
## Error Formats by Protocol
The framework handles validation execution automatically. Errors are returned as:
- **HTTP (Minimal API)**: RFC 7807 Problem Details — `400 Bad Request` with structured error body
- **gRPC**: Google Rich Error Model — `google.rpc.Status` with `BadRequest` detail containing `FieldViolations`
You do NOT need to manually invoke validation in handlers. The endpoint layer (Minimal API or gRPC source-generated service) handles it.
## Rules
- Inherit from `AbstractValidator<T>`, not `IValidator<T>` directly
- Define all rules in the constructor
- Always include `.WithMessage()` for user-facing error messages
- Validator constructor can inject services for async/database validation
-79
View File
@@ -1,79 +0,0 @@
{
"permissions": {
"allow": [
"Bash(dotnet build:*)",
"Bash(dotnet clean:*)",
"Bash(dotnet restore:*)",
"Bash(dotnet run:*)",
"Bash(dotnet test:*)",
"Bash(dotnet format:*)",
"Bash(dotnet add:*)",
"Bash(dotnet remove:*)",
"Bash(dotnet sln:*)",
"Bash(dotnet --list-sdks:*)",
"Bash(dotnet tool install:*)",
"Bash(dotnet ef:*)",
"Bash(grpcurl:*)",
"Bash(protogen:*)",
"Bash(mkdir:*)",
"Bash(ls:*)",
"Bash(git status:*)",
"Bash(git log:*)",
"Bash(git diff:*)",
"Bash(git branch:*)",
"Bash(git stash:*)",
"Bash(git add:*)",
"Bash(git checkout:*)",
"WebSearch",
"WebFetch(domain:learn.microsoft.com)",
"WebFetch(domain:github.com)",
"WebFetch(domain:www.nuget.org)",
"WebFetch(domain:stackoverflow.com)"
],
"ask": [
"Bash(dotnet publish:*)",
"Bash(dotnet pack:*)",
"Bash(git push:*)",
"Bash(git commit:*)"
]
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | jq -r \".tool_input.file_path // empty\"); if [[ -n \"$FILE\" && \"$FILE\" == *.cs ]]; then dotnet format --include \"$FILE\" --no-restore --diagnostics IDE0005 IDE0055 IDE0161 2>/dev/null || true; fi'",
"timeout": 15,
"statusMessage": "Formatting..."
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | jq -r \".tool_input.file_path // empty\"); if [[ -n \"$FILE\" && \"$FILE\" == *.proto ]]; then dotnet build --no-restore 2>&1 | grep -E \"(error|warning)\" | head -10; fi'",
"timeout": 30,
"statusMessage": "Validating proto..."
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "bash -c 'BUILD=$(dotnet build --no-restore 2>&1); if echo \"$BUILD\" | grep -q \"Build FAILED\"; then echo \"BUILD FAILED — errors below:\" >&2; echo \"$BUILD\" | grep -E \"error CS\" >&2; exit 2; fi'",
"timeout": 60,
"statusMessage": "Verifying build..."
}
]
}
]
}
}
-50
View File
@@ -1,50 +0,0 @@
{
"permissions": {
"allow": [
"Bash(dotnet clean:*)",
"Bash(dotnet run)",
"Bash(dotnet add:*)",
"Bash(timeout 5 dotnet run:*)",
"Bash(dotnet remove:*)",
"Bash(netstat:*)",
"Bash(findstr:*)",
"Bash(cat:*)",
"Bash(taskkill:*)",
"WebSearch",
"Bash(dotnet tool install:*)",
"Bash(protogen:*)",
"Bash(timeout 15 dotnet run:*)",
"Bash(where:*)",
"Bash(timeout 30 dotnet run:*)",
"Bash(timeout 60 dotnet run:*)",
"Bash(timeout 120 dotnet run:*)",
"Bash(git add:*)",
"Bash(curl:*)",
"Bash(timeout 3 cmd:*)",
"Bash(timeout:*)",
"Bash(tasklist:*)",
"Bash(dotnet build:*)",
"Bash(dotnet --list-sdks:*)",
"Bash(dotnet sln:*)",
"Bash(pkill:*)",
"Bash(python3:*)",
"Bash(grpcurl:*)",
"Bash(lsof:*)",
"Bash(xargs kill -9)",
"Bash(dotnet run:*)",
"Bash(find:*)",
"Bash(dotnet pack:*)",
"Bash(unzip:*)",
"WebFetch(domain:andrewlock.net)",
"WebFetch(domain:github.com)",
"WebFetch(domain:stackoverflow.com)",
"WebFetch(domain:www.kenmuse.com)",
"WebFetch(domain:blog.rsuter.com)",
"WebFetch(domain:natemcmaster.com)",
"WebFetch(domain:www.nuget.org)",
"Bash(mkdir:*)"
],
"deny": [],
"ask": []
}
}
-103
View File
@@ -1,103 +0,0 @@
---
name: add-command
description: Scaffold a new CQRS command with handler, validator, and DI registration. Use when the user wants to add a new command to a project.
argument-hint: <description of the command in natural language>
---
# Add Command
Scaffold a new command based on: $ARGUMENTS
## Instructions
### 1. Determine the command name
The name must express **domain intent**, not CRUD. If the user's description sounds like CRUD, translate it:
- "create a user" → `RegisterUserCommand`
- "update the order status" → contextual: `ShipOrderCommand`, `CancelOrderCommand`, `ApproveOrderCommand`
- "delete an account" → `DeactivateAccountCommand` or `CloseAccountCommand`
Ask the user to clarify if the intent is ambiguous (e.g., "update order" could mean many things).
### 2. Determine the feature folder
Place the file in the appropriate feature folder: `Features/{DomainArea}/`
If the folder doesn't exist, create it. If the project doesn't use `Features/` yet, check the existing structure and follow the same pattern.
### 3. Create the file
Create a single file `Features/{DomainArea}/{CommandName}Command.cs` containing all three classes:
```csharp
using FluentValidation;
using Svrnty.CQRS.Abstractions;
namespace {ProjectNamespace}.Features.{DomainArea};
// 1. Command POCO — record type, properties with defaults
public record {CommandName}Command
{
// Properties based on user description
// Strings default to string.Empty, collections to []
}
// 2. Validator — rules in constructor, always include .WithMessage()
public class {CommandName}CommandValidator : AbstractValidator<{CommandName}Command>
{
public {CommandName}CommandValidator()
{
// Validation rules based on command properties
}
}
// 3. Handler — always async with CancellationToken
public class {CommandName}CommandHandler : ICommandHandler<{CommandName}Command, {ResultType}>
{
public Task<{ResultType}> HandleAsync({CommandName}Command command, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
}
```
If the command has **no return value**, use `ICommandHandler<{CommandName}Command>` (single generic param) with `Task HandleAsync(...)`.
### 4. Register in DI
Find the project's service registration (typically `Program.cs` or a dedicated registration method) and add:
```csharp
// With validator (preferred)
builder.Services.AddCommand<{CommandName}Command, {ResultType}, {CommandName}CommandHandler, {CommandName}CommandValidator>();
// Without validator
builder.Services.AddCommand<{CommandName}Command, {ResultType}, {CommandName}CommandHandler>();
// No return value + validator
builder.Services.AddCommand<{CommandName}Command, {CommandName}CommandHandler, {CommandName}CommandValidator>();
```
Ensure the `using Svrnty.CQRS.FluentValidation;` namespace is imported if using the validator overload.
### 5. Proto message (if project uses gRPC)
If the project has a `Protos/` directory, add the corresponding proto message:
```protobuf
// In the CommandService definition
rpc {CommandName} ({CommandName}CommandRequest) returns ({CommandName}CommandResponse);
// Request message
message {CommandName}CommandRequest {
// fields matching command properties, snake_case, numbered sequentially
}
// Response message
message {CommandName}CommandResponse {
{result_type} result = 1; // omit if command has no return value
}
```
### 6. Summary
After creating everything, list what was created and where.
-125
View File
@@ -1,125 +0,0 @@
---
name: add-dynamic-query
description: Scaffold a dynamic query with IQueryableProviderOverride for automatic pagination, filtering, and sorting. This is the DEFAULT choice for any list/collection query.
argument-hint: <entity name and optional description>
---
# Add Dynamic Query
Scaffold a dynamic query based on: $ARGUMENTS
**This is the default query pattern.** It gives pagination, filtering, sorting, grouping, and aggregation for free.
## Instructions
### 1. Identify the entity
Determine the source entity type from the user's description. If the project uses a DAL with EF Core, the entity should already exist in the DbContext.
### 2. Determine the feature folder
Place the file in: `Features/{DomainArea}/`
### 3. Create the queryable provider
Create `Features/{DomainArea}/{Entity}QueryableProvider.cs`:
```csharp
using Svrnty.CQRS.DynamicQuery.Abstractions;
namespace {ProjectNamespace}.Features.{DomainArea};
public class {Entity}QueryableProvider : IQueryableProviderOverride<{Entity}>
{
private readonly {DbContextType} _db;
public {Entity}QueryableProvider({DbContextType} db) => _db = db;
public Task<IQueryable<{Entity}>> GetQueryableAsync(object query, CancellationToken cancellationToken = default)
=> Task.FromResult(_db.{EntityPlural}.AsQueryable());
}
```
If the entity needs a DTO projection (different shape for the API), note this for the registration step.
### 4. Create alter queryable service (if needed)
If the entity needs security filtering, tenant isolation, or default ordering, create `Features/{DomainArea}/{Entity}AlterQueryable.cs`:
```csharp
using Svrnty.CQRS.DynamicQuery.Abstractions;
namespace {ProjectNamespace}.Features.{DomainArea};
public class {Entity}AlterQueryable : IAlterQueryableService<{Entity}, {EntityOrDto}>
{
public Task<IQueryable<{Entity}>> AlterQueryableAsync(
IQueryable<{Entity}> query,
IDynamicQuery dynamicQuery,
CancellationToken cancellationToken = default)
{
// Add default ordering, security filters, etc.
return Task.FromResult(query.OrderByDescending(x => x.CreatedAt));
}
}
```
### 5. Register in DI
Find the project's service registration and add:
```csharp
// Basic — entity returned as-is
builder.Services.AddDynamicQueryWithProvider<{Entity}, {Entity}QueryableProvider>();
// With DTO projection — entity mapped to DTO
builder.Services.AddDynamicQueryWithProvider<{Entity}, {EntityDto}, {Entity}QueryableProvider>();
// With alter service (add after provider registration)
builder.Services.AddAlterQueryable<{Entity}, {EntityOrDto}, {Entity}AlterQueryable>();
```
### 6. Ensure dynamic query dependencies are registered
Check that these are registered (typically once per project, before `AddSvrntyCqrs`):
```csharp
builder.Services.AddTransient<IAsyncQueryableService, SimpleAsyncQueryableService>();
builder.Services.AddTransient<IQueryHandlerAsync, QueryHandlerAsync>();
```
If they're already present, don't add duplicates.
### 7. Proto message (if project uses gRPC)
```protobuf
// In the DynamicQueryService definition
rpc Query{EntityPlural} (DynamicQuery{EntityPlural}Request) returns (DynamicQuery{EntityPlural}Response);
// Entity message (if not already defined)
message {Entity} {
// fields matching entity properties
}
// Request — uses standard dynamic query fields
message DynamicQuery{EntityPlural}Request {
int32 page = 1;
int32 page_size = 2;
repeated DynamicQueryFilter filters = 3;
repeated DynamicQuerySort sorts = 4;
repeated DynamicQueryGroup groups = 5;
repeated DynamicQueryAggregate aggregates = 6;
}
// Response
message DynamicQuery{EntityPlural}Response {
repeated {Entity} data = 1;
int64 total_records = 2;
int32 number_of_pages = 3;
}
```
The standard `DynamicQueryFilter`, `DynamicQuerySort`, `DynamicQueryGroup`, and `DynamicQueryAggregate` messages should already be defined — reuse them.
### 8. Summary
After creating everything, list what was created and where.
-86
View File
@@ -1,86 +0,0 @@
---
name: add-query
description: Scaffold a new regular CQRS query with handler and DI registration. Use ONLY for single-entity lookups or non-queryable results. For list/collection queries, use /add-dynamic-query instead.
argument-hint: <description of the query in natural language>
---
# Add Query
Scaffold a new regular query based on: $ARGUMENTS
## Important: Is This the Right Skill?
**This skill is for the rare case.** Most queries should be dynamic queries (`/add-dynamic-query`).
Only use this for:
- Single entity by ID (e.g., `FetchOrderByIdQuery`)
- Non-entity results (e.g., `GetDashboardStatsQuery`, `CheckAvailabilityQuery`)
- Complex aggregation not expressible as IQueryable
If the user is asking for a list/collection query with filtering, sorting, or pagination → suggest `/add-dynamic-query` instead.
## Instructions
### 1. Determine the query name
Use descriptive, domain-oriented names:
- `FetchOrderByIdQuery` — fetching a single entity
- `GetSystemHealthQuery` — non-entity result
- `CheckInventoryAvailabilityQuery` — domain-specific check
### 2. Determine the feature folder
Place the file in: `Features/{DomainArea}/`
### 3. Create the file
Create `Features/{DomainArea}/{QueryName}Query.cs`:
```csharp
using Svrnty.CQRS.Abstractions;
namespace {ProjectNamespace}.Features.{DomainArea};
// 1. Query POCO
public record {QueryName}Query
{
// Parameters — e.g., Id for lookups
}
// 2. Handler
public class {QueryName}QueryHandler : IQueryHandler<{QueryName}Query, {ResultType}>
{
public Task<{ResultType}> HandleAsync({QueryName}Query query, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
}
```
### 4. Register in DI
```csharp
builder.Services.AddQuery<{QueryName}Query, {ResultType}, {QueryName}QueryHandler>();
// With validator (if needed)
builder.Services.AddQuery<{QueryName}Query, {ResultType}, {QueryName}QueryHandler, {QueryName}QueryValidator>();
```
### 5. Proto message (if project uses gRPC)
```protobuf
// In the QueryService definition
rpc {QueryName} ({QueryName}QueryRequest) returns ({QueryName}QueryResponse);
message {QueryName}QueryRequest {
// fields matching query properties
}
message {QueryName}QueryResponse {
{ResultMessage} result = 1;
}
```
### 6. Summary
After creating everything, list what was created and where.
+314 -47
View File
@@ -2,9 +2,6 @@
This file provides guidance to AI agents when working with code in this repository.
For domain-specific patterns (commands, queries, validation, gRPC, dynamic queries), see `.claude/rules/`.
For scaffolding commands, see `.claude/skills/` (`/add-command`, `/add-query`, `/add-dynamic-query`).
## Project Overview
This is Svrnty.CQRS, a modern implementation of Command Query Responsibility Segregation (CQRS) for .NET 10. It was forked from PoweredSoft.CQRS and provides:
@@ -14,10 +11,11 @@ This is Svrnty.CQRS, a modern implementation of Command Query Responsibility Seg
- Automatic gRPC endpoint generation with source generators and Google Rich Error Model validation
- Dynamic query capabilities (filtering, sorting, grouping, aggregation)
- FluentValidation support with RFC 7807 Problem Details (HTTP) and Google Rich Error Model (gRPC)
- AOT (Ahead-of-Time) compilation compatibility for core packages (where dependencies allow)
## Solution Structure
The solution contains projects organized by responsibility:
The solution contains 11 projects organized by responsibility (10 packages + 1 sample project):
**Abstractions (interfaces and contracts only):**
- `Svrnty.CQRS.Abstractions` - Core interfaces (ICommandHandler, IQueryHandler, discovery contracts)
@@ -26,42 +24,81 @@ The solution contains projects organized by responsibility:
**Implementation:**
- `Svrnty.CQRS` - Core discovery and registration logic
- `Svrnty.CQRS.MinimalApi` - Minimal API endpoint mapping for commands/queries
- `Svrnty.CQRS.MinimalApi` - Minimal API endpoint mapping for commands/queries (recommended for HTTP)
- `Svrnty.CQRS.DynamicQuery` - PoweredSoft.DynamicQuery integration for advanced filtering
- `Svrnty.CQRS.DynamicQuery.MinimalApi` - Minimal API endpoint mapping for dynamic queries
- `Svrnty.CQRS.FluentValidation` - Validation integration helpers
- `Svrnty.CQRS.Grpc` - gRPC service implementation support
- `Svrnty.CQRS.Grpc.Generators` - Source generator for .proto files and gRPC service implementations
**Sample:**
- `Svrnty.Sample` - Demo project showcasing both HTTP and gRPC endpoints
**Sample Projects:**
- `Svrnty.Sample` - Comprehensive demo project showcasing both HTTP and gRPC endpoints
**Key Design Principle:** Abstractions projects contain ONLY interfaces/attributes with minimal dependencies. Implementation projects depend on abstractions.
**Key Design Principle:** Abstractions projects contain ONLY interfaces/attributes with minimal dependencies. Implementation projects depend on abstractions. This allows consumers to reference abstractions without pulling in heavy implementation dependencies.
## Build Commands
```bash
dotnet restore # Restore dependencies
dotnet build # Build entire solution
dotnet build -c Release # Build in Release mode
dotnet pack -c Release -o ./artifacts -p:Version=1.0.0 # Create NuGet packages
# Restore dependencies
dotnet restore
# Build entire solution
dotnet build
# Build in Release mode
dotnet build -c Release
# Create NuGet packages (with version)
dotnet pack -c Release -o ./artifacts -p:Version=1.0.0
# Build specific project
dotnet build Svrnty.CQRS/Svrnty.CQRS.csproj
```
## Testing
No test projects currently exist. When adding tests:
- Place them in a `tests/` directory
This repository does not currently contain test projects. When adding tests:
- Place them in a `tests/` directory or alongside source projects
- Name them with `.Tests` suffix (e.g., `Svrnty.CQRS.Tests`)
## Architecture
### Core CQRS Pattern
The framework uses handler interfaces that follow this pattern:
```csharp
// Command with no result
ICommandHandler<TCommand>
Task HandleAsync(TCommand command, CancellationToken cancellationToken = default)
// Command with result
ICommandHandler<TCommand, TResult>
Task<TResult> HandleAsync(TCommand command, CancellationToken cancellationToken = default)
// Query (always returns result)
IQueryHandler<TQuery, TResult>
Task<TResult> HandleAsync(TQuery query, CancellationToken cancellationToken = default)
```
### Metadata-Driven Discovery
The framework uses a **metadata pattern** for runtime discovery:
1. `services.AddCommand<TCommand, THandler>()` registers the handler in DI and creates `ICommandMeta` metadata as a singleton
2. Discovery services (`ICommandDiscovery`, `IQueryDiscovery`) query all registered metadata from DI
3. Endpoint mapping (HTTP and gRPC) uses discovery to dynamically generate endpoints at startup
1. When you register a handler using `services.AddCommand<TCommand, THandler>()`, it:
- Registers the handler in DI as `ICommandHandler<TCommand, THandler>`
- Creates metadata (`ICommandMeta`) describing the command type, handler type, and result type
- Stores metadata as singleton in DI
2. Discovery services (`ICommandDiscovery`, `IQueryDiscovery`) implemented in `Svrnty.CQRS`:
- Query all registered metadata from DI container
- Provide lookup methods: `GetCommand(string name)`, `GetCommands()`, etc.
3. Endpoint mapping (HTTP and gRPC) uses discovery to:
- Enumerate all registered commands/queries
- Dynamically generate endpoints at application startup
- Apply naming conventions (convert to lowerCamelCase)
- Generate gRPC service implementations via source generators
**Key Files:**
- `Svrnty.CQRS.Abstractions/Discovery/` - Metadata interfaces
@@ -70,77 +107,307 @@ The framework uses a **metadata pattern** for runtime discovery:
- `Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs` - Dynamic query endpoint generation
- `Svrnty.CQRS.Grpc.Generators/` - gRPC service generation via source generators
### Integration
### Integration Options
Commands and queries can be exposed via HTTP (Minimal API), gRPC, or both simultaneously. The fluent configuration API handles all wiring:
There are two primary integration options for exposing commands and queries:
#### Option 1: gRPC (Recommended for performance-critical scenarios)
The **Svrnty.CQRS.Grpc** package with **Svrnty.CQRS.Grpc.Generators** source generator provides high-performance gRPC endpoints:
**Registration:**
```csharp
builder.Services.AddSvrntyCqrs(cqrs =>
{
cqrs.AddGrpc(grpc => grpc.EnableReflection());
cqrs.AddMinimalApi();
});
var builder = WebApplication.CreateBuilder(args);
app.UseSvrntyCqrs(); // Maps all endpoints
// Register CQRS services
builder.Services.AddSvrntyCQRS();
builder.Services.AddDefaultCommandDiscovery();
builder.Services.AddDefaultQueryDiscovery();
// Add your commands and queries
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler>();
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
// Add gRPC support
builder.Services.AddGrpc();
var app = builder.Build();
// Map auto-generated gRPC service implementations
app.MapGrpcService<CommandServiceImpl>();
app.MapGrpcService<QueryServiceImpl>();
// Enable gRPC reflection for tools like grpcurl
app.MapGrpcReflectionService();
app.Run();
```
See `Svrnty.Sample/Program.cs` for a complete working example.
**How It Works:**
1. Define `.proto` files in `Protos/` directory with your commands/queries as messages
2. Source generator automatically creates `CommandServiceImpl` and `QueryServiceImpl` implementations
3. Property names in C# commands must match proto field names (case-insensitive)
4. FluentValidation is automatically integrated with **Google Rich Error Model** for structured validation errors
5. Validation errors return `google.rpc.Status` with `BadRequest` containing `FieldViolations`
**Features:**
- High-performance binary protocol
- Automatic service implementation generation at compile time
- Google Rich Error Model for structured validation errors
- Full FluentValidation integration
- gRPC reflection support for development tools
- Suitable for microservices, internal APIs, and low-latency scenarios
**Key Files:**
- `Svrnty.CQRS.Grpc/` - Runtime support for gRPC services
- `Svrnty.CQRS.Grpc.Generators/` - Source generator for service implementations
#### Option 2: HTTP via Minimal API (Recommended for web/browser scenarios)
The **Svrnty.CQRS.MinimalApi** package provides HTTP endpoints for CQRS commands and queries:
**Registration:**
```csharp
var builder = WebApplication.CreateBuilder(args);
// Register CQRS services
builder.Services.AddSvrntyCQRS();
builder.Services.AddDefaultCommandDiscovery();
builder.Services.AddDefaultQueryDiscovery();
// Add your commands and queries
builder.Services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler>();
builder.Services.AddQuery<PersonQuery, IQueryable<Person>, PersonQueryHandler>();
// Add Swagger (optional)
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Map endpoints (this creates routes automatically)
app.MapSvrntyCommands(); // Maps all commands to POST /api/command/{name}
app.MapSvrntyQueries(); // Maps all queries to POST/GET /api/query/{name}
app.Run();
```
**How It Works:**
1. Extension methods iterate through `ICommandDiscovery` and `IQueryDiscovery`
2. For each command/query, creates Minimal API endpoints using `MapPost()`/`MapGet()`
3. Applies naming conventions (lowerCamelCase)
4. Respects `[CommandControllerIgnore]` and `[QueryControllerIgnore]` attributes
5. Integrates with `ICommandAuthorizationService` and `IQueryAuthorizationService`
6. Supports OpenAPI/Swagger documentation
**Features:**
- Queries support both POST (with JSON body) and GET (with query string parameters)
- Commands only support POST with JSON body
- Authorization via authorization services (returns 401/403 status codes)
- Customizable route prefixes: `MapSvrntyCommands("my-prefix")`
- Automatic OpenAPI tags: "Commands" and "Queries"
- RFC 7807 Problem Details for validation errors
- Full Swagger/OpenAPI support
**Key Files:**
- `Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs` - Main implementation
#### Option 3: Both gRPC and HTTP (Dual Protocol Support)
You can enable both protocols simultaneously, allowing clients to choose their preferred protocol:
```csharp
var builder = WebApplication.CreateBuilder(args);
// Register CQRS services
builder.Services.AddSvrntyCQRS();
builder.Services.AddDefaultCommandDiscovery();
builder.Services.AddDefaultQueryDiscovery();
// Add commands and queries
AddCommands(builder.Services);
AddQueries(builder.Services);
// Add both gRPC and HTTP support
builder.Services.AddGrpc();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Map both gRPC and HTTP endpoints
app.MapGrpcService<CommandServiceImpl>();
app.MapGrpcService<QueryServiceImpl>();
app.MapGrpcReflectionService();
app.MapSvrntyCommands();
app.MapSvrntyQueries();
app.Run();
```
**Benefits:**
- Single codebase supports multiple protocols
- gRPC for high-performance, low-latency scenarios (microservices, internal APIs)
- HTTP for web browsers, legacy clients, and public APIs
- Same commands, queries, and validation logic for both protocols
- Swagger UI available for HTTP endpoints, gRPC reflection for gRPC clients
### Dynamic Query System
Dynamic queries provide OData-like filtering capabilities:
**Core Components:**
- `IDynamicQuery<TSource, TDestination>` - Interface with GetFilters(), GetSorts(), GetGroups(), GetAggregates()
- `IQueryableProvider<TSource>` - Provides base IQueryable to query against
- `IAlterQueryableService<TSource, TDestination>` - Middleware to modify queries (e.g., security filters)
- `DynamicQueryHandler<TSource, TDestination>` - Executes queries using PoweredSoft.DynamicQuery
**Request Flow:**
1. HTTP request with filters/sorts/aggregates
2. Minimal API endpoint receives request
3. DynamicQueryHandler gets base queryable from IQueryableProvider
4. Applies alterations from all registered IAlterQueryableService instances
5. Builds PoweredSoft query criteria
6. Executes and returns IQueryExecutionResult
**Registration Example:**
```csharp
// Register dynamic query
services.AddDynamicQuery<Person, PersonDto>()
.AddDynamicQueryWithProvider<Person, PersonQueryableProvider>()
.AddAlterQueryable<Person, PersonDto, SecurityFilter>();
// Map dynamic query endpoints
app.MapSvrntyDynamicQueries(); // Creates POST/GET /api/query/{queryName} endpoints
```
**Key Files:**
- `Svrnty.CQRS.DynamicQuery/DynamicQueryHandler.cs` - Query execution logic
- `Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs` - HTTP endpoint mapping
## Package Configuration
- **Target Framework**: `net10.0` (except DynamicQuery.Abstractions: `netstandard2.1;net10.0`)
All projects target .NET 10.0 and use C# 14, sharing common configuration:
- **Target Framework**: `net10.0` (except DynamicQuery.Abstractions which multi-targets `netstandard2.1;net10.0`)
- **Language Version**: C# 14
- **IsAotCompatible**: Currently set but not enforced (many dependencies are not AOT-compatible yet)
- **Symbols**: Portable debug symbols with source, published as `.snupkg`
- **NuGet metadata**: Icon, README, license (MIT), and repository URL included in packages
- **Authors**: David Lebee, Mathias Beaulieu-Duncan
- **Repository**: https://git.openharbor.io/svrnty/dotnet-cqrs
### Key Dependencies
### Package Dependencies
**Core Dependencies:**
- **Microsoft.Extensions.DependencyInjection.Abstractions**: 10.0.0
- **FluentValidation**: 11.11.0
- **PoweredSoft.DynamicQuery**: 3.0.1
- **Grpc.AspNetCore**: 2.68.0+
- **Grpc.StatusProto**: 2.71.0+ (Rich Error Model)
- **Microsoft.CodeAnalysis.CSharp**: 5.0.0-2.final (source generators, targets netstandard2.0)
- **Pluralize.NET**: 1.0.2
**gRPC Dependencies (for Svrnty.CQRS.Grpc):**
- **Grpc.AspNetCore**: 2.68.0 or later
- **Grpc.AspNetCore.Server.Reflection**: 2.71.0 or later (optional, for reflection)
- **Grpc.StatusProto**: 2.71.0 or later (for Rich Error Model validation)
- **Grpc.Tools**: 2.76.0 or later (for .proto compilation)
**Source Generator Dependencies (for Svrnty.CQRS.Grpc.Generators):**
- **Microsoft.CodeAnalysis.CSharp**: 5.0.0-2.final
- **Microsoft.CodeAnalysis.Analyzers**: 3.11.0
- **Microsoft.Build.Utilities.Core**: 17.0.0
- Targets: netstandard2.0 (for Roslyn compatibility)
## Publishing
NuGet packages publish automatically via GitHub Actions (`.github/workflows/publish-nugets.yml`) when a release is created. Tag becomes the version.
NuGet packages are published automatically via GitHub Actions when a release is created:
**Workflow:** `.github/workflows/publish-nugets.yml`
1. Triggered on release publication
2. Extracts version from release tag
3. Runs `dotnet pack -c Release -p:Version={tag}`
4. Pushes to NuGet.org using `NUGET_API_KEY` secret
**Manual publish:**
```bash
# Manual publish
# Create packages with specific version
dotnet pack -c Release -o ./artifacts -p:Version=1.2.3
# Push to NuGet
dotnet nuget push ./artifacts/*.nupkg --source https://api.nuget.org/v3/index.json --api-key YOUR_KEY
```
## Development Workflow
**Adding a New Feature to the Framework:**
**Adding a New Command/Query Handler:**
1. Create command/query POCO in consumer project
2. Implement handler: `ICommandHandler<TCommand, TResult>`
3. Register in DI: `services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler>()`
4. (Optional) Add validator: `services.AddTransient<IValidator<CreatePersonCommand>, Validator>()`
5. Controller endpoint is automatically generated
**Adding a New Feature to Framework:**
1. Add interface to appropriate Abstractions project
2. Implement in corresponding implementation project
3. Update ServiceCollectionExtensions with registration method
4. When extending discovery, create corresponding metadata classes (implement ICommandMeta/IQueryMeta)
4. Ensure all projects maintain AOT compatibility (unless AspNetCore-specific)
5. Update package version and release notes
**Naming Conventions:**
- Commands/Queries: Use `[CommandName]` or `[QueryName]` attribute for custom names
- Default naming: Strips "Command"/"Query" suffix, converts to lowerCamelCase
- Example: `CreatePersonCommand` -> `createPerson` endpoint
## C# 14 Language Features
The project uses C# 14. Be aware of these reserved keywords:
- **`field`**: Contextual keyword in property accessors for implicit backing fields
- **`extension`**: Reserved for extension containers; use `@extension` for identifiers
The project now uses C# 14, which introduces several new features. Be aware of these breaking changes:
**Potential Breaking Changes:**
- **`field` keyword**: New contextual keyword in property accessors for implicit backing fields
- **`extension` keyword**: Reserved for extension containers; use `@extension` for identifiers
- **`partial` return type**: Cannot use `partial` as return type without escaping
- **Span<T> overload resolution**: New implicit conversions may select different overloads
- **`scoped` as lambda modifier**: Always treated as modifier in lambda parameters
**New Features Available:**
- Extension members (static extension members and extension properties)
- Implicit span conversions
- Unbound generic types with `nameof`
- Lambda parameter modifiers without type specification
- Partial instance constructors and events
- Null-conditional assignment (`?.=` and `?[]=`)
The codebase currently compiles without warnings on C# 14.
## Important Implementation Notes
1. **Async Everywhere**: All handlers are async. Always support CancellationToken.
2. **Generic Type Safety**: Framework relies heavily on generics. Maintain strong typing.
3. **Endpoint Mapping Timing**: Discovery services must be registered before calling `UseSvrntyCqrs()`.
4. **AOT Compatibility**: `IsAotCompatible` is set but not enforced — many dependencies are not AOT-compatible.
1. **AOT Compatibility**: Currently not enforced. The `IsAotCompatible` property is set on some projects but many dependencies (including FluentValidation, PoweredSoft.DynamicQuery) are not AOT-compatible. Future work may address this.
2. **Async Everywhere**: All handlers are async. Always support CancellationToken.
3. **Generic Type Safety**: Framework relies heavily on generics for compile-time safety. When adding features, maintain strong typing.
4. **Metadata Pattern**: When extending discovery, always create corresponding metadata classes (implement ICommandMeta/IQueryMeta).
5. **Endpoint Mapping Timing**: Endpoints are mapped at application startup. Discovery services must be registered before calling `MapSvrntyCommands()`/`MapSvrntyQueries()` or mapping gRPC services.
6. **FluentValidation Integration**:
- For HTTP: Validation happens automatically in the Minimal API pipeline. Errors return RFC 7807 Problem Details.
- For gRPC: Validation happens automatically via source-generated services. Errors return Google Rich Error Model with structured FieldViolations.
- The framework REGISTERS validators in DI; actual validation execution is handled by the endpoint implementations.
7. **DynamicQuery Interceptors**: Support up to 5 interceptors per query type. Interceptors modify PoweredSoft DynamicQuery behavior.
## Common Code Locations
- Handler interfaces: `Svrnty.CQRS.Abstractions/ICommandHandler.cs`, `IQueryHandler.cs`
- Discovery: `Svrnty.CQRS/Discovery/`
- Discovery implementations: `Svrnty.CQRS/Discovery/`
- Service registration: `*/ServiceCollectionExtensions.cs` in each project
- HTTP endpoints: `Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs`
- Dynamic queries: `Svrnty.CQRS.DynamicQuery/DynamicQueryHandler.cs`
- gRPC generators: `Svrnty.CQRS.Grpc.Generators/`
- Sample: `Svrnty.Sample/`
- HTTP endpoint mapping: `Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs`
- Dynamic query logic: `Svrnty.CQRS.DynamicQuery/DynamicQueryHandler.cs`
- Dynamic query endpoints: `Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs`
- gRPC support: `Svrnty.CQRS.Grpc/` runtime, `Svrnty.CQRS.Grpc.Generators/` source generators
- Sample application: `Svrnty.Sample/` - demonstrates both HTTP and gRPC integration
@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
namespace Svrnty.CQRS.Abstractions.Security;
/// <summary>
/// Shared shape for command and query authorization-check contexts. Checks
/// receive the request type, the materialized (and validated) request instance,
/// a scoped <see cref="IServiceProvider"/>, and a free-form <see cref="Items"/>
/// dictionary that lets checks in the same pipeline pass signals to each other
/// (e.g. a future mobile-attestation check stamping "mobile_attested" for the
/// Altcha check to read).
/// </summary>
public abstract class AuthorizationCheckContext
{
public required IServiceProvider Services { get; init; }
public IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>();
}
public sealed class CommandAuthorizationCheckContext : AuthorizationCheckContext
{
public required Type CommandType { get; init; }
public required object Command { get; init; }
}
public sealed class QueryAuthorizationCheckContext : AuthorizationCheckContext
{
public required Type QueryType { get; init; }
public required object Query { get; init; }
}
@@ -0,0 +1,27 @@
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Abstractions.Security;
/// <summary>
/// Cross-cutting authorization check that runs alongside (not in place of) the
/// consumer's <see cref="ICommandAuthorizationService"/>. Multiple
/// implementations may be registered; the framework resolves them as
/// <c>IEnumerable&lt;ICommandAuthorizationCheck&gt;</c> and runs each in
/// registration order. AND semantics — any non-<see cref="AuthorizationResult.Allowed"/>
/// short-circuits the pipeline.
/// </summary>
/// <remarks>
/// Use this seam for self-applying, attribute-driven checks shipped by
/// framework modules (proof-of-work, mobile attestation, rate-limit gates,
/// IP allow-lists). The check is responsible for inspecting
/// <see cref="CommandAuthorizationCheckContext.CommandType"/> attributes and
/// no-op'ing (return <see cref="AuthorizationResult.Allowed"/>) when it
/// doesn't apply.
/// </remarks>
public interface ICommandAuthorizationCheck
{
Task<AuthorizationResult> CheckAsync(
CommandAuthorizationCheckContext context,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,15 @@
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Abstractions.Security;
/// <summary>
/// Query-side counterpart to <see cref="ICommandAuthorizationCheck"/>. See
/// that interface's remarks for usage.
/// </summary>
public interface IQueryAuthorizationCheck
{
Task<AuthorizationResult> CheckAsync(
QueryAuthorizationCheckContext context,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,27 @@
namespace Svrnty.CQRS.Altcha.Abstractions;
/// <summary>
/// Marks a command or query as requiring proof-of-work (or equivalent
/// anti-abuse evidence) before the framework will dispatch it to the handler.
/// </summary>
/// <remarks>
/// The accompanying request type should implement <see cref="IHasAltchaSolution"/>
/// to carry the widget's solution payload. The framework's Altcha
/// authorization check (registered via <c>AddSvrntyAltcha()</c>) reads the
/// solution off the request and calls the configured <see cref="IAltchaVerifier"/>.
/// </remarks>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class AltchaAttribute : Attribute
{
/// <summary>
/// When <c>true</c> (default), a valid mobile-attestation token on the
/// request satisfies the requirement without needing a proof-of-work
/// solution. The Altcha check reads
/// <see cref="Svrnty.CQRS.Abstractions.Security.AuthorizationCheckContext.Items"/>[<c>"mobile_attested"</c>]
/// — when stamped <c>true</c> by an earlier check (e.g. an Apple
/// App Attest / Play Integrity verifier), the PoW check is skipped.
/// Set to <c>false</c> on commands where PoW must always run regardless
/// of caller.
/// </summary>
public bool AllowMobileAttestationBypass { get; set; } = true;
}
@@ -0,0 +1,28 @@
namespace Svrnty.CQRS.Altcha.Abstractions;
/// <summary>
/// Server-issued Altcha challenge. Shape matches what the
/// <a href="https://altcha.org/docs/v2/widget-v3/">altcha widget v3</a>
/// expects from its <c>challengeurl</c>.
/// </summary>
public sealed class AltchaChallenge
{
/// <summary>Hashing algorithm name (e.g. <c>SHA-256</c>).</summary>
public required string Algorithm { get; init; }
/// <summary>Hex-encoded hash the client must find a preimage for.</summary>
public required string Challenge { get; init; }
/// <summary>Hex-encoded salt (embeds an expiry timestamp).</summary>
public required string Salt { get; init; }
/// <summary>
/// HMAC-SHA256 of <c>algorithm|challenge|salt|maxnumber</c> using the
/// server's signing secret. Lets the verify step trust the issued
/// challenge without keeping per-challenge state.
/// </summary>
public required string Signature { get; init; }
/// <summary>Upper bound of the PoW search space (equals the requested complexity).</summary>
public required uint MaxNumber { get; init; }
}
@@ -0,0 +1,20 @@
namespace Svrnty.CQRS.Altcha.Abstractions;
/// <summary>
/// Outcome of an Altcha solution verification.
/// </summary>
public sealed class AltchaVerifyResult
{
public required bool Ok { get; init; }
/// <summary>
/// Diagnostic only — not surfaced to end users. Suggested values:
/// <c>signature-invalid</c>, <c>expired</c>, <c>pow-incorrect</c>,
/// <c>replayed</c>, <c>redis-unreachable</c>, <c>malformed</c>.
/// </summary>
public string? Reason { get; init; }
public static AltchaVerifyResult Success { get; } = new() { Ok = true };
public static AltchaVerifyResult Fail(string reason) => new() { Ok = false, Reason = reason };
}
@@ -0,0 +1,13 @@
namespace Svrnty.CQRS.Altcha.Abstractions;
/// <summary>
/// Mints an Altcha challenge for the widget to solve. The default
/// implementation in <c>Svrnty.CQRS.Altcha.Grpc</c> talks to a self-hosted
/// altcha service; the <c>Svrnty.CQRS.Altcha.MinimalApi</c> package exposes
/// <c>GET /api/altcha/challenge</c> that returns this DTO as the JSON
/// shape the widget expects.
/// </summary>
public interface IAltchaChallengeProvider
{
Task<AltchaChallenge> CreateAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,27 @@
namespace Svrnty.CQRS.Altcha.Abstractions;
/// <summary>
/// Resolves a per-request PoW complexity (search-space upper bound) for the
/// next Altcha challenge the server will mint. Implementations may consult
/// per-actor signals — repeat-offender counters, threat-intel headers,
/// reputation scores — to scale difficulty up for suspicious actors while
/// keeping the baseline cheap for everyone else.
/// </summary>
/// <remarks>
/// The framework ships a no-op <see cref="IAltchaDifficultyAdvisor"/> that
/// always returns <c>null</c>, meaning "use the upstream service's configured
/// default complexity." Applications opt into adaptive difficulty by
/// replacing the registration with their own implementation; consult request
/// context via <see cref="Microsoft.AspNetCore.Http.IHttpContextAccessor"/>
/// or scoped DI.
/// </remarks>
public interface IAltchaDifficultyAdvisor
{
/// <summary>
/// Returns the desired <c>maxNumber</c> (PoW search-space upper bound)
/// for the next challenge, or <c>null</c> to defer to the upstream
/// service default. The Altcha server clamps to its configured min/max,
/// so callers don't need to enforce bounds here.
/// </summary>
Task<uint?> GetComplexityAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,18 @@
namespace Svrnty.CQRS.Altcha.Abstractions;
/// <summary>
/// Verifies an Altcha solution payload. The default implementation in
/// <c>Svrnty.CQRS.Altcha.Grpc</c> talks to a self-hosted altcha service
/// over gRPC; consumers may register their own implementation (e.g. an
/// in-process variant or a different transport) and the
/// <see cref="ICommandAuthorizationCheck"/>-based pipeline picks it up
/// automatically.
/// </summary>
public interface IAltchaVerifier
{
/// <param name="payload">
/// Base64-encoded JSON payload produced by the Altcha widget — the same
/// string carried on <see cref="IHasAltchaSolution.AltchaSolution"/>.
/// </param>
Task<AltchaVerifyResult> VerifyAsync(string payload, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,16 @@
namespace Svrnty.CQRS.Altcha.Abstractions;
/// <summary>
/// Implemented by command and query POCOs that carry an Altcha widget
/// solution. The framework's Altcha check reads <see cref="AltchaSolution"/>
/// off the materialized request, so the value travels naturally over HTTP
/// (JSON body field) and gRPC (proto field) without any extra plumbing.
/// </summary>
public interface IHasAltchaSolution
{
/// <summary>
/// Base64-encoded JSON payload produced by the Altcha widget. Null /
/// empty causes the check to reject the request.
/// </summary>
string? AltchaSolution { get; set; }
}
@@ -0,0 +1,21 @@
namespace Svrnty.CQRS.Altcha.Abstractions;
/// <summary>
/// Phase 3 placeholder — when a future module implements Apple App Attest /
/// Google Play Integrity verification, it stamps
/// <see cref="Svrnty.CQRS.Abstractions.Security.AuthorizationCheckContext.Items"/>[<c>"mobile_attested"</c>]
/// based on the verification result, and the Altcha check reads that flag
/// to short-circuit when <see cref="AltchaAttribute.AllowMobileAttestationBypass"/>
/// is true.
/// </summary>
/// <remarks>
/// Intentionally left abstract and unwired in this phase. The interface
/// exists so Phase 3 can drop in an implementation without touching command
/// definitions or the Altcha check.
/// </remarks>
public interface IMobileAttestationProvider
{
/// <param name="attestationToken">Platform-specific attestation token from the request.</param>
/// <returns><c>true</c> if attestation passes; <c>false</c> otherwise.</returns>
Task<bool> VerifyAsync(string attestationToken, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>true</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Company>Svrnty</Company>
<Authors>David Lebee, Mathias Beaulieu-Duncan</Authors>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://git.openharbor.io/svrnty/dotnet-cqrs</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>
<IncludeSymbols>true</IncludeSymbols>
<IncludeSource>true</IncludeSource>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<None Include="..\icon.png" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS.Abstractions\Svrnty.CQRS.Abstractions.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,27 @@
using Grpc.Core;
namespace Svrnty.CQRS.Altcha.Grpc;
/// <summary>
/// Helper that builds gRPC call metadata (an <c>Authorization</c> header)
/// from <see cref="AltchaGrpcOptions.TokenProvider"/>. Kept as a separate
/// shared helper so the verifier and challenge provider apply identical
/// rules.
/// </summary>
internal static class AltchaCallCredentials
{
public static async Task<Metadata?> BuildMetadataAsync(AltchaGrpcOptions options, CancellationToken cancellationToken)
{
if (options.TokenProvider is null)
return null;
var token = await options.TokenProvider(cancellationToken);
if (string.IsNullOrWhiteSpace(token))
return null;
return new Metadata
{
{ "Authorization", $"Bearer {token}" }
};
}
}
@@ -0,0 +1,69 @@
using Grpc.Core;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Svrnty.CQRS.Altcha.Abstractions;
namespace Svrnty.CQRS.Altcha.Grpc;
/// <summary>
/// Default <see cref="IAltchaChallengeProvider"/> backed by gRPC. Calls
/// <c>AltchaService.CreateChallenge</c> on the configured endpoint and
/// projects the response onto <see cref="AltchaChallenge"/>.
/// </summary>
public sealed class AltchaGrpcChallengeProvider : IAltchaChallengeProvider
{
private readonly AltchaService.AltchaServiceClient _client;
private readonly IOptions<AltchaGrpcOptions> _options;
private readonly IAltchaDifficultyAdvisor _advisor;
private readonly ILogger<AltchaGrpcChallengeProvider> _logger;
public AltchaGrpcChallengeProvider(
AltchaService.AltchaServiceClient client,
IOptions<AltchaGrpcOptions> options,
IAltchaDifficultyAdvisor advisor,
ILogger<AltchaGrpcChallengeProvider> logger)
{
_client = client;
_options = options;
_advisor = advisor;
_logger = logger;
}
public async Task<AltchaChallenge> CreateAsync(CancellationToken cancellationToken = default)
{
var opts = _options.Value;
var metadata = await AltchaCallCredentials.BuildMetadataAsync(opts, cancellationToken);
var deadline = DateTime.UtcNow.Add(opts.CallTimeout);
var request = new CreateChallengeRequest();
var advisedComplexity = await _advisor.GetComplexityAsync(cancellationToken);
if (advisedComplexity is uint complexity)
{
request.Complexity = complexity;
_logger.LogDebug("Altcha advisor requested complexity {Complexity}", complexity);
}
try
{
var response = await _client.CreateChallengeAsync(
request,
headers: metadata,
deadline: deadline,
cancellationToken: cancellationToken);
return new AltchaChallenge
{
Algorithm = response.Algorithm,
Challenge = response.ChallengeHash,
Salt = response.Salt,
Signature = response.Signature,
MaxNumber = response.Maxnumber
};
}
catch (RpcException ex)
{
_logger.LogError(ex, "Altcha create-challenge failed against {Endpoint}: {Status}", opts.Endpoint, ex.StatusCode);
throw;
}
}
}
@@ -0,0 +1,31 @@
namespace Svrnty.CQRS.Altcha.Grpc;
/// <summary>
/// Configuration for <see cref="AltchaGrpcVerifier"/> and
/// <see cref="AltchaGrpcChallengeProvider"/>. Bind from configuration
/// (e.g. <c>"Altcha"</c> section) or pass via the registration delegate.
/// </summary>
public sealed class AltchaGrpcOptions
{
/// <summary>
/// gRPC endpoint of the altcha service. Typically the internal
/// docker / k8s address — e.g. <c>http://altcha:9090</c> or
/// <c>https://altcha.planb.svc.cluster.local:9090</c>.
/// </summary>
public string Endpoint { get; set; } = string.Empty;
/// <summary>
/// Optional per-call HMAC service-token provider. When set, the
/// returned string is sent as <c>Authorization: Bearer &lt;token&gt;</c>
/// on every outbound gRPC call. Use this to integrate with whatever
/// service-auth scheme the rest of the deployment uses (e.g. plan-b's
/// <c>ServiceTokenIssuer.GetToken("altcha")</c>).
/// </summary>
public Func<CancellationToken, Task<string>>? TokenProvider { get; set; }
/// <summary>
/// Per-call timeout for both <c>CreateChallenge</c> and
/// <c>VerifyChallenge</c>. Defaults to 5s.
/// </summary>
public TimeSpan CallTimeout { get; set; } = TimeSpan.FromSeconds(5);
}
@@ -0,0 +1,66 @@
using Grpc.Core;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Svrnty.CQRS.Altcha.Abstractions;
namespace Svrnty.CQRS.Altcha.Grpc;
/// <summary>
/// Default <see cref="IAltchaVerifier"/> backed by gRPC. Calls
/// <c>AltchaService.VerifyChallenge</c> on the configured endpoint and
/// maps any failure (transport error, deadline, server-reported failure)
/// to <see cref="AltchaVerifyResult.Fail"/>. Verification failures are
/// safe defaults — callers see an <c>Unauthorized</c> outcome from the
/// auth check.
/// </summary>
public sealed class AltchaGrpcVerifier : IAltchaVerifier
{
private readonly AltchaService.AltchaServiceClient _client;
private readonly IOptions<AltchaGrpcOptions> _options;
private readonly ILogger<AltchaGrpcVerifier> _logger;
public AltchaGrpcVerifier(
AltchaService.AltchaServiceClient client,
IOptions<AltchaGrpcOptions> options,
ILogger<AltchaGrpcVerifier> logger)
{
_client = client;
_options = options;
_logger = logger;
}
public async Task<AltchaVerifyResult> VerifyAsync(string payload, CancellationToken cancellationToken = default)
{
var opts = _options.Value;
try
{
var metadata = await AltchaCallCredentials.BuildMetadataAsync(opts, cancellationToken);
var deadline = DateTime.UtcNow.Add(opts.CallTimeout);
var response = await _client.VerifyChallengeAsync(
new VerifyChallengeRequest { Payload = payload },
headers: metadata,
deadline: deadline,
cancellationToken: cancellationToken);
return response.Ok
? AltchaVerifyResult.Success
: AltchaVerifyResult.Fail(string.IsNullOrEmpty(response.Reason) ? "invalid" : response.Reason);
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.DeadlineExceeded)
{
_logger.LogWarning(ex, "Altcha verify timed out against {Endpoint}.", opts.Endpoint);
return AltchaVerifyResult.Fail("verify-timeout");
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.Unavailable)
{
_logger.LogWarning(ex, "Altcha service unavailable at {Endpoint}.", opts.Endpoint);
return AltchaVerifyResult.Fail("service-unavailable");
}
catch (RpcException ex)
{
_logger.LogWarning(ex, "Altcha verify failed against {Endpoint}: {Status}", opts.Endpoint, ex.StatusCode);
return AltchaVerifyResult.Fail("rpc-error");
}
}
}
@@ -0,0 +1,69 @@
syntax = "proto3";
package svrnty.cqrs.altcha.v1;
option go_package = "svrnty.cqrs.altcha.v1;altchapb";
option csharp_namespace = "Svrnty.CQRS.Altcha.Grpc";
// AltchaService is the wire contract between any backend that gates
// commands/queries with the [Altcha] attribute and a self-hosted Altcha
// implementation. The default .NET client lives in this package; a
// reference Go server lives in plan-b's projects/altcha (which vendors
// this proto). Keep this file the source of truth for both sides.
service AltchaService {
// Mint a fresh challenge for the widget to solve. Server-stateless:
// the response embeds an HMAC signature that VerifyChallenge re-checks.
rpc CreateChallenge(CreateChallengeRequest) returns (Challenge);
// Verify a widget-produced solution payload. The server checks
// signature + expiry + PoW correctness, then atomically claims the
// challenge in a replay-protection cache (e.g. Redis SETNX) so each
// solution is single-use across all server replicas.
rpc VerifyChallenge(VerifyChallengeRequest) returns (VerifyChallengeResponse);
}
message CreateChallengeRequest {
// Optional per-request complexity override (PoW search-space upper
// bound). Higher = slower client solve. Server clamps to its
// configured min/max. Omit to use the server default.
optional uint32 complexity = 1;
}
message Challenge {
// Hashing algorithm — e.g. "SHA-256" (v2 default). Future versions may
// upgrade to PBKDF2 / Argon2; the field lets the widget pick the
// right solver.
string algorithm = 1;
// Hex-encoded hash the client must find a preimage for. Field is
// named "challenge_hash" rather than "challenge" to avoid a C#
// property/class name collision (the generated message class is
// also named Challenge); JSON projection for the widget remaps it.
string challenge_hash = 2;
// Hex-encoded random salt. Typically embeds the expiry timestamp so
// verifiers can re-derive the TTL without server state.
string salt = 3;
// HMAC-SHA256(secret, algorithm|challenge|salt|maxnumber). Lets
// VerifyChallenge confirm the challenge was issued by this server.
string signature = 4;
// PoW search-space upper bound. Equals the complexity used.
uint32 maxnumber = 5;
}
message VerifyChallengeRequest {
// Base64-encoded JSON payload produced by the Altcha widget — the
// value carried over the wire on IHasAltchaSolution.AltchaSolution.
string payload = 1;
}
message VerifyChallengeResponse {
bool ok = 1;
// Diagnostic only; not surfaced to end users. Suggested values:
// "signature-invalid", "expired", "pow-incorrect", "replayed",
// "redis-unreachable", "malformed".
string reason = 2;
}
@@ -0,0 +1,64 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Svrnty.CQRS.Altcha.Abstractions;
namespace Svrnty.CQRS.Altcha.Grpc;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers the gRPC-backed <see cref="IAltchaVerifier"/> and
/// <see cref="IAltchaChallengeProvider"/>. Configure the endpoint
/// and optional service-auth token provider via the
/// <paramref name="configure"/> delegate.
/// </summary>
/// <example>
/// <code>
/// services.AddSvrntyAltcha();
/// services.AddSvrntyAltchaGrpcVerifier(opts =>
/// {
/// opts.Endpoint = "http://altcha:9090";
/// opts.TokenProvider = async ct => await tokenIssuer.GetTokenAsync("altcha", ct);
/// });
/// </code>
/// </example>
public static IServiceCollection AddSvrntyAltchaGrpcVerifier(
this IServiceCollection services,
Action<AltchaGrpcOptions> configure)
{
services.Configure(configure);
RegisterCore(services);
return services;
}
/// <summary>
/// Binds <see cref="AltchaGrpcOptions"/> from a configuration section
/// (typically <c>"Altcha:Grpc"</c>) and registers the gRPC verifier
/// and challenge provider.
/// </summary>
public static IServiceCollection AddSvrntyAltchaGrpcVerifier(
this IServiceCollection services,
IConfiguration configuration)
{
services.Configure<AltchaGrpcOptions>(configuration);
RegisterCore(services);
return services;
}
private static void RegisterCore(IServiceCollection services)
{
services.AddGrpcClient<AltchaService.AltchaServiceClient>((sp, client) =>
{
var opts = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<AltchaGrpcOptions>>().Value;
if (string.IsNullOrWhiteSpace(opts.Endpoint))
throw new InvalidOperationException(
"Altcha gRPC endpoint not configured. Set AltchaGrpcOptions.Endpoint " +
"(e.g. http://altcha:9090).");
client.Address = new Uri(opts.Endpoint);
});
services.TryAddSingleton<IAltchaVerifier, AltchaGrpcVerifier>();
services.TryAddSingleton<IAltchaChallengeProvider, AltchaGrpcChallengeProvider>();
}
}
@@ -0,0 +1,47 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>false</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Company>Svrnty</Company>
<Authors>David Lebee, Mathias Beaulieu-Duncan</Authors>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://git.openharbor.io/svrnty/dotnet-cqrs</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>
<IncludeSymbols>true</IncludeSymbols>
<IncludeSource>true</IncludeSource>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<None Include="..\icon.png" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Google.Protobuf" Version="3.30.2" />
<PackageReference Include="Grpc.Net.ClientFactory" Version="2.71.0" />
<PackageReference Include="Grpc.Tools" Version="2.71.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS.Altcha.Abstractions\Svrnty.CQRS.Altcha.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="Protos\altcha.proto" GrpcServices="Client" />
</ItemGroup>
</Project>
@@ -0,0 +1,28 @@
using System.Text.Json.Serialization;
namespace Svrnty.CQRS.Altcha.MinimalApi;
/// <summary>
/// JSON projection of <see cref="Svrnty.CQRS.Altcha.Abstractions.AltchaChallenge"/>
/// in the exact shape the
/// <a href="https://altcha.org/docs/v2/widget-v3/">altcha widget v3</a>
/// expects from a <c>challengeurl</c> response. Property names are
/// lowercased and <c>challenge</c> (no underscore) to match the widget.
/// </summary>
public sealed class AltchaChallengeDto
{
[JsonPropertyName("algorithm")]
public required string Algorithm { get; init; }
[JsonPropertyName("challenge")]
public required string Challenge { get; init; }
[JsonPropertyName("salt")]
public required string Salt { get; init; }
[JsonPropertyName("signature")]
public required string Signature { get; init; }
[JsonPropertyName("maxnumber")]
public required uint MaxNumber { get; init; }
}
@@ -0,0 +1,50 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Altcha.Abstractions;
namespace Svrnty.CQRS.Altcha.MinimalApi;
public static class EndpointRouteBuilderExtensions
{
/// <summary>
/// Maps <c>GET {routePrefix}</c> (default <c>/api/altcha/challenge</c>)
/// returning a fresh challenge in the JSON shape the
/// <a href="https://altcha.org/docs/v2/widget-v3/">altcha widget</a>
/// consumes via its <c>challengeurl</c> attribute.
/// </summary>
/// <remarks>
/// Requires an <see cref="IAltchaChallengeProvider"/> to be registered
/// (typically by <c>AddSvrntyAltchaGrpcVerifier(...)</c>). The endpoint
/// allows anonymous access — the whole point is gating mutations from
/// unauthenticated callers, so the challenge endpoint must be reachable
/// without credentials.
/// </remarks>
public static IEndpointRouteBuilder MapSvrntyAltchaChallenge(
this IEndpointRouteBuilder endpoints,
string routePrefix = "/api/altcha/challenge")
{
endpoints.MapGet(routePrefix, async (
IAltchaChallengeProvider provider,
CancellationToken cancellationToken) =>
{
var challenge = await provider.CreateAsync(cancellationToken);
return Results.Ok(new AltchaChallengeDto
{
Algorithm = challenge.Algorithm,
Challenge = challenge.Challenge,
Salt = challenge.Salt,
Signature = challenge.Signature,
MaxNumber = challenge.MaxNumber
});
})
.AllowAnonymous()
.WithName("Altcha_Challenge_Get")
.WithTags("Altcha")
.Produces<AltchaChallengeDto>(200)
.Produces(503);
return endpoints;
}
}
@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>false</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Company>Svrnty</Company>
<Authors>David Lebee, Mathias Beaulieu-Duncan</Authors>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://git.openharbor.io/svrnty/dotnet-cqrs</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>
<IncludeSymbols>true</IncludeSymbols>
<IncludeSource>true</IncludeSource>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<None Include="..\icon.png" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS.Altcha.Abstractions\Svrnty.CQRS.Altcha.Abstractions.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,93 @@
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Svrnty.CQRS.Abstractions.Security;
using Svrnty.CQRS.Altcha.Abstractions;
namespace Svrnty.CQRS.Altcha;
/// <summary>
/// The single check that powers the <see cref="AltchaAttribute"/>. Plays both
/// <see cref="ICommandAuthorizationCheck"/> and <see cref="IQueryAuthorizationCheck"/>
/// roles so a request payload of either kind is gated identically.
/// </summary>
/// <remarks>
/// Self-applying: the check no-ops (returns <see cref="AuthorizationResult.Allowed"/>)
/// for any request whose type isn't decorated with <see cref="AltchaAttribute"/>.
/// Resolves <see cref="IAltchaVerifier"/> from the request scope per call so the
/// check is agnostic to the verifier's lifetime.
/// </remarks>
public sealed class AltchaAuthorizationCheck : ICommandAuthorizationCheck, IQueryAuthorizationCheck
{
internal const string ReasonItemKey = "altcha_reason";
internal const string MobileAttestedItemKey = "mobile_attested";
private readonly ILogger<AltchaAuthorizationCheck> _logger;
public AltchaAuthorizationCheck(ILogger<AltchaAuthorizationCheck> logger)
{
_logger = logger;
}
public Task<AuthorizationResult> CheckAsync(
CommandAuthorizationCheckContext context,
CancellationToken cancellationToken = default)
=> CheckCoreAsync(context, context.CommandType, context.Command, cancellationToken);
public Task<AuthorizationResult> CheckAsync(
QueryAuthorizationCheckContext context,
CancellationToken cancellationToken = default)
=> CheckCoreAsync(context, context.QueryType, context.Query, cancellationToken);
private async Task<AuthorizationResult> CheckCoreAsync(
AuthorizationCheckContext context,
Type subjectType,
object subject,
CancellationToken cancellationToken)
{
var altchaAttr = subjectType.GetCustomAttribute<AltchaAttribute>(inherit: false);
if (altchaAttr is null)
return AuthorizationResult.Allowed;
if (altchaAttr.AllowMobileAttestationBypass
&& context.Items.TryGetValue(MobileAttestedItemKey, out var attested)
&& attested is true)
{
_logger.LogDebug("Altcha bypassed for {Type}: mobile attestation present.", subjectType.FullName);
return AuthorizationResult.Allowed;
}
if (subject is not IHasAltchaSolution carrier)
{
// Developer error: [Altcha] on a request that doesn't carry the solution field.
_logger.LogError(
"[Altcha] is set on {Type} but the type does not implement IHasAltchaSolution. " +
"Verification cannot proceed — treating as forbidden.",
subjectType.FullName);
context.Items[ReasonItemKey] = "misconfigured";
return AuthorizationResult.Forbidden;
}
if (string.IsNullOrWhiteSpace(carrier.AltchaSolution))
{
_logger.LogWarning("Altcha required for {Type} but no solution was supplied.", subjectType.FullName);
context.Items[ReasonItemKey] = "missing";
return AuthorizationResult.Unauthorized;
}
var verifier = context.Services.GetRequiredService<IAltchaVerifier>();
var result = await verifier.VerifyAsync(carrier.AltchaSolution, cancellationToken);
if (!result.Ok)
{
_logger.LogWarning(
"Altcha verification rejected {Type}: reason={Reason}",
subjectType.FullName,
result.Reason ?? "unspecified");
context.Items[ReasonItemKey] = result.Reason ?? "invalid";
return AuthorizationResult.Unauthorized;
}
return AuthorizationResult.Allowed;
}
}
@@ -0,0 +1,15 @@
using Svrnty.CQRS.Altcha.Abstractions;
namespace Svrnty.CQRS.Altcha;
/// <summary>
/// No-op fallback registered by <c>AddSvrntyAltcha()</c>. Always returns
/// <c>null</c>, leaving complexity at the upstream Altcha service's
/// configured default. Applications that want adaptive difficulty
/// replace this registration with their own implementation.
/// </summary>
internal sealed class NullAltchaDifficultyAdvisor : IAltchaDifficultyAdvisor
{
public Task<uint?> GetComplexityAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<uint?>(null);
}
@@ -0,0 +1,36 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Svrnty.CQRS.Abstractions.Security;
using Svrnty.CQRS.Altcha.Abstractions;
namespace Svrnty.CQRS.Altcha;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers <see cref="AltchaAuthorizationCheck"/> as both an
/// <see cref="ICommandAuthorizationCheck"/> and an
/// <see cref="IQueryAuthorizationCheck"/>, plus a no-op
/// <see cref="IAltchaDifficultyAdvisor"/> that defers to the upstream
/// Altcha service's configured default complexity. Applications opt
/// into adaptive difficulty by registering their own
/// <see cref="IAltchaDifficultyAdvisor"/> before or after this call —
/// the <c>TryAdd</c> registration here yields to any existing one.
/// </summary>
/// <remarks>
/// The check is a no-op until an
/// <see cref="IAltchaVerifier"/> implementation is also registered
/// (typically via <c>AddSvrntyAltchaGrpcVerifier(...)</c> from
/// <c>Svrnty.CQRS.Altcha.Grpc</c>). Idempotent for the concrete check;
/// the multi-instance interface registrations are added unconditionally,
/// so callers should invoke this exactly once per application startup.
/// </remarks>
public static IServiceCollection AddSvrntyAltcha(this IServiceCollection services)
{
services.TryAddSingleton<AltchaAuthorizationCheck>();
services.AddSingleton<ICommandAuthorizationCheck>(sp => sp.GetRequiredService<AltchaAuthorizationCheck>());
services.AddSingleton<IQueryAuthorizationCheck>(sp => sp.GetRequiredService<AltchaAuthorizationCheck>());
services.TryAddSingleton<IAltchaDifficultyAdvisor, NullAltchaDifficultyAdvisor>();
return services;
}
}
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>true</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Company>Svrnty</Company>
<Authors>David Lebee, Mathias Beaulieu-Duncan</Authors>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://git.openharbor.io/svrnty/dotnet-cqrs</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>
<IncludeSymbols>true</IncludeSymbols>
<IncludeSource>true</IncludeSource>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<None Include="..\icon.png" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS.Abstractions\Svrnty.CQRS.Abstractions.csproj" />
<ProjectReference Include="..\Svrnty.CQRS.Altcha.Abstractions\Svrnty.CQRS.Altcha.Abstractions.csproj" />
</ItemGroup>
</Project>
+74 -8
View File
@@ -858,7 +858,8 @@ public class GrpcGenerator : IIncrementalGenerator
var constructorType = prop.FullyQualifiedType.TrimEnd('?');
return $"{indent}{prop.Name} = new {constructorType}({source}?.Items?.Select(x => System.Guid.Parse(x)).ToArray() ?? System.Array.Empty<System.Guid>()),";
}
return $"{indent}{prop.Name} = {source}?.Select(x => System.Guid.Parse(x)).ToList(),";
// proto repeated fields are never null — drop ?. to avoid CS8601 on assignment to non-nullable target
return $"{indent}{prop.Name} = {source}.Select(x => System.Guid.Parse(x)).ToList(),";
}
else if (prop.IsValueTypeCollection)
{
@@ -869,7 +870,8 @@ public class GrpcGenerator : IIncrementalGenerator
else
{
// Primitive list: just ToList()
return $"{indent}{prop.Name} = {source}?.ToList(),";
// proto repeated fields are never null — drop ?. to avoid CS8601 on assignment to non-nullable target
return $"{indent}{prop.Name} = {source}.ToList(),";
}
}
@@ -884,11 +886,11 @@ public class GrpcGenerator : IIncrementalGenerator
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = string.IsNullOrEmpty({source}) ? null : decimal.Parse({source}),";
return $"{indent}{prop.Name} = string.IsNullOrEmpty({source}) ? null : decimal.Parse({source}, System.Globalization.CultureInfo.InvariantCulture),";
}
else
{
return $"{indent}{prop.Name} = decimal.Parse({source}),";
return $"{indent}{prop.Name} = decimal.Parse({source}, System.Globalization.CultureInfo.InvariantCulture),";
}
}
@@ -969,7 +971,9 @@ public class GrpcGenerator : IIncrementalGenerator
var sb = new StringBuilder();
// For value type collections, the proto message has an Items field containing the repeated elements
var itemsSource = prop.IsValueTypeCollection ? $"{source}?.Items" : source;
sb.AppendLine($"{indent}{prop.Name} = {itemsSource}?.Select(x => new {prop.ElementType}");
// Value-type wrapper messages can be null (?.Items needs ?.). Plain proto repeated is never null.
var selectAccess = prop.IsValueTypeCollection ? "?." : ".";
sb.AppendLine($"{indent}{prop.Name} = {itemsSource}{selectAccess}Select(x => new {prop.ElementType}");
sb.AppendLine($"{indent}{{");
foreach (var nestedProp in prop.ElementNestedProperties!)
@@ -1031,11 +1035,11 @@ public class GrpcGenerator : IIncrementalGenerator
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = string.IsNullOrEmpty({source}) ? null : decimal.Parse({source}),";
return $"{indent}{prop.Name} = string.IsNullOrEmpty({source}) ? null : decimal.Parse({source}, System.Globalization.CultureInfo.InvariantCulture),";
}
else
{
return $"{indent}{prop.Name} = decimal.Parse({source}),";
return $"{indent}{prop.Name} = decimal.Parse({source}, System.Globalization.CultureInfo.InvariantCulture),";
}
}
@@ -1078,7 +1082,8 @@ public class GrpcGenerator : IIncrementalGenerator
var constructorType = prop.FullyQualifiedType.TrimEnd('?');
return $"{indent}{prop.Name} = new {constructorType}({source}?.ToArray() ?? System.Array.Empty<{prop.ElementType ?? "object"}>()),";
}
return $"{indent}{prop.Name} = {source}?.ToList(),";
// proto repeated fields are never null — drop ?. to avoid CS8601
return $"{indent}{prop.Name} = {source}.ToList(),";
}
// Handle complex types
@@ -2371,6 +2376,26 @@ public class GrpcGenerator : IIncrementalGenerator
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" // Authorization checks (cross-cutting; see ICommandAuthorizationCheck)");
sb.AppendLine(" var commandChecks = serviceProvider.GetServices<ICommandAuthorizationCheck>();");
sb.AppendLine(" if (commandChecks != null)");
sb.AppendLine(" {");
sb.AppendLine(" var checkContext = new CommandAuthorizationCheckContext");
sb.AppendLine(" {");
sb.AppendLine($" CommandType = typeof({command.FullyQualifiedName}),");
sb.AppendLine(" Command = command,");
sb.AppendLine(" Services = serviceProvider");
sb.AppendLine(" };");
sb.AppendLine(" foreach (var check in commandChecks)");
sb.AppendLine(" {");
sb.AppendLine(" var checkResult = await check.CheckAsync(checkContext, context.CancellationToken);");
sb.AppendLine(" if (checkResult == AuthorizationResult.Unauthorized)");
sb.AppendLine(" throw new RpcException(new global::Grpc.Core.Status(global::Grpc.Core.StatusCode.Unauthenticated, \"Unauthorized\"));");
sb.AppendLine(" if (checkResult == AuthorizationResult.Forbidden)");
sb.AppendLine(" throw new RpcException(new global::Grpc.Core.Status(global::Grpc.Core.StatusCode.PermissionDenied, \"Forbidden\"));");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine($" var handler = serviceProvider.GetRequiredService<{command.HandlerInterfaceName}>();");
if (command.HasResult)
@@ -2488,6 +2513,27 @@ public class GrpcGenerator : IIncrementalGenerator
sb.AppendLine(assignment);
}
sb.AppendLine(" };");
sb.AppendLine();
sb.AppendLine(" // Authorization checks (cross-cutting; see IQueryAuthorizationCheck)");
sb.AppendLine(" var queryChecks = serviceProvider.GetServices<IQueryAuthorizationCheck>();");
sb.AppendLine(" if (queryChecks != null)");
sb.AppendLine(" {");
sb.AppendLine(" var checkContext = new QueryAuthorizationCheckContext");
sb.AppendLine(" {");
sb.AppendLine($" QueryType = typeof({query.FullyQualifiedName}),");
sb.AppendLine(" Query = query,");
sb.AppendLine(" Services = serviceProvider");
sb.AppendLine(" };");
sb.AppendLine(" foreach (var check in queryChecks)");
sb.AppendLine(" {");
sb.AppendLine(" var checkResult = await check.CheckAsync(checkContext, context.CancellationToken);");
sb.AppendLine(" if (checkResult == AuthorizationResult.Unauthorized)");
sb.AppendLine(" throw new RpcException(new global::Grpc.Core.Status(global::Grpc.Core.StatusCode.Unauthenticated, \"Unauthorized\"));");
sb.AppendLine(" if (checkResult == AuthorizationResult.Forbidden)");
sb.AppendLine(" throw new RpcException(new global::Grpc.Core.Status(global::Grpc.Core.StatusCode.PermissionDenied, \"Forbidden\"));");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" var result = await handler.HandleAsync(query, context.CancellationToken);");
// Generate response with mapping if complex type
@@ -2823,6 +2869,26 @@ public class GrpcGenerator : IIncrementalGenerator
sb.AppendLine(" Aggregates = ConvertAggregates(request.Aggregates) ?? new()");
sb.AppendLine(" };");
sb.AppendLine();
sb.AppendLine(" // Authorization checks (cross-cutting; see IQueryAuthorizationCheck)");
sb.AppendLine(" var queryChecks = serviceProvider.GetServices<IQueryAuthorizationCheck>();");
sb.AppendLine(" if (queryChecks != null)");
sb.AppendLine(" {");
sb.AppendLine(" var checkContext = new QueryAuthorizationCheckContext");
sb.AppendLine(" {");
sb.AppendLine($" QueryType = typeof({dynamicQuery.QueryInterfaceName}),");
sb.AppendLine(" Query = query,");
sb.AppendLine(" Services = serviceProvider");
sb.AppendLine(" };");
sb.AppendLine(" foreach (var check in queryChecks)");
sb.AppendLine(" {");
sb.AppendLine(" var checkResult = await check.CheckAsync(checkContext, context.CancellationToken);");
sb.AppendLine(" if (checkResult == AuthorizationResult.Unauthorized)");
sb.AppendLine(" throw new RpcException(new global::Grpc.Core.Status(global::Grpc.Core.StatusCode.Unauthenticated, \"Unauthorized\"));");
sb.AppendLine(" if (checkResult == AuthorizationResult.Forbidden)");
sb.AppendLine(" throw new RpcException(new global::Grpc.Core.Status(global::Grpc.Core.StatusCode.PermissionDenied, \"Forbidden\"));");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine();
// Get the handler and execute
sb.AppendLine($" var handler = serviceProvider.GetRequiredService<IQueryHandler<{dynamicQuery.QueryInterfaceName}, IQueryExecutionResult<{dynamicQuery.DestinationTypeFullyQualified}>>>();");
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
@@ -16,6 +17,64 @@ namespace Svrnty.CQRS.MinimalApi;
public static class EndpointRouteBuilderExtensions
{
private static async Task<IResult?> RunCommandChecksAsync(
IServiceProvider serviceProvider,
Type commandType,
object command,
CancellationToken cancellationToken)
{
var checks = serviceProvider.GetServices<ICommandAuthorizationCheck>().ToList();
if (checks.Count == 0)
return null;
var context = new CommandAuthorizationCheckContext
{
CommandType = commandType,
Command = command,
Services = serviceProvider
};
foreach (var check in checks)
{
var result = await check.CheckAsync(context, cancellationToken);
if (result == AuthorizationResult.Forbidden)
return Results.StatusCode(403);
if (result == AuthorizationResult.Unauthorized)
return Results.Unauthorized();
}
return null;
}
private static async Task<IResult?> RunQueryChecksAsync(
IServiceProvider serviceProvider,
Type queryType,
object query,
CancellationToken cancellationToken)
{
var checks = serviceProvider.GetServices<IQueryAuthorizationCheck>().ToList();
if (checks.Count == 0)
return null;
var context = new QueryAuthorizationCheckContext
{
QueryType = queryType,
Query = query,
Services = serviceProvider
};
foreach (var check in checks)
{
var result = await check.CheckAsync(context, cancellationToken);
if (result == AuthorizationResult.Forbidden)
return Results.StatusCode(403);
if (result == AuthorizationResult.Unauthorized)
return Results.Unauthorized();
}
return null;
}
public static IEndpointRouteBuilder MapSvrntyQueries(this IEndpointRouteBuilder endpoints, string routePrefix = "api/query")
{
var queryDiscovery = endpoints.ServiceProvider.GetRequiredService<IQueryDiscovery>();
@@ -63,6 +122,10 @@ public static class EndpointRouteBuilderExtensions
if (query == null || !queryMeta.QueryType.IsInstanceOfType(query))
return Results.BadRequest("Invalid query payload");
var checkResult = await RunQueryChecksAsync(serviceProvider, queryMeta.QueryType, query, cancellationToken);
if (checkResult != null)
return checkResult;
var handler = serviceProvider.GetRequiredService(handlerType);
var handleMethod = handlerType.GetMethod("HandleAsync");
if (handleMethod == null)
@@ -128,6 +191,10 @@ public static class EndpointRouteBuilderExtensions
}
}
var checkResult = await RunQueryChecksAsync(serviceProvider, queryMeta.QueryType, query, cancellationToken);
if (checkResult != null)
return checkResult;
var handler = serviceProvider.GetRequiredService(handlerType);
var handleMethod = handlerType.GetMethod("HandleAsync");
if (handleMethod == null)
@@ -198,6 +265,10 @@ public static class EndpointRouteBuilderExtensions
if (command == null || !commandMeta.CommandType.IsInstanceOfType(command))
return Results.BadRequest("Invalid command payload");
var checkResult = await RunCommandChecksAsync(serviceProvider, commandMeta.CommandType, command, cancellationToken);
if (checkResult != null)
return checkResult;
var handler = serviceProvider.GetRequiredService(handlerType);
var handleMethod = handlerType.GetMethod("HandleAsync");
if (handleMethod == null)
@@ -240,6 +311,10 @@ public static class EndpointRouteBuilderExtensions
if (command == null || !commandMeta.CommandType.IsInstanceOfType(command))
return Results.BadRequest("Invalid command payload");
var checkResult = await RunCommandChecksAsync(serviceProvider, commandMeta.CommandType, command, cancellationToken);
if (checkResult != null)
return checkResult;
var handler = serviceProvider.GetRequiredService(handlerType);
var handleMethod = handlerType.GetMethod("HandleAsync");
if (handleMethod == null)
+56
View File
@@ -43,6 +43,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.Abstract
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.RabbitMQ", "Svrnty.CQRS.Events.RabbitMQ\Svrnty.CQRS.Events.RabbitMQ.csproj", "{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Altcha.Abstractions", "Svrnty.CQRS.Altcha.Abstractions\Svrnty.CQRS.Altcha.Abstractions.csproj", "{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Altcha", "Svrnty.CQRS.Altcha\Svrnty.CQRS.Altcha.csproj", "{9986C034-D585-4045-9F6C-99896B8A385B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Altcha.Grpc", "Svrnty.CQRS.Altcha.Grpc\Svrnty.CQRS.Altcha.Grpc.csproj", "{628DE10C-FCDB-418B-8341-FA246BBCF70E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Altcha.MinimalApi", "Svrnty.CQRS.Altcha.MinimalApi\Svrnty.CQRS.Altcha.MinimalApi.csproj", "{26B24C13-FA06-4611-A371-2B640B8066F2}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -257,6 +265,54 @@ Global
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x64.Build.0 = Release|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x86.ActiveCfg = Release|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x86.Build.0 = Release|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Debug|x64.ActiveCfg = Debug|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Debug|x64.Build.0 = Debug|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Debug|x86.ActiveCfg = Debug|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Debug|x86.Build.0 = Debug|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|Any CPU.Build.0 = Release|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|x64.ActiveCfg = Release|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|x64.Build.0 = Release|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|x86.ActiveCfg = Release|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|x86.Build.0 = Release|Any CPU
{9986C034-D585-4045-9F6C-99896B8A385B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9986C034-D585-4045-9F6C-99896B8A385B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9986C034-D585-4045-9F6C-99896B8A385B}.Debug|x64.ActiveCfg = Debug|Any CPU
{9986C034-D585-4045-9F6C-99896B8A385B}.Debug|x64.Build.0 = Debug|Any CPU
{9986C034-D585-4045-9F6C-99896B8A385B}.Debug|x86.ActiveCfg = Debug|Any CPU
{9986C034-D585-4045-9F6C-99896B8A385B}.Debug|x86.Build.0 = Debug|Any CPU
{9986C034-D585-4045-9F6C-99896B8A385B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9986C034-D585-4045-9F6C-99896B8A385B}.Release|Any CPU.Build.0 = Release|Any CPU
{9986C034-D585-4045-9F6C-99896B8A385B}.Release|x64.ActiveCfg = Release|Any CPU
{9986C034-D585-4045-9F6C-99896B8A385B}.Release|x64.Build.0 = Release|Any CPU
{9986C034-D585-4045-9F6C-99896B8A385B}.Release|x86.ActiveCfg = Release|Any CPU
{9986C034-D585-4045-9F6C-99896B8A385B}.Release|x86.Build.0 = Release|Any CPU
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Debug|x64.ActiveCfg = Debug|Any CPU
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Debug|x64.Build.0 = Debug|Any CPU
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Debug|x86.ActiveCfg = Debug|Any CPU
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Debug|x86.Build.0 = Debug|Any CPU
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|Any CPU.Build.0 = Release|Any CPU
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|x64.ActiveCfg = Release|Any CPU
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|x64.Build.0 = Release|Any CPU
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|x86.ActiveCfg = Release|Any CPU
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|x86.Build.0 = Release|Any CPU
{26B24C13-FA06-4611-A371-2B640B8066F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{26B24C13-FA06-4611-A371-2B640B8066F2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{26B24C13-FA06-4611-A371-2B640B8066F2}.Debug|x64.ActiveCfg = Debug|Any CPU
{26B24C13-FA06-4611-A371-2B640B8066F2}.Debug|x64.Build.0 = Debug|Any CPU
{26B24C13-FA06-4611-A371-2B640B8066F2}.Debug|x86.ActiveCfg = Debug|Any CPU
{26B24C13-FA06-4611-A371-2B640B8066F2}.Debug|x86.Build.0 = Debug|Any CPU
{26B24C13-FA06-4611-A371-2B640B8066F2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{26B24C13-FA06-4611-A371-2B640B8066F2}.Release|Any CPU.Build.0 = Release|Any CPU
{26B24C13-FA06-4611-A371-2B640B8066F2}.Release|x64.ActiveCfg = Release|Any CPU
{26B24C13-FA06-4611-A371-2B640B8066F2}.Release|x64.Build.0 = Release|Any CPU
{26B24C13-FA06-4611-A371-2B640B8066F2}.Release|x86.ActiveCfg = Release|Any CPU
{26B24C13-FA06-4611-A371-2B640B8066F2}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+4
View File
@@ -26,6 +26,10 @@
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS.Abstractions\Svrnty.CQRS.Abstractions.csproj" />
</ItemGroup>
@@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Configuration;
namespace Svrnty.CQRS.MinimalApi;
namespace Svrnty.CQRS;
public static class WebApplicationExtensions
{
+8
View File
@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Svrnty.CQRS;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Altcha;
using Svrnty.CQRS.Altcha.Abstractions;
using Svrnty.CQRS.DynamicQuery;
using Svrnty.CQRS.FluentValidation;
using Svrnty.CQRS.Grpc;
@@ -27,8 +29,14 @@ builder.Services.AddDynamicQueryWithProvider<User, UserQueryableProvider>();
// Register commands and queries with validators
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
builder.Services.AddCommand<ProtectedActionCommand, string, ProtectedActionCommandHandler>();
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
// Wire the Altcha check + a stub verifier so [Altcha] commands run
// through the ICommandAuthorizationCheck pipeline.
builder.Services.AddSvrntyAltcha();
builder.Services.AddSingleton<IAltchaVerifier, StubAltchaVerifier>();
// Configure CQRS with fluent API
builder.Services.AddSvrntyCqrs(cqrs =>
{
+37
View File
@@ -0,0 +1,37 @@
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Altcha.Abstractions;
namespace Svrnty.Sample;
// Exercises the ICommandAuthorizationCheck seam at runtime.
// The command is decorated with [Altcha] and carries the solution
// through IHasAltchaSolution. With Svrnty.CQRS.Altcha + a registered
// IAltchaVerifier, the framework's check pipeline reads the field,
// calls the verifier, and short-circuits on failure.
[Altcha]
public sealed class ProtectedActionCommand : IHasAltchaSolution
{
public string Action { get; set; } = string.Empty;
public string? AltchaSolution { get; set; }
}
public sealed class ProtectedActionCommandHandler : ICommandHandler<ProtectedActionCommand, string>
{
public Task<string> HandleAsync(ProtectedActionCommand command, CancellationToken cancellationToken = default)
{
return Task.FromResult($"executed:{command.Action}");
}
}
// Stub verifier that doesn't talk to an external altcha service —
// enough to exercise the check pipeline in isolation. Treats the
// literal string "valid-solution" as a passing PoW solution.
public sealed class StubAltchaVerifier : IAltchaVerifier
{
public Task<AltchaVerifyResult> VerifyAsync(string payload, CancellationToken cancellationToken = default)
{
if (payload == "valid-solution")
return Task.FromResult(AltchaVerifyResult.Success);
return Task.FromResult(AltchaVerifyResult.Fail("stub-rejected"));
}
}
+14
View File
@@ -9,6 +9,9 @@ service CommandService {
// AddUserCommand operation
rpc AddUser (AddUserCommandRequest) returns (AddUserCommandResponse);
// ProtectedActionCommand operation
rpc ProtectedAction (ProtectedActionCommandRequest) returns (ProtectedActionCommandResponse);
// RemoveUserCommand operation
rpc RemoveUser (RemoveUserCommandRequest) returns (RemoveUserCommandResponse);
@@ -40,6 +43,17 @@ message AddUserCommandResponse {
int32 result = 1;
}
// Request message for ProtectedActionCommand
message ProtectedActionCommandRequest {
string action = 1;
string altcha_solution = 2;
}
// Response message for ProtectedActionCommand
message ProtectedActionCommandResponse {
string result = 1;
}
// Request message for RemoveUserCommand
message RemoveUserCommandRequest {
int32 user_id = 1;
+2
View File
@@ -33,6 +33,8 @@
<ProjectReference Include="..\Svrnty.CQRS.DynamicQuery\Svrnty.CQRS.DynamicQuery.csproj" />
<ProjectReference Include="..\Svrnty.CQRS.DynamicQuery.MinimalApi\Svrnty.CQRS.DynamicQuery.MinimalApi.csproj" />
<ProjectReference Include="..\Svrnty.CQRS.Grpc.Abstractions\Svrnty.CQRS.Grpc.Abstractions.csproj" />
<ProjectReference Include="..\Svrnty.CQRS.Altcha.Abstractions\Svrnty.CQRS.Altcha.Abstractions.csproj" />
<ProjectReference Include="..\Svrnty.CQRS.Altcha\Svrnty.CQRS.Altcha.csproj" />
</ItemGroup>
<!-- Import the proto generation targets for testing (in production this would come from the NuGet package) -->