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.
# 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:
<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.
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 }));
}
}
| Member | Purpose |
|---|---|
Manifest | Metadata about the plugin — shown in the admin UI marketplace |
RegisterServices | Called once at startup. Add your services to the DI container. |
MapRoutes | Map 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.
| Property | Type | Description |
|---|---|---|
Id | string | Required. Stable slug used as route prefix and DB key. Use kebab-case. |
Name | string | Required. Human-readable display name. |
Version | string | Required. SemVer version string. |
Description | string | Short description for the marketplace. |
Category | string | Grouping label: "Workflow", "Security", "Integration", "Governance", etc. |
HasSettings | bool | Whether the plugin stores per-tenant configuration. |
HasEndpoints | bool | Whether MapRoutes contributes any HTTP endpoints. |
AllowHubAdminOverride | bool | If true, hub admins can disable this plugin per-hub. Default: true. |
UiContributions | IReadOnlyDictionary<string, string> | Map of UI slot → Vue component name. See Frontend Plugins. |
Dependencies | IReadOnlyList<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.
// 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.
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.
| Property | Type | Description |
|---|---|---|
ActionName | string | The action being attempted (e.g. "Entry.Publish") |
TenantId | Guid | Current tenant |
UserId | Guid | Actor performing the action |
EntityId | Guid | Target entity (entry, hub, etc.) |
Properties | IReadOnlyDictionary | Action-specific data: "content", "title", "hubId", etc. |
ActionGuardResult
| Factory Method | Effect |
|---|---|
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.
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:
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.
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.
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.
// 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.
| Constant | Value | Description |
|---|---|---|
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.
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.
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.
[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.
| Plugin | ID | Category | What it does |
|---|---|---|---|
| Workflow & Approvals | workflow | Workflow | Adds approval workflows to entry publishing. Guards Entry.Publish, exposes approval endpoints, contributes toolbar/sidebar UI components. |
| Content Rules | content-rules | Security | Detects secrets and PII on save. Supports stop (block) and overwrite (redact) modes. Built-in patterns for AWS keys, GitHub tokens, emails, SSNs. |
| Retention Policy | retention-policy | Governance | Intercepts Entry.Delete and redirects to soft-delete or archive based on hub/tenant policy. Grace period for purging. |
| Classification | classification | Security | Labels entries with classification levels (Public → Top Secret). Guards sensitive actions based on classification. |
| DeepL Translator | deepl | Translation | Translates 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.