WYSIWYG editing Image gallery upload Content templates
Production operations · preview.14

Running RichTextBox in production

Defaults are tuned for a typical SaaS customer; below are the knobs you'll touch when traffic shapes change — rate limiting, retry policy, audit logging, OpenTelemetry, and the health probe that keeps Kubernetes happy.

Rate limiting AI calls

POST /richtextbox/ai and POST /richtextbox/ai/stream share a per-IP token bucket so a single runaway tab can't burn through your OpenAI / Anthropic quota in seconds. Defaults: 30 calls per minute per IP.

builder.Services.AddRichTextBox(opts =>
{
    opts.AiRateLimit       = 30;                          // calls per window
    opts.AiRateLimitWindow = TimeSpan.FromMinutes(1);     // window length
});

Set AiRateLimit = 0 to disable. To swap the in-memory limiter for a Redis-backed implementation (so quotas are shared across instances), register your own:

builder.Services.AddSingleton<IRichTextBoxAiRateLimiter, RedisAiRateLimiter>();
builder.Services.AddRichTextBox();

TryAddSingleton is used internally, so registering your own implementation either before or after AddRichTextBox() wins.

Per-tenant rate limiting

The default key is the client IP. To switch to a tenant id (extracted from claims / cookies / a header), register a wrapper that pulls the key from the current HttpContext:

public sealed class TenantAiRateLimiter : IRichTextBoxAiRateLimiter
{
    private readonly DefaultRichTextBoxAiRateLimiter _inner = new();
    private readonly IHttpContextAccessor _http;

    public TenantAiRateLimiter(IHttpContextAccessor http) => _http = http;

    public AiRateLimitVerdict Check(string _, int limit, TimeSpan window)
    {
        var tenant = _http.HttpContext?.User.FindFirstValue("tenant_id") ?? "anon";
        return _inner.Check(tenant, limit, window);
    }
}

Idempotency — multi-instance with Redis

The default InMemoryIdempotencyStore is process-local, so a retry that lands on a different instance behind your load balancer misses the cache. For multi-instance deployments register a Redis-backed implementation; the editor library will use it transparently.

The interface is two methods. Drop this class into your project after dotnet add package StackExchange.Redis:

using RichTextBox;
using StackExchange.Redis;
using System.Text.Json;

public sealed class RedisIdempotencyStore : IIdempotencyStore
{
    private readonly IConnectionMultiplexer _redis;
    private const string KeyPrefix = "rtb:idem:";

    public RedisIdempotencyStore(IConnectionMultiplexer redis) => _redis = redis;

    public async Task<IdempotencyEntry?> TryGetAsync(string key, CancellationToken ct = default)
    {
        var db = _redis.GetDatabase();
        var raw = await db.StringGetAsync(KeyPrefix + key);
        if (raw.IsNullOrEmpty) return null;

        var wire = JsonSerializer.Deserialize<Wire>(raw!);
        if (wire is null) return null;
        return new IdempotencyEntry
        {
            StatusCode = wire.Status,
            ContentType = wire.ContentType ?? "application/json",
            Body = Convert.FromBase64String(wire.BodyBase64 ?? ""),
            ExpiresUtc = wire.ExpiresUtc,
        };
    }

    public async Task SetAsync(string key, IdempotencyEntry entry, TimeSpan ttl, CancellationToken ct = default)
    {
        var db = _redis.GetDatabase();
        var wire = new Wire
        {
            Status = entry.StatusCode,
            ContentType = entry.ContentType,
            BodyBase64 = Convert.ToBase64String(entry.Body),
            ExpiresUtc = DateTimeOffset.UtcNow + ttl,
        };
        // NX = first-write-wins; matches the in-memory contract.
        await db.StringSetAsync(KeyPrefix + key, JsonSerializer.Serialize(wire),
            expiry: ttl, when: When.NotExists);
    }

    private sealed class Wire
    {
        public int Status { get; set; }
        public string? ContentType { get; set; }
        public string? BodyBase64 { get; set; }
        public DateTimeOffset ExpiresUtc { get; set; }
    }
}

