Plugin SDK · .NET 8

Backend Plugin Development

Extend Rasepi's core behaviour with server-side plugins. Intercept actions, subscribe to events, add API endpoints, provide translation engines, and more. All through a clean, type-safe SDK.

📚

Overview

The Rasepi plugin system follows a registry-based architecture. Plugins are .NET classes that implement IPluginModule from the SDK. At startup, each plugin registers its services, and optionally maps API routes. At runtime, the Action Pipeline evaluates guards from enabled plugins before executing core actions.

Plugin Module

Entry point. Declares metadata, registers DI services, maps routes.

Plugin Manifest

Metadata record: ID, name, version, category, UI contributions, dependencies.

Plugin Registry

Singleton that holds all registered modules. Used at startup and runtime.

Tenant-Scoped

Plugins are installed per-tenant. Only guards from enabled plugins run.

🛠

Project Setup

Create a new class library project and reference the Plugin SDK.

bash
# Create plugin project
dotnet new classlib -n Rasepi.Plugins.MyPlugin -f net8.0
cd Rasepi.Plugins.MyPlugin

# Reference the SDK
dotnet add reference ../Rasepi.Plugins.SDK/Rasepi.Plugins.SDK.csproj

Your .csproj needs:

xml
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\Rasepi.Plugins.SDK\Rasepi.Plugins.SDK.csproj" />
  </ItemGroup>
</Project>
🔌

IPluginModule — Entry Point

Every backend plugin implements IPluginModule. This interface is the single entry point: it declares your manifest, registers DI services, and maps HTTP routes.

csharp
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Rasepi.Plugins.SDK;

namespace Rasepi.Plugins.MyPlugin;

public sealed class MyPluginModule : IPluginModule
{
    public const string PluginId = "my-plugin";

    public PluginManifest Manifest { get; } = new()
    {
        Id          = PluginId,
        Name        = "My Custom Plugin",
        Version     = "1.0.0",
        Description = "Does something useful",
        Category    = "Integration",
        HasSettings  = false,
        HasEndpoints = true,
    };

    public void RegisterServices(IServiceCollection services)
    {
        // Register your guards, services, repositories
        services.AddScoped<IActionGuard, MyPublishGuard>();
        services.AddScoped<IMyService, MyService>();
    }

    public void MapRoutes(IEndpointRouteBuilder routes)
    {
        // Routes are scoped to /api/plugins/my-plugin/...
        routes.MapGet("/status", () => Results.Ok(new { healthy = true }));
    }
}
MemberPurpose
ManifestMetadata about the plugin — shown in the admin UI marketplace
RegisterServicesCalled once at startup. Add your services to the DI container.
MapRoutesMap Minimal API endpoints. Routes are auto-prefixed to /api/plugins/{id}/
📄

PluginManifest

The manifest is a metadata record that describes your plugin to the platform and the admin UI.

PropertyTypeDescription
IdstringRequired. Stable slug used as route prefix and DB key. Use kebab-case.
NamestringRequired. Human-readable display name.
VersionstringRequired. SemVer version string.
DescriptionstringShort description for the marketplace.
CategorystringGrouping label: "Workflow", "Security", "Integration", "Governance", etc.
HasSettingsboolWhether the plugin stores per-tenant configuration.
HasEndpointsboolWhether MapRoutes contributes any HTTP endpoints.
AllowHubAdminOverrideboolIf true, hub admins can disable this plugin per-hub. Default: true.
UiContributionsIReadOnlyDictionary<string, string>Map of UI slot → Vue component name. See Frontend Plugins.
DependenciesIReadOnlyList<string>Plugin IDs that must be installed and enabled for this plugin to work.
🚀

Registration in Program.cs

Register your plugin in the application's startup. The AddPlugin extension instantiates the module, calls RegisterServices, and adds it to the registry. MapPluginRoutes maps all endpoint groups.

csharp
// Program.cs — Plugin Registration

// 1. Create the registry
var pluginRegistry = new PluginRegistry();
builder.Services.AddSingleton<IPluginRegistry>(pluginRegistry);

// 2. Core platform services
builder.Services.AddScoped<ITenantPluginResolver, TenantPluginResolver>();
builder.Services.AddScoped<ITenantPluginInstallationService, TenantPluginInstallationService>();
builder.Services.AddScoped<IActionPipeline, ActionPipeline>();

// 3. Register your plugins
pluginRegistry.AddPlugin<MyPluginModule>(builder.Services);

// ... after building the app:

