All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 36s
CI / test-python-backend-compliance (push) Successful in 32s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 18s
- contextBridge.ts: HostingCtx + FeaturesCtx (35 Felder), ~50 neue Platzhalter-Aliases - ruleEngine.ts: buildBoolContext() + applyConditionalBlocks() (IF/IF_NOT/IF_ANY) - ruleEngine.test.ts: 67 Tests (+18 für Phase C), alle grün - page.tsx: IF-Renderer in Pipeline, HOSTING+FEATURES Formular-Sections, erweiterter SDK-Prefill - scripts/apply_templates_023.py: 4 neue DE-Templates (Cookie v2, DSE, AGB, Impressum) - migrations/023_new_templates_de.sql: Dokumentation + Verifikations-Query Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
456 lines
14 KiB
TypeScript
456 lines
14 KiB
TypeScript
/**
|
|
* Template-Spec v1 — Rule Engine (Phase B)
|
|
*
|
|
* Evaluates ruleset.v1.json against a RuleInput and produces flags,
|
|
* applied defaults, validation errors/warnings, and removed block IDs.
|
|
*
|
|
* Design: zero external dependencies — minimal JSONLogic evaluator inline.
|
|
*/
|
|
|
|
import ruleset from './ruleset.v1.json'
|
|
import type { TemplateContext, ComputedFlags } from './contextBridge'
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
export interface RuleInput {
|
|
doc_type: string
|
|
render: { lang: string; variant: string }
|
|
context: TemplateContext
|
|
modules: { enabled: string[] }
|
|
}
|
|
|
|
export interface RuleViolation {
|
|
id: string
|
|
severity: 'ERROR' | 'WARN'
|
|
message: string
|
|
phase: string
|
|
}
|
|
|
|
export interface RuleEngineResult {
|
|
computedFlags: ComputedFlags
|
|
contextAfterDefaults: TemplateContext
|
|
violations: RuleViolation[]
|
|
warnings: RuleViolation[]
|
|
removedBlocks: string[]
|
|
appliedDefaults: string[]
|
|
}
|
|
|
|
// =============================================================================
|
|
// Minimal JSONLogic Evaluator
|
|
// Supports: var, ==, !=, in, and, or, >=, <=
|
|
// =============================================================================
|
|
|
|
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue }
|
|
|
|
interface Envelope {
|
|
doc_type: string
|
|
render: { lang: string; variant: string }
|
|
context: TemplateContext
|
|
computed_flags: ComputedFlags
|
|
modules: { enabled: string[] }
|
|
}
|
|
|
|
function getPath(obj: unknown, path: string): unknown {
|
|
if (path === '') return obj
|
|
const parts = path.split('.')
|
|
let cur: unknown = obj
|
|
for (const part of parts) {
|
|
if (cur == null || typeof cur !== 'object') return null
|
|
cur = (cur as Record<string, unknown>)[part]
|
|
}
|
|
return cur ?? null
|
|
}
|
|
|
|
function evaluate(expr: JsonValue, env: Envelope): unknown {
|
|
if (expr === null || typeof expr !== 'object' || Array.isArray(expr)) {
|
|
return expr
|
|
}
|
|
|
|
const ops = Object.keys(expr as Record<string, unknown>)
|
|
if (ops.length === 0) return null
|
|
const op = ops[0]
|
|
const args = (expr as Record<string, JsonValue>)[op]
|
|
|
|
switch (op) {
|
|
case 'var': {
|
|
const path = String(args)
|
|
return getPath(env, path)
|
|
}
|
|
case '==': {
|
|
const [a, b] = args as JsonValue[]
|
|
return evaluate(a, env) == evaluate(b, env) // eslint-disable-line eqeqeq
|
|
}
|
|
case '!=': {
|
|
const [a, b] = args as JsonValue[]
|
|
return evaluate(a, env) != evaluate(b, env) // eslint-disable-line eqeqeq
|
|
}
|
|
case '>=': {
|
|
const [a, b] = args as JsonValue[]
|
|
const av = evaluate(a, env)
|
|
const bv = evaluate(b, env)
|
|
if (av == null || bv == null) return false
|
|
return Number(av) >= Number(bv)
|
|
}
|
|
case '<=': {
|
|
const [a, b] = args as JsonValue[]
|
|
const av = evaluate(a, env)
|
|
const bv = evaluate(b, env)
|
|
if (av == null || bv == null) return false
|
|
return Number(av) <= Number(bv)
|
|
}
|
|
case 'in': {
|
|
const [needle, haystack] = args as JsonValue[]
|
|
const n = evaluate(needle, env)
|
|
const h = evaluate(haystack, env)
|
|
if (!Array.isArray(h)) return false
|
|
return h.includes(n as never)
|
|
}
|
|
case 'and': {
|
|
const items = args as JsonValue[]
|
|
return items.every((item) => !!evaluate(item, env))
|
|
}
|
|
case 'or': {
|
|
const items = args as JsonValue[]
|
|
return items.some((item) => !!evaluate(item, env))
|
|
}
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Deep clone helper
|
|
// =============================================================================
|
|
|
|
function deepClone<T>(obj: T): T {
|
|
return JSON.parse(JSON.stringify(obj))
|
|
}
|
|
|
|
// =============================================================================
|
|
// Set value at dot-path (mutates object for defaults application)
|
|
// =============================================================================
|
|
|
|
function setNestedPath(obj: Record<string, unknown>, dotPath: string, value: unknown): void {
|
|
const parts = dotPath.split('.')
|
|
let cur: Record<string, unknown> = obj
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
const part = parts[i]
|
|
if (cur[part] == null || typeof cur[part] !== 'object') {
|
|
cur[part] = {}
|
|
}
|
|
cur = cur[part] as Record<string, unknown>
|
|
}
|
|
cur[parts[parts.length - 1]] = value
|
|
}
|
|
|
|
// =============================================================================
|
|
// Phase runner helpers
|
|
// =============================================================================
|
|
|
|
type ComputeFlagEntry = {
|
|
id: string
|
|
set: string
|
|
expr: JsonValue
|
|
}
|
|
|
|
type AutoDefaultEntry = {
|
|
id: string
|
|
when: JsonValue
|
|
actions: { type: string; path: string; value: unknown }[]
|
|
}
|
|
|
|
type ValidationEntry = {
|
|
id: string
|
|
severity: string
|
|
when: JsonValue
|
|
assert_all: JsonValue[]
|
|
message: string
|
|
}
|
|
|
|
type BlockRemoveEntry = {
|
|
id: string
|
|
when: JsonValue
|
|
actions: { type: string; block_id: string }[]
|
|
}
|
|
|
|
type ModuleRequirementEntry = {
|
|
id: string
|
|
severity: string
|
|
when: JsonValue
|
|
assert_all: JsonValue[]
|
|
message: string
|
|
}
|
|
|
|
type WarningEntry = {
|
|
id: string
|
|
severity: string
|
|
when: JsonValue
|
|
assert_all: JsonValue[]
|
|
message: string
|
|
}
|
|
|
|
// =============================================================================
|
|
// Phase 1: compute_flags
|
|
// =============================================================================
|
|
|
|
function runComputeFlags(env: Envelope): ComputedFlags {
|
|
const flags: Record<string, boolean> = {}
|
|
for (const rule of (ruleset.compute_flags as unknown) as ComputeFlagEntry[]) {
|
|
const result = !!evaluate(rule.expr as JsonValue, env)
|
|
// set path like "computed_flags.IS_B2C"
|
|
const key = rule.set.replace('computed_flags.', '')
|
|
flags[key] = result
|
|
}
|
|
return flags as unknown as ComputedFlags
|
|
}
|
|
|
|
// =============================================================================
|
|
// Phase 2: auto_defaults
|
|
// =============================================================================
|
|
|
|
function runAutoDefaults(env: Envelope): string[] {
|
|
const applied: string[] = []
|
|
for (const rule of ruleset.auto_defaults as AutoDefaultEntry[]) {
|
|
if (!!evaluate(rule.when as JsonValue, env)) {
|
|
for (const action of rule.actions) {
|
|
if (action.type === 'set') {
|
|
// path like "context.SECURITY.LOG_RETENTION_DAYS"
|
|
const contextPath = action.path.replace('context.', '')
|
|
setNestedPath(env.context as unknown as Record<string, unknown>, contextPath, action.value)
|
|
}
|
|
}
|
|
applied.push(rule.id)
|
|
}
|
|
}
|
|
return applied
|
|
}
|
|
|
|
// =============================================================================
|
|
// Phase 4 / 5 / 6: hard_validations, module_requirements, warnings
|
|
// =============================================================================
|
|
|
|
function runValidations(
|
|
rules: ValidationEntry[],
|
|
env: Envelope,
|
|
phase: string
|
|
): RuleViolation[] {
|
|
const result: RuleViolation[] = []
|
|
for (const rule of rules) {
|
|
if (!evaluate(rule.when as JsonValue, env)) continue
|
|
// assert_all: if empty → always fires (WARN_LEGAL_REVIEW pattern)
|
|
const allPass =
|
|
rule.assert_all.length === 0
|
|
? false
|
|
: rule.assert_all.every((assertion) => !!evaluate(assertion as JsonValue, env))
|
|
if (!allPass) {
|
|
result.push({
|
|
id: rule.id,
|
|
severity: rule.severity as 'ERROR' | 'WARN',
|
|
message: rule.message,
|
|
phase,
|
|
})
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// =============================================================================
|
|
// Phase 5: auto_remove_blocks
|
|
// =============================================================================
|
|
|
|
function runAutoRemoveBlocks(env: Envelope): string[] {
|
|
const removed: string[] = []
|
|
for (const rule of ruleset.auto_remove_blocks as BlockRemoveEntry[]) {
|
|
if (!!evaluate(rule.when as JsonValue, env)) {
|
|
for (const action of rule.actions) {
|
|
if (action.type === 'remove_block') {
|
|
removed.push(action.block_id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return removed
|
|
}
|
|
|
|
// =============================================================================
|
|
// Main: runRuleset
|
|
// =============================================================================
|
|
|
|
export function runRuleset(input: RuleInput): RuleEngineResult {
|
|
// Deep clone context so we don't mutate caller's state
|
|
const clonedContext = deepClone(input.context)
|
|
|
|
const env: Envelope = {
|
|
doc_type: input.doc_type,
|
|
render: input.render,
|
|
context: clonedContext,
|
|
computed_flags: {} as ComputedFlags,
|
|
modules: input.modules,
|
|
}
|
|
|
|
// Phase 1: compute_flags
|
|
env.computed_flags = runComputeFlags(env)
|
|
|
|
// Phase 2: auto_defaults — mutates clonedContext
|
|
const appliedDefaults = runAutoDefaults(env)
|
|
|
|
// Phase 3: compute_flags again (after defaults)
|
|
env.computed_flags = runComputeFlags(env)
|
|
|
|
// Phase 4: hard_validations
|
|
const allHardViolations = runValidations(
|
|
(ruleset.hard_validations as unknown) as ValidationEntry[],
|
|
env,
|
|
'hard_validations'
|
|
)
|
|
|
|
// Phase 5: auto_remove_blocks
|
|
const removedBlocks = runAutoRemoveBlocks(env)
|
|
|
|
// Phase 6a: module_requirements
|
|
const moduleViolations = runValidations(
|
|
(ruleset.module_requirements as unknown) as ModuleRequirementEntry[],
|
|
env,
|
|
'module_requirements'
|
|
)
|
|
|
|
// Phase 6b: warnings
|
|
const warningViolations = runValidations(
|
|
(ruleset.warnings as unknown) as WarningEntry[],
|
|
env,
|
|
'warnings'
|
|
)
|
|
|
|
const allViolations = [...allHardViolations, ...moduleViolations, ...warningViolations]
|
|
|
|
return {
|
|
computedFlags: env.computed_flags,
|
|
contextAfterDefaults: clonedContext,
|
|
violations: allViolations.filter((v) => v.severity === 'ERROR'),
|
|
warnings: allViolations.filter((v) => v.severity === 'WARN'),
|
|
removedBlocks,
|
|
appliedDefaults,
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// getDocType: maps (templateType, language) → doc_type string
|
|
// =============================================================================
|
|
|
|
const DOC_TYPE_MAP: Record<string, string> = {
|
|
'nda+de': 'nda_de',
|
|
'nda+en': 'nda_en',
|
|
'sla+de': 'sla_de',
|
|
'acceptable_use+en': 'acceptable_use_en',
|
|
'community_guidelines+de': 'community_de',
|
|
'copyright_policy+de': 'copyright_de',
|
|
'cloud_service_agreement+de': 'cloud_contract_de',
|
|
'data_usage_clause+de': 'data_usage_clause_de',
|
|
'cookie_banner+de': 'cookie_banner_de',
|
|
'agb+de': 'agb_de',
|
|
'agb_clause+de': 'agb_de',
|
|
'clause+en': 'liability_clause_en',
|
|
}
|
|
|
|
export function getDocType(templateType: string, language: string): string {
|
|
const key = `${templateType}+${language}`
|
|
return DOC_TYPE_MAP[key] ?? `${templateType}_${language}`
|
|
}
|
|
|
|
// =============================================================================
|
|
// buildBoolContext: combines computed flags + derived + FEATURES booleans
|
|
// =============================================================================
|
|
|
|
export function buildBoolContext(ctx: TemplateContext, flags: ComputedFlags): Record<string, boolean> {
|
|
const f = ctx.FEATURES
|
|
return {
|
|
// --- From computed flags ---
|
|
IS_B2C: flags.IS_B2C,
|
|
IS_B2B: flags.IS_B2B,
|
|
SERVICE_IS_SAAS: flags.SERVICE_IS_SAAS,
|
|
SERVICE_IS_HYBRID: flags.SERVICE_IS_HYBRID,
|
|
HAS_PENALTY: flags.HAS_PENALTY,
|
|
HAS_ANALYTICS: flags.HAS_ANALYTICS,
|
|
ANALYTICS_ENABLED: flags.HAS_ANALYTICS, // alias used in cookie banner template
|
|
HAS_MARKETING: flags.HAS_MARKETING,
|
|
MARKETING_ENABLED: flags.HAS_MARKETING, // alias
|
|
|
|
// --- Derived from PROVIDER ---
|
|
HAS_REGISTER: !!(ctx.PROVIDER.REGISTER_COURT),
|
|
HAS_VAT_ID: !!(ctx.PROVIDER.VAT_ID),
|
|
CONTACT_PHONE: !!(ctx.PROVIDER.PHONE),
|
|
|
|
// --- Derived from PRIVACY ---
|
|
HAS_DPO: !!(ctx.PRIVACY.DPO_NAME),
|
|
|
|
// --- From FEATURES booleans ---
|
|
THIRD_COUNTRY_POSSIBLE: f.HAS_THIRD_COUNTRY,
|
|
HAS_THIRD_COUNTRY: f.HAS_THIRD_COUNTRY,
|
|
FUNCTIONAL_ENABLED: f.HAS_FUNCTIONAL_COOKIES,
|
|
HAS_FUNCTIONAL_COOKIES: f.HAS_FUNCTIONAL_COOKIES,
|
|
CMP_LOGS_CONSENTS: f.CMP_LOGS_CONSENTS,
|
|
HAS_NEWSLETTER: f.HAS_NEWSLETTER,
|
|
HAS_ACCOUNT: f.HAS_ACCOUNT,
|
|
HAS_PAYMENTS: f.HAS_PAYMENTS,
|
|
HAS_SUPPORT: f.HAS_SUPPORT,
|
|
HAS_SOCIAL_MEDIA: f.HAS_SOCIAL_MEDIA,
|
|
HAS_PAID_PLANS: f.HAS_PAID_PLANS,
|
|
HAS_SLA: f.HAS_SLA,
|
|
HAS_EXPORT_POLICY: f.HAS_EXPORT_POLICY,
|
|
HAS_WITHDRAWAL: f.HAS_WITHDRAWAL,
|
|
HAS_REGULATED_PROFESSION: f.HAS_REGULATED_PROFESSION,
|
|
HAS_EDITORIAL_RESPONSIBLE: f.HAS_EDITORIAL_RESPONSIBLE,
|
|
HAS_DISPUTE_RESOLUTION: f.HAS_DISPUTE_RESOLUTION,
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// applyConditionalBlocks: processes {{#IF}}/{{#IF_NOT}}/{{#IF_ANY}} directives
|
|
// Runs BEFORE placeholder substitution.
|
|
// =============================================================================
|
|
|
|
export function applyConditionalBlocks(content: string, boolCtx: Record<string, boolean>): string {
|
|
let result = content
|
|
|
|
// {{#IF_NOT COND}}...{{/IF_NOT}} — process before IF to avoid conflicts
|
|
result = result.replace(
|
|
/\{\{#IF_NOT ([A-Z_]+)\}\}([\s\S]*?)\{\{\/IF_NOT\}\}/g,
|
|
(_, cond: string, body: string) => (boolCtx[cond] ? '' : body)
|
|
)
|
|
|
|
// {{#IF_ANY A B C}}...{{/IF_ANY}}
|
|
result = result.replace(
|
|
/\{\{#IF_ANY ([A-Z_ ]+)\}\}([\s\S]*?)\{\{\/IF_ANY\}\}/g,
|
|
(_, conds: string, body: string) =>
|
|
conds.trim().split(/\s+/).some((c) => boolCtx[c]) ? body : ''
|
|
)
|
|
|
|
// {{#IF COND}}...{{/IF}}
|
|
result = result.replace(
|
|
/\{\{#IF ([A-Z_]+)\}\}([\s\S]*?)\{\{\/IF\}\}/g,
|
|
(_, cond: string, body: string) => (boolCtx[cond] ? body : '')
|
|
)
|
|
|
|
return result
|
|
}
|
|
|
|
// =============================================================================
|
|
// applyBlockRemoval: removes [BLOCK:ID]…[/BLOCK:ID] markers from content
|
|
// =============================================================================
|
|
|
|
export function applyBlockRemoval(content: string, removedBlocks: string[]): string {
|
|
let result = content
|
|
for (const blockId of removedBlocks) {
|
|
// Matches [BLOCK:ID]...content...[/BLOCK:ID] including optional trailing newline
|
|
const escaped = blockId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
const pattern = new RegExp(
|
|
`\\[BLOCK:${escaped}\\][\\s\\S]*?\\[\\/BLOCK:${escaped}\\]\\n?`,
|
|
'g'
|
|
)
|
|
result = result.replace(pattern, '')
|
|
}
|
|
return result
|
|
}
|