create-safest-tools 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +71 -0
  3. package/bin/create-safest-tools.mjs +12 -0
  4. package/package.json +34 -0
  5. package/src/cli.mjs +128 -0
  6. package/src/config.mjs +151 -0
  7. package/src/scaffold.mjs +30 -0
  8. package/template/LICENSE +201 -0
  9. package/template/README.md +35 -0
  10. package/template/console/README.md +28 -0
  11. package/template/console/analytics/AnalyticsWorkspace.tsx +55 -0
  12. package/template/console/analytics/controller.ts +133 -0
  13. package/template/console/analytics/events.ts +33 -0
  14. package/template/console/analytics/store.ts +33 -0
  15. package/template/console/analytics/types.ts +38 -0
  16. package/template/console/appeals/AppealWorkspace.tsx +73 -0
  17. package/template/console/appeals/controller.ts +125 -0
  18. package/template/console/appeals/events.ts +33 -0
  19. package/template/console/appeals/store.ts +35 -0
  20. package/template/console/appeals/types.ts +37 -0
  21. package/template/console/assistant/AssistantWorkspace.tsx +133 -0
  22. package/template/console/assistant/types.ts +34 -0
  23. package/template/console/auth/AccountJourney.tsx +69 -0
  24. package/template/console/auth/InvitationAcceptance.tsx +145 -0
  25. package/template/console/auth/OAuthButtons.tsx +78 -0
  26. package/template/console/auth/OperatorLogin.tsx +110 -0
  27. package/template/console/auth/PasswordRecovery.tsx +132 -0
  28. package/template/console/auth/account-route.ts +15 -0
  29. package/template/console/auth/browser-navigation.ts +7 -0
  30. package/template/console/auth/events.ts +15 -0
  31. package/template/console/command/CommandCentre.tsx +119 -0
  32. package/template/console/command/controller.ts +267 -0
  33. package/template/console/command/events.ts +19 -0
  34. package/template/console/command/store.ts +36 -0
  35. package/template/console/command/types.ts +61 -0
  36. package/template/console/components/AnalystIdentity.tsx +25 -0
  37. package/template/console/components/PageHeader.tsx +27 -0
  38. package/template/console/configuration/ConfigurationDialog.tsx +200 -0
  39. package/template/console/configuration/ConfigurationWorkspace.tsx +66 -0
  40. package/template/console/configuration/FormBuilder.tsx +143 -0
  41. package/template/console/configuration/api.ts +40 -0
  42. package/template/console/configuration/events.ts +14 -0
  43. package/template/console/configuration/types.ts +79 -0
  44. package/template/console/lib/format.ts +49 -0
  45. package/template/console/lib/http.ts +96 -0
  46. package/template/console/main.tsx +225 -0
  47. package/template/console/operations/OperationsWorkspace.tsx +126 -0
  48. package/template/console/operations/controller.ts +237 -0
  49. package/template/console/operations/events.ts +37 -0
  50. package/template/console/operations/store.ts +32 -0
  51. package/template/console/operations/types.ts +107 -0
  52. package/template/console/people/PeopleWorkspace.tsx +81 -0
  53. package/template/console/people/types.ts +25 -0
  54. package/template/console/profile/ProfileWorkspace.tsx +136 -0
  55. package/template/console/profile/events.ts +9 -0
  56. package/template/console/profile/types.ts +6 -0
  57. package/template/console/quality/QualityWorkspace.tsx +87 -0
  58. package/template/console/quality/controller.ts +154 -0
  59. package/template/console/quality/events.ts +34 -0
  60. package/template/console/quality/store.ts +36 -0
  61. package/template/console/quality/types.ts +70 -0
  62. package/template/console/queues/QueueEditor.tsx +173 -0
  63. package/template/console/queues/QueueWorkspace.tsx +109 -0
  64. package/template/console/queues/controller.ts +194 -0
  65. package/template/console/queues/events.ts +34 -0
  66. package/template/console/queues/store.ts +37 -0
  67. package/template/console/queues/types.ts +102 -0
  68. package/template/console/registry/RegistryDialog.tsx +198 -0
  69. package/template/console/registry/RegistryWorkspace.tsx +108 -0
  70. package/template/console/registry/events.ts +14 -0
  71. package/template/console/registry/types.ts +52 -0
  72. package/template/console/reports/ReportDrawer.tsx +159 -0
  73. package/template/console/reports/ReportWorkspace.tsx +90 -0
  74. package/template/console/reports/controller.ts +517 -0
  75. package/template/console/reports/events.ts +42 -0
  76. package/template/console/reports/store.ts +39 -0
  77. package/template/console/reports/types.ts +230 -0
  78. package/template/console/settings/BrandEditor.tsx +126 -0
  79. package/template/console/settings/ChannelDialog.tsx +241 -0
  80. package/template/console/settings/SettingsWorkspace.tsx +155 -0
  81. package/template/console/settings/events.ts +19 -0
  82. package/template/console/settings/handoff.ts +22 -0
  83. package/template/console/settings/types.ts +93 -0
  84. package/template/console/shell/WorkspaceShell.tsx +160 -0
  85. package/template/console/shell/controller.ts +182 -0
  86. package/template/console/shell/events.ts +25 -0
  87. package/template/console/shell/navigation.ts +61 -0
  88. package/template/console/shell/store.ts +56 -0
  89. package/template/console/shell/types.ts +86 -0
  90. package/template/console/workflows/CreateWorkflowDialog.tsx +87 -0
  91. package/template/console/workflows/WorkflowCanvas.tsx +42 -0
  92. package/template/console/workflows/WorkflowDialogs.tsx +6 -0
  93. package/template/console/workflows/WorkflowStudio.tsx +331 -0
  94. package/template/console/workflows/WorkflowWorkspace.tsx +82 -0
  95. package/template/console/workflows/events.ts +17 -0
  96. package/template/console/workflows/graph.ts +156 -0
  97. package/template/console/workflows/templates.ts +70 -0
  98. package/template/console/workflows/types.ts +201 -0
  99. package/template/gitignore.template +18 -0
  100. package/template/migrations/0001_reports_foundation.sql +444 -0
  101. package/template/migrations/0002_human_report_loop.sql +65 -0
  102. package/template/migrations/0003_delivery_reliability.sql +18 -0
  103. package/template/migrations/0004_public_intake.sql +14 -0
  104. package/template/migrations/0005_operations_visibility.sql +24 -0
  105. package/template/migrations/0006_ai_governance.sql +198 -0
  106. package/template/migrations/0007_ai_release_gates.sql +6 -0
  107. package/template/migrations/0008_retention_analytics_exports.sql +52 -0
  108. package/template/migrations/0009_retention_derived_copies.sql +14 -0
  109. package/template/migrations/0010_ai_quality_controls.sql +26 -0
  110. package/template/migrations/0011_analyst_presence.sql +28 -0
  111. package/template/migrations/0012_queue_policies.sql +87 -0
  112. package/template/migrations/0013_routing_agents.sql +63 -0
  113. package/template/migrations/0014_webhook_enrichments.sql +122 -0
  114. package/template/migrations/0015_queue_owned_ai.sql +55 -0
  115. package/template/migrations/0016_operator_accounts.sql +67 -0
  116. package/template/migrations/0017_operator_profiles_and_recovery.sql +33 -0
  117. package/template/migrations/0018_platform_configuration.sql +382 -0
  118. package/template/migrations/0019_workflow_authoring_runtime.sql +372 -0
  119. package/template/migrations/0020_tasks_findings_assistant_budgets.sql +427 -0
  120. package/template/migrations/0021_abuse_evidence_operations.sql +324 -0
  121. package/template/migrations/0022_workflow_dispatch_operations.sql +42 -0
  122. package/template/migrations/0023_component_connection_execution.sql +75 -0
  123. package/template/migrations/0024_access_runtime_integrity.sql +72 -0
  124. package/template/migrations/0025_installation_timezone.sql +11 -0
  125. package/template/migrations/0026_ai_and_egress_execution_controls.sql +49 -0
  126. package/template/migrations/0027_prompt_and_ai_registry.sql +37 -0
  127. package/template/migrations/0028_step_attempt_ai_provenance.sql +13 -0
  128. package/template/migrations/0029_evidence_fetch_transport.sql +5 -0
  129. package/template/migrations/0030_evidence_dlq_incidents.sql +45 -0
  130. package/template/migrations/0031_shadow_quality_integrity.sql +7 -0
  131. package/template/migrations/0032_action_delivery_outbox.sql +55 -0
  132. package/template/migrations/0033_configuration_and_assistant_drafts.sql +43 -0
  133. package/template/migrations/0034_installation_integrations.sql +31 -0
  134. package/template/migrations/0035_workspace_governance.sql +21 -0
  135. package/template/migrations/0036_published_routing_baseline.sql +15 -0
  136. package/template/migrations/0037_builtin_phishing_specialist.sql +71 -0
  137. package/template/migrations/0038_remove_deprecated_enrichment_runtime.sql +228 -0
  138. package/template/migrations/0039_secure_reporting_channels.sql +45 -0
  139. package/template/migrations/0040_notification_only_reporting.sql +24 -0
  140. package/template/migrations/0041_better_auth_credentials.sql +17 -0
  141. package/template/package.json +47 -0
  142. package/template/public/_headers +27 -0
  143. package/template/public/app-icon-192.png +0 -0
  144. package/template/public/app-icon-512.png +0 -0
  145. package/template/public/brand-icon.svg +7 -0
  146. package/template/public/brand-tokens.css +80 -0
  147. package/template/public/console/auth-shell.js +19340 -0
  148. package/template/public/customer-brand.js +58 -0
  149. package/template/public/embed/embed.css +139 -0
  150. package/template/public/embed/embed.js +408 -0
  151. package/template/public/embed/index.html +91 -0
  152. package/template/public/favicon.svg +7 -0
  153. package/template/public/fonts/Manrope-Variable.ttf +0 -0
  154. package/template/public/fonts/Newsreader-Italic-Variable.ttf +0 -0
  155. package/template/public/fonts/Newsreader-Variable.ttf +0 -0
  156. package/template/public/index.html +123 -0
  157. package/template/public/logo-primary.svg +7 -0
  158. package/template/public/logo-reversed.svg +7 -0
  159. package/template/public/manifest.webmanifest +21 -0
  160. package/template/public/public-report.js +184 -0
  161. package/template/public/report/index.html +39 -0
  162. package/template/public/report/public-report.css +20 -0
  163. package/template/public/social-card.png +0 -0
  164. package/template/public/styles.css +1521 -0
  165. package/template/public/widget.css +80 -0
  166. package/template/public/widget.js +220 -0
  167. package/template/reports.config.example.json +38 -0
  168. package/template/reports.schema.json +89 -0
  169. package/template/scripts/reports-auth-onboarding.mjs +180 -0
  170. package/template/scripts/reports-backup.mjs +276 -0
  171. package/template/scripts/reports-cloudflare-preflight.mjs +152 -0
  172. package/template/scripts/reports-deploy.mjs +173 -0
  173. package/template/scripts/reports-plan.mjs +226 -0
  174. package/template/scripts/reports-restore.mjs +180 -0
  175. package/template/scripts/reports-secrets.mjs +73 -0
  176. package/template/scripts/reports-uninstall-plan.mjs +32 -0
  177. package/template/src/agent-executor.ts +330 -0
  178. package/template/src/ai-observability.ts +163 -0
  179. package/template/src/ai-registry-validation.ts +244 -0
  180. package/template/src/ai-registry.ts +292 -0
  181. package/template/src/api-cursor.ts +84 -0
  182. package/template/src/assistant.ts +554 -0
  183. package/template/src/audit.ts +39 -0
  184. package/template/src/backup-service.ts +269 -0
  185. package/template/src/budget-control.ts +127 -0
  186. package/template/src/component-executor.ts +845 -0
  187. package/template/src/configuration-registry.ts +550 -0
  188. package/template/src/connection-egress.ts +430 -0
  189. package/template/src/connection-oauth.ts +368 -0
  190. package/template/src/evidence-service.ts +335 -0
  191. package/template/src/human-tasks.ts +316 -0
  192. package/template/src/index.ts +4114 -0
  193. package/template/src/installation-admin.ts +301 -0
  194. package/template/src/intake-abuse.ts +156 -0
  195. package/template/src/platform-registry-validation.ts +367 -0
  196. package/template/src/platform-registry.ts +753 -0
  197. package/template/src/report-admin.ts +342 -0
  198. package/template/src/report-ai-quality.ts +390 -0
  199. package/template/src/report-ai-validation.ts +80 -0
  200. package/template/src/report-ai.ts +857 -0
  201. package/template/src/report-auth.ts +292 -0
  202. package/template/src/report-better-auth.ts +583 -0
  203. package/template/src/report-context-schema.ts +132 -0
  204. package/template/src/report-context.ts +126 -0
  205. package/template/src/report-crypto.ts +142 -0
  206. package/template/src/report-delivery.ts +579 -0
  207. package/template/src/report-form-validation.ts +51 -0
  208. package/template/src/report-governance.ts +510 -0
  209. package/template/src/report-http.ts +90 -0
  210. package/template/src/report-operations.ts +685 -0
  211. package/template/src/report-operator-accounts.ts +810 -0
  212. package/template/src/report-presence.ts +398 -0
  213. package/template/src/report-queue-validation.ts +309 -0
  214. package/template/src/report-queues.ts +478 -0
  215. package/template/src/report-repository.ts +972 -0
  216. package/template/src/report-router-agent.ts +328 -0
  217. package/template/src/report-router-validation.ts +81 -0
  218. package/template/src/report-routing.ts +178 -0
  219. package/template/src/report-turnstile.ts +142 -0
  220. package/template/src/report-types.ts +224 -0
  221. package/template/src/report-validation.ts +279 -0
  222. package/template/src/report-workflow-validation.ts +119 -0
  223. package/template/src/report-workflow.ts +886 -0
  224. package/template/src/shadow-quality.ts +278 -0
  225. package/template/src/workflow-actions.ts +782 -0
  226. package/template/src/workflow-compiler.ts +230 -0
  227. package/template/src/workflow-dynamic-runtime.ts +613 -0
  228. package/template/src/workflow-effects.ts +316 -0
  229. package/template/src/workflow-expressions.ts +191 -0
  230. package/template/src/workflow-platform-types.ts +121 -0
  231. package/template/src/workflow-platform-validation.ts +540 -0
  232. package/template/src/workflow-repository.ts +916 -0
  233. package/template/src/workflow-runs.ts +686 -0
  234. package/template/src/workflow-simulator.ts +211 -0
  235. package/template/src/workspace-branding.ts +128 -0
  236. package/template/src/workspace-governance.ts +279 -0
  237. package/template/tsconfig.console.json +23 -0
  238. package/template/tsconfig.json +22 -0
  239. package/template/vite.console.config.ts +20 -0
  240. package/template/worker-configuration.d.ts +65 -0
