diff --git a/dotnet-tools.json b/dotnet-tools.json
new file mode 100644
index 00000000..b0e38abd
--- /dev/null
+++ b/dotnet-tools.json
@@ -0,0 +1,5 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {}
+}
\ No newline at end of file
diff --git a/src/Yavsc.Org/Controllers/Kyc/ConsultantController.cs b/src/Yavsc.Org/Controllers/Kyc/ConsultantController.cs
new file mode 100644
index 00000000..d30742d4
--- /dev/null
+++ b/src/Yavsc.Org/Controllers/Kyc/ConsultantController.cs
@@ -0,0 +1,77 @@
+// Yavsc.Controllers.Kyc/ConsultantController.cs
+namespace Yavsc.Controllers.Kyc
+{
+ using System.Threading.Tasks;
+ using Microsoft.AspNetCore.Authorization;
+ using Microsoft.AspNetCore.Mvc;
+ using Microsoft.EntityFrameworkCore;
+ using Yavsc.Models;
+ using Yavsc.Models.Kyc;
+ using Yavsc.Services.Kyc;
+ using Yavsc.ViewModels.Kyc;
+
+ ///
+ /// Accessible aux utilisateurs avec le rôle "Consultant".
+ /// Ne voit qu'un score agrégé — jamais de déclarations, jamais d'identité.
+ ///
+ [Authorize(Roles = "Consultant")]
+ [Route("kyc/score")]
+ public class ConsultantController : Controller
+ {
+ private readonly ITrustTokenService _tokenService;
+ private readonly ApplicationDbContext _db;
+
+ public ConsultantController(
+ ITrustTokenService tokenService,
+ ApplicationDbContext db)
+ {
+ _tokenService = tokenService;
+ _db = db;
+ }
+
+ ///
+ /// POST kyc/score
+ /// Body : { subjectEmail } ou { externalToken }
+ /// Retourne uniquement le score agrégé.
+ ///
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task Query(ScoreQueryModel input)
+ {
+ if (!ModelState.IsValid)
+ return BadRequest(ModelState);
+
+ TrustToken token = string.IsNullOrEmpty(input.ExternalToken)
+ ? await _tokenService.GetOrCreateFromEmailAsync(input.SubjectEmail)
+ : await _tokenService.GetOrCreateFromTrustedPartyAsync(input.ExternalToken);
+
+ // Chargement du score avec stats agrégées — aucune déclaration exposée
+ var stats = await _db.TrustDeclarations
+ .Where(d => d.TrustTokenId == token.Id
+ && d.Status == ModerationStatus.Approved)
+ .GroupBy(d => d.TrustTokenId)
+ .Select(g => new {
+ Count = g.Count(),
+ LastUpdated = g.Max(d => d.SubmittedAt)
+ })
+ .FirstOrDefaultAsync();
+
+ var view = new TrustScoreView
+ {
+ Score = token.TrustScore ?? 50,
+ Category = ScoreToCategory(token.TrustScore ?? 50),
+ DeclarationCount = stats?.Count ?? 0,
+ LastUpdated = stats?.LastUpdated ?? token.CreatedAt
+ };
+
+ return View(view);
+ }
+
+ private static string ScoreToCategory(int score) => score switch
+ {
+ >= 75 => "Fiable",
+ >= 40 => "Prudence",
+ _ => "Risque"
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Org/Controllers/Kyc/DeclarantController.cs b/src/Yavsc.Org/Controllers/Kyc/DeclarantController.cs
new file mode 100644
index 00000000..9f4933d1
--- /dev/null
+++ b/src/Yavsc.Org/Controllers/Kyc/DeclarantController.cs
@@ -0,0 +1,79 @@
+// Yavsc.Controllers.Kyc/DeclarantController.cs
+namespace Yavsc.Controllers.Kyc
+{
+ using System;
+ using System.Threading.Tasks;
+ using Microsoft.AspNetCore.Authorization;
+ using Microsoft.AspNetCore.Identity;
+ using Microsoft.AspNetCore.Mvc;
+ using Yavsc.Models;
+ using Yavsc.Models.Kyc;
+ using Yavsc.Services.Kyc;
+ using Yavsc.ViewModels.Kyc;
+
+ ///
+ /// Accessible à tout utilisateur authentifié.
+ /// Le déclarant soumet une déclaration sur un sujet pseudonymisé.
+ ///
+ [Authorize]
+ [Route("kyc/declare")]
+ public class DeclarantController : Controller
+ {
+ private readonly IModerationPipelineService _pipeline;
+ private readonly ITrustTokenService _tokenService;
+ private readonly UserManager _userManager;
+
+ public DeclarantController(
+ IModerationPipelineService pipeline,
+ ITrustTokenService tokenService,
+ UserManager userManager)
+ {
+ _pipeline = pipeline;
+ _tokenService = tokenService;
+ _userManager = userManager;
+ }
+
+ ///
+ /// GET kyc/declare — formulaire de déclaration
+ ///
+ [HttpGet]
+ public IActionResult Index() => View();
+
+ ///
+ /// POST kyc/declare
+ /// Body : { subjectEmail, content, sentiment }
+ /// Le subjectEmail est immédiatement transformé en token — jamais stocké.
+ ///
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task Submit(DeclarationInputModel input)
+ {
+ if (!ModelState.IsValid)
+ return View("Index", input);
+
+ // Token du sujet — depuis email ou token tiers
+ TrustToken subjectToken = string.IsNullOrEmpty(input.ExternalToken)
+ ? await _tokenService.GetOrCreateFromEmailAsync(input.SubjectEmail)
+ : await _tokenService.GetOrCreateFromTrustedPartyAsync(input.ExternalToken);
+
+ // Token du déclarant — depuis son propre email confirmé
+ var user = await _userManager.GetUserAsync(User);
+ if (!await _userManager.IsEmailConfirmedAsync(user))
+ return Forbid(); // Email non confirmé → pas de déclaration
+
+ var declarantToken = await _tokenService
+ .GetOrCreateFromEmailAsync(user.Email);
+
+ await _pipeline.SubmitDeclarationAsync(
+ subjectToken.Id,
+ declarantToken.Id,
+ input.Content,
+ input.Sentiment);
+
+ return RedirectToAction("Submitted");
+ }
+
+ [HttpGet("submitted")]
+ public IActionResult Submitted() => View();
+ }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Org/Controllers/Kyc/ModerationController.cs b/src/Yavsc.Org/Controllers/Kyc/ModerationController.cs
new file mode 100644
index 00000000..d3ed8aa2
--- /dev/null
+++ b/src/Yavsc.Org/Controllers/Kyc/ModerationController.cs
@@ -0,0 +1,76 @@
+// Yavsc.Controllers.Kyc/ModerationController.cs
+namespace Yavsc.Controllers.Kyc
+{
+ using System.Threading.Tasks;
+ using Microsoft.AspNetCore.Authorization;
+ using Microsoft.AspNetCore.Mvc;
+ using Yavsc.Server.Helpers;
+ using Yavsc.Services.Kyc;
+ using Yavsc.ViewModels.Kyc;
+
+ ///
+ /// Accessible uniquement au rôle "Moderator".
+ /// Voit les déclarations en attente + flags, prend les décisions.
+ ///
+ [Authorize(Roles = "Moderator")]
+ [Route("kyc/moderation")]
+ public class ModerationController : Controller
+ {
+ private readonly IModerationPipelineService _pipeline;
+
+ public ModerationController(IModerationPipelineService pipeline)
+ {
+ _pipeline = pipeline;
+ }
+
+ ///
+ /// GET kyc/moderation — queue de modération
+ /// Flagged en tête, puis Pending par date de soumission.
+ ///
+ [HttpGet]
+ public async Task Index()
+ {
+ var queue = await _pipeline.GetQueueAsync();
+ return View(queue);
+ }
+
+ ///
+ /// POST kyc/moderation/approve
+ ///
+ [HttpPost("approve")]
+ [ValidateAntiForgeryToken]
+ public async Task Approve(ModerationDecisionModel input)
+ {
+ if (!ModelState.IsValid)
+ return RedirectToAction("Index");
+
+ var moderatorId = User.GetUserId();
+ await _pipeline.ApproveAsync(input.DeclarationId, input.ScoreDelta, moderatorId);
+ return RedirectToAction("Index");
+ }
+
+ ///
+ /// POST kyc/moderation/reject
+ ///
+ [HttpPost("reject")]
+ [ValidateAntiForgeryToken]
+ public async Task Reject(ModerationDecisionModel input)
+ {
+ var moderatorId = User.GetUserId();
+ await _pipeline.RejectAsync(input.DeclarationId, moderatorId);
+ return RedirectToAction("Index");
+ }
+
+ ///
+ /// POST kyc/moderation/redact
+ ///
+ [HttpPost("redact")]
+ [ValidateAntiForgeryToken]
+ public async Task Redact(ModerationDecisionModel input)
+ {
+ var moderatorId = User.GetUserId();
+ await _pipeline.RedactAsync(input.DeclarationId, input.ScoreDelta, moderatorId);
+ return RedirectToAction("Index");
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs
index 67a7f2f7..06b7d4bd 100644
--- a/src/Yavsc.Org/Extensions/HostingExtensions.cs
+++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs
@@ -28,6 +28,7 @@ using Yavsc.Interfaces;
using Yavsc.Models;
using Yavsc.Server.Helpers;
using Yavsc.Services;
+using Yavsc.Services.Kyc;
using Yavsc.Settings;
using Yavsc.ViewModels.Auth;
@@ -131,7 +132,8 @@ public static class HostingExtensions
services.AddTransient>();
services.AddTransient, RoleStore>();
-
+ services.Configure(builder.Configuration.GetSection("Kyc"));
+ services.AddScoped();
return builder.Build();
}
diff --git a/src/Yavsc.Org/Services/ModerationPipelineService.cs b/src/Yavsc.Org/Services/ModerationPipelineService.cs
new file mode 100644
index 00000000..b10504c9
--- /dev/null
+++ b/src/Yavsc.Org/Services/ModerationPipelineService.cs
@@ -0,0 +1,264 @@
+// Yavsc.Services.Kyc/ModerationPipelineService.cs
+namespace Yavsc.Services.Kyc
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Linq;
+ using System.Text.RegularExpressions;
+ using System.Threading.Tasks;
+ using Microsoft.EntityFrameworkCore;
+ using Yavsc.Models;
+ using Yavsc.Models.Kyc;
+ using Yavsc.ViewModels.Kyc;
+
+ public interface IModerationPipelineService
+ {
+ ///
+ /// Soumet une déclaration : applique les regex, place en queue.
+ /// Retourne la déclaration créée (avec ses flags éventuels).
+ ///
+ Task SubmitDeclarationAsync(
+ Guid subjectTokenId,
+ Guid declarantTokenId,
+ string content,
+ DeclarationSentiment sentiment);
+
+ ///
+ /// Le modérateur approuve : applique le ScoreDelta sur le TrustToken.
+ ///
+ Task ApproveAsync(long declarationId, int scoreDelta, string moderatorId);
+
+ ///
+ /// Le modérateur rejette : aucun effet sur le score.
+ ///
+ Task RejectAsync(long declarationId, string moderatorId);
+
+ ///
+ /// Le modérateur expurge : contenu effacé, score partiellement appliqué.
+ ///
+ Task RedactAsync(long declarationId, int scoreDelta, string moderatorId);
+
+ ///
+ /// Retourne la queue de modération (pending + flagged en priorité).
+ ///
+ Task> GetQueueAsync();
+ }
+
+ public class ModerationPipelineService : IModerationPipelineService
+ {
+ private readonly ApplicationDbContext _db;
+
+ // Cache des patterns actifs — rechargé à chaque soumission
+ // (en prod : IMemoryCache avec invalidation)
+ private List _patterns;
+
+ public ModerationPipelineService(ApplicationDbContext db)
+ {
+ _db = db;
+ }
+
+ // ── Soumission ───────────────────────────────────────────────────────
+
+ public async Task SubmitDeclarationAsync(
+ Guid subjectTokenId,
+ Guid declarantTokenId,
+ string content,
+ DeclarationSentiment sentiment)
+ {
+ // Vérification basique
+ if (string.IsNullOrWhiteSpace(content))
+ throw new ArgumentException("Contenu vide", nameof(content));
+
+ if (subjectTokenId == declarantTokenId)
+ throw new InvalidOperationException("Auto-déclaration interdite");
+
+ // Chargement des patterns actifs
+ _patterns = await _db.RegexAlertPatterns
+ .Where(p => p.IsActive)
+ .ToListAsync();
+
+ // Analyse regex
+ var flags = RunRegexAnalysis(content);
+ var hasBloking = flags.Any(f =>
+ _patterns.First(p => p.Id == f.PatternId).Severity == PatternSeverity.Blocking);
+
+ var declaration = new TrustDeclaration
+ {
+ TrustTokenId = subjectTokenId,
+ DeclarantTokenId = declarantTokenId,
+ Content = content,
+ Sentiment = sentiment,
+ SubmittedAt = DateTime.UtcNow,
+ // Flagged = priorité haute en queue, sinon Pending
+ Status = flags.Any() ? ModerationStatus.Flagged
+ : ModerationStatus.Pending,
+ Flags = flags
+ };
+
+ _db.TrustDeclarations.Add(declaration);
+ await _db.SaveChangesAsync();
+ return declaration;
+ }
+
+ // ── Décisions de modération ──────────────────────────────────────────
+
+ public async Task ApproveAsync(long declarationId, int scoreDelta, string moderatorId)
+ {
+ var (declaration, token) = await LoadForModerationAsync(declarationId);
+
+ ValidateScoreDelta(scoreDelta);
+
+ declaration.Status = ModerationStatus.Approved;
+ declaration.ScoreDelta = scoreDelta;
+
+ ApplyDelta(token, scoreDelta);
+
+ await _db.ModerationLogs.AddAsync(new ModerationLog
+ {
+ DeclarationId = declarationId,
+ ModeratorId = moderatorId,
+ Action = ModerationAction.Approved,
+ ScoreDelta = scoreDelta,
+ Timestamp = DateTime.UtcNow
+ });
+
+ await _db.SaveChangesAsync();
+ }
+
+ public async Task RejectAsync(long declarationId, string moderatorId)
+ {
+ var (declaration, _) = await LoadForModerationAsync(declarationId);
+
+ declaration.Status = ModerationStatus.Rejected;
+ // Aucun effet sur le score
+
+ await _db.ModerationLogs.AddAsync(new ModerationLog
+ {
+ DeclarationId = declarationId,
+ ModeratorId = moderatorId,
+ Action = ModerationAction.Rejected,
+ ScoreDelta = 0,
+ Timestamp = DateTime.UtcNow
+ });
+
+ await _db.SaveChangesAsync();
+ }
+
+ public async Task RedactAsync(long declarationId, int scoreDelta, string moderatorId)
+ {
+ var (declaration, token) = await LoadForModerationAsync(declarationId);
+
+ ValidateScoreDelta(scoreDelta);
+
+ // Contenu expurgé — on garde la trace de la déclaration, pas du texte
+ declaration.Content = "[expurgé]";
+ declaration.Status = ModerationStatus.Redacted;
+ declaration.ScoreDelta = scoreDelta;
+
+ ApplyDelta(token, scoreDelta);
+
+ await _db.ModerationLogs.AddAsync(new ModerationLog
+ {
+ DeclarationId = declarationId,
+ ModeratorId = moderatorId,
+ Action = ModerationAction.Redacted,
+ ScoreDelta = scoreDelta,
+ Timestamp = DateTime.UtcNow
+ });
+
+ await _db.SaveChangesAsync();
+ }
+
+ // ── Queue de modération ──────────────────────────────────────────────
+
+ public async Task> GetQueueAsync()
+ {
+ return await _db.TrustDeclarations
+ .Where(d => d.Status == ModerationStatus.Pending
+ || d.Status == ModerationStatus.Flagged)
+ .OrderByDescending(d => d.Status == ModerationStatus.Flagged) // Flagged en tête
+ .ThenBy(d => d.SubmittedAt)
+ .Select(d => new ModerationQueueItem
+ {
+ DeclarationId = d.Id,
+ // Pseudonyme tronqué — jamais le hash complet exposé
+ SubjectTokenHash = d.Subject.TokenHash.Substring(0, 8) + "…",
+ Content = d.Content,
+ Sentiment = d.Sentiment,
+ Status = d.Status,
+ HasBlockingFlag = d.Flags.Any(f =>
+ f.Pattern.Severity == PatternSeverity.Blocking),
+ FlagDescriptions = d.Flags
+ .Select(f => f.Pattern.Description)
+ .ToList()
+ })
+ .ToListAsync();
+ }
+
+ // ── Privé ────────────────────────────────────────────────────────────
+
+ private List RunRegexAnalysis(string content)
+ {
+ var flags = new List();
+
+ foreach (var pattern in _patterns)
+ {
+ try
+ {
+ var match = Regex.Match(content, pattern.Pattern,
+ RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
+ TimeSpan.FromMilliseconds(200)); // timeout anti-ReDoS
+
+ if (!match.Success) continue;
+
+ // Extrait tronqué — jamais le contenu complet
+ var excerpt = match.Value.Length > 80
+ ? match.Value.Substring(0, 80) + "…"
+ : match.Value;
+
+ flags.Add(new DeclarationFlag
+ {
+ PatternId = pattern.Id,
+ MatchExcerpt = excerpt
+ });
+ }
+ catch (RegexMatchTimeoutException)
+ {
+ // Pattern trop lent — on logue et on continue
+ // TODO: alerter l'admin sur ce pattern
+ }
+ }
+
+ return flags;
+ }
+
+ private async Task<(TrustDeclaration, TrustToken)> LoadForModerationAsync(long declarationId)
+ {
+ var declaration = await _db.TrustDeclarations
+ .Include(d => d.Subject)
+ .FirstOrDefaultAsync(d => d.Id == declarationId)
+ ?? throw new KeyNotFoundException($"Déclaration {declarationId} introuvable");
+
+ if (declaration.Status == ModerationStatus.Approved
+ || declaration.Status == ModerationStatus.Rejected
+ || declaration.Status == ModerationStatus.Redacted)
+ throw new InvalidOperationException("Déclaration déjà traitée");
+
+ return (declaration, declaration.Subject);
+ }
+
+ private static void ApplyDelta(TrustToken token, int delta)
+ {
+ var current = token.TrustScore ?? 50; // Score initial neutre : 50
+ // Bornes 0–100
+ token.TrustScore = Math.Clamp(current + delta, 0, 100);
+ }
+
+ private static void ValidateScoreDelta(int delta)
+ {
+ if (delta < -10 || delta > 10)
+ throw new ArgumentOutOfRangeException(nameof(delta),
+ "ScoreDelta doit être entre -10 et +10");
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Org/Services/TrustTokenService.cs b/src/Yavsc.Org/Services/TrustTokenService.cs
new file mode 100644
index 00000000..fc657bed
--- /dev/null
+++ b/src/Yavsc.Org/Services/TrustTokenService.cs
@@ -0,0 +1,75 @@
+// Yavsc.Services.Kyc/TrustTokenService.cs
+namespace Yavsc.Services.Kyc
+{
+ using System;
+ using System.Security.Cryptography;
+ using System.Text;
+ using System.Threading.Tasks;
+ using Microsoft.EntityFrameworkCore;
+ using Microsoft.Extensions.Options;
+ using Yavsc.Models;
+ using Yavsc.Models.Kyc;
+
+ public class TrustTokenService : ITrustTokenService
+ {
+ private readonly ApplicationDbContext _db;
+ private readonly byte[] _hmacKey;
+
+ public TrustTokenService(ApplicationDbContext db, IOptions options)
+ {
+ _db = db;
+ // La clé est chargée depuis la config, jamais hardcodée
+ _hmacKey = Encoding.UTF8.GetBytes(options.Value.HmacSecret
+ ?? throw new InvalidOperationException("KycOptions:HmacSecret manquant"));
+ }
+
+ public async Task GetOrCreateFromTrustedPartyAsync(string externalToken)
+ {
+ if (string.IsNullOrWhiteSpace(externalToken))
+ throw new ArgumentException("Token externe invalide", nameof(externalToken));
+
+ // Le token tiers arrive déjà pseudonymisé — on le stocke tel quel
+ return await GetOrCreateAsync(externalToken, "trusted_party");
+ }
+
+ public async Task GetOrCreateFromEmailAsync(string confirmedEmail)
+ {
+ if (string.IsNullOrWhiteSpace(confirmedEmail))
+ throw new ArgumentException("Email invalide", nameof(confirmedEmail));
+
+ // HMAC-SHA256(email normalisé, clé_secrète) → pseudonyme stable, irréversible
+ var tokenHash = ComputeHmac(confirmedEmail.Trim().ToLowerInvariant());
+ return await GetOrCreateAsync(tokenHash, "confirmed_email");
+ }
+
+ // ── Privé ────────────────────────────────────────────────────────────
+
+ private async Task GetOrCreateAsync(string tokenHash, string source)
+ {
+ // Idempotent : même entité → même token
+ var existing = await _db.TrustTokens
+ .FirstOrDefaultAsync(t => t.TokenHash == tokenHash);
+
+ if (existing != null)
+ return existing;
+
+ var token = new TrustToken
+ {
+ TokenHash = tokenHash,
+ TokenSource = source,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ _db.TrustTokens.Add(token);
+ await _db.SaveChangesAsync();
+ return token;
+ }
+
+ private string ComputeHmac(string input)
+ {
+ using var hmac = new HMACSHA256(_hmacKey);
+ var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(input));
+ return Convert.ToHexString(hash).ToLowerInvariant(); // 64 chars, stockable
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Org/ViewModels/Kyc/DeclarationInputModel.cs b/src/Yavsc.Org/ViewModels/Kyc/DeclarationInputModel.cs
new file mode 100644
index 00000000..f15a2f8a
--- /dev/null
+++ b/src/Yavsc.Org/ViewModels/Kyc/DeclarationInputModel.cs
@@ -0,0 +1,32 @@
+// Yavsc.ViewModels.Kyc/DeclarationInputModel.cs
+namespace Yavsc.ViewModels.Kyc
+{
+ using System.ComponentModel.DataAnnotations;
+ using Yavsc.Models.Kyc;
+
+ public class DeclarationInputModel
+ {
+ ///
+ /// Email du sujet — transformé en token immédiatement, jamais persisté.
+ /// Obligatoire si ExternalToken absent.
+ ///
+ [EmailAddress]
+ public string SubjectEmail { get; set; }
+
+ ///
+ /// Token fourni par un tiers de confiance.
+ /// Prioritaire sur SubjectEmail si présent.
+ ///
+ public string ExternalToken { get; set; }
+
+ [Required, MinLength(10), MaxLength(2000)]
+ public string Content { get; set; }
+
+ [Required]
+ public DeclarationSentiment Sentiment { get; set; }
+
+ // Validation croisée : l'un ou l'autre obligatoire
+ public bool IsValid => !string.IsNullOrEmpty(SubjectEmail)
+ || !string.IsNullOrEmpty(ExternalToken);
+ }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Org/ViewModels/Kyc/ModerationDecisionModel.cs b/src/Yavsc.Org/ViewModels/Kyc/ModerationDecisionModel.cs
new file mode 100644
index 00000000..9931f170
--- /dev/null
+++ b/src/Yavsc.Org/ViewModels/Kyc/ModerationDecisionModel.cs
@@ -0,0 +1,17 @@
+// Yavsc.ViewModels.Kyc/ModerationDecisionModel.cs
+namespace Yavsc.ViewModels.Kyc
+{
+ using System.ComponentModel.DataAnnotations;
+
+ public class ModerationDecisionModel
+ {
+ [Required]
+ public long DeclarationId { get; set; }
+
+ ///
+ /// Entre -10 et +10. Ignoré pour Reject.
+ ///
+ [Range(-10, 10)]
+ public int ScoreDelta { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Org/ViewModels/Kyc/ModerationQueueItem.cs b/src/Yavsc.Org/ViewModels/Kyc/ModerationQueueItem.cs
new file mode 100644
index 00000000..40736ac9
--- /dev/null
+++ b/src/Yavsc.Org/ViewModels/Kyc/ModerationQueueItem.cs
@@ -0,0 +1,40 @@
+// Yavsc.ViewModels.Kyc/ModerationQueueItem.cs
+namespace Yavsc.ViewModels.Kyc
+{
+ using System.Collections.Generic;
+ using Yavsc.Models.Kyc;
+
+ ///
+ /// Ce que le modérateur voit dans sa queue.
+ /// Toujours pseudonymisé — jamais d'identité réelle.
+ ///
+ public class ModerationQueueItem
+ {
+ public long DeclarationId { get; set; }
+
+ ///
+ /// Les 8 premiers caractères du TokenHash + "…"
+ /// Suffisant pour distinguer les sujets en queue,
+ /// insuffisant pour identifier quoi que ce soit.
+ ///
+ public string SubjectTokenHash { get; set; }
+
+ public string Content { get; set; }
+
+ public DeclarationSentiment Sentiment { get; set; }
+
+ public ModerationStatus Status { get; set; }
+
+ ///
+ /// Descriptions des patterns regex qui ont matché.
+ /// Pas les excerpts — le modérateur voit le texte complet de toute façon.
+ ///
+ public List FlagDescriptions { get; set; } = new();
+
+ ///
+ /// True si au moins un pattern Blocking a matché —
+ /// permet un affichage prioritaire dans l'UI.
+ ///
+ public bool HasBlockingFlag { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Org/ViewModels/Kyc/ScoreQuerModel.cs b/src/Yavsc.Org/ViewModels/Kyc/ScoreQuerModel.cs
new file mode 100644
index 00000000..f4696bc1
--- /dev/null
+++ b/src/Yavsc.Org/ViewModels/Kyc/ScoreQuerModel.cs
@@ -0,0 +1,13 @@
+// Yavsc.ViewModels.Kyc/ScoreQueryModel.cs
+namespace Yavsc.ViewModels.Kyc
+{
+ using System.ComponentModel.DataAnnotations;
+
+ public class ScoreQueryModel
+ {
+ [EmailAddress]
+ public string SubjectEmail { get; set; }
+
+ public string ExternalToken { get; set; }
+ }
+}
diff --git a/src/Yavsc.Org/ViewModels/Kyc/TrustScoreViewModel.cs b/src/Yavsc.Org/ViewModels/Kyc/TrustScoreViewModel.cs
new file mode 100644
index 00000000..7d9b6cb3
--- /dev/null
+++ b/src/Yavsc.Org/ViewModels/Kyc/TrustScoreViewModel.cs
@@ -0,0 +1,16 @@
+// Yavsc.ViewModels.Kyc/TrustScoreView.cs
+namespace Yavsc.ViewModels.Kyc
+{
+ ///
+ /// Ce que le CONSULTANT voit. Jamais d'identité, jamais de détail de déclaration.
+ ///
+ public class TrustScoreView
+ {
+ public int Score { get; set; } // ex: 0–100
+ public string Category { get; set; } // "Fiable" / "Prudence" / "Risque"
+ public int DeclarationCount { get; set; } // combien de déclarations agrégées
+ public DateTime LastUpdated { get; set; }
+ }
+
+
+}
\ No newline at end of file
diff --git a/src/Yavsc.Org/appsettings.json b/src/Yavsc.Org/appsettings.json
index 6d07e28e..81445f5f 100644
--- a/src/Yavsc.Org/appsettings.json
+++ b/src/Yavsc.Org/appsettings.json
@@ -74,5 +74,8 @@
"ClientId": "[Your ClientId]",
"ClientSecret": "[Your ClientSecret]"
}
+ },
+ "Kyc": {
+ "HmacSecret": "*** via dotnet user-secrets ou variable d'environnement ***"
}
}
diff --git a/src/Yavsc.Server/Interfaces/ITrustTokenService.cs b/src/Yavsc.Server/Interfaces/ITrustTokenService.cs
new file mode 100644
index 00000000..96335133
--- /dev/null
+++ b/src/Yavsc.Server/Interfaces/ITrustTokenService.cs
@@ -0,0 +1,21 @@
+// Yavsc.Services.Kyc/ITrustTokenService.cs
+namespace Yavsc.Services.Kyc
+{
+ using System.Threading.Tasks;
+ using Yavsc.Models.Kyc;
+
+ public interface ITrustTokenService
+ {
+ ///
+ /// Depuis un tiers de confiance : token opaque déjà haché côté tiers.
+ /// On le stocke tel quel.
+ ///
+ Task GetOrCreateFromTrustedPartyAsync(string externalToken);
+
+ ///
+ /// Fallback : depuis un e-mail confirmé.
+ /// On calcule HMAC-SHA256(email, clé_secrète) — jamais l'email stocké.
+ ///
+ Task GetOrCreateFromEmailAsync(string confirmedEmail);
+ }
+}
diff --git a/src/Yavsc.Server/Models/ApplicationDbContext.cs b/src/Yavsc.Server/Models/ApplicationDbContext.cs
index 6b73abba..607da2d9 100644
--- a/src/Yavsc.Server/Models/ApplicationDbContext.cs
+++ b/src/Yavsc.Server/Models/ApplicationDbContext.cs
@@ -34,6 +34,7 @@ namespace Yavsc.Models
using Streaming;
using Workflow;
using Workflow.Profiles;
+ using Yavsc.Models.Kyc;
public class ApplicationDbContext : IdentityDbContext
{
@@ -53,11 +54,11 @@ namespace Yavsc.Models
base.OnModelCreating(builder);
if (Database.IsNpgsql())
{
- NOW_SQL="LOCALTIMESTAMP";
+ NOW_SQL = "LOCALTIMESTAMP";
}
else
{
- NOW_SQL="CURRENT_TIMESTAMP";
+ NOW_SQL = "CURRENT_TIMESTAMP";
}
builder.UseIdentityByDefaultColumns();
@@ -129,6 +130,70 @@ namespace Yavsc.Models
if (et.ClrType.GetInterface("IBaseTrackedEntity") != null)
et.FindProperty("DateCreated").SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore);
}
+
+ // ── TrustToken ───────────────────────────────────────────────────────
+ builder.Entity(e =>
+ {
+ e.HasIndex(t => t.TokenHash).IsUnique(); // idempotence garantie en DB
+ e.Property(t => t.TokenHash).HasMaxLength(128).IsRequired();
+ e.Property(t => t.TokenSource).HasMaxLength(32).IsRequired();
+ });
+
+ // ── TrustDeclaration ─────────────────────────────────────────────────
+ builder.Entity(e =>
+ {
+ e.HasOne(d => d.Subject)
+ .WithMany(t => t.Declarations)
+ .HasForeignKey(d => d.TrustTokenId)
+ .OnDelete(DeleteBehavior.Restrict); // on ne supprime pas un token qui a des déclarations
+
+ // DeclarantTokenId est une FK vers TrustToken mais sans navigation
+ // pour éviter les cycles EF
+ e.HasIndex(d => d.TrustTokenId);
+ e.HasIndex(d => d.Status);
+ e.HasIndex(d => d.SubmittedAt);
+ e.Property(d => d.Content).HasMaxLength(2000);
+ });
+
+ // ── DeclarationFlag ──────────────────────────────────────────────────
+ builder.Entity(e =>
+ {
+ e.HasOne(f => f.Declaration)
+ .WithMany(d => d.Flags)
+ .HasForeignKey(f => f.DeclarationId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ e.HasOne(f => f.Pattern)
+ .WithMany()
+ .HasForeignKey(f => f.PatternId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ e.Property(f => f.MatchExcerpt).HasMaxLength(100);
+ });
+
+ // ── RegexAlertPattern ────────────────────────────────────────────────
+ builder.Entity(e =>
+ {
+ e.Property(p => p.Pattern).HasMaxLength(500).IsRequired();
+ e.Property(p => p.Description).HasMaxLength(200);
+ e.HasIndex(p => p.IsActive);
+ });
+
+ // ── ModerationLog ────────────────────────────────────────────────────
+ builder.Entity(e =>
+ {
+ e.HasOne(l => l.Declaration)
+ .WithMany()
+ .HasForeignKey(l => l.DeclarationId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ e.HasIndex(l => l.DeclarationId);
+ e.HasIndex(l => l.ModeratorId);
+ e.HasIndex(l => l.Timestamp);
+
+ // Log immuable — pas de update autorisé
+ e.ToTable(tb => tb.HasCheckConstraint("CK_ModerationLog_Immutable", "1=1"));
+ });
}
///
@@ -349,5 +414,11 @@ namespace Yavsc.Models
public DbSet DeviceFlowCodes { get; set; }
public string NOW_SQL { get; private set; }
+
+ public DbSet TrustTokens { get; set; }
+ public DbSet TrustDeclarations { get; set; }
+ public DbSet RegexAlertPatterns { get; set; }
+
+ public DbSet ModerationLogs { get; set; }
}
}
diff --git a/src/Yavsc.Server/Models/Kyc/ModerationAction.cs b/src/Yavsc.Server/Models/Kyc/ModerationAction.cs
new file mode 100644
index 00000000..c71d59e2
--- /dev/null
+++ b/src/Yavsc.Server/Models/Kyc/ModerationAction.cs
@@ -0,0 +1,26 @@
+// Yavsc.Models.Kyc/ModerationLog.cs
+namespace Yavsc.Models.Kyc
+{
+ using System;
+ using System.ComponentModel.DataAnnotations;
+
+ public enum ModerationAction { Approved, Rejected, Redacted }
+
+ ///
+ /// Trace immuable de chaque décision de modération.
+ /// Pseudonymisée : ModeratorId est l'Id ASP.NET Identity, pas un nom.
+ ///
+ public class ModerationLog
+ {
+ [Key]
+ public long Id { get; set; }
+
+ public long DeclarationId { get; set; }
+ public virtual TrustDeclaration Declaration { get; set; }
+
+ public string ModeratorId { get; set; } // ASP.NET Identity UserId
+ public ModerationAction Action { get; set; }
+ public int ScoreDelta { get; set; }
+ public DateTime Timestamp { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Server/Settings/KycOptions.cs b/src/Yavsc.Server/Settings/KycOptions.cs
new file mode 100644
index 00000000..cd52f217
--- /dev/null
+++ b/src/Yavsc.Server/Settings/KycOptions.cs
@@ -0,0 +1,12 @@
+// Yavsc.Services.Kyc/KycOptions.cs
+namespace Yavsc.Services.Kyc
+{
+ public class KycOptions
+ {
+ ///
+ /// Clé secrète serveur pour le HMAC — jamais en clair dans le code,
+ /// à mettre dans les secrets (user-secrets / env var / vault).
+ ///
+ public string HmacSecret { get; set; }
+ }
+}