// 4. Map plugin routes (auto-scoped to /api/plugins/{id})
app.MapPluginRoutes(pluginRegistry);

💡 How it works

AddPlugin<T>() does three things: (1) creates a new T(), (2) registers it in the PluginRegistry, and (3) calls module.RegisterServices(services) so your guards and services enter the DI container. At route-mapping time, MapPluginRoutes iterates all modules with HasEndpoints = true and calls MapRoutes on a route group pre-scoped to /api/plugins/{manifest.Id} with .RequireAuthorization().

🛡

Action Guards

Guards intercept core actions before they execute. They can allow, allow with modifications (e.g. content redaction), or deny an action. Guards are pure evaluators. They must not mutate state.

csharp
using Rasepi.Plugins.SDK;

namespace Rasepi.Plugins.MyPlugin;

/// <summary>
/// Example: Block publishing entries that contain "TODO" markers.
/// </summary>
public sealed class NoDraftMarkersGuard : IActionGuard
{
    // Must match your plugin module's ID
    public string PluginId => MyPluginModule.PluginId;

    // Which action to intercept (null = ALL actions)
    public string? ActionName => ActionNames.Entry.Publish;

    public Task<ActionGuardResult> EvaluateAsync(
        ActionGuardContext context,
        IServiceProvider services,
        CancellationToken ct = default)
    {
        var content = context.Get<string>("content") ?? "";

        if (content.Contains("TODO", StringComparison.OrdinalIgnoreCase))
        {
            return Task.FromResult(ActionGuardResult.Deny(
                reasonCode: "my-plugin.has-todos",
                message: "Cannot publish: entry still contains TODO markers."));
        }

        return Task.FromResult(ActionGuardResult.Allow());
    }
}

ActionGuardContext

The context passed to every guard. Use Get<T>(key) for typed property access.

PropertyTypeDescription
ActionNamestringThe action being attempted (e.g. "Entry.Publish")
TenantIdGuidCurrent tenant
UserIdGuidActor performing the action
EntityIdGuidTarget entity (entry, hub, etc.)
PropertiesIReadOnlyDictionaryAction-specific data: "content", "title", "hubId", etc.

ActionGuardResult

Factory MethodEffect
ActionGuardResult.Allow()Let the action proceed normally
ActionGuardResult.AllowWithModifications(mods)Allow but apply changes (e.g. redacted content)
ActionGuardResult.Deny(reasonCode, message, metadata?)Block the action. reasonCode is machine-readable; message is human-readable.

Event Handlers

Subscribe to domain events for fire-and-forget side effects — send notifications, update external systems, write audit logs. Exceptions are caught and logged; they never break the core flow.

csharp
using Rasepi.Plugins.SDK;

namespace Rasepi.Plugins.MyPlugin;

public sealed class SlackNotifierHandler : IPluginEventHandler
{
    public string PluginId => MyPluginModule.PluginId;

    public IReadOnlyList<string> SubscribedEvents => new[]
    {
        "Entry.Published",
        "Entry.Expired"
    };

    public async Task HandleAsync(
        string eventName,
        Guid entityId,
        Guid? tenantId,
        Guid? userId,
        string payloadJson,
        IServiceProvider services,
        CancellationToken ct = default)
    {
        var slackClient = services.GetRequiredService<ISlackClient>();

        var message = eventName switch
        {
            "Entry.Published" => $"📣 Entry {entityId} was just published!",
            "Entry.Expired"   => $"⚠️ Entry {entityId} has expired.",
            _ => null
        };

        if (message != null)
            await slackClient.PostAsync("#documentation", message);
    }
}

Register the handler in your RegisterServices:

csharp
public void RegisterServices(IServiceCollection services)
{
    services.AddScoped<IPluginEventHandler, SlackNotifierHandler>();
    services.AddHttpClient<ISlackClient, SlackClient>();
}
🌐

API Endpoints

Plugins can expose custom HTTP endpoints using .NET Minimal APIs. Routes are automatically prefixed to /api/plugins/{your-plugin-id}/ and require authorization.

csharp
namespace Rasepi.Plugins.MyPlugin;

public static class MyPluginEndpoints
{
    public static void Map(IEndpointRouteBuilder routes)
    {
        // GET /api/plugins/my-plugin/config
        routes.MapGet("/config", async (IMyService svc) =>
        {
            var config = await svc.GetConfigAsync();
            return Results.Ok(config);
        });

        // POST /api/plugins/my-plugin/webhook
        routes.MapPost("/webhook", async (
            HttpRequest req, IMyService svc) =>
        {
            using var reader = new StreamReader(req.Body);
            var payload = await reader.ReadToEndAsync();
            await svc.ProcessWebhookAsync(payload);
            return Results.Accepted();
        });
    }
}