@@ -0,0 +1,550 @@
1
+ import { canonicalJson } from "./audit";
2
+ import { sha256 } from "./report-crypto";
3
+ import { ApiError } from "./report-http";
4
+ import type { OperatorSession } from "./report-types";
5
+ import { parseRoutingCriteria } from "./report-queue-validation";
6
+ import { assertMappedFieldsAllowed } from "./component-executor";
7
+ import type { JsonValue } from "./workflow-platform-types";
8
+ import { evaluateHardSafeguards, routingCriteriaMatches, type RoutingInput } from "./report-routing";
9
+
10
+ export type ConfigurationType = "form" | "policy" | "template" | "routing_rules";
11
+
12
+ interface ValidationIssue { code: string; path?: string; message: string }
13
+ export interface ConfigurationValidation { valid: boolean; errors: ValidationIssue[]; warnings: ValidationIssue[] }
14
+
15
+ const keyPattern = /^[a-z][a-z0-9-]{1,79}$/u;
16
+ const identifierPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/u;
17
+ const answerKeyPattern = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/u;
18
+
19
+ function now(): string { return new Date().toISOString(); }
20
+
21
+ function object(value: unknown, code = "configuration_content_invalid"): Record<string, unknown> {
22
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new ApiError(400, code, "Configuration content must be an object.");
23
+ return value as Record<string, unknown>;
24
+ }
25
+
26
+ function issue(code: string, message: string, path?: string): ValidationIssue {
27
+ return { code, message, ...(path ? { path } : {}) };
28
+ }
29
+
30
+ function stringValue(value: unknown, minimum: number, maximum: number): boolean {
31
+ return typeof value === "string" && value.trim().length >= minimum && value.length <= maximum;
32
+ }
33
+
34
+ function stringList(value: unknown, maximum = 100): value is string[] {
35
+ return Array.isArray(value) && value.length <= maximum
36
+ && value.every((entry) => typeof entry === "string" && identifierPattern.test(entry))
37
+ && new Set(value).size === value.length;
38
+ }
39
+
40
+ function intakeEnrichmentEntries(content: Record<string, unknown>): Array<{ componentVersionId: string; inputMapping: Record<string, JsonValue> }> {
41
+ const result: Array<{ componentVersionId: string; inputMapping: Record<string, JsonValue> }> = [];
42
+ if (Array.isArray(content.intake_enrichments)) {
43
+ for (const entry of content.intake_enrichments) {
44
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
45
+ const value = entry as Record<string, unknown>;
46
+ if (typeof value.component_version_id === "string" && value.input_mapping && typeof value.input_mapping === "object" && !Array.isArray(value.input_mapping)) {
47
+ result.push({ componentVersionId: value.component_version_id, inputMapping: value.input_mapping as Record<string, JsonValue> });
48
+ }
49
+ }
50
+ }
51
+ return result;
52
+ }
53
+
54
+ interface RoutingFixtureResult {
55
+ name: string;
56
+ passed: boolean;
57
+ selectedQueueId: string;
58
+ expectedQueueId: string;
59
+ safeguardRequired: boolean;
60
+ matchedRuleIds: string[];
61
+ }
62
+
63
+ function routingFixtureResult(content: Record<string, unknown>, fixtureValue: unknown, index: number): RoutingFixtureResult {
64
+ const fixture = object(fixtureValue, "routing_fixture_invalid");
65
+ if (Object.keys(fixture).some((field) => !["name", "input", "expected"].includes(field))) throw new Error("Fixture contains an unsupported field.");
66
+ if (!stringValue(fixture.name, 2, 120)) throw new Error("Fixture name must contain 2 to 120 characters.");
67
+ const input = object(fixture.input, "routing_fixture_input_invalid");
68
+ if (Object.keys(input).some((field) => !["reason_code", "source", "target_type", "trusted_facts", "answers", "enrichments", "priority"].includes(field))) throw new Error("Fixture input contains an unsupported field.");
69
+ if (typeof input.reason_code !== "string" || !identifierPattern.test(input.reason_code)
70
+ || typeof input.source !== "string" || !identifierPattern.test(input.source)
71
+ || typeof input.target_type !== "string" || !identifierPattern.test(input.target_type)) throw new Error("Fixture input requires valid reason_code, source, and target_type values.");
72
+ const trustedFacts = object(input.trusted_facts ?? {}, "routing_fixture_input_invalid");
73
+ if (Object.values(trustedFacts).some((value) => value !== null && !["string", "number", "boolean"].includes(typeof value))) throw new Error("Fixture trusted facts must be scalar JSON values.");
74
+ const answers = object(input.answers ?? {}, "routing_fixture_input_invalid");
75
+ const enrichments = object(input.enrichments ?? {}, "routing_fixture_input_invalid");
76
+ if (new TextEncoder().encode(canonicalJson({ trustedFacts, answers, enrichments })).byteLength > 65_536) throw new Error("Fixture input exceeds the 64 KiB limit.");
77
+ if (input.priority !== undefined && (!Number.isInteger(input.priority) || Number(input.priority) < 0 || Number(input.priority) > 100_000)) throw new Error("Fixture priority is invalid.");
78
+ const expected = object(fixture.expected, "routing_fixture_expected_invalid");
79
+ if (Object.keys(expected).some((field) => !["queue_id", "safeguard_required", "matched_rule_ids"].includes(field))) throw new Error("Fixture expectation contains an unsupported field.");
80
+ if (typeof expected.queue_id !== "string" || !identifierPattern.test(expected.queue_id)) throw new Error("Fixture expected.queue_id is required.");
81
+ if (expected.safeguard_required !== undefined && typeof expected.safeguard_required !== "boolean") throw new Error("Fixture expected.safeguard_required must be boolean.");
82
+ if (expected.matched_rule_ids !== undefined && !stringList(expected.matched_rule_ids, 100)) throw new Error("Fixture expected.matched_rule_ids is invalid.");
83
+ const routingInput: RoutingInput = {
84
+ reasonCode: input.reason_code,
85
+ source: input.source,
86
+ targetType: input.target_type,
87
+ trustedFacts: trustedFacts as RoutingInput["trustedFacts"],
88
+ answers,
89
+ enrichments,
90
+ ...(input.priority === undefined ? {} : { priority: Number(input.priority) }),
91
+ };
92
+ const rules = (content.rules as unknown[]).map((ruleValue) => {
93
+ const rule = object(ruleValue, "routing_rule_invalid");
94
+ return {
95
+ id: String(rule.id), queueId: String(rule.queue_id), priority: Number(rule.priority),
96
+ criteria: parseRoutingCriteria({ match: rule.match, conditions: rule.conditions }),
97
+ };
98
+ }).sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id));
99
+ const safeguard = evaluateHardSafeguards(routingInput);
100
+ const matchedRuleIds = safeguard.requiresHuman ? [] : rules.filter((rule) => routingCriteriaMatches(routingInput, rule.criteria)).map((rule) => rule.id);
101
+ const selectedQueueId = safeguard.requiresHuman
102
+ ? String(content.safeguard_queue_id)
103
+ : rules.find((rule) => matchedRuleIds.includes(rule.id))?.queueId ?? String(content.default_queue_id);
104
+ const expectedRuleIds = expected.matched_rule_ids as string[] | undefined;
105
+ const passed = selectedQueueId === expected.queue_id
106
+ && (expected.safeguard_required === undefined || expected.safeguard_required === safeguard.requiresHuman)
107
+ && (expectedRuleIds === undefined || canonicalJson(expectedRuleIds) === canonicalJson(matchedRuleIds));
108
+ return {
109
+ name: String(fixture.name ?? `fixture-${index + 1}`), passed, selectedQueueId,
110
+ expectedQueueId: expected.queue_id, safeguardRequired: safeguard.requiresHuman, matchedRuleIds,
111
+ };
112
+ }
113
+
114
+ function routingFixtureResults(content: Record<string, unknown>): RoutingFixtureResult[] {
115
+ return Array.isArray(content.fixtures)
116
+ ? content.fixtures.map((fixture, index) => routingFixtureResult(content, fixture, index)) : [];
117
+ }
118
+
119
+ export function validateConfiguration(type: ConfigurationType, value: unknown): ConfigurationValidation {
120
+ const errors: ValidationIssue[] = [];
121
+ const warnings: ValidationIssue[] = [];
122
+ let content: Record<string, unknown>;
123
+ try { content = object(value); } catch (error) {
124
+ return { valid: false, errors: [issue("configuration_content_invalid", error instanceof Error ? error.message : "Invalid content.")], warnings };
125
+ }
126
+ if (type === "form") {
127
+ const allowed = new Set(["title", "description", "target_types", "reasons", "fields", "consent_notice"]);
128
+ for (const field of Object.keys(content)) if (!allowed.has(field)) errors.push(issue("form_field_unsupported", `Unsupported form property ${field}.`, field));
129
+ if (!stringValue(content.title, 3, 160)) errors.push(issue("form_title_invalid", "title must contain 3 to 160 characters.", "title"));
130
+ if (content.description !== undefined && !stringValue(content.description, 0, 2_000)) errors.push(issue("form_description_invalid", "description is too long.", "description"));
131
+ if (!stringList(content.target_types, 50) || content.target_types.length === 0) errors.push(issue("form_target_types_invalid", "target_types must be a non-empty unique identifier list.", "target_types"));
132
+ if (!Array.isArray(content.reasons) || content.reasons.length === 0 || content.reasons.length > 100) {
133
+ errors.push(issue("form_reasons_invalid", "reasons must contain 1 to 100 entries.", "reasons"));
134
+ } else {
135
+ const codes = new Set<string>();
136
+ content.reasons.forEach((entry, index) => {
137
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return errors.push(issue("form_reason_invalid", "Each reason must be an object.", `reasons.${index}`));
138
+ const reason = entry as Record<string, unknown>;
139
+ if (typeof reason.code !== "string" || !identifierPattern.test(reason.code) || codes.has(reason.code)) errors.push(issue("form_reason_code_invalid", "Reason codes must be valid and unique.", `reasons.${index}.code`));
140
+ else codes.add(reason.code);
141
+ if (!stringValue(reason.label, 1, 160)) errors.push(issue("form_reason_label_invalid", "Reason labels must contain 1 to 160 characters.", `reasons.${index}.label`));
142
+ });
143
+ }
144
+ const fieldTypes = new Set(["short_text", "long_text", "select", "multi_select", "boolean", "number", "url"]);
145
+ if (!Array.isArray(content.fields) || content.fields.length > 50) errors.push(issue("form_fields_invalid", "fields must contain at most 50 entries.", "fields"));
146
+ else {
147
+ const keys = new Set<string>();
148
+ content.fields.forEach((entry, index) => {
149
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return errors.push(issue("form_field_invalid", "Each field must be an object.", `fields.${index}`));
150
+ const field = entry as Record<string, unknown>;
151
+ const allowedFieldProperties = new Set(["key", "label", "type", "required", "max_length", "help_text", "placeholder", "options", "min_value", "max_value"]);
152
+ if (Object.keys(field).some((property) => !allowedFieldProperties.has(property))) errors.push(issue("form_field_property_unsupported", "The field contains an unsupported property.", `fields.${index}`));
153
+ if (typeof field.key !== "string" || !answerKeyPattern.test(field.key) || keys.has(field.key) || ["__proto__", "prototype", "constructor"].includes(field.key)) errors.push(issue("form_field_key_invalid", "Field keys must begin with a letter, use only letters, digits, underscores or hyphens, and be unique.", `fields.${index}.key`));
154
+ else keys.add(field.key);
155
+ if (!stringValue(field.label, 1, 160)) errors.push(issue("form_field_label_invalid", "Field labels are required.", `fields.${index}.label`));
156
+ if (typeof field.type !== "string" || !fieldTypes.has(field.type)) errors.push(issue("form_field_type_invalid", "Field type is unsupported.", `fields.${index}.type`));
157
+ if (field.required !== undefined && typeof field.required !== "boolean") errors.push(issue("form_field_required_invalid", "required must be boolean.", `fields.${index}.required`));
158
+ if (field.help_text !== undefined && !stringValue(field.help_text, 0, 500)) errors.push(issue("form_field_help_invalid", "help_text must contain at most 500 characters.", `fields.${index}.help_text`));
159
+ if (field.placeholder !== undefined && !stringValue(field.placeholder, 0, 300)) errors.push(issue("form_field_placeholder_invalid", "placeholder must contain at most 300 characters.", `fields.${index}.placeholder`));
160
+ if (field.max_length !== undefined && (!Number.isInteger(field.max_length) || Number(field.max_length) < 1 || Number(field.max_length) > 4_000)) errors.push(issue("form_field_length_invalid", "max_length must be an integer from 1 to 4000.", `fields.${index}.max_length`));
161
+ if (field.type === "url" && field.max_length !== undefined && Number(field.max_length) > 2_048) errors.push(issue("form_field_length_invalid", "URL fields are limited to 2048 characters.", `fields.${index}.max_length`));
162
+ if (field.type === "number") {
163
+ if (field.min_value !== undefined && (typeof field.min_value !== "number" || !Number.isFinite(field.min_value))) errors.push(issue("form_field_number_bound_invalid", "min_value must be a finite number.", `fields.${index}.min_value`));
164
+ if (field.max_value !== undefined && (typeof field.max_value !== "number" || !Number.isFinite(field.max_value))) errors.push(issue("form_field_number_bound_invalid", "max_value must be a finite number.", `fields.${index}.max_value`));
165
+ if (typeof field.min_value === "number" && typeof field.max_value === "number" && field.min_value > field.max_value) errors.push(issue("form_field_number_range_invalid", "min_value cannot be greater than max_value.", `fields.${index}`));
166
+ } else if (field.min_value !== undefined || field.max_value !== undefined) errors.push(issue("form_field_number_bound_invalid", "Only number fields may define numeric bounds.", `fields.${index}`));
167
+ if (field.type === "select" || field.type === "multi_select") {
168
+ if (!Array.isArray(field.options) || field.options.length === 0 || field.options.length > 100) errors.push(issue("form_field_options_invalid", "Choice fields must contain 1 to 100 options.", `fields.${index}.options`));
169
+ else {
170
+ const optionValues = new Set<string>();
171
+ field.options.forEach((optionValue, optionIndex) => {
172
+ if (!optionValue || typeof optionValue !== "object" || Array.isArray(optionValue)) return errors.push(issue("form_field_option_invalid", "Each option must have a value and label.", `fields.${index}.options.${optionIndex}`));
173
+ const option = optionValue as Record<string, unknown>;
174
+ if (Object.keys(option).some((property) => !["value", "label"].includes(property))) errors.push(issue("form_field_option_property_unsupported", "An option contains an unsupported property.", `fields.${index}.options.${optionIndex}`));
175
+ if (typeof option.value !== "string" || !identifierPattern.test(option.value) || optionValues.has(option.value)) errors.push(issue("form_field_option_value_invalid", "Option values must be valid and unique.", `fields.${index}.options.${optionIndex}.value`));
176
+ else optionValues.add(option.value);
177
+ if (!stringValue(option.label, 1, 160)) errors.push(issue("form_field_option_label_invalid", "Option labels must contain 1 to 160 characters.", `fields.${index}.options.${optionIndex}.label`));
178
+ });
179
+ }
180
+ } else if (field.options !== undefined) errors.push(issue("form_field_options_invalid", "Only choice fields may define options.", `fields.${index}.options`));
181
+ });
182
+ }
183
+ if (!stringValue(content.consent_notice, 10, 4_000)) errors.push(issue("form_consent_invalid", "consent_notice must contain 10 to 4,000 characters.", "consent_notice"));
184
+ } else if (type === "policy") {
185
+ const allowed = new Set(["title", "rules"]);
186
+ for (const field of Object.keys(content)) if (!allowed.has(field)) errors.push(issue("policy_field_unsupported", `Unsupported policy property ${field}.`, field));
187
+ if (!stringValue(content.title, 3, 160)) errors.push(issue("policy_title_invalid", "title must contain 3 to 160 characters.", "title"));
188
+ if (!Array.isArray(content.rules) || content.rules.length === 0 || content.rules.length > 500) errors.push(issue("policy_rules_invalid", "rules must contain 1 to 500 entries.", "rules"));
189
+ else {
190
+ const codes = new Set<string>();
191
+ content.rules.forEach((entry, index) => {
192
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return errors.push(issue("policy_rule_invalid", "Each rule must be an object.", `rules.${index}`));
193
+ const rule = entry as Record<string, unknown>;
194
+ if (typeof rule.code !== "string" || !identifierPattern.test(rule.code) || codes.has(rule.code)) errors.push(issue("policy_rule_code_invalid", "Rule codes must be valid and unique.", `rules.${index}.code`));
195
+ else codes.add(rule.code);
196
+ if (!stringValue(rule.title, 3, 200)) errors.push(issue("policy_rule_title_invalid", "Rule titles are required.", `rules.${index}.title`));
197
+ });
198
+ }
199
+ } else if (type === "template") {
200
+ const allowed = new Set(["audience", "purpose", "locale", "body_template", "automation_allowed", "variables_schema"]);
201
+ for (const field of Object.keys(content)) if (!allowed.has(field)) errors.push(issue("template_field_unsupported", `Unsupported template property ${field}.`, field));
202
+ if (content.audience !== "reporter" && content.audience !== "affected_user") errors.push(issue("template_audience_invalid", "audience is invalid.", "audience"));
203
+ if (!["follow_up", "outcome", "affected_user_notice", "appeal_outcome"].includes(String(content.purpose))) errors.push(issue("template_purpose_invalid", "purpose is invalid.", "purpose"));
204
+ if (typeof content.locale !== "string" || !/^[a-z]{2,3}(?:-[A-Z]{2})?$/u.test(content.locale)) errors.push(issue("template_locale_invalid", "locale is invalid.", "locale"));
205
+ if (!stringValue(content.body_template, 1, 10_000)) errors.push(issue("template_body_invalid", "body_template must contain 1 to 10,000 characters.", "body_template"));
206
+ if (content.automation_allowed !== undefined && typeof content.automation_allowed !== "boolean") errors.push(issue("template_automation_invalid", "automation_allowed must be boolean.", "automation_allowed"));
207
+ if (!content.variables_schema || typeof content.variables_schema !== "object" || Array.isArray(content.variables_schema)) errors.push(issue("template_variables_invalid", "variables_schema must be an object.", "variables_schema"));
208
+ if (typeof content.body_template === "string") {
209
+ const schema = content.variables_schema && typeof content.variables_schema === "object" && !Array.isArray(content.variables_schema) ? content.variables_schema as Record<string, unknown> : {};
210
+ const placeholders = [...content.body_template.matchAll(/\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/gu)].map((match) => match[1]!);
211
+ if (/\{\{\{|\}\}\}/u.test(content.body_template)) errors.push(issue("template_unescaped_expression", "Only simple double-brace variables are allowed.", "body_template"));
212
+ for (const placeholder of placeholders) if (!(placeholder in schema)) errors.push(issue("template_variable_undefined", `Variable ${placeholder} is not declared.`, "body_template"));
213
+ }
214
+ } else {
215
+ const allowed = new Set(["rules", "default_queue_id", "safeguard_queue_id", "intake_enrichments", "fixtures"]);
216
+ for (const field of Object.keys(content)) if (!allowed.has(field)) errors.push(issue("routing_field_unsupported", `Unsupported routing property ${field}.`, field));
217
+ if (!Array.isArray(content.rules) || content.rules.length > 100) errors.push(issue("routing_rules_invalid", "rules must be an array of at most 100 entries.", "rules"));
218
+ else {
219
+ const ids = new Set<string>();
220
+ const priorities = new Set<number>();
221
+ content.rules.forEach((entry, index) => {
222
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return errors.push(issue("routing_rule_invalid", "Each routing rule must be an object.", `rules.${index}`));
223
+ const rule = entry as Record<string, unknown>;
224
+ if (Object.keys(rule).some((field) => !["id", "queue_id", "priority", "match", "conditions"].includes(field))) errors.push(issue("routing_rule_field_unsupported", "A routing rule contains an unsupported field.", `rules.${index}`));
225
+ if (typeof rule.id !== "string" || !identifierPattern.test(rule.id) || ids.has(rule.id)) errors.push(issue("routing_rule_id_invalid", "Routing rule IDs must be valid and unique.", `rules.${index}.id`));
226
+ else ids.add(rule.id);
227
+ if (typeof rule.queue_id !== "string" || !identifierPattern.test(rule.queue_id)) errors.push(issue("routing_rule_queue_invalid", "Each rule must target a valid queue ID.", `rules.${index}.queue_id`));
228
+ if (!Number.isInteger(rule.priority) || Number(rule.priority) < 0 || Number(rule.priority) > 100_000 || priorities.has(Number(rule.priority))) errors.push(issue("routing_rule_priority_invalid", "Rule priorities must be unique integers from 0 to 100000.", `rules.${index}.priority`));
229
+ else priorities.add(Number(rule.priority));
230
+ try { parseRoutingCriteria({ match: rule.match, conditions: rule.conditions }); } catch (error) {
231
+ errors.push(issue("routing_rule_condition_invalid", error instanceof Error ? error.message : "Routing conditions are invalid.", `rules.${index}`));
232
+ }
233
+ });
234
+ }
235
+ if (typeof content.default_queue_id !== "string" || !identifierPattern.test(content.default_queue_id)) errors.push(issue("routing_default_invalid", "default_queue_id is required.", "default_queue_id"));
236
+ if (typeof content.safeguard_queue_id !== "string" || !identifierPattern.test(content.safeguard_queue_id)) errors.push(issue("routing_safeguard_invalid", "safeguard_queue_id is required.", "safeguard_queue_id"));
237
+ if (content.intake_enrichments !== undefined) {
238
+ if (!Array.isArray(content.intake_enrichments) || content.intake_enrichments.length > 5) {
239
+ errors.push(issue("routing_enrichments_invalid", "intake_enrichments must contain at most five entries.", "intake_enrichments"));
240
+ } else {
241
+ content.intake_enrichments.forEach((entry, index) => {
242
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return errors.push(issue("routing_enrichment_invalid", "Each intake enrichment must be an object.", `intake_enrichments.${index}`));
243
+ const enrichment = entry as Record<string, unknown>;
244
+ if (Object.keys(enrichment).some((field) => !["component_version_id", "input_mapping"].includes(field))) errors.push(issue("routing_enrichment_field_unsupported", "An intake enrichment contains an unsupported field.", `intake_enrichments.${index}`));
245
+ if (typeof enrichment.component_version_id !== "string" || !identifierPattern.test(enrichment.component_version_id)) errors.push(issue("routing_enrichment_component_invalid", "component_version_id must identify an immutable published component version.", `intake_enrichments.${index}.component_version_id`));
246
+ if (!enrichment.input_mapping || typeof enrichment.input_mapping !== "object" || Array.isArray(enrichment.input_mapping)
247
+ || Object.keys(enrichment.input_mapping).length > 50 || Object.keys(enrichment.input_mapping).some((key) => !identifierPattern.test(key) || ["__proto__", "prototype", "constructor"].includes(key))) {
248
+ errors.push(issue("routing_enrichment_mapping_invalid", "input_mapping must be a bounded object with safe output keys.", `intake_enrichments.${index}.input_mapping`));
249
+ }
250
+ });
251
+ }
252
+ }
253
+ const intakeIds = intakeEnrichmentEntries(content).map((entry) => entry.componentVersionId);
254
+ if (intakeIds.length > 5 || new Set(intakeIds).size !== intakeIds.length) errors.push(issue("routing_enrichments_invalid", "At most five distinct intake component versions may be configured.", "intake_enrichments"));
255
+ if (content.fixtures !== undefined && (!Array.isArray(content.fixtures) || content.fixtures.length > 100)) {
256
+ errors.push(issue("routing_fixtures_invalid", "fixtures must contain at most 100 entries.", "fixtures"));
257
+ } else if (Array.isArray(content.fixtures)) {
258
+ const names = new Set<string>();
259
+ if (Array.isArray(content.rules) && content.rules.length > 0 && content.fixtures.length === 0) warnings.push(issue("routing_fixtures_missing", "Published routing rules should include deterministic and safeguard fixtures.", "fixtures"));
260
+ if (!errors.some((entry) => entry.code.startsWith("routing_rule_") || entry.code === "routing_default_invalid" || entry.code === "routing_safeguard_invalid")) {
261
+ content.fixtures.forEach((fixture, index) => {
262
+ try {
263
+ const result = routingFixtureResult(content, fixture, index);
264
+ if (names.has(result.name)) errors.push(issue("routing_fixture_name_duplicate", "Routing fixture names must be unique.", `fixtures.${index}.name`));
265
+ names.add(result.name);
266
+ if (!result.passed) errors.push(issue("routing_fixture_failed", `Fixture ${result.name} selected ${result.selectedQueueId}, not ${result.expectedQueueId}.`, `fixtures.${index}`));
267
+ } catch (error) {
268
+ errors.push(issue("routing_fixture_invalid", error instanceof Error ? error.message : "The routing fixture is invalid.", `fixtures.${index}`));
269
+ }
270
+ });
271
+ }
272
+ }
273
+ }
274
+ return { valid: errors.length === 0, errors, warnings };
275
+ }
276
+
277
+ function tableFor(type: ConfigurationType): string {
278
+ return type === "form" ? "report_form_versions" : type === "policy" ? "policy_versions" : type === "template" ? "message_template_versions" : "routing_rule_versions";
279
+ }
280
+
281
+ function parsedConfigurationJson(value: unknown, fallback: unknown): unknown {
282
+ if (typeof value !== "string") return fallback;
283
+ try { return JSON.parse(value) as unknown; } catch { return fallback; }
284
+ }
285
+
286
+ function publishedConfigurationContent(type: ConfigurationType, row: Record<string, unknown>): Record<string, unknown> {
287
+ if (type === "form") return {
288
+ title: row.title,
289
+ description: row.description ?? "",
290
+ target_types: parsedConfigurationJson(row.target_types_json, []),
291
+ reasons: parsedConfigurationJson(row.reasons_json, []),
292
+ fields: parsedConfigurationJson(row.fields_json, []),
293
+ consent_notice: row.consent_notice,
294
+ };
295
+ if (type === "policy") return {
296
+ title: row.title,
297
+ rules: parsedConfigurationJson(row.rules_json, []),
298
+ };
299
+ if (type === "template") return {
300
+ audience: row.audience,
301
+ purpose: row.purpose,
302
+ locale: row.locale,
303
+ body_template: row.body_template,
304
+ automation_allowed: row.automation_allowed === 1,
305
+ variables_schema: parsedConfigurationJson(row.variables_schema_json, {}),
306
+ };
307
+ const content = parsedConfigurationJson(row.rules_json, {});
308
+ return content && typeof content === "object" && !Array.isArray(content)
309
+ ? content as Record<string, unknown>
310
+ : { rules: Array.isArray(content) ? content : [] };
311
+ }
312
+
313
+ export async function listConfigurations(db: D1Database, type: ConfigurationType): Promise<Record<string, unknown>[]> {
314
+ const keyColumn = type === "form" ? "form_key" : type === "policy" ? "policy_key" : type === "template" ? "template_key" : "'default'";
315
+ const published = await db.prepare(`SELECT ${keyColumn} AS configurationKey, MAX(version) AS latestVersion, MAX(published_at) AS latestPublishedAt FROM ${tableFor(type)} WHERE installation_id = 'default' GROUP BY ${keyColumn} ORDER BY ${keyColumn}`).all<Record<string, unknown>>();
316
+ const drafts = await db.prepare(`SELECT id, configuration_key AS configurationKey, revision, validation_status AS validationStatus, last_published_version_id AS lastPublishedVersionId, updated_at AS updatedAt FROM configuration_drafts WHERE installation_id = 'default' AND configuration_type = ?1 ORDER BY configuration_key`).bind(type).all<Record<string, unknown>>();
317
+ const byKey = new Map<string, Record<string, unknown>>();
318
+ for (const row of published.results) byKey.set(String(row.configurationKey), { configurationKey: row.configurationKey, latestVersion: row.latestVersion, latestPublishedAt: row.latestPublishedAt, draft: null });
319
+ for (const draft of drafts.results) byKey.set(String(draft.configurationKey), { ...(byKey.get(String(draft.configurationKey)) ?? { configurationKey: draft.configurationKey, latestVersion: null, latestPublishedAt: null }), draft });
320
+ return [...byKey.values()];
321
+ }
322
+
323
+ export interface PublishedFormSummary {
324
+ id: string;
325
+ formKey: string;
326
+ version: number;
327
+ title: string;
328
+ description: string;
329
+ targetTypes: string[];
330
+ fieldCount: number;
331
+ publishedAt: string;
332
+ }
333
+
334
+ export async function listPublishedForms(db: D1Database): Promise<PublishedFormSummary[]> {
335
+ const rows = await db.prepare(`
336
+ SELECT id, form_key AS formKey, version, title, description, target_types_json AS targetTypesJson,
337
+ fields_json AS fieldsJson, published_at AS publishedAt
338
+ FROM report_form_versions
339
+ WHERE installation_id = 'default' AND published_at IS NOT NULL AND retired_at IS NULL
340
+ ORDER BY published_at DESC, version DESC
341
+ `).all<{ id: string; formKey: string; version: number; title: string; description: string; targetTypesJson: string; fieldsJson: string; publishedAt: string }>();
342
+ return rows.results.map((row) => {
343
+ const targetTypes = parsedConfigurationJson(row.targetTypesJson, []);
344
+ const fields = parsedConfigurationJson(row.fieldsJson, []);
345
+ return {
346
+ id: row.id,
347
+ formKey: row.formKey,
348
+ version: row.version,
349
+ title: row.title,
350
+ description: row.description,
351
+ targetTypes: Array.isArray(targetTypes) ? targetTypes.filter((value): value is string => typeof value === "string") : [],
352
+ fieldCount: Array.isArray(fields) ? fields.length : 0,
353
+ publishedAt: row.publishedAt,
354
+ };
355
+ });
356
+ }
357
+
358
+ export async function loadConfiguration(db: D1Database, type: ConfigurationType, key: string): Promise<Record<string, unknown> | null> {
359
+ const draft = await db.prepare(`SELECT id, configuration_key AS configurationKey, content_json AS contentJson, content_digest AS contentDigest, validation_json AS validationJson, validation_status AS validationStatus, revision, last_published_version_id AS lastPublishedVersionId, created_by AS createdBy, updated_by AS updatedBy, created_at AS createdAt, updated_at AS updatedAt FROM configuration_drafts WHERE installation_id = 'default' AND configuration_type = ?1 AND configuration_key = ?2 LIMIT 1`).bind(type, key).first<Record<string, unknown>>();
360
+ const keyColumn = type === "form" ? "form_key" : type === "policy" ? "policy_key" : type === "template" ? "template_key" : null;
361
+ const versions = keyColumn
362
+ ? await db.prepare(`SELECT * FROM ${tableFor(type)} WHERE installation_id = 'default' AND ${keyColumn} = ?1 ORDER BY version DESC, created_at DESC`).bind(key).all<Record<string, unknown>>()
363
+ : await db.prepare(`SELECT * FROM routing_rule_versions WHERE installation_id = 'default' ORDER BY version DESC`).all<Record<string, unknown>>();
364
+ if (!draft && versions.results.length === 0) return null;
365
+ return {
366
+ draft: draft ? { ...draft, content: JSON.parse(String(draft.contentJson)), validation: JSON.parse(String(draft.validationJson)), contentJson: undefined, validationJson: undefined } : null,
367
+ versions: versions.results.map((version) => ({ ...version, content: publishedConfigurationContent(type, version) })),
368
+ };
369
+ }
370
+
371
+ export async function createConfigurationDraft(
372
+ db: D1Database,
373
+ type: ConfigurationType,
374
+ key: string,
375
+ contentValue: unknown,
376
+ session: OperatorSession,
377
+ idempotencyKey: string,
378
+ ): Promise<{ draftId: string; revision: number; validation: ConfigurationValidation; idempotentReplay: boolean }> {
379
+ if (!keyPattern.test(key)) throw new ApiError(400, "configuration_key_invalid", "Configuration keys must use lowercase letters, digits, and hyphens.");
380
+ const replay = await db.prepare(`SELECT target_id AS draftId, details_json AS detailsJson FROM audit_events WHERE idempotency_key = ?1 AND action = 'configuration.draft_created' LIMIT 1`).bind(`configuration-draft:${idempotencyKey}`).first<{ draftId: string; detailsJson: string }>();
381
+ if (replay) return { draftId: replay.draftId, revision: 1, validation: JSON.parse(replay.detailsJson) as ConfigurationValidation, idempotentReplay: true };
382
+ const content = object(contentValue);
383
+ const validation = validateConfiguration(type, content);
384
+ const contentJson = canonicalJson(content);
385
+ if (new TextEncoder().encode(contentJson).byteLength > 524_288) throw new ApiError(413, "configuration_too_large", "Configuration drafts are limited to 512 KiB.");
386
+ const draftId = crypto.randomUUID();
387
+ const timestamp = now();
388
+ await db.batch([
389
+ db.prepare(`INSERT INTO configuration_drafts (id, installation_id, configuration_type, configuration_key, content_json, content_digest, validation_json, validation_status, revision, created_by, updated_by, created_at, updated_at) VALUES (?1, 'default', ?2, ?3, ?4, ?5, ?6, ?7, 1, ?8, ?8, ?9, ?9)`)
390
+ .bind(draftId, type, key, contentJson, `sha256:${await sha256(contentJson)}`, canonicalJson(validation), validation.valid ? "valid" : "invalid", session.actor.id, timestamp),
391
+ db.prepare(`INSERT INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at) VALUES (?1, ?2, 'configuration.draft_created', ?3, ?4, 'configuration_draft', ?5, ?6, ?7)`)
392
+ .bind(crypto.randomUUID(), `configuration-draft:${idempotencyKey}`, session.actor.type, session.actor.id, draftId, canonicalJson(validation), timestamp),
393
+ ]);
394
+ return { draftId, revision: 1, validation, idempotentReplay: false };
395
+ }
396
+
397
+ export async function updateConfigurationDraft(
398
+ db: D1Database,
399
+ type: ConfigurationType,
400
+ key: string,
401
+ contentValue: unknown,
402
+ expectedRevision: number,
403
+ session: OperatorSession,
404
+ ): Promise<{ draftId: string; revision: number; validation: ConfigurationValidation }> {
405
+ const content = object(contentValue);
406
+ const validation = validateConfiguration(type, content);
407
+ const contentJson = canonicalJson(content);
408
+ if (new TextEncoder().encode(contentJson).byteLength > 524_288) throw new ApiError(413, "configuration_too_large", "Configuration drafts are limited to 512 KiB.");
409
+ const timestamp = now();
410
+ const changed = await db.prepare(`UPDATE configuration_drafts SET content_json = ?4, content_digest = ?5, validation_json = ?6, validation_status = ?7, revision = revision + 1, updated_by = ?8, updated_at = ?9 WHERE installation_id = 'default' AND configuration_type = ?1 AND configuration_key = ?2 AND revision = ?3 RETURNING id, revision`)
411
+ .bind(type, key, expectedRevision, contentJson, `sha256:${await sha256(contentJson)}`, canonicalJson(validation), validation.valid ? "valid" : "invalid", session.actor.id, timestamp).first<{ id: string; revision: number }>();
412
+ if (!changed) throw new ApiError(409, "configuration_revision_conflict", "The configuration draft changed after it was loaded.");
413
+ return { draftId: changed.id, revision: changed.revision, validation };
414
+ }
415
+
416
+ async function assertRoutingDependencies(db: D1Database, content: Record<string, unknown>): Promise<void> {
417
+ const queueIds = [String(content.default_queue_id), String(content.safeguard_queue_id), ...((content.rules as Record<string, unknown>[]).map((rule) => String(rule.queue_id)))];
418
+ const uniqueQueueIds = [...new Set(queueIds)];
419
+ const placeholders = uniqueQueueIds.map((_, index) => `?${index + 1}`).join(", ");
420
+ const active = await db.prepare(`
421
+ SELECT q.id, v.management_mode AS managementMode FROM queue_definitions q
422
+ JOIN queue_versions v ON v.id = q.active_version_id
423
+ WHERE q.id IN (${placeholders}) AND q.purpose = 'reports' AND q.status = 'active' AND v.retired_at IS NULL
424
+ `).bind(...uniqueQueueIds).all<{ id: string; managementMode: string }>();
425
+ const byId = new Map(active.results.map((queue) => [queue.id, queue]));
426
+ const missing = uniqueQueueIds.filter((id) => !byId.has(id));
427
+ if (missing.length) throw new ApiError(422, "routing_queue_unavailable", "Every routing rule and fallback must reference an active report queue.", { missing_queue_ids: missing });
428
+ if (byId.get(String(content.safeguard_queue_id))?.managementMode !== "human") throw new ApiError(422, "routing_safeguard_not_human", "The safeguard queue must be human-managed.");
429
+ if (byId.get(String(content.default_queue_id))?.managementMode !== "human") throw new ApiError(422, "routing_default_not_human", "The default fallback queue must be human-managed.");
430
+ const intake = intakeEnrichmentEntries(content);
431
+ if (!intake.length) return;
432
+ const componentIds = intake.map((entry) => entry.componentVersionId);
433
+ const componentPlaceholders = componentIds.map((_, index) => `?${index + 1}`).join(", ");
434
+ const components = await db.prepare(`
435
+ SELECT v.id, v.definition_id AS definitionId,
436
+ v.allowed_input_fields_json AS allowedInputFieldsJson,
437
+ v.risk_class AS riskClass, v.timeout_policy_json AS timeoutPolicyJson,
438
+ v.retry_policy_json AS retryPolicyJson, v.implementation_kind AS implementationKind,
439
+ d.effect_class AS effectClass, d.status
440
+ FROM component_versions v JOIN component_definitions d ON d.id = v.definition_id
441
+ WHERE v.id IN (${componentPlaceholders}) AND v.published_at IS NOT NULL AND v.retired_at IS NULL
442
+ `).bind(...componentIds).all<{
443
+ id: string; definitionId: string; allowedInputFieldsJson: string; riskClass: string;
444
+ timeoutPolicyJson: string; retryPolicyJson: string; implementationKind: string; effectClass: string; status: string;
445
+ }>();
446
+ const byComponentId = new Map(components.results.map((component) => [component.id, component]));
447
+ const unavailable = componentIds.filter((id) => !byComponentId.has(id));
448
+ if (unavailable.length) throw new ApiError(422, "routing_enrichment_unavailable", "Every intake enrichment must pin an available published component version.", { unavailable_component_version_ids: unavailable });
449
+ if (new Set(components.results.map((component) => component.definitionId)).size !== components.results.length) {
450
+ throw new ApiError(422, "routing_enrichment_definition_duplicate", "Only one immutable version of each component may run during intake routing.");
451
+ }
452
+ for (const entry of intake) {
453
+ const component = byComponentId.get(entry.componentVersionId)!;
454
+ let allowedFields: string[] = [];
455
+ let timeoutPolicy: Record<string, unknown> = {};
456
+ let retryPolicy: Record<string, unknown> = {};
457
+ try {
458
+ allowedFields = JSON.parse(component.allowedInputFieldsJson) as string[];
459
+ timeoutPolicy = JSON.parse(component.timeoutPolicyJson) as Record<string, unknown>;
460
+ retryPolicy = JSON.parse(component.retryPolicyJson) as Record<string, unknown>;
461
+ } catch {
462
+ throw new ApiError(422, "routing_enrichment_configuration_invalid", "An intake component contains invalid stored policy JSON.", { component_version_id: component.id });
463
+ }
464
+ if (!Array.isArray(allowedFields) || component.effectClass !== "read_only" || component.status !== "active"
465
+ || component.riskClass !== "low" || component.implementationKind === "workers_ai") {
466
+ throw new ApiError(422, "routing_enrichment_not_cheap", "Intake routing accepts only active, low-risk, read-only non-generative components.", { component_version_id: component.id });
467
+ }
468
+ const timeoutMs = Number(timeoutPolicy.timeout_ms ?? 5_000);
469
+ const maximumAttempts = Number(retryPolicy.maximum_attempts ?? 1);
470
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 5_000 || maximumAttempts !== 1) {
471
+ throw new ApiError(422, "routing_enrichment_not_bounded", "Intake components must have a 100-5000ms timeout and exactly one attempt.", { component_version_id: component.id });
472
+ }
473
+ try { assertMappedFieldsAllowed(entry.inputMapping, allowedFields); } catch {
474
+ throw new ApiError(422, "routing_enrichment_field_denied", "An intake input mapping reads a field the component has not allowlisted.", { component_version_id: component.id });
475
+ }
476
+ }
477
+ }
478
+
479
+ export async function validateStoredConfiguration(db: D1Database, type: ConfigurationType, key: string): Promise<ConfigurationValidation> {
480
+ const draft = await db.prepare(`SELECT id, content_json AS contentJson FROM configuration_drafts WHERE installation_id = 'default' AND configuration_type = ?1 AND configuration_key = ?2 LIMIT 1`).bind(type, key).first<{ id: string; contentJson: string }>();
481
+ if (!draft) throw new ApiError(404, "configuration_draft_not_found", "The configuration draft does not exist.");
482
+ const content = object(JSON.parse(draft.contentJson));
483
+ const validation = validateConfiguration(type, content);
484
+ if (type === "routing_rules" && validation.valid) {
485
+ try { await assertRoutingDependencies(db, content); } catch (error) {
486
+ if (error instanceof ApiError) validation.errors.push(issue(error.code, error.message));
487
+ else throw error;
488
+ validation.valid = false;
489
+ }
490
+ }
491
+ await db.prepare(`UPDATE configuration_drafts SET validation_json = ?2, validation_status = ?3, updated_at = ?4 WHERE id = ?1`).bind(draft.id, canonicalJson(validation), validation.valid ? "valid" : "invalid", now()).run();
492
+ return validation;
493
+ }
494
+
495
+ export async function publishConfiguration(
496
+ db: D1Database,
497
+ type: ConfigurationType,
498
+ key: string,
499
+ expectedRevision: number,
500
+ session: OperatorSession,
501
+ idempotencyKey: string,
502
+ ): Promise<{ versionId: string; version: number; idempotentReplay: boolean }> {
503
+ const replay = await db.prepare(`SELECT target_id AS versionId, details_json AS detailsJson FROM audit_events WHERE idempotency_key = ?1 AND action = 'configuration.published' LIMIT 1`).bind(`configuration-publish:${idempotencyKey}`).first<{ versionId: string; detailsJson: string }>();
504
+ if (replay) return { versionId: replay.versionId, version: Number((JSON.parse(replay.detailsJson) as Record<string, unknown>).version), idempotentReplay: true };
505
+ const draft = await db.prepare(`SELECT id, content_json AS contentJson, revision FROM configuration_drafts WHERE installation_id = 'default' AND configuration_type = ?1 AND configuration_key = ?2 LIMIT 1`).bind(type, key).first<{ id: string; contentJson: string; revision: number }>();
506
+ if (!draft) throw new ApiError(404, "configuration_draft_not_found", "The configuration draft does not exist.");
507
+ if (draft.revision !== expectedRevision) throw new ApiError(409, "configuration_revision_conflict", "The configuration draft changed after it was loaded.");
508
+ const content = object(JSON.parse(draft.contentJson));
509
+ const validation = validateConfiguration(type, content);
510
+ if (!validation.valid) throw new ApiError(422, "configuration_invalid", "The configuration draft cannot be published.", { errors: validation.errors });
511
+ if (type === "routing_rules") await assertRoutingDependencies(db, content);
512
+ const table = tableFor(type);
513
+ const next = await db.prepare(`SELECT COALESCE(MAX(version), 0) + 1 AS version FROM ${table} WHERE installation_id = 'default'`).first<{ version: number }>();
514
+ const version = Number(next?.version ?? 1);
515
+ const versionId = `${key}-v${version}-${crypto.randomUUID().slice(0, 8)}`;
516
+ const timestamp = now();
517
+ let insert: D1PreparedStatement;
518
+ if (type === "form") insert = db.prepare(`INSERT INTO report_form_versions (id, installation_id, form_key, version, title, description, target_types_json, reasons_json, fields_json, consent_notice, published_at, created_by, created_at) VALUES (?1, 'default', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?10)`).bind(versionId, key, version, content.title, content.description ?? "", canonicalJson(content.target_types), canonicalJson(content.reasons), canonicalJson(content.fields), content.consent_notice, timestamp, session.actor.id);
519
+ else if (type === "policy") insert = db.prepare(`INSERT INTO policy_versions (id, installation_id, policy_key, version, title, rules_json, published_at, created_by, created_at) VALUES (?1, 'default', ?2, ?3, ?4, ?5, ?6, ?7, ?6)`).bind(versionId, key, version, content.title, canonicalJson(content.rules), timestamp, session.actor.id);
520
+ else if (type === "template") insert = db.prepare(`INSERT INTO message_template_versions (id, installation_id, template_key, version, audience, purpose, locale, body_template, published_at, created_by, created_at, automation_allowed, variables_schema_json) VALUES (?1, 'default', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?8, ?10, ?11)`).bind(versionId, key, version, content.audience, content.purpose, content.locale, content.body_template, timestamp, session.actor.id, content.automation_allowed === true ? 1 : 0, canonicalJson(content.variables_schema));
521
+ else {
522
+ const fixtureResults = routingFixtureResults(content);
523
+ insert = db.prepare(`INSERT INTO routing_rule_versions (id, installation_id, version, rules_json, fixture_results_json, published_at, created_by, created_at) VALUES (?1, 'default', ?2, ?3, ?4, ?5, ?6, ?5)`).bind(
524
+ versionId, version, canonicalJson(content),
525
+ canonicalJson({ valid: fixtureResults.every((fixture) => fixture.passed), tested_at: timestamp, fixtures: fixtureResults }),
526
+ timestamp, session.actor.id,
527
+ );
528
+ }
529
+ await db.batch([
530
+ insert,
531
+ db.prepare(`UPDATE configuration_drafts SET last_published_version_id = ?2, updated_at = ?3 WHERE id = ?1 AND revision = ?4`).bind(draft.id, versionId, timestamp, expectedRevision),
532
+ db.prepare(`INSERT INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at) VALUES (?1, ?2, 'configuration.published', ?3, ?4, 'configuration_version', ?5, ?6, ?7)`).bind(crypto.randomUUID(), `configuration-publish:${idempotencyKey}`, session.actor.type, session.actor.id, versionId, canonicalJson({ type, key, version, draftId: draft.id, revision: draft.revision }), timestamp),
533
+ ]);
534
+ return { versionId, version, idempotentReplay: false };
535
+ }
536
+
537
+ export function renderTemplatePreview(contentValue: unknown, variablesValue: unknown): { body: string } {
538
+ const content = object(contentValue);
539
+ const validation = validateConfiguration("template", content);
540
+ if (!validation.valid) throw new ApiError(422, "template_invalid", "The template is invalid.", { errors: validation.errors });
541
+ const variables = object(variablesValue, "template_variables_invalid");
542
+ const schema = object(content.variables_schema, "template_variables_invalid");
543
+ for (const key of Object.keys(variables)) {
544
+ if (!(key in schema) || ["__proto__", "prototype", "constructor"].includes(key)) throw new ApiError(400, "template_variable_denied", `Variable ${key} is not declared.`);
545
+ const value = variables[key];
546
+ if (!["string", "number", "boolean"].includes(typeof value)) throw new ApiError(400, "template_variable_invalid", "Template variables must be scalar values.");
547
+ }
548
+ const body = String(content.body_template).replace(/\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/gu, (_match, key: string) => String(variables[key] ?? ""));
549
+ return { body };
550
+ }