Wire it up:

builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
    ConnectionMultiplexer.Connect(builder.Configuration["Redis:ConnectionString"]));

// Replace the default in-memory store. Register BEFORE AddRichTextBox
// so the framework's TryAddSingleton respects the override.
builder.Services.AddSingleton<IIdempotencyStore, RedisIdempotencyStore>();
builder.Services.AddRichTextBox(opts =>
{
    opts.IdempotencyTtl = TimeSpan.FromHours(1);
    opts.IdempotencyPruneInterval = TimeSpan.Zero; // Redis handles TTL natively
});
First-write-wins via NX. The When.NotExists flag mirrors the in-memory store's contract — concurrent retries see one canonical body even when both finish before the cache SET lands. Redis handles TTL natively, so disable the background pruner.

Upload validators — ClamAV virus scanning

IUploadValidator hooks into the upload pipeline after the magic-byte check. Common production need: scan every upload through ClamAV before it lands on disk / in S3. Here's a reference implementation using ClamAV's INSTREAM protocol over a TCP socket.

Drop into your project; no NuGet dependency required:

using RichTextBox.Uploads;
using System.Net.Sockets;
using System.Text;

public sealed class ClamAvUploadValidator : IUploadValidator
{
    private readonly ClamAvOptions _options;
    public ClamAvUploadValidator(ClamAvOptions options) => _options = options;

    public async Task<UploadValidationResult> ValidateAsync(UploadStoreRequest request, CancellationToken ct = default)
    {
        try
        {
            using var client = new TcpClient();
            await client.ConnectAsync(_options.Host, _options.Port, ct);
            using var stream = client.GetStream();

            // INSTREAM command followed by length-prefixed chunks
            // terminated by a zero-length chunk. See clamav.net/clamd.
            var cmd = Encoding.ASCII.GetBytes("zINSTREAM\0");
            await stream.WriteAsync(cmd, ct);

            request.Content.Position = 0;
            var buffer = new byte[8192];
            int n;
            while ((n = await request.Content.ReadAsync(buffer, ct)) > 0)
            {
                var lenPrefix = BitConverter.GetBytes(IPAddress.HostToNetworkOrder(n));
                await stream.WriteAsync(lenPrefix, ct);
                await stream.WriteAsync(buffer.AsMemory(0, n), ct);
            }
            await stream.WriteAsync(new byte[] { 0, 0, 0, 0 }, ct);

            using var reader = new StreamReader(stream, Encoding.ASCII);
            var response = await reader.ReadToEndAsync(ct);

            // Response shapes: "stream: OK" or "stream: Eicar-Test-Signature FOUND"
            return response.Contains("FOUND", StringComparison.Ordinal)
                ? UploadValidationResult.Reject("Upload contains malware (ClamAV)")
                : UploadValidationResult.Accept();
        }
        catch (Exception ex) when (_options.AllowOnScannerFailure)
        {
            // Fail open if the scanner is unreachable. Production hosts
            // usually want fail-closed — set AllowOnScannerFailure=false.
            return UploadValidationResult.Accept();
        }
    }
}

public sealed class ClamAvOptions
{
    public string Host { get; set; } = "localhost";
    public int Port { get; set; } = 3310;
    public bool AllowOnScannerFailure { get; set; } = false;
}

Register it:

builder.Services.AddSingleton(new ClamAvOptions
{
    Host = builder.Configuration["ClamAv:Host"] ?? "clamav-clamd",
    Port = int.Parse(builder.Configuration["ClamAv:Port"] ?? "3310"),
});
builder.Services.AddSingleton<IUploadValidator, ClamAvUploadValidator>();
Fail-open vs fail-closed. The reference uses AllowOnScannerFailure to pick. Production: fail closed (block uploads when ClamAV is unreachable). Development: fail open so a missing scanner doesn't block local testing. Multiple validators register naturally — pair the virus scanner with a tenant-quota validator and the framework runs both, short-circuiting on the first reject.

Custom prompt templates

