Compare commits

...

17 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
mathias 7614f68512 Merge pull request 'feat/claude-code-harness' (#1) from feat/claude-code-harness into main
Publish NuGets / build (release) Successful in 32s
Reviewed-on: #1
2026-03-12 03:35:26 -04:00
Mathias Beaulieu-Duncan fdee02c960 Apply dotnet format with new editorconfig rules
Automated formatting: BOM removal, using sort order, final newlines,
whitespace normalization across all projects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 03:30:50 -04:00
Mathias Beaulieu-Duncan a4525bad6a Add Claude Code harness: rules, skills, hooks, and editorconfig
- Add path-specific rules for commands/queries, dynamic queries, validation, and gRPC
- Add /add-command, /add-query, /add-dynamic-query scaffolding skills
- Add project settings with post-edit formatting, proto validation, and build-gate hooks
- Add .editorconfig codifying existing code style conventions
- Trim CLAUDE.md from 414 to 130 lines (domain details moved to rules)
- Add .harness-version tracking for the shared claude-harness repo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 03:30:27 -04:00
Svrnty 3df094b9e7 docs: sanitise product references, add "Where This Fits" to README
Co-Authored-By: Svrnty Inc. <jp@svrnty.io, mathias@svrnty.io>
2026-02-27 13:08:17 -05:00
75 changed files with 4710 additions and 3449 deletions
-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": []
}
}
+96
View File
@@ -0,0 +1,96 @@
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{csproj,props,targets,xml}]
indent_size = 2
[*.{json,yml,yaml}]
indent_size = 2
[*.proto]
indent_size = 2
[*.cs]
# Namespace
csharp_style_namespace_declarations = file_scoped:warning
# Braces — Allman style
csharp_new_line_before_open_brace = all
# Usings
dotnet_sort_system_directives_first = true
csharp_using_directive_placement = outside_namespace:warning
# var preferences — use var when type is apparent
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
# Expression bodies — prefer for simple members
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
csharp_style_expression_bodied_constructors = false:suggestion
csharp_style_expression_bodied_operators = when_on_single_line:suggestion
csharp_style_expression_bodied_properties = true:suggestion
csharp_style_expression_bodied_accessors = true:suggestion
csharp_style_expression_bodied_lambdas = true:suggestion
# Pattern matching
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
# Null checking
csharp_style_throw_expression = true:suggestion
csharp_style_conditional_delegate_call = true:suggestion
# Modifier preferences — exclude interface members (netstandard2.1 compat)
dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
# Field naming — _camelCase for private fields
dotnet_naming_rule.private_fields_should_be_camel_case.severity = warning
dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields
dotnet_naming_rule.private_fields_should_be_camel_case.style = camel_case_underscore
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, private_protected
dotnet_naming_symbols.private_fields.required_modifiers =
dotnet_naming_style.camel_case_underscore.required_prefix = _
dotnet_naming_style.camel_case_underscore.capitalization = camel_case
# Constants — PascalCase
dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants
dotnet_naming_rule.constants_should_be_pascal_case.style = pascal_case
dotnet_naming_symbols.constants.applicable_kinds = field
dotnet_naming_symbols.constants.required_modifiers = const
dotnet_naming_style.pascal_case.capitalization = pascal_case
# Interfaces — I prefix
dotnet_naming_rule.interfaces_should_begin_with_i.severity = warning
dotnet_naming_rule.interfaces_should_begin_with_i.symbols = interfaces
dotnet_naming_rule.interfaces_should_begin_with_i.style = begins_with_i
dotnet_naming_symbols.interfaces.applicable_kinds = interface
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.capitalization = pascal_case
# Async methods — Async suffix
dotnet_naming_rule.async_methods_should_end_with_async.severity = suggestion
dotnet_naming_rule.async_methods_should_end_with_async.symbols = async_methods
dotnet_naming_rule.async_methods_should_end_with_async.style = ends_with_async
dotnet_naming_symbols.async_methods.applicable_kinds = method
dotnet_naming_symbols.async_methods.required_modifiers = async
dotnet_naming_style.ends_with_async.required_suffix = Async
dotnet_naming_style.ends_with_async.capitalization = pascal_case
+1 -1
View File
@@ -1,6 +1,6 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file provides guidance to AI agents when working with code in this repository.
## Project Overview
+9
View File
@@ -4,6 +4,15 @@
Our implementation of query and command responsibility segregation (CQRS).
## Where This Fits
This is a backend framework of the [Svrnty Agent System](../README.md).
**Layer**: Framework
**Depends on**: Nothing (standalone .NET framework)
**Depended on by**: a-gent-app (backend services), flutter_cqrs_datasource (client)
**Git**: [git.openharbor.io/svrnty/dotnet-cqrs](https://git.openharbor.io/svrnty/dotnet-cqrs)
## Getting Started
> Install nuget package to your awesome project.
@@ -1,4 +1,4 @@
using System;
using System;
namespace Svrnty.CQRS.Abstractions.Attributes;
@@ -1,4 +1,4 @@
using System;
using System;
namespace Svrnty.CQRS.Abstractions.Attributes;
@@ -1,4 +1,4 @@
using System;
using System;
using System.Reflection;
using Svrnty.CQRS.Abstractions.Attributes;
@@ -1,4 +1,4 @@
using System;
using System;
namespace Svrnty.CQRS.Abstractions.Discovery;
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
namespace Svrnty.CQRS.Abstractions.Discovery;
@@ -1,4 +1,4 @@
using System;
using System;
namespace Svrnty.CQRS.Abstractions.Discovery;
@@ -10,4 +10,4 @@ public interface IQueryMeta
Type QueryResultType { get; }
string Category { get; }
string LowerCamelCaseName { get; }
}
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Reflection;
using Svrnty.CQRS.Abstractions.Attributes;
+2 -2
View File
@@ -1,4 +1,4 @@
using System.Threading;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Abstractions;
@@ -13,4 +13,4 @@ public interface ICommandHandler<in TCommand, TCommandResult>
where TCommand : class
{
Task<TCommandResult> HandleAsync(TCommand command, CancellationToken cancellationToken = default);
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
using System.Threading;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Abstractions;
@@ -7,4 +7,4 @@ public interface IQueryHandler<in TQuery, TQueryResult>
where TQuery : class
{
Task<TQueryResult> HandleAsync(TQuery query, CancellationToken cancellationToken = default);
}
}
@@ -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; }
}
@@ -1,8 +1,8 @@
namespace Svrnty.CQRS.Abstractions.Security;
namespace Svrnty.CQRS.Abstractions.Security;
public enum AuthorizationResult
{
Unauthorized,
Forbidden,
Allowed
}
}
@@ -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);
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Threading;
using System.Threading.Tasks;
@@ -7,4 +7,4 @@ namespace Svrnty.CQRS.Abstractions.Security;
public interface ICommandAuthorizationService
{
Task<AuthorizationResult> IsAllowedAsync(Type commandType, 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);
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Threading;
using System.Threading.Tasks;
@@ -7,4 +7,4 @@ namespace Svrnty.CQRS.Abstractions.Security;
public interface IQueryAuthorizationService
{
Task<AuthorizationResult> IsAllowedAsync(Type queryType, CancellationToken cancellationToken = default);
}
}
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Abstractions.Discovery;
@@ -47,4 +47,4 @@ public static class ServiceCollectionExtensions
return services;
}
}
}
@@ -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>
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
@@ -1,4 +1,4 @@
using System.Linq;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -13,4 +13,4 @@ public interface IAlterQueryableService<TSource, TDestination, in TParams>
where TParams : class
{
Task<IQueryable<TSource>> AlterQueryableAsync(IQueryable<TSource> query, IDynamicQueryParams<TParams> dynamicQuery, CancellationToken cancellationToken = default);
}
}
@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using PoweredSoft.DynamicQuery.Core;
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
@@ -15,7 +15,7 @@ public interface IDynamicQuery<TSource, TDestination, out TParams> : IDynamicQue
where TDestination : class
where TParams : class
{
}
public interface IDynamicQuery
@@ -26,4 +26,4 @@ public interface IDynamicQuery
List<IAggregate> GetAggregates();
int? GetPage();
int? GetPageSize();
}
}
@@ -1,9 +1,9 @@
using System;
using System;
using System.Collections.Generic;
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
public interface IDynamicQueryInterceptorProvider<TSource, TDestination>
{
IEnumerable<Type> GetInterceptorsTypes();
}
}
@@ -1,4 +1,4 @@
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
public interface IDynamicQueryParams<out TParams>
where TParams : class
@@ -1,4 +1,4 @@
using System.Linq;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -7,4 +7,4 @@ namespace Svrnty.CQRS.DynamicQuery.Abstractions;
public interface IQueryableProvider<TSource>
{
Task<IQueryable<TSource>> GetQueryableAsync(object query, CancellationToken cancellationToken = default);
}
}
@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using PoweredSoft.DynamicQuery.Core;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Abstractions.Attributes;
using Svrnty.CQRS.Abstractions.Discovery;
@@ -14,7 +15,6 @@ using Svrnty.CQRS.Abstractions.Security;
using Svrnty.CQRS.DynamicQuery;
using Svrnty.CQRS.DynamicQuery.Abstractions;
using Svrnty.CQRS.DynamicQuery.Discover;
using PoweredSoft.DynamicQuery.Core;
namespace Svrnty.CQRS.DynamicQuery.MinimalApi;
@@ -1,4 +1,4 @@
using System;
using System;
using Pluralize.NET;
using Svrnty.CQRS.Abstractions.Discovery;
@@ -7,7 +7,7 @@ namespace Svrnty.CQRS.DynamicQuery.Discover;
public class DynamicQueryMeta(Type queryType, Type serviceType, Type queryResultType)
: QueryMeta(queryType, serviceType, queryResultType)
{
public Type SourceType => QueryType.GetGenericArguments()[0];
public Type SourceType => QueryType.GetGenericArguments()[0];
public Type DestinationType => QueryType.GetGenericArguments()[1];
public override string Category => "DynamicQuery";
public override string Name
+1 -1
View File
@@ -1,8 +1,8 @@
using System.Collections.Generic;
using System.Linq;
using Svrnty.CQRS.DynamicQuery.Abstractions;
using PoweredSoft.DynamicQuery;
using PoweredSoft.DynamicQuery.Core;
using Svrnty.CQRS.DynamicQuery.Abstractions;
namespace Svrnty.CQRS.DynamicQuery;
@@ -1,6 +1,6 @@
using System;
using PoweredSoft.DynamicQuery;
using PoweredSoft.DynamicQuery.Core;
using System;
namespace Svrnty.CQRS.DynamicQuery;
+10 -10
View File
@@ -1,23 +1,23 @@
using Svrnty.CQRS.DynamicQuery.Abstractions;
using PoweredSoft.DynamicQuery.Core;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using PoweredSoft.DynamicQuery.Core;
using Svrnty.CQRS.DynamicQuery.Abstractions;
namespace Svrnty.CQRS.DynamicQuery;
public class DynamicQueryHandler<TSource, TDestination>
: DynamicQueryHandlerBase<TSource, TDestination>,
: DynamicQueryHandlerBase<TSource, TDestination>,
Svrnty.CQRS.Abstractions.IQueryHandler<IDynamicQuery<TSource, TDestination>, IQueryExecutionResult<TDestination>>
where TSource : class
where TDestination : class
{
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
IServiceProvider serviceProvider) : base(queryHandlerAsync, queryableProviders, alterQueryableServices, dynamicQueryInterceptorProviders, serviceProvider)
{
}
@@ -29,7 +29,7 @@ public class DynamicQueryHandler<TSource, TDestination>
}
public class DynamicQueryHandler<TSource, TDestination, TParams>
: DynamicQueryHandlerBase<TSource, TDestination>,
: DynamicQueryHandlerBase<TSource, TDestination>,
Svrnty.CQRS.Abstractions.IQueryHandler<IDynamicQuery<TSource, TDestination, TParams>, IQueryExecutionResult<TDestination>>
where TSource : class
where TDestination : class
@@ -37,10 +37,10 @@ public class DynamicQueryHandler<TSource, TDestination, TParams>
{
private readonly IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams;
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams,
IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams,
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
IServiceProvider serviceProvider) : base(queryHandlerAsync, queryableProviders, alterQueryableServices, dynamicQueryInterceptorProviders, serviceProvider)
{
@@ -49,7 +49,7 @@ public class DynamicQueryHandler<TSource, TDestination, TParams>
protected override async Task<IQueryable<TSource>> AlterSourceAsync(IQueryable<TSource> source, IDynamicQuery query, CancellationToken cancellationToken)
{
source = await base.AlterSourceAsync(source, query, cancellationToken);
source = await base.AlterSourceAsync(source, query, cancellationToken);
if (query is IDynamicQueryParams<TParams> withParams)
{
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
@@ -6,9 +6,9 @@ using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Svrnty.CQRS.DynamicQuery.Abstractions;
using PoweredSoft.DynamicQuery;
using PoweredSoft.DynamicQuery.Core;
using Svrnty.CQRS.DynamicQuery.Abstractions;
namespace Svrnty.CQRS.DynamicQuery;
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using PoweredSoft.Data.Core;
@@ -91,10 +91,10 @@ public static class ServiceCollectionExtensions
where TParams : class
=> AddDynamicQueryWithParams<TSourceAndDestination, TSourceAndDestination, TParams>(services, name: name);
public static IServiceCollection AddDynamicQueryWithParams<TSource, TDestination, TParams>(this IServiceCollection services, string name = null)
where TSource : class
where TDestination : class
where TParams : class
public static IServiceCollection AddDynamicQueryWithParams<TSource, TDestination, TParams>(this IServiceCollection services, string name = null)
where TSource : class
where TDestination : class
where TParams : class
{
// add query handler.
services.AddTransient<IQueryHandler<IDynamicQuery<TSource, TDestination, TParams>, IQueryExecutionResult<TDestination>>, DynamicQueryHandler<TSource, TDestination, TParams>>();
@@ -133,7 +133,7 @@ public static class ServiceCollectionExtensions
where TParams : class
where TService : class, IAlterQueryableService<TSourceAndTDestination, TSourceAndTDestination, TParams>
{
return services.AddTransient<IAlterQueryableService< TSourceAndTDestination, TSourceAndTDestination, TParams>, TService>();
return services.AddTransient<IAlterQueryableService<TSourceAndTDestination, TSourceAndTDestination, TParams>, TService>();
}
public static IServiceCollection AddAlterQueryableWithParams<TSource, TDestination, TParams, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TService>
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Abstractions;
@@ -39,7 +39,7 @@ public static class ServiceCollectionExtensions
{
services.AddQuery<TQuery, TQueryResult, TQueryHandler>()
.AddFluentValidator<TQuery, TValidator>();
return services;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,102 +1,101 @@
using System.Collections.Generic;
namespace Svrnty.CQRS.Grpc.Generators.Helpers
namespace Svrnty.CQRS.Grpc.Generators.Helpers;
internal static class ProtoTypeMapper
{
internal static class ProtoTypeMapper
private static readonly Dictionary<string, string> TypeMap = new Dictionary<string, string>
{
private static readonly Dictionary<string, string> TypeMap = new Dictionary<string, string>
// Primitives
{ "System.String", "string" },
{ "System.Boolean", "bool" },
{ "System.Int32", "int32" },
{ "System.Int64", "int64" },
{ "System.UInt32", "uint32" },
{ "System.UInt64", "uint64" },
{ "System.Single", "float" },
{ "System.Double", "double" },
{ "System.Byte", "uint32" },
{ "System.SByte", "int32" },
{ "System.Int16", "int32" },
{ "System.UInt16", "uint32" },
{ "System.Decimal", "string" }, // Decimal as string to preserve precision
{ "System.DateTime", "int64" }, // Unix timestamp
{ "System.DateTimeOffset", "int64" }, // Unix timestamp
{ "System.Guid", "string" },
{ "System.TimeSpan", "int64" }, // Ticks
// Nullable variants
{ "System.Boolean?", "bool" },
{ "System.Int32?", "int32" },
{ "System.Int64?", "int64" },
{ "System.UInt32?", "uint32" },
{ "System.UInt64?", "uint64" },
{ "System.Single?", "float" },
{ "System.Double?", "double" },
{ "System.Byte?", "uint32" },
{ "System.SByte?", "int32" },
{ "System.Int16?", "int32" },
{ "System.UInt16?", "uint32" },
{ "System.Decimal?", "string" },
{ "System.DateTime?", "int64" },
{ "System.DateTimeOffset?", "int64" },
{ "System.Guid?", "string" },
{ "System.TimeSpan?", "int64" },
};
public static string MapToProtoType(string csharpType, out bool isRepeated, out bool isOptional)
{
isRepeated = false;
isOptional = false;
// Handle byte[] as bytes proto type (NOT repeated uint32)
if (csharpType == "System.Byte[]" || csharpType == "byte[]" || csharpType == "Byte[]")
{
// Primitives
{ "System.String", "string" },
{ "System.Boolean", "bool" },
{ "System.Int32", "int32" },
{ "System.Int64", "int64" },
{ "System.UInt32", "uint32" },
{ "System.UInt64", "uint64" },
{ "System.Single", "float" },
{ "System.Double", "double" },
{ "System.Byte", "uint32" },
{ "System.SByte", "int32" },
{ "System.Int16", "int32" },
{ "System.UInt16", "uint32" },
{ "System.Decimal", "string" }, // Decimal as string to preserve precision
{ "System.DateTime", "int64" }, // Unix timestamp
{ "System.DateTimeOffset", "int64" }, // Unix timestamp
{ "System.Guid", "string" },
{ "System.TimeSpan", "int64" }, // Ticks
// Nullable variants
{ "System.Boolean?", "bool" },
{ "System.Int32?", "int32" },
{ "System.Int64?", "int64" },
{ "System.UInt32?", "uint32" },
{ "System.UInt64?", "uint64" },
{ "System.Single?", "float" },
{ "System.Double?", "double" },
{ "System.Byte?", "uint32" },
{ "System.SByte?", "int32" },
{ "System.Int16?", "int32" },
{ "System.UInt16?", "uint32" },
{ "System.Decimal?", "string" },
{ "System.DateTime?", "int64" },
{ "System.DateTimeOffset?", "int64" },
{ "System.Guid?", "string" },
{ "System.TimeSpan?", "int64" },
};
public static string MapToProtoType(string csharpType, out bool isRepeated, out bool isOptional)
{
isRepeated = false;
isOptional = false;
// Handle byte[] as bytes proto type (NOT repeated uint32)
if (csharpType == "System.Byte[]" || csharpType == "byte[]" || csharpType == "Byte[]")
{
return "bytes";
}
// Handle arrays
if (csharpType.EndsWith("[]"))
{
isRepeated = true;
var elementType = csharpType.Substring(0, csharpType.Length - 2);
return MapToProtoType(elementType, out _, out _);
}
// Handle generic collections
if (csharpType.StartsWith("System.Collections.Generic.List<") ||
csharpType.StartsWith("System.Collections.Generic.IList<") ||
csharpType.StartsWith("System.Collections.Generic.IEnumerable<") ||
csharpType.StartsWith("System.Collections.Generic.ICollection<"))
{
isRepeated = true;
var startIndex = csharpType.IndexOf('<') + 1;
var endIndex = csharpType.LastIndexOf('>');
var elementType = csharpType.Substring(startIndex, endIndex - startIndex);
return MapToProtoType(elementType, out _, out _);
}
// Handle nullable value types
if (csharpType.EndsWith("?"))
{
isOptional = true;
}
// Check if it's a known primitive type
if (TypeMap.TryGetValue(csharpType, out var protoType))
{
return protoType;
}
// For unknown types, assume it's a custom message type
// Extract just the type name without namespace
var lastDot = csharpType.LastIndexOf('.');
if (lastDot >= 0)
{
return csharpType.Substring(lastDot + 1).Replace("?", "");
}
return csharpType.Replace("?", "");
return "bytes";
}
// Handle arrays
if (csharpType.EndsWith("[]"))
{
isRepeated = true;
var elementType = csharpType.Substring(0, csharpType.Length - 2);
return MapToProtoType(elementType, out _, out _);
}
// Handle generic collections
if (csharpType.StartsWith("System.Collections.Generic.List<") ||
csharpType.StartsWith("System.Collections.Generic.IList<") ||
csharpType.StartsWith("System.Collections.Generic.IEnumerable<") ||
csharpType.StartsWith("System.Collections.Generic.ICollection<"))
{
isRepeated = true;
var startIndex = csharpType.IndexOf('<') + 1;
var endIndex = csharpType.LastIndexOf('>');
var elementType = csharpType.Substring(startIndex, endIndex - startIndex);
return MapToProtoType(elementType, out _, out _);
}
// Handle nullable value types
if (csharpType.EndsWith("?"))
{
isOptional = true;
}
// Check if it's a known primitive type
if (TypeMap.TryGetValue(csharpType, out var protoType))
{
return protoType;
}
// For unknown types, assume it's a custom message type
// Extract just the type name without namespace
var lastDot = csharpType.LastIndexOf('.');
if (lastDot >= 0)
{
return csharpType.Substring(lastDot + 1).Replace("?", "");
}
return csharpType.Replace("?", "");
}
}
@@ -1,83 +1,82 @@
using System.Collections.Generic;
using Microsoft.CodeAnalysis;
namespace Svrnty.CQRS.Grpc.Generators.Models
namespace Svrnty.CQRS.Grpc.Generators.Models;
public class CommandInfo
{
public class CommandInfo
public string Name { get; set; }
public string FullyQualifiedName { get; set; }
public string Namespace { get; set; }
public List<PropertyInfo> Properties { get; set; }
public string? ResultType { get; set; }
public string? ResultFullyQualifiedName { get; set; }
public bool HasResult => ResultType != null;
public string HandlerInterfaceName { get; set; }
public List<PropertyInfo> ResultProperties { get; set; }
public bool IsResultPrimitiveType { get; set; }
public CommandInfo()
{
public string Name { get; set; }
public string FullyQualifiedName { get; set; }
public string Namespace { get; set; }
public List<PropertyInfo> Properties { get; set; }
public string? ResultType { get; set; }
public string? ResultFullyQualifiedName { get; set; }
public bool HasResult => ResultType != null;
public string HandlerInterfaceName { get; set; }
public List<PropertyInfo> ResultProperties { get; set; }
public bool IsResultPrimitiveType { get; set; }
public CommandInfo()
{
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
Properties = new List<PropertyInfo>();
HandlerInterfaceName = string.Empty;
ResultProperties = new List<PropertyInfo>();
IsResultPrimitiveType = false;
}
}
public class PropertyInfo
{
public string Name { get; set; }
public string Type { get; set; }
public string FullyQualifiedType { get; set; }
public string ProtoType { get; set; }
public int FieldNumber { get; set; }
public bool IsComplexType { get; set; }
public List<PropertyInfo> NestedProperties { get; set; }
// Type conversion metadata
public bool IsEnum { get; set; }
public bool IsList { get; set; }
public bool IsNullable { get; set; }
public bool IsDecimal { get; set; }
public bool IsDateTime { get; set; }
public bool IsDateTimeOffset { get; set; }
public bool IsGuid { get; set; }
public bool IsJsonElement { get; set; }
public bool IsBinaryType { get; set; } // Stream, byte[], MemoryStream
public bool IsStream { get; set; } // Specifically Stream types (not byte[])
public bool IsReadOnly { get; set; } // Read-only/computed properties should be skipped
public bool IsValueTypeCollection { get; set; } // Value types that implement IList<T> (like NpgsqlPolygon)
public string? ElementType { get; set; }
public bool IsElementComplexType { get; set; }
public bool IsElementGuid { get; set; }
public List<PropertyInfo>? ElementNestedProperties { get; set; }
public PropertyInfo()
{
Name = string.Empty;
Type = string.Empty;
FullyQualifiedType = string.Empty;
ProtoType = string.Empty;
IsComplexType = false;
NestedProperties = new List<PropertyInfo>();
IsEnum = false;
IsList = false;
IsNullable = false;
IsDecimal = false;
IsDateTime = false;
IsDateTimeOffset = false;
IsGuid = false;
IsJsonElement = false;
IsBinaryType = false;
IsStream = false;
IsReadOnly = false;
IsValueTypeCollection = false;
IsElementComplexType = false;
IsElementGuid = false;
}
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
Properties = new List<PropertyInfo>();
HandlerInterfaceName = string.Empty;
ResultProperties = new List<PropertyInfo>();
IsResultPrimitiveType = false;
}
}
public class PropertyInfo
{
public string Name { get; set; }
public string Type { get; set; }
public string FullyQualifiedType { get; set; }
public string ProtoType { get; set; }
public int FieldNumber { get; set; }
public bool IsComplexType { get; set; }
public List<PropertyInfo> NestedProperties { get; set; }
// Type conversion metadata
public bool IsEnum { get; set; }
public bool IsList { get; set; }
public bool IsNullable { get; set; }
public bool IsDecimal { get; set; }
public bool IsDateTime { get; set; }
public bool IsDateTimeOffset { get; set; }
public bool IsGuid { get; set; }
public bool IsJsonElement { get; set; }
public bool IsBinaryType { get; set; } // Stream, byte[], MemoryStream
public bool IsStream { get; set; } // Specifically Stream types (not byte[])
public bool IsReadOnly { get; set; } // Read-only/computed properties should be skipped
public bool IsValueTypeCollection { get; set; } // Value types that implement IList<T> (like NpgsqlPolygon)
public string? ElementType { get; set; }
public bool IsElementComplexType { get; set; }
public bool IsElementGuid { get; set; }
public List<PropertyInfo>? ElementNestedProperties { get; set; }
public PropertyInfo()
{
Name = string.Empty;
Type = string.Empty;
FullyQualifiedType = string.Empty;
ProtoType = string.Empty;
IsComplexType = false;
NestedProperties = new List<PropertyInfo>();
IsEnum = false;
IsList = false;
IsNullable = false;
IsDecimal = false;
IsDateTime = false;
IsDateTimeOffset = false;
IsGuid = false;
IsJsonElement = false;
IsBinaryType = false;
IsStream = false;
IsReadOnly = false;
IsValueTypeCollection = false;
IsElementComplexType = false;
IsElementGuid = false;
}
}
@@ -1,28 +1,27 @@
namespace Svrnty.CQRS.Grpc.Generators.Models
{
public class DynamicQueryInfo
{
public string Name { get; set; }
public string SourceType { get; set; }
public string SourceTypeFullyQualified { get; set; }
public string DestinationType { get; set; }
public string DestinationTypeFullyQualified { get; set; }
public string? ParamsType { get; set; }
public string? ParamsTypeFullyQualified { get; set; }
public string HandlerInterfaceName { get; set; }
public string QueryInterfaceName { get; set; }
public bool HasParams { get; set; }
namespace Svrnty.CQRS.Grpc.Generators.Models;
public DynamicQueryInfo()
{
Name = string.Empty;
SourceType = string.Empty;
SourceTypeFullyQualified = string.Empty;
DestinationType = string.Empty;
DestinationTypeFullyQualified = string.Empty;
HandlerInterfaceName = string.Empty;
QueryInterfaceName = string.Empty;
HasParams = false;
}
public class DynamicQueryInfo
{
public string Name { get; set; }
public string SourceType { get; set; }
public string SourceTypeFullyQualified { get; set; }
public string DestinationType { get; set; }
public string DestinationTypeFullyQualified { get; set; }
public string? ParamsType { get; set; }
public string? ParamsTypeFullyQualified { get; set; }
public string HandlerInterfaceName { get; set; }
public string QueryInterfaceName { get; set; }
public bool HasParams { get; set; }
public DynamicQueryInfo()
{
Name = string.Empty;
SourceType = string.Empty;
SourceTypeFullyQualified = string.Empty;
DestinationType = string.Empty;
DestinationTypeFullyQualified = string.Empty;
HandlerInterfaceName = string.Empty;
QueryInterfaceName = string.Empty;
HasParams = false;
}
}
@@ -1,50 +1,49 @@
using System.Collections.Generic;
namespace Svrnty.CQRS.Grpc.Generators.Models
namespace Svrnty.CQRS.Grpc.Generators.Models;
/// <summary>
/// Represents a discovered streaming notification type for proto/gRPC generation.
/// </summary>
public class NotificationInfo
{
/// <summary>
/// Represents a discovered streaming notification type for proto/gRPC generation.
/// The notification type name (e.g., "InventoryChangeNotification").
/// </summary>
public class NotificationInfo
public string Name { get; set; }
/// <summary>
/// The fully qualified type name including namespace.
/// </summary>
public string FullyQualifiedName { get; set; }
/// <summary>
/// The namespace of the notification type.
/// </summary>
public string Namespace { get; set; }
/// <summary>
/// The property name used as the subscription key (from [StreamingNotification] attribute).
/// </summary>
public string SubscriptionKeyProperty { get; set; }
/// <summary>
/// The subscription key property info.
/// </summary>
public PropertyInfo SubscriptionKeyInfo { get; set; }
/// <summary>
/// All properties of the notification type.
/// </summary>
public List<PropertyInfo> Properties { get; set; }
public NotificationInfo()
{
/// <summary>
/// The notification type name (e.g., "InventoryChangeNotification").
/// </summary>
public string Name { get; set; }
/// <summary>
/// The fully qualified type name including namespace.
/// </summary>
public string FullyQualifiedName { get; set; }
/// <summary>
/// The namespace of the notification type.
/// </summary>
public string Namespace { get; set; }
/// <summary>
/// The property name used as the subscription key (from [StreamingNotification] attribute).
/// </summary>
public string SubscriptionKeyProperty { get; set; }
/// <summary>
/// The subscription key property info.
/// </summary>
public PropertyInfo SubscriptionKeyInfo { get; set; }
/// <summary>
/// All properties of the notification type.
/// </summary>
public List<PropertyInfo> Properties { get; set; }
public NotificationInfo()
{
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
SubscriptionKeyProperty = string.Empty;
SubscriptionKeyInfo = new PropertyInfo();
Properties = new List<PropertyInfo>();
}
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
SubscriptionKeyProperty = string.Empty;
SubscriptionKeyInfo = new PropertyInfo();
Properties = new List<PropertyInfo>();
}
}
+24 -25
View File
@@ -1,30 +1,29 @@
using System.Collections.Generic;
namespace Svrnty.CQRS.Grpc.Generators.Models
{
public class QueryInfo
{
public string Name { get; set; }
public string FullyQualifiedName { get; set; }
public string Namespace { get; set; }
public List<PropertyInfo> Properties { get; set; }
public string ResultType { get; set; }
public string ResultFullyQualifiedName { get; set; }
public string HandlerInterfaceName { get; set; }
public List<PropertyInfo> ResultProperties { get; set; }
public bool IsResultPrimitiveType { get; set; }
namespace Svrnty.CQRS.Grpc.Generators.Models;
public QueryInfo()
{
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
Properties = new List<PropertyInfo>();
ResultType = string.Empty;
ResultFullyQualifiedName = string.Empty;
HandlerInterfaceName = string.Empty;
ResultProperties = new List<PropertyInfo>();
IsResultPrimitiveType = false;
}
public class QueryInfo
{
public string Name { get; set; }
public string FullyQualifiedName { get; set; }
public string Namespace { get; set; }
public List<PropertyInfo> Properties { get; set; }
public string ResultType { get; set; }
public string ResultFullyQualifiedName { get; set; }
public string HandlerInterfaceName { get; set; }
public List<PropertyInfo> ResultProperties { get; set; }
public bool IsResultPrimitiveType { get; set; }
public QueryInfo()
{
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
Properties = new List<PropertyInfo>();
ResultType = string.Empty;
ResultFullyQualifiedName = string.Empty;
HandlerInterfaceName = string.Empty;
ResultProperties = new List<PropertyInfo>();
IsResultPrimitiveType = false;
}
}
@@ -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
+3 -2
View File
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Discovery;
@@ -43,7 +44,7 @@ public class CqrsBuilder
/// <summary>
/// Adds a command handler to the CQRS pipeline
/// </summary>
public CqrsBuilder AddCommand<TCommand, TCommandHandler>()
public CqrsBuilder AddCommand<TCommand, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TCommandHandler>()
where TCommand : class
where TCommandHandler : class, ICommandHandler<TCommand>
{
@@ -54,7 +55,7 @@ public class CqrsBuilder
/// <summary>
/// Adds a command handler with result to the CQRS pipeline
/// </summary>
public CqrsBuilder AddCommand<TCommand, TResult, TCommandHandler>()
public CqrsBuilder AddCommand<TCommand, TResult, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TCommandHandler>()
where TCommand : class
where TCommandHandler : class, ICommandHandler<TCommand, TResult>
{
+1 -1
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using Svrnty.CQRS.Abstractions.Discovery;
+1 -1
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using Svrnty.CQRS.Abstractions.Discovery;
+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
{
+11 -3
View File
@@ -1,11 +1,13 @@
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;
using Svrnty.Sample;
using Svrnty.CQRS.MinimalApi;
using Svrnty.CQRS.DynamicQuery;
using Svrnty.CQRS.Abstractions;
using Svrnty.Sample;
var builder = WebApplication.CreateBuilder(args);
@@ -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;
+1 -1
View File
@@ -1,5 +1,5 @@
using PoweredSoft.Data.Core;
using System.Linq.Expressions;
using PoweredSoft.Data.Core;
namespace Svrnty.Sample;
+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) -->