// In your module:
public void MapRoutes(IEndpointRouteBuilder routes)
{
    MyPluginEndpoints.Map(routes);
}

🔒 Route security

All plugin routes are wrapped with .RequireAuthorization() automatically. If you need public endpoints, you must explicitly add .AllowAnonymous() on individual routes. For admin-only endpoints, add your own authorization policy.

🌐

Translation Providers

Implement ITranslationProviderPlugin to integrate a machine translation engine. Rasepi's block-level translation system calls your provider for individual paragraphs or batches.

csharp
using Rasepi.Plugins.SDK;

public sealed class MyTranslationProvider : ITranslationProviderPlugin
{
    public string Id => "my-translator";
    public string Name => "My Translation Engine";
    public string Version => "1.0.0";
    public bool IsEnabled { get; set; } = true;

    private ILogger? _logger;

    public void Initialize(IPluginContext context)
    {
        _logger = context.Logger;
    }

    public string[] GetSupportedLanguages()
    {
        return new[] { "EN", "DE", "FR", "ES", "IT", "JA", "ZH" };
    }

    public async Task<string> TranslateAsync(
        string text, string sourceLanguage, string targetLanguage)
    {
        // Call your translation API
        var client = new HttpClient();
        var result = await client.PostAsJsonAsync(
            "https://api.mytranslator.com/translate",
            new { text, from = sourceLanguage, to = targetLanguage });
        var data = await result.Content.ReadFromJsonAsync<TranslateResponse>();
        return data?.TranslatedText ?? text;
    }

    public async Task<TranslationBatchResult> TranslateBatchAsync(
        Dictionary<string, string> texts,
        string sourceLanguage, string targetLanguage)
    {
        // Batch translate for efficiency
        var results = new Dictionary<string, string>();
        var billed = 0;

        foreach (var (key, text) in texts)
        {
            results[key] = await TranslateAsync(text, sourceLanguage, targetLanguage);
            billed += text.Length;
        }

        return new TranslationBatchResult(results, billed);
    }
}
📦

Export & Import Plugins

Add custom file format support for entry export/import.

csharp
// Export to custom format
public sealed class MarkdownExporter : IExportPlugin
{
    public string Id => "markdown-export";
    public string Name => "Markdown Exporter";
    public string Version => "1.0.0";
    public bool IsEnabled { get; set; } = true;

    public string ExportFormat => "Markdown";
    public string FileExtension => ".md";

    public void Initialize(IPluginContext context) { }

    public async Task<byte[]> ExportAsync(object entry)
    {
        // Convert entry to Markdown
        var markdown = ConvertToMarkdown(entry);
        return Encoding.UTF8.GetBytes(markdown);
    }
}

// Import from custom format
public sealed class DocxImporter : IImportPlugin
{
    public string Id => "docx-import";
    public string Name => "Word Importer";
    public string Version => "1.0.0";
    public bool IsEnabled { get; set; } = true;

    public string ImportFormat => "Word Document";
    public string[] SupportedFileExtensions => new[] { ".docx" };

    public void Initialize(IPluginContext context) { }

    public async Task<object> ImportAsync(byte[] fileData)
    {
        // Parse .docx and produce TipTap-compatible JSON
        return ParseDocx(fileData);
    }
}
🔗

Action Names Reference

Canonical action identifiers. Use these constants with IActionGuard.ActionName.

ConstantValueDescription
ActionNames.Entry.Create"Entry.Create"Creating a new entry
ActionNames.Entry.Save"Entry.Save"Saving entry content
ActionNames.Entry.Publish"Entry.Publish"Publishing a draft entry
ActionNames.Entry.Delete"Entry.Delete"Deleting an entry
ActionNames.Entry.Archive"Entry.Archive"Archiving an entry
ActionNames.Entry.Restore"Entry.Restore"Restoring an archived entry
ActionNames.Entry.Renew"Entry.Renew"Renewing entry expiry
ActionNames.Hub.Create"Hub.Create"Creating a hub
ActionNames.Hub.Update"Hub.Update"Updating a hub
ActionNames.Hub.Delete"Hub.Delete"Deleting a hub
ActionNames.Hub.TransferOwnership"Hub.TransferOwnership"Transferring hub ownership
ActionNames.Translation.Create"Translation.Create"Creating a translation
ActionNames.Translation.Publish"Translation.Publish"Publishing a translation
🔄