IRichTextBoxPromptTemplateProvider lets you override the per-mode system prompts the built-in resolvers send. Useful for tenant-specific tone (formal British English), non-English locales, additional guardrails, or A/B-testing prompt wording without forking the resolver.

using RichTextBox.AiResolvers;

public sealed class FormalTonePromptProvider : IRichTextBoxPromptTemplateProvider
{
    private readonly DefaultRichTextBoxPromptTemplateProvider _fallback = new();

    public string BuildSystemPrompt(RichTextBoxAiRequest request, string? operatorSuffix)
    {
        var baseline = _fallback.BuildSystemPrompt(request, operatorSuffix);
        return baseline + "\n\nAlways reply in formal British English; avoid contractions.";
    }

    public string BuildUserMessage(RichTextBoxAiRequest request)
        => _fallback.BuildUserMessage(request);
}

builder.Services.AddSingleton<IRichTextBoxPromptTemplateProvider, FormalTonePromptProvider>();

Retry policy on transient provider failures

OpenAI, Anthropic, and Azure OpenAI all return 429 ("rate limit") and 503 ("overloaded") under normal load. The built-in resolvers retry these (and 502 / 504, plus connection drops) with exponential backoff and full jitter, capped at 30 seconds. The provider's Retry-After header is honoured when present.

services.AddRichTextBoxOpenAiResolver(opts =>
{
    opts.ApiKey         = builder.Configuration["OpenAI:ApiKey"];
    opts.MaxRetryAttempts = 2;                              // 0 to disable
    opts.RetryBaseDelay   = TimeSpan.FromMilliseconds(500); // base for exponential backoff
});

Total wall-clock cost of a typical retry sequence (worst case, no Retry-After header):

AttemptDelay beforeCumulative
1 (initial)0 ms0 ms
2~500 ms + jitter~500 ms
3~1000 ms + jitter~1500 ms

With three total attempts the editor's user sees a brief delay rather than a hard error on transient blips. Set MaxRetryAttempts = 0 to fail fast (e.g. for the streaming endpoint where retries can complicate the SSE state machine, though the streaming codepath is naturally retry-friendly because the helper retries before the first SSE byte goes out).

Security audit logging

Every security-relevant rejection emits a structured LogWarning under category RichTextBox.Audit with a stable EventId. SIEM rules can pivot on the IDs without parsing free-text messages.

EventIdNameFires when
8001LicenseInvalidAn endpoint refused a request because the .lic file was missing or invalid.
8101UploadMagicByteMismatchMagic bytes don't match the declared extension (e.g. .exe renamed to .png).
8102UploadRejectedByValidatorAn IUploadValidator returned Reject (virus / quota / moderation).
8103UploadInvalidExtensionUpload rejected for being outside the allow-list.
8104UploadOversizeUpload exceeded MaxUploadBytes / per-extension cap.
8105UploadFolderTraversalAttemptPath normaliser caught a traversal attempt.
8201AiRequestOversizeAI request body exceeded MaxRequestBytes.
8202AiRateLimitExceededCaller exceeded the per-IP AI rate limit.
8203AiResolverExceptionThe AI resolver threw; full exception logged with a correlation id.
8204AiKeyVaultMissBYOK vault returned no key — client sees friendly “AI not configured”.
8205AiKeyVaultHitBYOK vault returned a key. KeyId logged; secret never is.
8206AiResponseFilterMatchedAn IRichTextBoxAiResponseFilter mutated the response.

Routing audit events to a dedicated stream (`IRichTextBoxAuditSink`)

By default audit events flow through ILogger alongside everything else. Compliance-regulated workloads (HIPAA, PCI, SOC2) typically want them written to a separate write-once destination — Splunk HEC, an S3 bucket with object-lock, or an audit-only database table — so changes to the host's general logger config can't silently drop the audit trail.

The package ships JsonLinesAuditSink, which appends one JSON object per event to a configurable file path (the format Splunk, Datadog, Logstash, and Vector all consume natively):

builder.Services.AddRichTextBox();
builder.Services.AddSingleton<IRichTextBoxAuditSink>(sp =>
    new JsonLinesAuditSink(
        filePath: "/var/log/myapp/rtb-audit.jsonl",
        logger:   sp.GetService<ILogger<JsonLinesAuditSink>>()));
