# All'interno dell'architettura Rasepi: Plugin, protezioni d'azione e pipeline

> Un approfondimento tecnico su come funzionano il sistema di plugin, la pipeline di action guard e il motore di traduzione a livello di blocco di Rasepi, con codice reale tratto dalla base di codice.

2026-03-06 · Tim Cadenbach · https://www.tcdev.de/it/blog/how-plugin-guardrail-and-pipeline-systems-work/

---

La maggior parte delle piattaforme di documentazione parla di "estensibilità" come le compagnie aeree parlano di "spazio per le gambe". Tecnicamente presente, praticamente deludente. Volevo che l'architettura di Rasepi fosse veramente estensibile senza diventare imprevedibile, quindi abbiamo costruito tre sistemi interconnessi: **plugins** per le capacità, **action guards** per il controllo e **pipelines** per l'esecuzione deterministica.

Questo post illustra il funzionamento di ciascuno di essi nella nostra base di codice reale.

![Architettura Rasepi: Plugin, Guardie e Pipeline che lavorano insieme](https://www.tcdev.de/it/blog/img/architecture-pipeline.svg)

## Il sistema di plugin: modulare per design

Ogni plugin in Rasepi implementa `IPluginModule`, un'unica interfaccia che dichiara che cos'è il plugin, di quali servizi ha bisogno e quali percorsi espone:

```csharp
public interface IPluginModule
{
    PluginManifest Manifest { get; }
    void RegisterServices(IServiceCollection services);
    void MapRoutes(IEndpointRouteBuilder routes);
}
```

Il `PluginManifest` è un dato puro. Descrive il plugin senza eseguire nulla:

```csharp
public sealed class PluginManifest
{
    public required string Id { get; init; }
    public required string Name { get; init; }
    public required string Version { get; init; }
    public string Description { get; init; }
    public string Category { get; init; }
    public IReadOnlyDictionary<string, string> UiContributions { get; init; }
    public bool HasSettings { get; init; }
    public bool HasEndpoints { get; init; }
    public IReadOnlyList<string> Dependencies { get; init; }
}
```

Notare `UiContributions`. Questo dizionario mappa i punti di estensione del frontend con i nomi dei componenti, in modo che il frontend Vue sappia quali componenti dell'interfaccia utente contribuisce a ciascun plugin (un pulsante della barra degli strumenti, un pannello della barra laterale, una pagina di impostazioni).

### La registrazione è una riga per ogni plugin

All'avvio, registriamo i plugin attraverso un'API fluente:

```csharp
var pluginRegistry = new PluginRegistry();

pluginRegistry
    .AddPlugin<WorkflowPluginModule>(builder.Services)
    .AddPlugin<RulesPluginModule>(builder.Services)
    .AddPlugin<RetentionPluginModule>(builder.Services)
    .AddPlugin<ClassificationPluginModule>(builder.Services);
```

Ogni chiamata istanzia il modulo, lo memorizza nel registro e chiama `RegisterServices()` per collegare le sue dipendenze. Dopo la creazione dell'applicazione, una singola riga mappa tutti i percorsi dei plugin:

```csharp
app.MapPluginRoutes(pluginRegistry);
```

Sotto il cofano, ogni plugin riceve un gruppo di rotte con uno scope in `/plugins/{pluginId}/`, con l'autorizzazione applicata automaticamente.

### Esempio reale: il plugin Workflow

Ecco come appare un plugin reale, il modulo Workflow & Approvals:

```csharp
public sealed class WorkflowPluginModule : IPluginModule
{
    public const string PluginId = "workflow";

    public PluginManifest Manifest { get; } = new()
    {
        Id = PluginId,
        Name = "Workflow & Approvals",
        Version = "1.0.0",
        Description = "Adds approval workflows to entry publishing.",
        Category = "Workflow",
        HasSettings = true,
        HasEndpoints = true,
        UiContributions = new Dictionary<string, string>
        {
            ["entry.toolbar.publish"] = "WorkflowPublishButton",
            ["entry.sidebar.status"]  = "WorkflowStatusPanel",
            ["hub.admin.settings"]    = "WorkflowHubSettings",
        }
    };

    public void RegisterServices(IServiceCollection services)
    {
        services.AddScoped<IWorkflowService, WorkflowService>();
        services.AddScoped<IActionGuard, WorkflowPublishGuard>();
    }

    public void MapRoutes(IEndpointRouteBuilder routes)
    {
        WorkflowEndpoints.Map(routes);
    }
}
```

La piattaforma principale non fa mai riferimento a `WorkflowService` o `WorkflowPublishGuard` direttamente. Li scopre attraverso il contenitore DI. Questa è la chiave per azzerare l'accoppiamento. L'applicazione principale non tocca mai il codice dei plugin.

## Guardie d'azione: il livello di controllo

I plugin aggiungono capacità. Le guardie d'azione decidono se questa capacità, o qualsiasi azione del nucleo, può procedere. Sono validatori sincroni che intercettano le operazioni prima dell'esecuzione.

![Flusso di valutazione delle guardie d'azione](https://www.tcdev.de/it/blog/img/action-guard-flow.svg)

L'interfaccia è volutamente minimale:

```csharp
public interface IActionGuard
{
    string PluginId { get; }
    string? ActionName { get; }  // null means guard ALL actions

    Task<ActionGuardResult> EvaluateAsync(
        ActionGuardContext context,
        IServiceProvider services,
        CancellationToken ct = default);
}
```

Quando `ActionName` è `null`, la guardia viene eseguita per ogni azione. Quando è impostato su qualcosa come `"Entry.Publish"`, intercetta solo quell'azione specifica.

### I contratti di contesto e di risultato

Ogni guardia riceve un contesto tipizzato con il nome dell'azione, il tenant, l'utente, l'entità e un bagaglio di proprietà:

```csharp
public sealed record ActionGuardContext(
    string ActionName,
    Guid TenantId,
    Guid UserId,
    Guid EntityId,
    IReadOnlyDictionary<string, object?> Properties)
{
    public T? Get<T>(string key) =>
        Properties.TryGetValue(key, out var v) && v is T typed
            ? typed : default;
}
```

E ogni guardia restituisce un risultato prevedibile: allow, deny o allow-with-modifications:

```csharp
public sealed record ActionGuardResult
{
    public bool IsAllowed { get; init; }
    public string? ReasonCode { get; init; }
    public string? Message { get; init; }
    public IReadOnlyDictionary<string, object?>? Modifications { get; init; }

    public static ActionGuardResult Allow() =>
        new() { IsAllowed = true };

    public static ActionGuardResult Deny(
        string reasonCode, string message) =>
        new() { IsAllowed = false, ReasonCode = reasonCode, Message = message };
}
```

Il campo `Modifications` è importante. Un guardiano può approvare un'azione ma riscrivere parte del contenuto (ad esempio, redigere i segreti prima della pubblicazione).

### Nomi canonici delle azioni

Definiamo tutte le azioni intercettabili come costanti di stringa, in modo che non ci siano ambiguità su ciò che un guardiano può indirizzare:

```csharp
public static class ActionNames
{
    public static class Entry
    {
        public const string Create  = "Entry.Create";
        public const string Save    = "Entry.Save";
        public const string Publish = "Entry.Publish";
        public const string Delete  = "Entry.Delete";
        public const string Archive = "Entry.Archive";
        public const string Renew   = "Entry.Renew";
    }

    public static class Hub
    {
        public const string Create = "Hub.Create";
        public const string Delete = "Hub.Delete";
        public const string TransferOwnership = "Hub.TransferOwnership";
    }

    public static class Translation
    {
        public const string Create  = "Translation.Create";
        public const string Publish = "Translation.Publish";
    }
}
```

### Esempio reale: bloccare la pubblicazione senza approvazione

Il plugin Workflow registra una guardia che intercetta `Entry.Publish`:

```csharp
public sealed class WorkflowPublishGuard : IActionGuard
{
    public string PluginId => WorkflowPluginModule.PluginId;
    public string? ActionName => ActionNames.Entry.Publish;

    public async Task<ActionGuardResult> EvaluateAsync(
        ActionGuardContext context,
        IServiceProvider services,
        CancellationToken ct = default)
    {
        var db = services.GetRequiredService<RasepiDbContext>();
        var entry = await db.Entries
            .AsNoTracking()
            .FirstOrDefaultAsync(e => e.Id == context.EntityId, ct);

        if (entry is null)
            return ActionGuardResult.Allow();

        var workflowService = services.GetRequiredService<IWorkflowService>();
        var check = await workflowService
            .CheckPublishAllowedAsync(entry.Id, entry.HubId);

        if (check.IsAllowed)
            return ActionGuardResult.Allow();

        return ActionGuardResult.Deny(
            "workflow.approval_required",
            check.Message ?? "Approval required before publishing.");
    }
}
```

La piattaforma principale non sa nulla dei flussi di lavoro di approvazione. Chiama semplicemente `Entry.Publish` attraverso la pipeline e la guardia lo blocca se il flusso di lavoro non è stato completato.

## La pipeline di azione: dove tutto converge

Il `ActionPipeline` è il percorso di esecuzione unico per tutte le operazioni protette. Risolve quali protezioni si applicano, le valuta e blocca o esegue l'azione.

```csharp
public sealed class ActionPipeline : IActionPipeline
{
    public async Task<ActionPipelineResult> ExecuteAsync(
        string actionName,
        ActionGuardContext context,
        Func<Task> action,
        CancellationToken ct = default)
    {
        var result = await EvaluateAsync(actionName, context, ct);
        if (!result.IsAllowed) return result;

        await action();  // All guards passed — execute

        return result;   // Return modifications for caller
    }
}
```

Il metodo `EvaluateAsync` fa il lavoro pesante:

```csharp
public async Task<ActionPipelineResult> EvaluateAsync(
    string actionName,
    ActionGuardContext context,
    CancellationToken ct = default)
{
    // 1. Which plugins are enabled for this tenant?
    var enabledPlugins = await _resolver.GetEnabledPluginIdsAsync();

    // 2. Which guards match this action?
    var applicable = _guards
        .Where(g => enabledPlugins.Contains(g.PluginId))
        .Where(g => g.ActionName == null || g.ActionName == actionName)
        .ToList();

    // 3. Evaluate each guard
    var denials = new List<ActionGuardResult>();
    var modifications = new List<ActionGuardResult>();

    foreach (var guard in applicable)
    {
        try
        {
            var guardResult = await guard.EvaluateAsync(context, _services, ct);
            if (!guardResult.IsAllowed)
                denials.Add(guardResult);
            else if (guardResult.Modifications?.Count > 0)
                modifications.Add(guardResult);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Guard threw. Treating as Allow.");
        }
    }

    // 4. Any denial blocks the whole action
    if (denials.Count > 0)
        return ActionPipelineResult.Blocked(denials);

    return modifications.Count > 0
        ? ActionPipelineResult.Allowed(modifications)
        : ActionPipelineResult.Allowed();
}
```

Qui ci sono tre importanti decisioni di progettazione:

1. **Risoluzione per inquilino.** Il `TenantPluginResolver` controlla quali plugin ogni inquilino ha installato e abilitato. Una protezione per un plugin disabilitato non viene mai eseguita.
2. **Tutti devono passare.** Se una guardia nega, l'azione viene bloccata. Si tratta di una posizione di sicurezza intenzionale.
3. **Se una guardia lancia un'eccezione, questa viene registrata e trattata come `Allow()`. Questo impedisce a un plugin non funzionante di bloccare l'intera piattaforma.

### Risoluzione dei plugin per inquilino

Il resolver interroga la tabella `TenantPluginInstallations` (automaticamente ottimizzata per il tenant corrente dai filtri di interrogazione globale EF):

```csharp
public sealed class TenantPluginResolver : ITenantPluginResolver
{
    public async Task<IReadOnlySet<string>> GetEnabledPluginIdsAsync(
        CancellationToken ct = default)
    {
        if (_cache is not null) return _cache;

        var ids = await _db.TenantPluginInstallations
            .Where(i => i.IsEnabled)
            .Select(i => i.PluginId)
            .ToListAsync(ct);

        _cache = ids.ToHashSet();
        return _cache;
    }
}
```

## Effetti collaterali guidati dagli eventi

Le azioni sono sincrone. Gli effetti collaterali non lo sono. Dopo il completamento di un'azione, il servizio pubblica un evento di dominio:

```csharp
await _eventPublisher.PublishAsync(
    EventNames.Entry.Created, entry.Id, new { entry.OriginalLanguage });
```

Gli eventi vengono inseriti in un canale in memoria ed elaborati da un `EventConsumerWorker` in background. Il worker instrada gli eventi a più sistemi:

- **Tracciamento dell'attività.** Registra chi ha fatto cosa, quando
- Fatturazione delle traduzioni.** Traccia i costi per fornitore
- **Gestori di eventi dei plugin.** Qualsiasi plugin può sottoscrivere gli eventi del dominio

I gestori di eventi dei plugin implementano `IPluginEventHandler`:

```csharp
public interface IPluginEventHandler
{
    string PluginId { get; }
    IReadOnlyList<string> SubscribedEvents { get; }

    Task HandleAsync(
        string eventName, Guid entityId,
        Guid? tenantId, Guid? userId,
        string payloadJson, IServiceProvider services,
        CancellationToken ct = default);
}
```

Il worker invoca solo i gestori il cui plugin è abilitato per il tenant. Ciò significa che gli effetti collaterali del plugin A non si disperdono mai in un inquilino che ha installato solo il plugin B.

## Il motore di traduzione a livello di blocco

Questo è il punto in cui l'architettura dà i suoi frutti in modo più evidente.

![Traduzione a livello di blocco: solo i blocchi modificati vengono ritradotti](https://www.tcdev.de/it/blog/img/block-translation.svg)

Le piattaforme tradizionali traducono interi documenti. Noi traduciamo singoli **blocchi**: paragrafi, intestazioni, voci di elenco. Quando un utente modifica un paragrafo in un documento di 50 blocchi, solo quel paragrafo deve essere ritradotto. Questa è la fonte del nostro 94% di risparmio sui costi.

### Come vengono creati i blocchi da TipTap JSON

Quando un utente salva un documento, l'editor TipTap invia JSON come questo:

```json
{
  "type": "doc",
  "content": [
    {
      "type": "paragraph",
      "attrs": { "blockId": "a1b2c3d4-..." },
      "content": [{ "type": "text", "text": "Hello world" }]
    }
  ]
}
```

Il `BlockTranslationService` analizza questo JSON e crea singoli record `EntryBlock`:

```csharp
public async Task<List<EntryBlock>> CreateBlocksFromDocumentAsync(
    Guid entryId, string language, string contentJson,
    int version, Guid userId)
{
    var doc = JsonDocument.Parse(contentJson);
    var content = doc.RootElement.GetProperty("content");

    int position = 0;
    foreach (var node in content.EnumerateArray())
    {
        var blockType = node.GetProperty("type").GetString();
        var blockJson = JsonSerializer.Serialize(node);

        // Strip metadata attrs before hashing
        var hashInput = StripBlockMetaAttrs(blockJson);

        var block = new EntryBlock
        {
            Id = ExtractOrGenerateBlockId(node),
            EntryId = entryId,
            Language = language,
            Position = position++,
            BlockType = blockType,
            ContentJson = blockJson,
            ContentHash = CalculateContentHash(hashInput),
            IsNoTranslate = ExtractNoTranslateFlag(node),
            Version = version,
        };

        _context.EntryBlocks.Add(block);
    }

    await _context.SaveChangesAsync();
    return blocks;
}
```

### Hashing SHA256 per il rilevamento delle stalle

L'hash del contenuto è il cuore del rilevamento delle stalle. Eseguiamo l'hash del contenuto del blocco (dopo aver eliminato gli attributi dei metadati come `blockId` e `deleted`) utilizzando SHA256:

```csharp
private string CalculateContentHash(string content)
{
    using var sha256 = SHA256.Create();
    var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(content));
    return Convert.ToHexString(hashBytes);
}
```

Quando un blocco sorgente cambia, il suo hash cambia. Il sistema confronta quindi il `SourceContentHash` di ogni blocco di traduzione con l'hash corrente della sorgente, e le discrepanze vengono contrassegnate con `Stale`:

```csharp
public async Task MarkTranslationsAsStaleAsync(List<Guid> changedBlockIds)
{
    var affected = await _context.TranslationBlocks
        .Where(t => changedBlockIds.Contains(t.SourceBlockId))
        .ToListAsync();

    foreach (var translation in affected)
    {
        translation.Status = TranslationStatus.Stale;
        translation.UpdatedAt = DateTime.UtcNow;
    }

    await _context.SaveChangesAsync();
}
```

### Adattamento della struttura

I traduttori possono cambiare i tipi di blocco tra le varie lingue. Un elenco puntato inglese potrebbe diventare un elenco numerato tedesco, una preferenza culturale. Il sistema ne tiene traccia:

```csharp
var translation = new TranslationBlock
{
    SourceBlockId = sourceBlockId,
    Language = targetLanguage,
    BlockType = translatedBlockType,
    SourceBlockType = sourceBlock.BlockType,
    IsStructureAdapted = translatedBlockType != sourceBlock.BlockType,
    SourceContentHash = sourceBlock.ContentHash,
    Status = TranslationStatus.UpToDate,
};
```

### Fornitori di traduzione come plugin

I servizi di traduzione esterni (DeepL, Google Translate, ecc.) si collegano tramite `ITranslationProviderPlugin`:

```csharp
public interface ITranslationProviderPlugin : IRasepiPlugin
{
    string[] GetSupportedLanguages();

    Task<string> TranslateAsync(
        string text, string sourceLanguage, string targetLanguage);

    Task<TranslationBatchResult> TranslateBatchAsync(
        Dictionary<string, string> texts,
        string sourceLanguage, string targetLanguage);
}
```

Il metodo batch riceve un dizionario di ID di blocchi da contenere, li traduce tutti e restituisce le traduzioni con un conteggio di caratteri. Dato che inviamo solo i blocchi non completi, e non l'intero documento, i costi rimangono minimi.

## Isolamento degli inquilini: la rete di sicurezza invisibile

Tutti i sistemi sopra descritti funzionano all'interno di un rigoroso isolamento degli inquilini.

Il `TenantContextMiddleware` risolve l'inquilino dal JWT su ogni richiesta e verifica l'appartenenza:

```csharp
public async Task InvokeAsync(
    HttpContext context, TenantContext tenantContext, RasepiDbContext db)
{
    var tenantIdClaim = context.User.FindFirstValue("tenant_id");
    var userIdClaim = context.User.FindFirstValue(ClaimTypes.NameIdentifier);

    // Populate scoped context
    tenantContext.TenantId = Guid.Parse(tenantIdClaim);
    tenantContext.UserId = Guid.Parse(userIdClaim);

    // Verify membership — fail closed
    var membership = await db.TenantMemberships
        .Where(m => m.TenantId == tenantContext.TenantId
                  && m.UserId == tenantContext.UserId)
        .FirstOrDefaultAsync();

    if (membership == null)
    {
        context.Response.StatusCode = 401;
        return;  // No membership = no access
    }
}
```

I filtri globali delle query di Entity Framework assicurano che, anche se uno sviluppatore dimentica di filtrare per tenant, il livello del database lo faccia automaticamente:

```csharp
modelBuilder.Entity<Hub>()
    .HasQueryFilter(h => h.TenantId == _tenantContext.TenantId);
```

Il risultato: `db.Hubs.ToListAsync()` restituisce sempre solo gli hub del tenant corrente. Le fughe di dati richiedono l'aggiramento attivo del filtro di query, che è vietato nella nostra base di codice.

## Il quadro completo

Quando un utente fa clic su "Pubblica" su una voce, ecco cosa succede:

1. **L'autenticazione convalida il JWT, `TenantContextMiddleware` risolve e verifica l'inquilino.
2. **Il controllore chiama la pipeline.** `IActionPipeline.ExecuteAsync("Entry.Publish", context, action)`
3. **La pipeline risolve le guardie.** Interroga i plugin abilitati dall'inquilino e seleziona le guardie applicabili.
4. **Le guardie valutano.** La guardia del flusso di lavoro verifica le approvazioni, la guardia della conservazione verifica i criteri, la guardia delle regole convalida il contenuto. Tutti passano? La voce viene pubblicata.
5. **Gli eventi si attivano.** L'evento `Entry.Published` viene richiesto. Un lavoratore in background registra le attività, aggiorna la fatturazione delle traduzioni e chiama i gestori di eventi del plugin.
6. **Verifica delle traduzioni dei blocchi.** I blocchi obsoleti vengono identificati per essere ritradotti.

Ogni livello svolge il proprio lavoro. Nessun livello si inserisce in un altro. Questa è l'architettura.

> Non abbiamo costruito questo perché l'estensibilità è di moda. L'abbiamo costruita perché una piattaforma di documentazione che non può adattarsi al flusso di lavoro di ogni team, alla fine sarà sostituita da una che può farlo. E una piattaforma che si adatta senza guardrail finirà per rompere qualcosa di importante.