Action Pipeline

The Action Pipeline is the core orchestration system. When a service triggers an action, the pipeline resolves which plugins are enabled for the current tenant, finds applicable guards, and evaluates them sequentially.

text
Action Triggered (e.g. Entry.Publish)
  │
  ▼
IActionPipeline.ExecuteAsync()
  │
  ├─ Resolve enabled plugins for tenant (ITenantPluginResolver)
  │
  ├─ Filter IActionGuard implementations:
  │    • Guard.PluginId must be in enabled set
  │    • Guard.ActionName == null (wildcard) OR matches action
  │
  ├─ Evaluate guards sequentially:
  │    ├─ Allow           →  continue
  │    ├─ Allow + Mods    →  collect modifications
  │    ├─ Deny            →  collect denial, stop
  │    └─ Exception       →  log, treat as Allow (fail-open)
  │
  └─ Result:
       ├─ All allowed     →  execute the action
       ├─ With mods       →  execute with modifications applied
       └─ Any denial      →  return 422 with reasonCode + message

⚠️ Important: Guards must be pure

Guards must not mutate any state. They are evaluators only. If your guard needs side effects, use an Event Handler instead. If a guard throws an exception, the pipeline logs it and treats the result as Allow to avoid breaking the action on plugin errors.

🏢

Multi-Tenant Scoping

Rasepi is multi-tenant. Plugin guards only run if they belong to a plugin that is installed and enabled for the current request's tenant. The ITenantPluginResolver handles this.

text
Plugin Registration (startup, global)
  ↓
Tenant Installs Plugin (via API /api/tenant/plugins/{id}/install)
  ↓
Tenant Enables Plugin (via API /api/tenant/plugins/{id}/enable)
  ↓
Request arrives → TenantContext resolved from JWT
  ↓
Pipeline queries: "which plugins are enabled for this tenant?"
  ↓
Only those guards execute

Per-tenant settings are stored via /api/tenant/plugins/{id}/settings as JSON. Access them in your service by reading from TenantPluginInstallation.SettingsJson.

🧪

Testing Your Plugin

Unit test guards by constructing an ActionGuardContext and calling EvaluateAsync directly.

csharp
[Fact]
public async Task Publish_blocked_when_content_has_todos()
{
    var guard = new NoDraftMarkersGuard();
    var context = new ActionGuardContext(
        ActionName: ActionNames.Entry.Publish,
        TenantId: Guid.NewGuid(),
        UserId: Guid.NewGuid(),
        EntityId: Guid.NewGuid(),
        Properties: new Dictionary<string, object?>
        {
            ["content"] = "This is a TODO item that needs work"
        });

    var result = await guard.EvaluateAsync(context, Mock.Of<IServiceProvider>());

    Assert.False(result.IsAllowed);
    Assert.Equal("my-plugin.has-todos", result.ReasonCode);
}

[Fact]
public async Task Publish_allowed_when_content_is_clean()
{
    var guard = new NoDraftMarkersGuard();
    var context = new ActionGuardContext(
        ActionName: ActionNames.Entry.Publish,
        TenantId: Guid.NewGuid(),
        UserId: Guid.NewGuid(),
        EntityId: Guid.NewGuid(),
        Properties: new Dictionary<string, object?>
        {
            ["content"] = "Clean, publication-ready content"
        });

    var result = await guard.EvaluateAsync(context, Mock.Of<IServiceProvider>());

    Assert.True(result.IsAllowed);
}
📦

Built-in Plugin Examples

Rasepi ships with several built-in plugins you can study as reference implementations.

PluginIDCategoryWhat it does
Workflow & ApprovalsworkflowWorkflowAdds approval workflows to entry publishing. Guards Entry.Publish, exposes approval endpoints, contributes toolbar/sidebar UI components.
Content Rulescontent-rulesSecurityDetects secrets and PII on save. Supports stop (block) and overwrite (redact) modes. Built-in patterns for AWS keys, GitHub tokens, emails, SSNs.
Retention Policyretention-policyGovernanceIntercepts Entry.Delete and redirects to soft-delete or archive based on hub/tenant policy. Grace period for purging.
ClassificationclassificationSecurityLabels entries with classification levels (Public → Top Secret). Guards sensitive actions based on classification.
DeepL TranslatordeeplTranslationTranslates blocks via the DeepL API. Supports 30+ languages, batch translation, character billing.

Browse the source code in backend/Rasepi.Api/Plugins/ for full implementation details.