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,328 @@
1
+ import { canonicalJson } from "./audit";
2
+ import { ApiError } from "./report-http";
3
+ import type { QueueRoutingPolicy, RoutingInput } from "./report-routing";
4
+ import type { OperatorSession } from "./report-types";
5
+ import type { RoutingAgentConfigurationInput, RoutingAgentRollout } from "./report-router-validation";
6
+
7
+ export interface RoutingAiBinding {
8
+ run(model: string, input: Record<string, unknown>): Promise<unknown>;
9
+ }
10
+
11
+ interface RoutingAgentRow {
12
+ id: string;
13
+ version: number;
14
+ enabled: number;
15
+ rollout_mode: RoutingAgentRollout;
16
+ provider: "workers_ai";
17
+ model: string;
18
+ instructions: string;
19
+ candidate_queue_ids_json: string;
20
+ safeguard_queue_id: string;
21
+ default_queue_id: string;
22
+ confidence_threshold: number;
23
+ allowed_fields_json: string;
24
+ published_at: string;
25
+ created_by: string;
26
+ created_at: string;
27
+ }
28
+
29
+ export interface ActiveRoutingAgent {
30
+ id: string;
31
+ version: number;
32
+ enabled: boolean;
33
+ rolloutMode: RoutingAgentRollout;
34
+ model: string;
35
+ instructions: string;
36
+ candidateQueueIds: string[];
37
+ safeguardQueueId: string;
38
+ defaultQueueId: string;
39
+ confidenceThreshold: number;
40
+ allowedFields: string[];
41
+ publishedAt: string;
42
+ createdBy: string;
43
+ createdAt: string;
44
+ }
45
+
46
+ const ROUTER_TIMEOUT_MS = 12_000;
47
+
48
+ function now(): string {
49
+ return new Date().toISOString();
50
+ }
51
+
52
+ function stringArray(value: string): string[] {
53
+ try {
54
+ const parsed: unknown = JSON.parse(value);
55
+ return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : [];
56
+ } catch {
57
+ return [];
58
+ }
59
+ }
60
+
61
+ function publicAgent(row: RoutingAgentRow): ActiveRoutingAgent {
62
+ return {
63
+ id: row.id,
64
+ version: row.version,
65
+ enabled: row.enabled === 1,
66
+ rolloutMode: row.rollout_mode,
67
+ model: row.model,
68
+ instructions: row.instructions,
69
+ candidateQueueIds: stringArray(row.candidate_queue_ids_json),
70
+ safeguardQueueId: row.safeguard_queue_id,
71
+ defaultQueueId: row.default_queue_id,
72
+ confidenceThreshold: row.confidence_threshold,
73
+ allowedFields: stringArray(row.allowed_fields_json),
74
+ publishedAt: row.published_at,
75
+ createdBy: row.created_by,
76
+ createdAt: row.created_at,
77
+ };
78
+ }
79
+
80
+ export async function activeRoutingAgent(db: D1Database): Promise<ActiveRoutingAgent | null> {
81
+ const row = await db.prepare(`
82
+ SELECT * FROM routing_agent_versions
83
+ WHERE installation_id = 'default' AND published_at IS NOT NULL
84
+ ORDER BY version DESC, id DESC LIMIT 1
85
+ `).first<RoutingAgentRow>();
86
+ return row ? publicAgent(row) : null;
87
+ }
88
+
89
+ export async function listRoutingAgents(db: D1Database): Promise<ActiveRoutingAgent[]> {
90
+ const rows = await db.prepare(`
91
+ SELECT * FROM routing_agent_versions
92
+ WHERE installation_id = 'default' AND published_at IS NOT NULL
93
+ ORDER BY version DESC, id DESC LIMIT 50
94
+ `).all<RoutingAgentRow>();
95
+ return rows.results.map(publicAgent);
96
+ }
97
+
98
+ async function validateQueueAssignments(db: D1Database, input: RoutingAgentConfigurationInput): Promise<void> {
99
+ const requiredHuman = await db.prepare(`
100
+ SELECT q.id
101
+ FROM queue_definitions q JOIN queue_versions v ON v.id = q.active_version_id
102
+ WHERE q.id IN (?1, ?2) AND q.purpose = 'reports' AND q.status = 'active' AND v.management_mode = 'human'
103
+ `).bind(input.safeguardQueueId, input.defaultQueueId).all<{ id: string }>();
104
+ const validHumans = new Set(requiredHuman.results.map((row) => row.id));
105
+ if (!validHumans.has(input.safeguardQueueId)) {
106
+ throw new ApiError(400, "invalid_safeguard_queue", "The safeguard queue must be an active human-managed report queue.");
107
+ }
108
+ if (!validHumans.has(input.defaultQueueId)) {
109
+ throw new ApiError(400, "invalid_default_queue", "The default queue must be an active human-managed report queue.");
110
+ }
111
+ if (!input.candidateQueueIds.length) return;
112
+ const placeholders = input.candidateQueueIds.map((_, index) => `?${index + 1}`).join(", ");
113
+ const candidates = await db.prepare(`
114
+ SELECT id FROM queue_definitions
115
+ WHERE id IN (${placeholders}) AND purpose = 'reports' AND status = 'active' AND active_version_id IS NOT NULL
116
+ `).bind(...input.candidateQueueIds).all<{ id: string }>();
117
+ const valid = new Set(candidates.results.map((row) => row.id));
118
+ if (input.candidateQueueIds.some((id) => !valid.has(id))) {
119
+ throw new ApiError(400, "invalid_candidate_queue", "Every routing-agent candidate must be an active report queue with a published policy.");
120
+ }
121
+ }
122
+
123
+ export async function publishRoutingAgent(
124
+ db: D1Database,
125
+ input: RoutingAgentConfigurationInput,
126
+ session: OperatorSession,
127
+ idempotencyKey: string,
128
+ ): Promise<{ id: string; version: number; idempotentReplay: boolean }> {
129
+ const replay = await db.prepare(`
130
+ SELECT target_id FROM configuration_mutations
131
+ WHERE idempotency_key = ?1 AND action = 'routing_agent.publish' AND actor_id = ?2 LIMIT 1
132
+ `).bind(idempotencyKey, session.actor.id).first<{ target_id: string }>();
133
+ if (replay) {
134
+ const existing = await db.prepare(`SELECT version FROM routing_agent_versions WHERE id = ?1 LIMIT 1`)
135
+ .bind(replay.target_id).first<{ version: number }>();
136
+ if (existing) return { id: replay.target_id, version: existing.version, idempotentReplay: true };
137
+ }
138
+ const reused = await db.prepare(`SELECT action, actor_id FROM configuration_mutations WHERE idempotency_key = ?1 LIMIT 1`)
139
+ .bind(idempotencyKey).first<{ action: string; actor_id: string }>();
140
+ if (reused) throw new ApiError(409, "idempotency_key_reused", "This Idempotency-Key was already used for another configuration mutation.");
141
+ await validateQueueAssignments(db, input);
142
+ const latest = await db.prepare(`SELECT COALESCE(MAX(version), 0) AS version FROM routing_agent_versions WHERE installation_id = 'default'`)
143
+ .first<{ version: number }>();
144
+ const version = Number(latest?.version ?? 0) + 1;
145
+ const id = crypto.randomUUID();
146
+ const createdAt = now();
147
+ const response = { id, version };
148
+ await db.batch([
149
+ db.prepare(`
150
+ INSERT INTO routing_agent_versions (
151
+ id, installation_id, version, enabled, rollout_mode, provider, model, instructions,
152
+ candidate_queue_ids_json, safeguard_queue_id, default_queue_id, confidence_threshold,
153
+ allowed_fields_json, published_at, created_by, created_at, idempotency_key
154
+ ) VALUES (?1, 'default', ?2, ?3, ?4, 'workers_ai', ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?12, ?14)
155
+ `).bind(
156
+ id, version, input.enabled ? 1 : 0, input.rolloutMode, input.model, input.instructions,
157
+ canonicalJson(input.candidateQueueIds), input.safeguardQueueId, input.defaultQueueId,
158
+ input.confidenceThreshold, canonicalJson(input.allowedFields), createdAt, session.actor.id, idempotencyKey,
159
+ ),
160
+ db.prepare(`
161
+ INSERT INTO configuration_mutations (idempotency_key, action, actor_id, target_id, response_json, created_at)
162
+ VALUES (?1, 'routing_agent.publish', ?2, ?3, ?4, ?5)
163
+ `).bind(idempotencyKey, session.actor.id, id, canonicalJson(response), createdAt),
164
+ db.prepare(`
165
+ INSERT OR IGNORE INTO audit_events (
166
+ id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at
167
+ ) VALUES (?1, ?2, 'routing_agent.published', ?3, ?4, 'routing_agent', ?5, ?6, ?7)
168
+ `).bind(
169
+ crypto.randomUUID(), `routing-agent:${idempotencyKey}`, session.actor.type, session.actor.id, id,
170
+ canonicalJson({ version, enabled: input.enabled, rolloutMode: input.rolloutMode, candidateQueueIds: input.candidateQueueIds }), createdAt,
171
+ ),
172
+ ]);
173
+ return { id, version, idempotentReplay: false };
174
+ }
175
+
176
+ function boundedReportInput(input: RoutingInput, allowedFields: Set<string>): Record<string, unknown> {
177
+ const result: Record<string, unknown> = {};
178
+ if (allowedFields.has("reason_code")) result.reason_code = input.reasonCode;
179
+ if (allowedFields.has("source")) result.source = input.source;
180
+ if (allowedFields.has("target_type")) result.target_type = input.targetType;
181
+ if (allowedFields.has("answers.details") && typeof input.answers.details === "string") {
182
+ result.details = input.answers.details.slice(0, 4_000);
183
+ }
184
+ if (allowedFields.has("trusted_facts")) result.trusted_facts = input.trustedFacts;
185
+ if (allowedFields.has("enrichments") && input.enrichments) {
186
+ const serialized = canonicalJson(input.enrichments);
187
+ result.enrichments = new TextEncoder().encode(serialized).byteLength <= 16_384
188
+ ? input.enrichments
189
+ : { available_keys: Object.keys(input.enrichments).slice(0, 50), omitted_for_size: true };
190
+ }
191
+ return result;
192
+ }
193
+
194
+ function unwrap(value: unknown): unknown {
195
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
196
+ const response = value as Record<string, unknown>;
197
+ let output: unknown = response.response ?? response.result ?? value;
198
+ if (typeof output === "string") {
199
+ try { output = JSON.parse(output) as unknown; } catch { return output; }
200
+ }
201
+ return output;
202
+ }
203
+
204
+ export function validateRoutingAgentOutput(
205
+ value: unknown,
206
+ candidates: Set<string>,
207
+ threshold: number,
208
+ ): { queueId: string | null; confidence: number | null; needsHuman: boolean; rationale: string | null; errors: string[] } {
209
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
210
+ return { queueId: null, confidence: null, needsHuman: true, rationale: null, errors: ["output_not_object"] };
211
+ }
212
+ const input = value as Record<string, unknown>;
213
+ const errors: string[] = [];
214
+ if (Object.keys(input).some((key) => !["queue_id", "confidence", "needs_human", "rationale"].includes(key))) errors.push("unknown_output_field");
215
+ const queueId = typeof input.queue_id === "string" && candidates.has(input.queue_id) ? input.queue_id : null;
216
+ if (!queueId) errors.push("queue_not_candidate");
217
+ const confidence = typeof input.confidence === "number" && Number.isFinite(input.confidence)
218
+ && input.confidence >= 0 && input.confidence <= 1 ? input.confidence : null;
219
+ if (confidence === null) errors.push("confidence_invalid");
220
+ if (typeof input.needs_human !== "boolean") errors.push("needs_human_invalid");
221
+ const rationale = typeof input.rationale === "string" && input.rationale.trim().length >= 3 && input.rationale.trim().length <= 500
222
+ ? input.rationale.trim() : null;
223
+ if (!rationale) errors.push("rationale_invalid");
224
+ const needsHuman = input.needs_human !== false || confidence === null || confidence < threshold;
225
+ return { queueId, confidence, needsHuman, rationale, errors };
226
+ }
227
+
228
+ async function recordRun(
229
+ db: D1Database,
230
+ input: {
231
+ runId: string;
232
+ reportId: string;
233
+ agentId: string;
234
+ jobId: string;
235
+ status: "succeeded" | "failed" | "rejected" | "shadowed";
236
+ candidates: string[];
237
+ queueId: string | null;
238
+ confidence: number | null;
239
+ needsHuman: boolean;
240
+ rationale: string | null;
241
+ errors: string[];
242
+ startedAt: string;
243
+ },
244
+ ): Promise<void> {
245
+ await db.prepare(`
246
+ INSERT INTO routing_agent_runs (
247
+ id, report_id, routing_agent_version_id, background_job_id, status,
248
+ candidate_queue_ids_json, selected_queue_id, confidence, needs_human, rationale,
249
+ validation_errors_json, started_at, finished_at
250
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
251
+ `).bind(
252
+ input.runId, input.reportId, input.agentId, input.jobId, input.status,
253
+ canonicalJson(input.candidates), input.queueId, input.confidence, input.needsHuman ? 1 : 0,
254
+ input.rationale, canonicalJson(input.errors), input.startedAt, now(),
255
+ ).run();
256
+ }
257
+
258
+ export async function executeRoutingAgent(
259
+ db: D1Database,
260
+ ai: RoutingAiBinding | undefined,
261
+ agent: ActiveRoutingAgent,
262
+ reportId: string,
263
+ jobId: string,
264
+ input: RoutingInput,
265
+ queues: QueueRoutingPolicy[],
266
+ ): Promise<{ queueId: string | null; accepted: boolean }> {
267
+ if (!agent.enabled || agent.rolloutMode === "off") return { queueId: null, accepted: false };
268
+ const activeIds = new Set(queues.map((queue) => queue.queueId));
269
+ const candidates = agent.candidateQueueIds.filter((id) => activeIds.has(id));
270
+ const runId = crypto.randomUUID();
271
+ const startedAt = now();
272
+ if (!ai || !candidates.length) {
273
+ await recordRun(db, {
274
+ runId, reportId, agentId: agent.id, jobId, status: "failed", candidates,
275
+ queueId: null, confidence: null, needsHuman: true, rationale: null,
276
+ errors: [ai ? "no_active_candidates" : "ai_binding_unavailable"], startedAt,
277
+ });
278
+ return { queueId: null, accepted: false };
279
+ }
280
+ const queueDescriptions = await db.prepare(`
281
+ SELECT q.id, q.name, q.description, v.reviewer_instructions AS reviewerInstructions
282
+ FROM queue_definitions q JOIN queue_versions v ON v.id = q.active_version_id
283
+ WHERE q.status = 'active' AND q.purpose = 'reports'
284
+ `).all<{ id: string; name: string; description: string; reviewerInstructions: string }>();
285
+ const choices = queueDescriptions.results.filter((queue) => candidates.includes(queue.id)).map((queue) => ({
286
+ id: queue.id, name: queue.name, description: queue.description, policy_summary: queue.reviewerInstructions.slice(0, 1_000),
287
+ }));
288
+ try {
289
+ const response = await Promise.race([
290
+ ai.run(agent.model, {
291
+ messages: [
292
+ { role: "system", content: `${agent.instructions}\nYou are a routing agent only. Do not decide the report or propose an application action.` },
293
+ { role: "user", content: canonicalJson({ report: boundedReportInput(input, new Set(agent.allowedFields)), candidate_queues: choices }) },
294
+ ],
295
+ response_format: {
296
+ type: "json_schema",
297
+ json_schema: {
298
+ type: "object", additionalProperties: false,
299
+ properties: {
300
+ queue_id: { type: "string", enum: candidates },
301
+ confidence: { type: "number", minimum: 0, maximum: 1 },
302
+ needs_human: { type: "boolean" },
303
+ rationale: { type: "string", minLength: 3, maxLength: 500 },
304
+ },
305
+ required: ["queue_id", "confidence", "needs_human", "rationale"],
306
+ },
307
+ },
308
+ }),
309
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error("routing_agent_timeout")), ROUTER_TIMEOUT_MS)),
310
+ ]);
311
+ const validated = validateRoutingAgentOutput(unwrap(response), new Set(candidates), agent.confidenceThreshold);
312
+ const accepted = agent.rolloutMode === "active" && !validated.needsHuman && validated.errors.length === 0;
313
+ await recordRun(db, {
314
+ runId, reportId, agentId: agent.id, jobId,
315
+ status: validated.errors.length ? "rejected" : agent.rolloutMode === "shadow" ? "shadowed" : "succeeded",
316
+ candidates, queueId: validated.queueId, confidence: validated.confidence, needsHuman: validated.needsHuman,
317
+ rationale: validated.rationale, errors: validated.errors, startedAt,
318
+ });
319
+ return { queueId: validated.queueId, accepted };
320
+ } catch (error) {
321
+ await recordRun(db, {
322
+ runId, reportId, agentId: agent.id, jobId, status: "failed", candidates,
323
+ queueId: null, confidence: null, needsHuman: true, rationale: null,
324
+ errors: [error instanceof Error && error.message === "routing_agent_timeout" ? "timeout" : "execution_failed"], startedAt,
325
+ });
326
+ return { queueId: null, accepted: false };
327
+ }
328
+ }
@@ -0,0 +1,81 @@
1
+ import { ApiError } from "./report-http";
2
+
3
+ export type RoutingAgentRollout = "off" | "shadow" | "active";
4
+
5
+ export interface RoutingAgentConfigurationInput {
6
+ enabled: boolean;
7
+ rolloutMode: RoutingAgentRollout;
8
+ model: string;
9
+ instructions: string;
10
+ candidateQueueIds: string[];
11
+ safeguardQueueId: string;
12
+ defaultQueueId: string;
13
+ confidenceThreshold: number;
14
+ allowedFields: Array<"reason_code" | "source" | "target_type" | "answers.details" | "trusted_facts" | "enrichments">;
15
+ }
16
+
17
+ const rollouts = new Set<RoutingAgentRollout>(["off", "shadow", "active"]);
18
+ const fields = new Set<RoutingAgentConfigurationInput["allowedFields"][number]>([
19
+ "reason_code", "source", "target_type", "answers.details", "trusted_facts", "enrichments",
20
+ ]);
21
+ const idPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/u;
22
+
23
+ function identifier(value: unknown, name: string): string {
24
+ if (typeof value !== "string" || !idPattern.test(value)) {
25
+ throw new ApiError(400, "invalid_routing_agent_configuration", `${name} is invalid.`);
26
+ }
27
+ return value;
28
+ }
29
+
30
+ function list<T extends string>(value: unknown, name: string, allowed?: Set<T>): T[] {
31
+ if (!Array.isArray(value) || value.length > 50) {
32
+ throw new ApiError(400, "invalid_routing_agent_configuration", `${name} must contain at most 50 values.`);
33
+ }
34
+ const result = value.map((item, index) => identifier(item, `${name}[${index}]`) as T);
35
+ if (new Set(result).size !== result.length) throw new ApiError(400, "invalid_routing_agent_configuration", `${name} contains duplicates.`);
36
+ if (allowed && result.some((item) => !allowed.has(item))) {
37
+ throw new ApiError(400, "invalid_routing_agent_configuration", `${name} contains an unsupported field.`);
38
+ }
39
+ return result;
40
+ }
41
+
42
+ export function parseRoutingAgentConfiguration(value: Record<string, unknown>): RoutingAgentConfigurationInput {
43
+ const allowed = new Set([
44
+ "enabled", "rollout_mode", "model", "instructions", "candidate_queue_ids",
45
+ "safeguard_queue_id", "default_queue_id", "confidence_threshold", "allowed_fields",
46
+ ]);
47
+ const unexpected = Object.keys(value).find((key) => !allowed.has(key));
48
+ if (unexpected) throw new ApiError(400, "invalid_routing_agent_configuration", `The routing agent contains unsupported field ${unexpected}.`);
49
+ if (typeof value.enabled !== "boolean") throw new ApiError(400, "invalid_routing_agent_configuration", "enabled must be a boolean.");
50
+ if (typeof value.rollout_mode !== "string" || !rollouts.has(value.rollout_mode as RoutingAgentRollout)) {
51
+ throw new ApiError(400, "invalid_routing_agent_configuration", "rollout_mode must be off, shadow, or active.");
52
+ }
53
+ const rolloutMode = value.rollout_mode as RoutingAgentRollout;
54
+ if (rolloutMode === "off" && value.enabled) throw new ApiError(400, "invalid_routing_agent_configuration", "An off routing agent cannot be enabled.");
55
+ if (rolloutMode !== "off" && !value.enabled) throw new ApiError(400, "invalid_routing_agent_configuration", "Shadow and active routing agents must be enabled.");
56
+ if (typeof value.model !== "string" || !value.model.startsWith("@cf/") || value.model.length > 200) {
57
+ throw new ApiError(400, "invalid_routing_agent_configuration", "model must be a Workers AI @cf model identifier.");
58
+ }
59
+ if (typeof value.instructions !== "string" || value.instructions.trim().length < 20 || value.instructions.trim().length > 10_000) {
60
+ throw new ApiError(400, "invalid_routing_agent_configuration", "instructions must contain 20 to 10000 characters.");
61
+ }
62
+ const candidateQueueIds = list<string>(value.candidate_queue_ids, "candidate_queue_ids");
63
+ if (rolloutMode !== "off" && candidateQueueIds.length < 1) {
64
+ throw new ApiError(400, "invalid_routing_agent_configuration", "An enabled routing agent requires at least one candidate queue.");
65
+ }
66
+ if (typeof value.confidence_threshold !== "number" || !Number.isFinite(value.confidence_threshold)
67
+ || value.confidence_threshold < 0 || value.confidence_threshold > 1) {
68
+ throw new ApiError(400, "invalid_routing_agent_configuration", "confidence_threshold must be from 0 to 1.");
69
+ }
70
+ return {
71
+ enabled: value.enabled,
72
+ rolloutMode,
73
+ model: value.model,
74
+ instructions: value.instructions.trim(),
75
+ candidateQueueIds,
76
+ safeguardQueueId: identifier(value.safeguard_queue_id, "safeguard_queue_id"),
77
+ defaultQueueId: identifier(value.default_queue_id, "default_queue_id"),
78
+ confidenceThreshold: value.confidence_threshold,
79
+ allowedFields: list(value.allowed_fields, "allowed_fields", fields),
80
+ };
81
+ }
@@ -0,0 +1,178 @@
1
+ import type { QueueManagementMode, QueueRolloutMode, RoutingCondition } from "./report-queue-validation";
2
+
3
+ export interface RoutingInput {
4
+ reasonCode: string;
5
+ source: string;
6
+ targetType: string;
7
+ trustedFacts: Record<string, string | number | boolean | null>;
8
+ answers: Record<string, unknown>;
9
+ enrichments?: Record<string, unknown>;
10
+ priority?: number;
11
+ }
12
+
13
+ export interface QueueRoutingPolicy {
14
+ queueId: string;
15
+ queueVersionId: string;
16
+ managementMode: QueueManagementMode;
17
+ rolloutMode: QueueRolloutMode;
18
+ priority: number;
19
+ responseSlaMinutes: number | null;
20
+ fallbackQueueId: string | null;
21
+ position: number;
22
+ routingCriteria: { match: "all" | "any"; conditions: RoutingCondition[] };
23
+ adjudicationPolicyVersionId: string;
24
+ }
25
+
26
+ export interface RoutingResult {
27
+ queueId: string;
28
+ queueVersionId: string;
29
+ state: "human_review" | "ai_review";
30
+ priority: number;
31
+ reasonCode: string;
32
+ responseSlaMinutes: number | null;
33
+ safeguardReasons: string[];
34
+ }
35
+
36
+ const protectedReasons = new Set([
37
+ "child_safety", "credible_threat", "immediate_danger", "self_harm", "terrorism",
38
+ "violent_extremism", "non_consensual_intimate_imagery", "sextortion", "legal_notice",
39
+ "law_enforcement", "trusted_flagger", "threat",
40
+ ]);
41
+
42
+ const protectedFacts = [
43
+ "protected_category", "minor_involved", "high_impact", "identity_missing",
44
+ "evidence_unavailable", "trusted_facts_conflict", "contested", "appeal",
45
+ ] as const;
46
+
47
+ const protectedContent = /\b(?:underage|minor|child sexual|suicid(?:e|al)|self[- ]harm|kill (?:myself|them|him|her)|bomb threat|credible threat|sextortion|intimate image)\b/iu;
48
+ const humanRequest = /\b(?:human review|real person|appeal|contest(?:ed|ing)? (?:this|the) decision)\b/iu;
49
+
50
+ function answerText(answers: Record<string, unknown>): string {
51
+ return Object.values(answers).filter((value): value is string => typeof value === "string").join("\n").slice(0, 20_000);
52
+ }
53
+
54
+ export function evaluateHardSafeguards(input: RoutingInput): { requiresHuman: boolean; reasons: string[] } {
55
+ const reasons: string[] = [];
56
+ if (protectedReasons.has(input.reasonCode)) reasons.push("protected_reason_code");
57
+ for (const fact of protectedFacts) {
58
+ if (input.trustedFacts[fact] === true) reasons.push(`trusted_fact:${fact}`);
59
+ }
60
+ const content = answerText(input.answers);
61
+ if (protectedContent.test(content)) reasons.push("protected_content_detected");
62
+ if (humanRequest.test(content)) reasons.push("human_review_requested");
63
+ return { requiresHuman: reasons.length > 0, reasons };
64
+ }
65
+
66
+ function nestedValue(value: unknown, path: string[]): unknown {
67
+ let current = value;
68
+ for (const part of path) {
69
+ if (!current || typeof current !== "object" || Array.isArray(current)) return undefined;
70
+ current = (current as Record<string, unknown>)[part];
71
+ }
72
+ return current;
73
+ }
74
+
75
+ function routingValue(input: RoutingInput, safeguard: { requiresHuman: boolean }, field: string): unknown {
76
+ if (field === "reason_code") return input.reasonCode;
77
+ if (field === "source") return input.source;
78
+ if (field === "target_type") return input.targetType;
79
+ if (field === "priority") return input.priority ?? 50;
80
+ if (field === "safeguard.requires_human") return safeguard.requiresHuman;
81
+ if (field.startsWith("trusted_fact.")) return input.trustedFacts[field.slice("trusted_fact.".length)];
82
+ if (field.startsWith("answer.")) return input.answers[field.slice("answer.".length)];
83
+ if (field.startsWith("enrichment.")) {
84
+ return nestedValue(input.enrichments ?? {}, field.slice("enrichment.".length).split("."));
85
+ }
86
+ return undefined;
87
+ }
88
+
89
+ function equal(left: unknown, right: unknown): boolean {
90
+ return typeof left === typeof right && left === right;
91
+ }
92
+
93
+ export function matchesRoutingCondition(actual: unknown, condition: RoutingCondition): boolean {
94
+ if (condition.operator === "exists") return actual !== undefined && actual !== null;
95
+ if (actual === undefined || actual === null) return false;
96
+ const expected = condition.value;
97
+ if (condition.operator === "eq") return equal(actual, expected);
98
+ if (condition.operator === "neq") return !equal(actual, expected);
99
+ if (condition.operator === "in") return Array.isArray(expected) && expected.some((value) => equal(actual, value));
100
+ if (condition.operator === "not_in") return Array.isArray(expected) && !expected.some((value) => equal(actual, value));
101
+ if (condition.operator === "contains") {
102
+ if (typeof actual === "string" && typeof expected === "string") return actual.toLowerCase().includes(expected.toLowerCase());
103
+ return Array.isArray(actual) && actual.some((value) => equal(value, expected));
104
+ }
105
+ if ((typeof actual !== "number" && typeof actual !== "string")
106
+ || (typeof expected !== "number" && typeof expected !== "string")) return false;
107
+ if (condition.operator === "gt") return actual > expected;
108
+ if (condition.operator === "gte") return actual >= expected;
109
+ if (condition.operator === "lt") return actual < expected;
110
+ if (condition.operator === "lte") return actual <= expected;
111
+ return false;
112
+ }
113
+
114
+ export function queueMatches(
115
+ input: RoutingInput,
116
+ queue: QueueRoutingPolicy,
117
+ safeguard = evaluateHardSafeguards(input),
118
+ ): boolean {
119
+ const results = queue.routingCriteria.conditions.map((condition) => (
120
+ matchesRoutingCondition(routingValue(input, safeguard, condition.field), condition)
121
+ ));
122
+ return queue.routingCriteria.match === "all" ? results.every(Boolean) : results.length > 0 && results.some(Boolean);
123
+ }
124
+
125
+ export function routingCriteriaMatches(
126
+ input: RoutingInput,
127
+ criteria: { match: "all" | "any"; conditions: RoutingCondition[] },
128
+ ): boolean {
129
+ const safeguard = evaluateHardSafeguards(input);
130
+ const results = criteria.conditions.map((condition) => matchesRoutingCondition(routingValue(input, safeguard, condition.field), condition));
131
+ return criteria.match === "all" ? results.every(Boolean) : results.length > 0 && results.some(Boolean);
132
+ }
133
+
134
+ function routedState(queue: QueueRoutingPolicy): "human_review" | "ai_review" {
135
+ if (queue.managementMode === "human" || queue.rolloutMode === "off" || queue.rolloutMode === "shadow") return "human_review";
136
+ return "ai_review";
137
+ }
138
+
139
+ export function selectRoute(
140
+ input: RoutingInput,
141
+ queues: QueueRoutingPolicy[],
142
+ options: {
143
+ defaultQueueId: string;
144
+ safeguardQueueId: string;
145
+ deterministicQueueId?: string | null;
146
+ agentQueueId?: string | null;
147
+ agentAccepted?: boolean;
148
+ },
149
+ ): RoutingResult | null {
150
+ const safeguard = evaluateHardSafeguards(input);
151
+ const byId = new Map(queues.map((queue) => [queue.queueId, queue]));
152
+ let queue: QueueRoutingPolicy | undefined;
153
+ let reasonCode: string;
154
+ if (safeguard.requiresHuman) {
155
+ queue = byId.get(options.safeguardQueueId);
156
+ reasonCode = "hard_safeguard_human_route";
157
+ } else if (options.deterministicQueueId) {
158
+ queue = byId.get(options.deterministicQueueId);
159
+ reasonCode = "published_routing_rule_match";
160
+ } else if (options.agentAccepted && options.agentQueueId) {
161
+ queue = byId.get(options.agentQueueId);
162
+ reasonCode = "validated_routing_agent_selection";
163
+ } else {
164
+ queue = byId.get(options.defaultQueueId);
165
+ reasonCode = "default_queue_route";
166
+ }
167
+ if (!queue) return null;
168
+ if (safeguard.requiresHuman && queue.managementMode !== "human") return null;
169
+ return {
170
+ queueId: queue.queueId,
171
+ queueVersionId: queue.queueVersionId,
172
+ state: safeguard.requiresHuman ? "human_review" : routedState(queue),
173
+ priority: safeguard.requiresHuman ? Math.max(90, queue.priority) : queue.priority,
174
+ reasonCode,
175
+ responseSlaMinutes: queue.responseSlaMinutes,
176
+ safeguardReasons: safeguard.reasons,
177
+ };
178
+ }