TemplateRendererService coordinates Repository, variable-name conversion, Compiler, CompilationCache, SecurityChecker, VariableAnalyzer, and ChannelAdapterFactory. The pieces exist, but tenant lookup, engine selection, module authorization, validation reporting, and degradation semantics break the current production path.
1. Current rendering flow
The first break is the fixed empty TenantId. Template creation requires a non-empty tenant and the repository queries TenantId+Key, so RenderAsync normally finds neither tenant nor PLATFORM templates. The command supplies no CurrentTenant and there is no fallback algorithm.
2. CallerModule has no authorization effect
RenderTemplate.Command accepts ModuleCaller and Contracts contains a ModuleAuthorization allowlist. TemplateRendererService never uses callerModule, and no source path invokes ModuleAuthorization. A caller who passes generic template View can claim any registered ModuleCaller.
OwnerModule is currently metadata/search only; it does not restrict rendering or editing.
public async Task<Result<TemplateRenderResult>> RenderAsync( string tenantId, string key, ModuleCaller caller, IReadOnlyDictionary<string, object> variables, CancellationToken ct){ // Derive module identity from a service credential/contract, never an HTTP enum. var authorization = modulePolicy.Authorize(caller, key); if (authorization.IsFailure) return Result.Failure<TemplateRenderResult>(authorization.Error);
// Resolve tenant override, then PLATFORM, with explicit locale/channel dimensions. var template = await templates.ResolveAsync( tenantId, key, culture.Current, channel.Current, ct); return await RenderValidatedAsync(template, variables, ct);}3. Engine catalog versus compiler
TemplateEngine declares Scriban, Liquid, and Handlebars. DI registers one ScribanTemplateCompiler. Its Compile/Validate methods receive engine but never branch; every value calls Template.Parse.
Liquid and Handlebars are therefore not implemented capabilities. Their syntax may fail as Scriban or pass as literal text. Reject them at the API until each has an adapter and contract suite.
The Scriban context sets LoopLimit=1000 and a member renamer. Source comments mention recursion limits, but no explicit RecursiveLimit, total execution deadline, or output-size bound is set. An HTTP rate limit does not bound one expensive rendering request.
4. Compile/render degradation
RenderTemplateTextAsync returns the original template text on Compile Failure and also on Render Failure. The outer EnableDegradation path runs only for thrown exceptions and returns [render failed] key.
Syntax/variable errors can therefore look successful while leaking {{ secret }} template source downstream. High-value notifications should return a typed failure or an approved static fallback with degraded=true.
The cache key is SHA256(template text), without tenant, key, version, or engine. Text-based reuse is reasonable today, but Engine and compiler version must join the key when multiple engines exist. Cache get/set failures are ignored and rendering continues.
5. Preview loses validation failures
ValidateTemplateAsync returns Result.Success(TemplateValidationResult) even when IsValid=false. Preview checks only validationResult.IsSuccess; it therefore constructs a new empty IsValid=true report and discards syntax errors, security violations, and missing variables.
var validation = await renderer.ValidateTemplateAsync( titleTemplate, bodyTemplate, engine, variables, ct);if (validation.IsFailure) return Result.Failure<TemplatePreviewResult>(validation.Error);
// Forward IsValid/Errors/SecurityViolations/MissingVariables. A successful// method call does not mean that the submitted template content is valid.var report = validation.GetValueOrThrow();return Result.Success(new TemplatePreviewResult( renderedTitle, renderedBody, adaptedContent, report));CreateTemplate and UpdateTemplate call neither ValidateTemplateAsync nor SecurityChecker. Invalid or dangerous content can become the active version; Validate/Preview are optional tools, not write gates.
6. Variable naming and schema
NamingConvention supports PascalCase, SnakeCase, and Auto. The renderer adapts the dictionary and the Scriban context installs a MemberRenamer. Aggregate TemplateVariable rows do not participate: required/default/data type do not drive binding.
Variables are arbitrary IReadOnlyDictionary<string,object> values. Restrict allowed scalar/DTO types, nesting, collection size, and string length; never expose EF aggregates, lazy proxies, streams, or objects that contain secrets.
Each published version should snapshot a variable schema and enforce unknown-variable policy, required values, conversion, defaults, PII classification, and maximum lengths before rendering.
7. Channel-adapter matrix
| TemplateChannel | Current behavior | Boundary |
|---|---|---|
| HTML wrapper; title encoded | body inserted as raw HTML | |
| DingTalk/WeCom/Feishu | strip HTML, Markdown, truncate | regex stripping is not a parser |
| SMS | strip HTML, normalize whitespace, ≤500 | characters, not provider billing segments/bytes |
| Inbox | title encoded; body raw HTML | no sanitizer; computed plain text is unused |
| Html | regex removes selected tags/events/js href | not an allowlist sanitizer; bypassable |
| PDF/Word/Excel | registered in factory | Adapt returns NotImplemented Failure |
Renderer does not propagate adapter lookup/adaptation failures. It returns overall Success with AdaptedContent=null. PDF/Word/Excel therefore appear successful without producing a document.
8. HTML/XSS boundary
Email and Inbox insert rendered Body directly into HTML. Scriban does not automatically HTML-encode every variable. A variable containing <img onerror=...> can reach mail or web output. HtmlChannelAdapter’s regex covers only selected paired tags, embed, quoted on* attributes, and href=javascript; it is not equivalent to a mature sanitizer.
Use context-specific encoding: HTML text, attributes, URLs, and plain text are distinct. Recommended controls:
- allow approved components or constrained Markdown for authors;
- encode variables as text by default; only a trusted sanitizer may create SafeHtml;
- apply scheme+host allowlists to URL variables;
- run a DOM-based allowlist sanitizer after rendering;
- treat CSP/client defenses as a second layer, not encoding replacement;
- never log full templates or variable values on validation failure.
TemplateSecurityChecker searches source strings for System.IO/Net/Reflection/Database/Process patterns. It is neither a template sandbox nor XSS sanitizer, and the write path does not call it.
9. Combining templates with Notification
There is no single API for “resolve by tenant/language → validate variables → render → create → deliver.” A caller can compose services, but the empty-tenant Render defect and non-idempotent Create make the current combination unsuitable for GA.
A target facade can accept NotificationIntent(Code, Recipient, Locale, Variables, BusinessId), then:
- resolve a published template by Tenant+Code+Locale+Channel;
- validate variable schema and sensitivity;
- render each channel with its own output context;
- create one logical row using Tenant+Code+Recipient+BusinessId uniqueness;
- transactionally save fact and outbox;
- create recoverable DeliveryAttempts.
Do not render one HTML body and reuse it for SMS, IM, and Inbox. Channels need separate templates or context-specific encoding.
10. Required tests
- tenant template, PLATFORM fallback, override, locale/channel selection;
- forged CallerModule, OwnerModule mismatch, and permission separation;
- explicit support/rejection for Scriban/Liquid/Handlebars;
- loop limit, deep object, large output, cancellation, and timeout;
- compile/render failures never leak source; fallback is visibly degraded;
- Preview preserves every Validate error and Create/Update reject invalid content;
- required/default/type schema and unknown-variable behavior;
- HTML text/attribute/URL injection and sanitizer bypass corpus;
- stable NotImplemented for PDF/Word/Excel instead of false success;
- cache isolation by engine/version and concurrent compilation.