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.
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.
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); } }
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 });
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.
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>();
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.
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>();
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):
| Attempt | Delay before | Cumulative |
|---|---|---|
| 1 (initial) | 0 ms | 0 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).
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.
| EventId | Name | Fires when |
|---|---|---|
| 8001 | LicenseInvalid | An endpoint refused a request because the .lic file was missing or invalid. |
| 8101 | UploadMagicByteMismatch | Magic bytes don't match the declared extension (e.g. .exe renamed to .png). |
| 8102 | UploadRejectedByValidator | An IUploadValidator returned Reject (virus / quota / moderation). |
| 8103 | UploadInvalidExtension | Upload rejected for being outside the allow-list. |
| 8104 | UploadOversize | Upload exceeded MaxUploadBytes / per-extension cap. |
| 8105 | UploadFolderTraversalAttempt | Path normaliser caught a traversal attempt. |
| 8201 | AiRequestOversize | AI request body exceeded MaxRequestBytes. |
| 8202 | AiRateLimitExceeded | Caller exceeded the per-IP AI rate limit. |
| 8203 | AiResolverException | The AI resolver threw; full exception logged with a correlation id. |
| 8204 | AiKeyVaultMiss | BYOK vault returned no key — client sees friendly “AI not configured”. |
| 8205 | AiKeyVaultHit | BYOK vault returned a key. KeyId logged; secret never is. |
| 8206 | AiResponseFilterMatched | An IRichTextBoxAiResponseFilter mutated the response. |
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>>()));
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"
}
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.
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:
| Span | Tags |
|---|---|
RichTextBox.AI.Resolve | ai.provider, ai.model, ai.mode, ai.request_bytes, ai.response_bytes, http.status_code |
RichTextBox.Export.Docx | docx.html_bytes, docx.title |
RichTextBox.Upload.Save | upload.file_name, upload.folder, upload.bytes, upload.web_path |
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.
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().
See the AI Toolkit and Cloud upload providers demos for hands-on integration patterns.