Behaviour. Events arrive on a bounded Channel; a single background drainer writes them to disk. RecordAsync never blocks — if the channel is full (slow disk, full volume) events are dropped and a single warning logged. DroppedCount is exposed as a metric. Disk failures don't take down editor request paths.

Multiple sinks register through standard DI — pair JsonLines with a real-time stream:

builder.Services.AddSingleton<IRichTextBoxAuditSink, JsonLinesAuditSink>();
builder.Services.AddSingleton<IRichTextBoxAuditSink, MySplunkHecSink>();
// Both sinks receive every event in registration order. A throwing
// sink never starves siblings — the framework catches synchronous
// exceptions in fanout.

Sample log entry (Serilog JSON formatter):

{
  "@t": "2026-04-25T19:02:14.512Z",
  "@l": "Warning",
  "@m": "Magic-byte mismatch: rejected logo.png (declared .png) from 203.0.113.42",
  "@i": "8101",
  "EventId": { "Id": 8101, "Name": "UploadMagicByteMismatch" },
  "FileName": "logo.png",
  "Extension": ".png",
  "RemoteIp": "203.0.113.42",
  "SourceContext": "RichTextBox.Audit"
}

Prompt-injection screening

IRichTextBoxAiPolicy is the gate the AI endpoints consult before invoking the resolver. The built-in DefaultPromptInjectionPolicy rejects requests carrying off-the-shelf jailbreak templates ("ignore previous instructions", "you are now DAN", etc.). Layer additional policies for PII redaction, tenant-scoped mode allow-lists, or model-based moderation.

builder.Services.AddRichTextBox();
builder.Services.AddSingleton<IRichTextBoxAiPolicy, DefaultPromptInjectionPolicy>();
builder.Services.AddSingleton<IRichTextBoxAiPolicy, MyTenantPolicy>();

Multiple policies run in registration order; the first Reject short-circuits the pipeline. The client receives a 400 (or SSE error frame for streaming) with the policy's reason verbatim — keep reasons user-safe.

OpenTelemetry distributed tracing

Every AI call, DOCX export, and upload emits a span through the RichTextBox.AspNetCore ActivitySource. Wire it into your tracer-provider:

builder.Services.AddOpenTelemetry().WithTracing(t => t
    .AddSource(RichTextBoxDiagnostics.SourceName)   // "RichTextBox.AspNetCore"
    .AddAspNetCoreInstrumentation()
    .AddHttpClientInstrumentation()
    .AddOtlpExporter());

Span names and tags:

SpanTags
RichTextBox.AI.Resolveai.provider, ai.model, ai.mode, ai.request_bytes, ai.response_bytes, http.status_code
RichTextBox.Export.Docxdocx.html_bytes, docx.title
RichTextBox.Upload.Saveupload.file_name, upload.folder, upload.bytes, upload.web_path

Health probe (Kubernetes / load balancers)

GET /richtextbox/health returns 200 with a JSON status payload when the license is valid; 503 otherwise. Use it as a Kubernetes liveness/readiness probe target:

livenessProbe:
  httpGet:
    path: /richtextbox/health
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 30

Sample response:

{
  "status": "ok",
  "license": "ok",
  "aiResolver": "OpenAiResolver",
  "uploadStore": "LocalDiskUploadStore",
  "uptimeSeconds": 12847
}

Configure the path with RichTextBoxOptions.HealthEndpoint if you need it under a different prefix.

Startup-time configuration validation

RichTextBoxOptionsValidator + AiResolverOptionsValidator<T> run at the first IOptions.Value resolution — before the host accepts a single request. Bad config (forgotten leading / on an endpoint path, negative byte cap, heartbeat longer than timeout, missing API key) throws OptionsValidationException at startup so deployments fail loudly instead of failing on the first user request.

No wiring required — the validators register automatically when you call AddRichTextBox() / AddRichTextBoxOpenAiResolver().

Need more?

See the AI Toolkit and Cloud upload providers demos for hands-on integration patterns.