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,972 @@
1
+ import { AUDIT_GENESIS_HASH, canonicalJson, hashAuditEntry } from "./audit";
2
+ import { aiConfigurationForRouting } from "./report-ai";
3
+ import { ApiError } from "./report-http";
4
+ import { encryptReportContact, randomPublicReference, sha256 } from "./report-crypto";
5
+ import { analystAvatarColor } from "./report-presence";
6
+ import { activeRoutingAgent, executeRoutingAgent, type RoutingAiBinding } from "./report-router-agent";
7
+ import { evaluateHardSafeguards, queueMatches, selectRoute, type QueueRoutingPolicy, type RoutingInput } from "./report-routing";
8
+ import type { QueueManagementMode, QueueRolloutMode, RoutingCondition } from "./report-queue-validation";
9
+ import type { AiJob, DeliveryJob, InboxReport, IntakeReceipt, ReportSubmission, ReportState } from "./report-types";
10
+ import type { WorkflowDispatchJob } from "./report-types";
11
+ import { preparePinnedWorkflowRunsForRouting } from "./workflow-runs";
12
+ import { planIntakeProtection } from "./intake-abuse";
13
+ import { validateAnswersAgainstFormFields } from "./report-form-validation";
14
+ import type { JsonObject, JsonValue } from "./workflow-platform-types";
15
+
16
+ export interface IntegrationRow {
17
+ id: string;
18
+ key_id: string;
19
+ allowed_origins_json: string;
20
+ enabled: number;
21
+ channel_mode: "contextual" | "anonymous";
22
+ form_version_id: string | null;
23
+ context_schema_json: string;
24
+ }
25
+
26
+ interface FormRow {
27
+ id: string;
28
+ target_types_json: string;
29
+ reasons_json: string;
30
+ fields_json: string;
31
+ consent_notice: string;
32
+ }
33
+
34
+ interface ExistingReportRow {
35
+ id: string;
36
+ public_reference: string;
37
+ state: ReportState;
38
+ received_at: string;
39
+ }
40
+
41
+ interface AuditTipRow {
42
+ sequence: number;
43
+ entry_hash: string;
44
+ }
45
+
46
+ interface InboxReportRow {
47
+ id: string;
48
+ reference: string;
49
+ state: ReportState;
50
+ priority: number;
51
+ reasonCode: string;
52
+ queueId: string | null;
53
+ targetType: string;
54
+ targetReference: string;
55
+ submittedAt: string;
56
+ receivedAt: string;
57
+ updatedAt: string;
58
+ dueAt: string | null;
59
+ claimReviewerId: string | null;
60
+ claimedAt: string | null;
61
+ claimExpiresAt: string | null;
62
+ analystDisplayName: string | null;
63
+ analystAvatarUrl: string | null;
64
+ analystAvatarColor: string | null;
65
+ }
66
+
67
+ interface RoutingJobRow {
68
+ id: string;
69
+ report_id: string;
70
+ state: "pending" | "enqueued" | "processing" | "complete" | "failed" | "dead_letter";
71
+ reason_code: string;
72
+ trusted_facts_json: string;
73
+ report_state: ReportState;
74
+ source: string;
75
+ form_version_id: string;
76
+ target_type: string;
77
+ target_reference: string;
78
+ owner_reference: string | null;
79
+ customer_url: string | null;
80
+ public_reference: string;
81
+ installation_id: string;
82
+ }
83
+
84
+ interface QueueRoutingRow {
85
+ queueId: string;
86
+ queueVersionId: string;
87
+ managementMode: QueueManagementMode;
88
+ rolloutMode: QueueRolloutMode;
89
+ priority: number;
90
+ responseSlaMinutes: number | null;
91
+ fallbackQueueId: string | null;
92
+ position: number;
93
+ aiBoundsJson: string;
94
+ }
95
+
96
+ interface PublishedRoutingRule {
97
+ id: string;
98
+ queueId: string;
99
+ priority: number;
100
+ routingCriteria: { match: "all" | "any"; conditions: RoutingCondition[] };
101
+ }
102
+
103
+ interface PublishedRoutingRuleSet {
104
+ id: string;
105
+ defaultQueueId: string;
106
+ safeguardQueueId: string;
107
+ intakeEnrichments: Array<{ componentVersionId: string; inputMapping: Record<string, JsonValue> }>;
108
+ rules: PublishedRoutingRule[];
109
+ }
110
+
111
+ export interface RoutingIntakeEnrichmentInput {
112
+ reportId: string;
113
+ componentVersionId: string;
114
+ inputMapping: Record<string, JsonValue>;
115
+ context: JsonObject;
116
+ operationId: string;
117
+ }
118
+
119
+ export type RoutingIntakeEnrichmentExecutor = (input: RoutingIntakeEnrichmentInput) => Promise<{
120
+ componentKey: string;
121
+ data: JsonObject;
122
+ }>;
123
+
124
+ function isoNow(): string {
125
+ return new Date().toISOString();
126
+ }
127
+
128
+ function addMinutes(timestamp: string, minutes: number): string {
129
+ return new Date(Date.parse(timestamp) + minutes * 60_000).toISOString();
130
+ }
131
+
132
+ function parseJsonArray(value: string): unknown[] {
133
+ try {
134
+ const parsed: unknown = JSON.parse(value);
135
+ return Array.isArray(parsed) ? parsed : [];
136
+ } catch {
137
+ return [];
138
+ }
139
+ }
140
+
141
+ function d1Message(error: unknown): string {
142
+ return error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
143
+ }
144
+
145
+ function isConstraintError(error: unknown): boolean {
146
+ const message = d1Message(error);
147
+ return message.includes("constraint") || message.includes("unique") || message.includes("foreign key");
148
+ }
149
+
150
+ function receipt(row: ExistingReportRow, idempotentReplay: boolean): IntakeReceipt {
151
+ return {
152
+ reportId: row.id,
153
+ reference: row.public_reference,
154
+ state: row.state,
155
+ receivedAt: row.received_at,
156
+ idempotentReplay,
157
+ };
158
+ }
159
+
160
+ export async function loadIntegration(db: D1Database, integrationId: string): Promise<IntegrationRow | null> {
161
+ return db.prepare(`
162
+ SELECT id, key_id, allowed_origins_json, enabled,
163
+ channel_mode, form_version_id, context_schema_json
164
+ FROM application_integrations
165
+ WHERE id = ?1
166
+ LIMIT 1
167
+ `).bind(integrationId).first<IntegrationRow>();
168
+ }
169
+
170
+ export function integrationAllowsOrigin(integration: IntegrationRow, origin: string): boolean {
171
+ return parseJsonArray(integration.allowed_origins_json).some((value) => value === origin);
172
+ }
173
+
174
+ export async function installationAllowsWidgetOrigin(db: D1Database, origin: string): Promise<boolean> {
175
+ const rows = await db.prepare(`SELECT allowed_origins_json AS originsJson FROM application_integrations WHERE installation_id = 'default' AND enabled = 1`)
176
+ .all<{ originsJson: string }>();
177
+ return rows.results.some((row) => parseJsonArray(row.originsJson).some((value) => value === origin));
178
+ }
179
+
180
+ async function loadExisting(db: D1Database, integrationId: string, idempotencyKey: string): Promise<ExistingReportRow | null> {
181
+ return db.prepare(`
182
+ SELECT id, public_reference, state, received_at
183
+ FROM reports
184
+ WHERE integration_id = ?1 AND idempotency_key = ?2
185
+ LIMIT 1
186
+ `).bind(integrationId, idempotencyKey).first<ExistingReportRow>();
187
+ }
188
+
189
+ export async function loadContextReportReplay(
190
+ db: D1Database,
191
+ integrationId: string,
192
+ contextJti: string,
193
+ idempotencyKey: string,
194
+ ): Promise<IntakeReceipt | null> {
195
+ const row = await db.prepare(`
196
+ SELECT r.id, r.public_reference, r.state, r.received_at
197
+ FROM context_token_uses u
198
+ JOIN reports r ON r.id = u.report_id
199
+ WHERE u.jti = ?1 AND u.integration_id = ?2
200
+ AND r.integration_id = ?2 AND r.idempotency_key = ?3
201
+ LIMIT 1
202
+ `).bind(contextJti, integrationId, idempotencyKey).first<ExistingReportRow>();
203
+ if (!row) return null;
204
+ return receipt(row, true);
205
+ }
206
+
207
+ async function loadForm(db: D1Database, formVersionId: string): Promise<FormRow | null> {
208
+ return db.prepare(`
209
+ SELECT id, target_types_json, reasons_json, fields_json, consent_notice
210
+ FROM report_form_versions
211
+ WHERE id = ?1 AND published_at IS NOT NULL AND retired_at IS NULL
212
+ LIMIT 1
213
+ `).bind(formVersionId).first<FormRow>();
214
+ }
215
+
216
+ function validateAgainstForm(submission: ReportSubmission, form: FormRow): void {
217
+ const targets = new Set(parseJsonArray(form.target_types_json).filter((value): value is string => typeof value === "string"));
218
+ if (!targets.has(submission.target.type)) {
219
+ throw new ApiError(400, "target_type_not_allowed", "The selected form does not accept this target type.", { field: "target.type" });
220
+ }
221
+ const reasons = new Set(parseJsonArray(form.reasons_json)
222
+ .map((value) => value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>).code : null)
223
+ .filter((value): value is string => typeof value === "string"));
224
+ if (!reasons.has(submission.reasonCode)) {
225
+ throw new ApiError(400, "reason_not_allowed", "The selected form does not accept this reason.", { field: "reason_code" });
226
+ }
227
+ if (submission.consentNotice !== form.consent_notice) {
228
+ throw new ApiError(409, "form_notice_changed", "The form notice changed. Reload the form before submitting.");
229
+ }
230
+ validateAnswersAgainstFormFields(submission.answers, parseJsonArray(form.fields_json));
231
+ }
232
+
233
+ async function auditTip(db: D1Database): Promise<AuditTipRow> {
234
+ return await db.prepare(`SELECT sequence, entry_hash FROM audit_entries ORDER BY sequence DESC LIMIT 1`).first<AuditTipRow>()
235
+ ?? { sequence: 0, entry_hash: AUDIT_GENESIS_HASH };
236
+ }
237
+
238
+ export interface CreateReportOptions {
239
+ contactEncryptionKey: string;
240
+ emailEnabled: boolean;
241
+ notificationEnabled: boolean;
242
+ abusePepper: string;
243
+ networkAddress?: string | null;
244
+ turnstileOutcome?: string | null;
245
+ surgeThresholdPerFiveMinutes: number;
246
+ }
247
+
248
+ export async function createReport(
249
+ db: D1Database,
250
+ submission: ReportSubmission,
251
+ options: CreateReportOptions,
252
+ ): Promise<{ receipt: IntakeReceipt; jobId: string; deliveryJobs: DeliveryJob[]; created: boolean; route: boolean }> {
253
+ if (submission.source === "public_page" && submission.reporter.email && !options.emailEnabled && !options.notificationEnabled) {
254
+ throw new ApiError(503, "reporter_notifications_unavailable", "Anonymous reporting is temporarily unavailable because its secure email notification channel is not configured.");
255
+ }
256
+ const integration = await loadIntegration(db, submission.integrationId);
257
+ if (!integration || integration.enabled !== 1) throw new ApiError(401, "integration_not_available", "The application integration is not enabled.");
258
+ const existing = await loadExisting(db, submission.integrationId, submission.idempotencyKey);
259
+ if (existing) {
260
+ return { receipt: receipt(existing, true), jobId: `route:${existing.id}`, deliveryJobs: [], created: false, route: false };
261
+ }
262
+ const form = await loadForm(db, submission.formVersionId);
263
+ if (!form) throw new ApiError(400, "form_not_available", "The selected report form is not available.");
264
+ validateAgainstForm(submission, form);
265
+
266
+ const intakePlan = await planIntakeProtection(db, submission, {
267
+ pepper: options.abusePepper,
268
+ networkAddress: options.networkAddress ?? null,
269
+ turnstileOutcome: options.turnstileOutcome ?? null,
270
+ surgeThresholdPerFiveMinutes: options.surgeThresholdPerFiveMinutes,
271
+ });
272
+
273
+ const reportId = crypto.randomUUID();
274
+ const publicReference = randomPublicReference();
275
+ const receivedAt = isoNow();
276
+ const jobId = `route:${reportId}`;
277
+ const reporterParticipantId = crypto.randomUUID();
278
+ const contactCiphertext = submission.reporter.email
279
+ ? await encryptReportContact(options.contactEncryptionKey, submission.reporter.email, reportId, "reporter")
280
+ : null;
281
+ const receiptMessageId = crypto.randomUUID();
282
+ const receiptNoticeId = crypto.randomUUID();
283
+ const receiptDeliveryId = crypto.randomUUID();
284
+ const receiptBody = `We received report ${publicReference}. The safety team will send updates through your configured notification channel.`;
285
+ const directEmail = Boolean(submission.reporter.email && options.emailEnabled);
286
+ const webhookNotification = Boolean(!directEmail && options.notificationEnabled
287
+ && (submission.reporter.email || submission.reporter.reference));
288
+ const deliveryJobs: DeliveryJob[] = directEmail
289
+ ? [{ version: 1, jobId: `email:${receiptDeliveryId}`, type: "deliver_email", outboxId: receiptDeliveryId }]
290
+ : webhookNotification
291
+ ? [{ version: 1, jobId: `notification:${receiptDeliveryId}`, type: "deliver_notification", outboxId: receiptDeliveryId }]
292
+ : [];
293
+ const receiptDeliveryState = deliveryJobs.length ? "queued" : "suppressed";
294
+ const detailsJson = canonicalJson({
295
+ source: submission.source,
296
+ formVersionId: submission.formVersionId,
297
+ reasonCode: submission.reasonCode,
298
+ targetType: submission.target.type,
299
+ evidenceMode: submission.evidence.mode,
300
+ intakeDecision: intakePlan.disposition,
301
+ duplicateGroupId: intakePlan.duplicateGroupId,
302
+ });
303
+ const auditAction = intakePlan.disposition === "quarantine"
304
+ ? "report.quarantined" : intakePlan.disposition === "group" ? "report.grouped" : "report.accepted";
305
+
306
+ for (let auditAttempt = 0; auditAttempt < 3; auditAttempt += 1) {
307
+ const tip = await auditTip(db);
308
+ const auditSequence = tip.sequence + 1;
309
+ const auditId = crypto.randomUUID();
310
+ const auditHash = await hashAuditEntry({
311
+ sequence: auditSequence,
312
+ action: auditAction,
313
+ actorType: "integration",
314
+ actorId: submission.integrationId,
315
+ targetType: "report",
316
+ targetId: reportId,
317
+ detailsJson,
318
+ previousHash: tip.entry_hash,
319
+ createdAt: receivedAt,
320
+ });
321
+
322
+ const statements: D1PreparedStatement[] = [
323
+ db.prepare(`
324
+ INSERT INTO duplicate_groups (
325
+ id, installation_id, fingerprint_version, fingerprint,
326
+ target_type, target_reference_hash, reason_family, state,
327
+ canonical_report_id, member_count, first_seen_at, last_seen_at,
328
+ created_at, updated_at
329
+ ) VALUES (?1, 'default', 'v1', ?2, ?3, ?4, ?5, 'active', NULL, 0, ?6, ?6, ?6, ?6)
330
+ ON CONFLICT(installation_id, fingerprint_version, fingerprint) DO UPDATE SET
331
+ last_seen_at = excluded.last_seen_at, updated_at = excluded.updated_at
332
+ `).bind(
333
+ intakePlan.duplicateGroupId, intakePlan.campaignFingerprint,
334
+ submission.target.type, intakePlan.targetKeyHash, submission.reasonCode, receivedAt,
335
+ ),
336
+ db.prepare(`
337
+ INSERT INTO reports (
338
+ id, public_reference, installation_id, integration_id, idempotency_key, source,
339
+ form_version_id, policy_version_id, state, priority, reason_code, locale,
340
+ consent_notice, consented_at, submitted_at, received_at, updated_at, routing_status,
341
+ duplicate_group_id, quarantine_reason_code
342
+ ) VALUES (?1, ?2, 'default', ?3, ?4, ?5, ?6, 'general-policy-v1', 'received', 50, ?7, ?8, ?9, ?10, ?11, ?12, ?12, ?13, ?14, ?15)
343
+ `).bind(
344
+ reportId, publicReference, submission.integrationId, submission.idempotencyKey, submission.source,
345
+ submission.formVersionId, submission.reasonCode, submission.locale, submission.consentNotice,
346
+ submission.consentedAt, submission.submittedAt, receivedAt,
347
+ intakePlan.route ? "pending" : "complete", intakePlan.duplicateGroupId,
348
+ intakePlan.disposition === "quarantine" ? intakePlan.reasonCodes[0] : null,
349
+ ),
350
+ db.prepare(`
351
+ INSERT INTO report_participants (id, report_id, audience, reference, contact_mode, contact_ciphertext, created_at)
352
+ VALUES (?1, ?2, 'reporter', ?3, ?4, ?5, ?6)
353
+ `).bind(reporterParticipantId, reportId, submission.reporter.reference ?? null, submission.reporter.contactMode, contactCiphertext, receivedAt),
354
+ db.prepare(`
355
+ INSERT INTO report_targets (
356
+ id, report_id, target_type, target_reference, owner_reference, customer_url,
357
+ signed_facts_json, routing_facts_json, created_at
358
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
359
+ `).bind(
360
+ crypto.randomUUID(), reportId, submission.target.type, submission.target.reference,
361
+ submission.target.ownerReference ?? null, submission.target.customerUrl ?? null,
362
+ canonicalJson(submission.trustedFacts), canonicalJson(submission.routingFacts), receivedAt,
363
+ ),
364
+ db.prepare(`
365
+ INSERT INTO report_state_history (id, report_id, from_state, to_state, reason_code, actor_type, actor_id, created_at)
366
+ VALUES (?1, ?2, NULL, 'received', ?3, 'integration', ?4, ?5)
367
+ `).bind(
368
+ crypto.randomUUID(), reportId,
369
+ intakePlan.disposition === "quarantine" ? "intake_quarantined" : intakePlan.disposition === "group" ? "intake_grouped" : "durable_intake",
370
+ submission.integrationId, receivedAt,
371
+ ),
372
+ db.prepare(`
373
+ INSERT INTO background_jobs (
374
+ id, report_id, job_type, idempotency_key, state, payload_json, available_at, created_at, updated_at
375
+ ) VALUES (?1, ?2, 'route_report', ?1, ?3, ?4, ?5, ?5, ?5)
376
+ `).bind(
377
+ jobId, reportId, intakePlan.route ? "pending" : "complete",
378
+ canonicalJson({ reportId, intakeDecisionId: intakePlan.decisionId, disposition: intakePlan.disposition }), receivedAt,
379
+ ),
380
+ db.prepare(`
381
+ INSERT INTO intake_decisions (
382
+ id, installation_id, integration_id, report_id, request_id,
383
+ decision, reason_codes_json, source, network_key_hash,
384
+ contact_key_hash, target_key_hash, campaign_fingerprint,
385
+ turnstile_outcome, rate_summary_json, created_at
386
+ ) VALUES (?1, 'default', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
387
+ `).bind(
388
+ intakePlan.decisionId, submission.integrationId, reportId, intakePlan.requestId,
389
+ intakePlan.disposition, canonicalJson(intakePlan.reasonCodes), submission.source,
390
+ intakePlan.networkKeyHash, intakePlan.contactKeyHash, intakePlan.targetKeyHash,
391
+ intakePlan.campaignFingerprint, options.turnstileOutcome ?? null,
392
+ canonicalJson(intakePlan.rateSummary), receivedAt,
393
+ ),
394
+ db.prepare(`UPDATE reports SET intake_decision_id = ?2 WHERE id = ?1`).bind(reportId, intakePlan.decisionId),
395
+ db.prepare(`
396
+ INSERT INTO duplicate_group_members (group_id, report_id, similarity_basis_json, added_at)
397
+ VALUES (?1, ?2, ?3, ?4)
398
+ `).bind(
399
+ intakePlan.duplicateGroupId, reportId,
400
+ canonicalJson({ fingerprint_version: "v1", exact_fingerprint: true, target_hash: intakePlan.targetKeyHash }), receivedAt,
401
+ ),
402
+ db.prepare(`
403
+ UPDATE duplicate_groups SET canonical_report_id = COALESCE(canonical_report_id, ?2),
404
+ member_count = (SELECT COUNT(*) FROM duplicate_group_members WHERE group_id = ?1 AND removed_at IS NULL),
405
+ last_seen_at = ?3, updated_at = ?3 WHERE id = ?1
406
+ `).bind(intakePlan.duplicateGroupId, reportId, receivedAt),
407
+ db.prepare(`
408
+ INSERT INTO audit_entries (
409
+ sequence, id, action, actor_type, actor_id, target_type, target_id, details_json,
410
+ previous_hash, entry_hash, created_at
411
+ ) VALUES (?1, ?2, ?3, 'integration', ?4, 'report', ?5, ?6, ?7, ?8, ?9)
412
+ `).bind(auditSequence, auditId, auditAction, submission.integrationId, reportId, detailsJson, tip.entry_hash, auditHash, receivedAt),
413
+ db.prepare(`
414
+ INSERT INTO case_messages (
415
+ id, report_id, audience, direction, sender_type, sender_id, body, template_version_id,
416
+ automated, delivery_state, created_at, idempotency_key
417
+ ) VALUES (?1, ?2, 'reporter', 'outbound', 'system', 'intake', ?3, 'report-received-v1', 1, ?4, ?5, ?6)
418
+ `).bind(receiptMessageId, reportId, receiptBody, receiptDeliveryState, receivedAt, `report-received:${reportId}`),
419
+ db.prepare(`
420
+ INSERT INTO notices (
421
+ id, report_id, audience, template_version_id, rendered_body, delivery_state,
422
+ created_at, updated_at, idempotency_key, message_id
423
+ ) VALUES (?1, ?2, 'reporter', 'report-received-v1', ?3, ?4, ?5, ?5, ?6, ?7)
424
+ `).bind(receiptNoticeId, reportId, receiptBody, receiptDeliveryState, receivedAt, `report-received:${reportId}`, receiptMessageId),
425
+ ];
426
+
427
+ if (directEmail && submission.reporter.email) {
428
+ statements.push(db.prepare(`
429
+ INSERT INTO email_deliveries (
430
+ id, installation_id, message_id, audience, recipient_hash,
431
+ provider, state, attempt_count, next_attempt_at, created_at, updated_at
432
+ ) VALUES (?1, 'default', ?2, 'reporter', ?3, 'cloudflare_email_service', 'pending', 0, ?4, ?4, ?4)
433
+ `).bind(
434
+ receiptDeliveryId, receiptMessageId,
435
+ `sha256:${await sha256(`email-recipient-v1:${options.abusePepper}:${submission.reporter.email}`)}`,
436
+ receivedAt,
437
+ ));
438
+ } else if (webhookNotification) {
439
+ statements.push(db.prepare(`
440
+ INSERT INTO notification_outbox (
441
+ id, notice_id, idempotency_key, channel, payload_json, status,
442
+ next_attempt_at, created_at, updated_at
443
+ ) VALUES (?1, ?2, ?3, 'customer_webhook', ?4, 'pending', ?5, ?5, ?5)
444
+ `).bind(receiptDeliveryId, receiptNoticeId, `notice:${receiptNoticeId}`, canonicalJson({
445
+ version: 1,
446
+ event: "report.received",
447
+ notification_id: receiptDeliveryId,
448
+ report_reference: publicReference,
449
+ ...(submission.reporter.reference ? { recipient_reference: submission.reporter.reference } : {}),
450
+ audience: "reporter",
451
+ message: receiptBody,
452
+ }), receivedAt));
453
+ }
454
+
455
+ if (intakePlan.activateSurge) {
456
+ statements.push(db.prepare(`
457
+ INSERT INTO abuse_surge_states (
458
+ installation_id, state, reason_codes_json, activated_at,
459
+ recover_after, revision, updated_by, updated_at
460
+ ) VALUES ('default', 'surge', '["public_intake_velocity"]', ?1, ?2, 1, 'system:intake-guard', ?1)
461
+ ON CONFLICT(installation_id) DO UPDATE SET state = 'surge',
462
+ reason_codes_json = excluded.reason_codes_json,
463
+ activated_at = COALESCE(abuse_surge_states.activated_at, excluded.activated_at),
464
+ recover_after = excluded.recover_after, revision = abuse_surge_states.revision + 1,
465
+ updated_by = excluded.updated_by, updated_at = excluded.updated_at
466
+ `).bind(receivedAt, addMinutes(receivedAt, 15)));
467
+ }
468
+
469
+ if (submission.contextJti) {
470
+ statements.push(db.prepare(`
471
+ INSERT INTO context_token_uses (jti, integration_id, report_id, used_at)
472
+ VALUES (?1, ?2, ?3, ?4)
473
+ `).bind(submission.contextJti, submission.integrationId, reportId, receivedAt));
474
+ }
475
+ if (submission.contextId) {
476
+ statements.push(db.prepare(`
477
+ UPDATE report_contexts SET used_at = ?2, report_id = ?3
478
+ WHERE id = ?1 AND integration_id = ?4 AND used_at IS NULL
479
+ `).bind(submission.contextId, receivedAt, reportId, submission.integrationId));
480
+ }
481
+ for (const [key, value] of Object.entries(submission.answers)) {
482
+ statements.push(db.prepare(`
483
+ INSERT INTO report_answers (report_id, field_key, value_json, created_at)
484
+ VALUES (?1, ?2, ?3, ?4)
485
+ `).bind(reportId, key, canonicalJson(value), receivedAt));
486
+ }
487
+ const evidenceReferences = new Set([submission.target.reference, ...submission.evidence.references]);
488
+ for (const reference of evidenceReferences) {
489
+ statements.push(db.prepare(`
490
+ INSERT INTO evidence_references (id, report_id, evidence_kind, reference, created_at)
491
+ VALUES (?1, ?2, ?3, ?4, ?5)
492
+ `).bind(
493
+ crypto.randomUUID(), reportId, submission.source === "public_page" ? "allegation" : "customer_reference",
494
+ reference, receivedAt,
495
+ ));
496
+ }
497
+
498
+ try {
499
+ await db.batch(statements);
500
+ const row: ExistingReportRow = { id: reportId, public_reference: publicReference, state: "received", received_at: receivedAt };
501
+ return { receipt: receipt(row, false), jobId, deliveryJobs, created: true, route: intakePlan.route };
502
+ } catch (error) {
503
+ if (!isConstraintError(error)) throw error;
504
+ const concurrent = await loadExisting(db, submission.integrationId, submission.idempotencyKey);
505
+ if (concurrent) {
506
+ return { receipt: receipt(concurrent, true), jobId: `route:${concurrent.id}`, deliveryJobs: [], created: false, route: false };
507
+ }
508
+ if (submission.contextJti) {
509
+ const used = await db.prepare(`SELECT report_id FROM context_token_uses WHERE jti = ?1 LIMIT 1`)
510
+ .bind(submission.contextJti).first<{ report_id: string }>();
511
+ if (used) throw new ApiError(409, "context_token_replayed", "This signed context token has already been used.");
512
+ }
513
+ if (auditAttempt === 2) throw new ApiError(503, "audit_contention", "The report could not be accepted yet. Retry with the same idempotency key.");
514
+ }
515
+ }
516
+ throw new ApiError(503, "intake_unavailable", "The report could not be accepted yet. Retry with the same idempotency key.");
517
+ }
518
+
519
+ export async function markJobEnqueued(db: D1Database, jobId: string): Promise<void> {
520
+ await db.prepare(`
521
+ UPDATE background_jobs SET state = 'enqueued', last_error_code = NULL, updated_at = ?2
522
+ WHERE id = ?1 AND state IN ('pending', 'failed')
523
+ `).bind(jobId, isoNow()).run();
524
+ await db.prepare(`
525
+ UPDATE reports SET routing_status = 'enqueued', routing_error_code = NULL, updated_at = ?2
526
+ WHERE id = (SELECT report_id FROM background_jobs WHERE id = ?1) AND routing_status IN ('pending', 'failed')
527
+ `).bind(jobId, isoNow()).run();
528
+ }
529
+
530
+ export async function markJobEnqueueFailed(db: D1Database, jobId: string, errorCode: string): Promise<void> {
531
+ const now = isoNow();
532
+ await db.batch([
533
+ db.prepare(`
534
+ UPDATE background_jobs
535
+ SET state = 'failed', attempt_count = attempt_count + 1, last_error_code = ?2, updated_at = ?3
536
+ WHERE id = ?1 AND state != 'complete'
537
+ `).bind(jobId, errorCode.slice(0, 100), now),
538
+ db.prepare(`
539
+ UPDATE reports
540
+ SET routing_status = 'failed', routing_attempts = routing_attempts + 1, routing_error_code = ?2, updated_at = ?3
541
+ WHERE id = (SELECT report_id FROM background_jobs WHERE id = ?1) AND routing_status != 'complete'
542
+ `).bind(jobId, errorCode.slice(0, 100), now),
543
+ ]);
544
+ }
545
+
546
+ export async function pendingRoutingJobs(db: D1Database, limit = 50): Promise<Array<{ id: string; reportId: string }>> {
547
+ const staleBefore = new Date(Date.now() - 5 * 60_000).toISOString();
548
+ const result = await db.prepare(`
549
+ SELECT id, report_id AS reportId
550
+ FROM background_jobs
551
+ WHERE job_type = 'route_report'
552
+ AND (state IN ('pending', 'failed') OR (state = 'processing' AND updated_at <= ?3))
553
+ AND available_at <= ?1
554
+ ORDER BY available_at ASC, id ASC
555
+ LIMIT ?2
556
+ `).bind(isoNow(), Math.max(1, Math.min(100, limit)), staleBefore).all<{ id: string; reportId: string }>();
557
+ return result.results;
558
+ }
559
+
560
+ function parsedRoutingPolicy(row: QueueRoutingRow): QueueRoutingPolicy | null {
561
+ try {
562
+ const aiBounds: unknown = JSON.parse(row.aiBoundsJson);
563
+ return {
564
+ queueId: row.queueId,
565
+ queueVersionId: row.queueVersionId,
566
+ managementMode: row.managementMode,
567
+ rolloutMode: row.rolloutMode,
568
+ priority: row.priority,
569
+ responseSlaMinutes: row.responseSlaMinutes,
570
+ fallbackQueueId: row.fallbackQueueId,
571
+ position: row.position,
572
+ routingCriteria: { match: "all", conditions: [] },
573
+ adjudicationPolicyVersionId: aiBounds && typeof aiBounds === "object" && !Array.isArray(aiBounds)
574
+ && typeof (aiBounds as Record<string, unknown>).policyVersionId === "string"
575
+ ? String((aiBounds as Record<string, unknown>).policyVersionId)
576
+ : "general-policy-v1",
577
+ };
578
+ } catch {
579
+ return null;
580
+ }
581
+ }
582
+
583
+ async function activeQueueRoutingPolicies(db: D1Database): Promise<QueueRoutingPolicy[]> {
584
+ const rows = await db.prepare(`
585
+ SELECT q.id AS queueId, v.id AS queueVersionId, v.management_mode AS managementMode,
586
+ v.mode AS rolloutMode, v.priority, v.response_sla_minutes AS responseSlaMinutes,
587
+ v.fallback_queue_id AS fallbackQueueId, q.position, v.ai_bounds_json AS aiBoundsJson
588
+ FROM queue_definitions q JOIN queue_versions v ON v.id = q.active_version_id
589
+ WHERE q.purpose = 'reports' AND q.status = 'active'
590
+ ORDER BY q.position, q.id
591
+ `).all<QueueRoutingRow>();
592
+ return rows.results.map(parsedRoutingPolicy).filter((queue): queue is QueueRoutingPolicy => Boolean(queue));
593
+ }
594
+
595
+ async function activePublishedRoutingRules(db: D1Database): Promise<PublishedRoutingRuleSet> {
596
+ const row = await db.prepare(`
597
+ SELECT id, rules_json AS rulesJson FROM routing_rule_versions
598
+ WHERE installation_id = 'default' AND published_at IS NOT NULL
599
+ ORDER BY version DESC, id DESC LIMIT 1
600
+ `).first<{ id: string; rulesJson: string }>();
601
+ if (!row) throw new ApiError(503, "published_routing_rules_missing", "A published routing rule version is required before reports can be routed.");
602
+ try {
603
+ const content = JSON.parse(row.rulesJson) as Record<string, unknown>;
604
+ if (!content || typeof content !== "object" || Array.isArray(content)
605
+ || typeof content.default_queue_id !== "string" || typeof content.safeguard_queue_id !== "string"
606
+ || !Array.isArray(content.rules)) throw new Error("invalid");
607
+ const rules = content.rules.map((value): PublishedRoutingRule => {
608
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid");
609
+ const rule = value as Record<string, unknown>;
610
+ if (typeof rule.id !== "string" || typeof rule.queue_id !== "string" || !Number.isInteger(rule.priority)
611
+ || (rule.match !== "all" && rule.match !== "any") || !Array.isArray(rule.conditions)) throw new Error("invalid");
612
+ return {
613
+ id: rule.id,
614
+ queueId: rule.queue_id,
615
+ priority: Number(rule.priority),
616
+ routingCriteria: { match: rule.match, conditions: rule.conditions as RoutingCondition[] },
617
+ };
618
+ }).sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id));
619
+ const intakeEnrichments: Array<{ componentVersionId: string; inputMapping: Record<string, JsonValue> }> = [];
620
+ if (Array.isArray(content.intake_enrichments)) {
621
+ for (const value of content.intake_enrichments) {
622
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid");
623
+ const entry = value as Record<string, unknown>;
624
+ if (typeof entry.component_version_id !== "string" || !entry.input_mapping || typeof entry.input_mapping !== "object" || Array.isArray(entry.input_mapping)) throw new Error("invalid");
625
+ intakeEnrichments.push({ componentVersionId: entry.component_version_id, inputMapping: entry.input_mapping as Record<string, JsonValue> });
626
+ }
627
+ }
628
+ if (intakeEnrichments.length > 5 || new Set(intakeEnrichments.map((entry) => entry.componentVersionId)).size !== intakeEnrichments.length) throw new Error("invalid");
629
+ return {
630
+ id: row.id,
631
+ defaultQueueId: content.default_queue_id,
632
+ safeguardQueueId: content.safeguard_queue_id,
633
+ intakeEnrichments,
634
+ rules,
635
+ };
636
+ } catch {
637
+ throw new ApiError(503, "published_routing_rules_invalid", "The published routing rule version cannot be evaluated safely.");
638
+ }
639
+ }
640
+
641
+ export async function processRoutingJob(
642
+ db: D1Database,
643
+ jobId: string,
644
+ ai?: RoutingAiBinding,
645
+ intakeExecutor?: RoutingIntakeEnrichmentExecutor,
646
+ ): Promise<Array<AiJob | WorkflowDispatchJob>> {
647
+ const job = await db.prepare(`
648
+ SELECT j.id, j.report_id, j.state, r.reason_code, r.source, r.form_version_id,
649
+ r.installation_id, r.public_reference,
650
+ t.target_type, t.target_reference, t.owner_reference, t.customer_url,
651
+ t.routing_facts_json AS trusted_facts_json, r.state AS report_state
652
+ FROM background_jobs j
653
+ JOIN reports r ON r.id = j.report_id
654
+ JOIN report_targets t ON t.report_id = r.id
655
+ WHERE j.id = ?1 AND j.job_type = 'route_report'
656
+ LIMIT 1
657
+ `).bind(jobId).first<RoutingJobRow>();
658
+ if (!job) throw new ApiError(404, "job_not_found", "The routing job does not exist.");
659
+ if (job.state === "complete") return [];
660
+ if (job.report_state !== "received") {
661
+ await db.prepare(`UPDATE background_jobs SET state = 'complete', updated_at = ?2 WHERE id = ?1`).bind(jobId, isoNow()).run();
662
+ return [];
663
+ }
664
+ const claimedAt = isoNow();
665
+ const staleBefore = new Date(Date.now() - 5 * 60_000).toISOString();
666
+ const claim = await db.prepare(`
667
+ UPDATE background_jobs SET state = 'processing', attempt_count = attempt_count + 1,
668
+ last_error_code = NULL, updated_at = ?2
669
+ WHERE id = ?1 AND job_type = 'route_report'
670
+ AND (state IN ('pending','enqueued','failed') OR (state = 'processing' AND updated_at <= ?3))
671
+ `).bind(jobId, claimedAt, staleBefore).run();
672
+ if ((claim.meta.changes ?? 0) !== 1) return [];
673
+ await db.prepare(`UPDATE reports SET routing_status = 'processing', routing_error_code = NULL, updated_at = ?2 WHERE id = ?1 AND state = 'received'`)
674
+ .bind(job.report_id, claimedAt).run();
675
+ let trustedFacts: Record<string, string | number | boolean | null> = {};
676
+ try {
677
+ const parsed: unknown = JSON.parse(job.trusted_facts_json);
678
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) trustedFacts = parsed as typeof trustedFacts;
679
+ } catch {
680
+ trustedFacts = { trusted_facts_conflict: true };
681
+ }
682
+ const answersResult = await db.prepare(`SELECT field_key, value_json FROM report_answers WHERE report_id = ?1`)
683
+ .bind(job.report_id).all<{ field_key: string; value_json: string }>();
684
+ const answers: Record<string, unknown> = {};
685
+ for (const row of answersResult.results) {
686
+ try { answers[row.field_key] = JSON.parse(row.value_json) as unknown; } catch { answers[row.field_key] = null; }
687
+ }
688
+ const routingInput: RoutingInput = {
689
+ reasonCode: job.reason_code,
690
+ source: job.source,
691
+ targetType: job.target_type,
692
+ trustedFacts,
693
+ answers,
694
+ priority: 50,
695
+ };
696
+ const queues = await activeQueueRoutingPolicies(db);
697
+ const routingRules = await activePublishedRoutingRules(db);
698
+ const routingAgent = await activeRoutingAgent(db);
699
+ const defaultQueueId = routingRules.defaultQueueId;
700
+ const safeguardQueueId = routingRules.safeguardQueueId;
701
+ if (!defaultQueueId || !safeguardQueueId) {
702
+ throw new ApiError(503, "routing_human_fallback_missing", "Routing requires active default and safeguard human queues.");
703
+ }
704
+ const safeguard = evaluateHardSafeguards(routingInput);
705
+ const byQueueId = new Map(queues.map((queue) => [queue.queueId, queue]));
706
+ let matchedRules = routingRules.rules.filter((rule) => {
707
+ const queue = byQueueId.get(rule.queueId);
708
+ return queue ? queueMatches(routingInput, { ...queue, routingCriteria: rule.routingCriteria }, safeguard) : false;
709
+ });
710
+ let intakeEnrichmentFailures: string[] = [];
711
+ if (!safeguard.requiresHuman && matchedRules.length === 0 && routingRules.intakeEnrichments.length) {
712
+ if (intakeExecutor) {
713
+ const context = JSON.parse(canonicalJson({
714
+ report: {
715
+ id: job.report_id, public_reference: job.public_reference, reason_code: job.reason_code,
716
+ source: job.source, target_type: job.target_type, priority: 50,
717
+ },
718
+ target: {
719
+ type: job.target_type, reference: job.target_reference,
720
+ owner_reference: job.owner_reference, customer_url: job.customer_url,
721
+ signed_facts: trustedFacts,
722
+ },
723
+ answers,
724
+ trusted_facts: trustedFacts,
725
+ })) as JsonObject;
726
+ const settled = await Promise.allSettled(routingRules.intakeEnrichments.map((entry) => intakeExecutor({
727
+ reportId: job.report_id,
728
+ componentVersionId: entry.componentVersionId,
729
+ inputMapping: entry.inputMapping,
730
+ context,
731
+ operationId: `${jobId}:intake:${entry.componentVersionId}`,
732
+ })));
733
+ const enrichments: Record<string, unknown> = {};
734
+ settled.forEach((result, index) => {
735
+ const componentVersionId = routingRules.intakeEnrichments[index]!.componentVersionId;
736
+ if (result.status === "fulfilled") enrichments[result.value.componentKey] = result.value.data;
737
+ else intakeEnrichmentFailures.push(componentVersionId);
738
+ });
739
+ routingInput.enrichments = enrichments;
740
+ matchedRules = routingRules.rules.filter((rule) => {
741
+ const queue = byQueueId.get(rule.queueId);
742
+ return queue ? queueMatches(routingInput, { ...queue, routingCriteria: rule.routingCriteria }, safeguard) : false;
743
+ });
744
+ } else {
745
+ intakeEnrichmentFailures = routingRules.intakeEnrichments.map((entry) => entry.componentVersionId);
746
+ }
747
+ }
748
+ const deterministicQueueId = matchedRules[0]?.queueId ?? null;
749
+ const agentDecision = routingAgent && !safeguard.requiresHuman && !deterministicQueueId
750
+ ? await executeRoutingAgent(db, ai, routingAgent, job.report_id, jobId, routingInput, queues)
751
+ : { queueId: null, accepted: false };
752
+ let route = selectRoute(routingInput, queues, {
753
+ defaultQueueId,
754
+ safeguardQueueId,
755
+ deterministicQueueId,
756
+ agentQueueId: agentDecision.queueId,
757
+ agentAccepted: agentDecision.accepted,
758
+ });
759
+ if (!route) throw new ApiError(503, "routing_policy_unavailable", "No safe active queue policy can receive this report.");
760
+ let aiConfiguration = await aiConfigurationForRouting(db, route.queueId, route.queueVersionId);
761
+ if (route.state === "ai_review" && !aiConfiguration) {
762
+ const selectedQueue = queues.find((queue) => queue.queueId === route!.queueId);
763
+ const fallback = queues.find((queue) => queue.queueId === selectedQueue?.fallbackQueueId && queue.managementMode === "human")
764
+ ?? queues.find((queue) => queue.queueId === defaultQueueId && queue.managementMode === "human");
765
+ if (!fallback) throw new ApiError(503, "routing_ai_fallback_missing", "The selected AI queue has no available human fallback.");
766
+ route = {
767
+ queueId: fallback.queueId,
768
+ queueVersionId: fallback.queueVersionId,
769
+ state: "human_review",
770
+ priority: Math.max(60, fallback.priority),
771
+ reasonCode: "ai_runtime_unavailable_human_fallback",
772
+ responseSlaMinutes: fallback.responseSlaMinutes,
773
+ safeguardReasons: [],
774
+ };
775
+ }
776
+ const now = isoNow();
777
+ const aiJobId = aiConfiguration && (route.state === "ai_review" || aiConfiguration.mode === "shadow")
778
+ ? `ai:${job.report_id}:${aiConfiguration.id}`
779
+ : null;
780
+ const routedQueue = queues.find((queue) => queue.queueId === route.queueId);
781
+ const workflowRuns = await preparePinnedWorkflowRunsForRouting(db, {
782
+ reportId: job.report_id,
783
+ installationId: job.installation_id,
784
+ queueId: route.queueId,
785
+ queueVersionId: route.queueVersionId,
786
+ createdAt: now,
787
+ });
788
+ const statements = [
789
+ db.prepare(`
790
+ UPDATE reports
791
+ SET queue_id = ?2, queue_version_id = ?3, policy_version_id = ?4, state = ?5, priority = ?6, due_at = ?7,
792
+ routing_rule_version_id = ?8, routing_agent_version_id = ?9, routing_reason_json = ?10,
793
+ routing_status = 'complete', routing_error_code = NULL, updated_at = ?11
794
+ WHERE id = ?1 AND state = 'received'
795
+ `).bind(
796
+ job.report_id, route.queueId, route.queueVersionId, routedQueue?.adjudicationPolicyVersionId ?? "general-policy-v1", route.state, route.priority,
797
+ route.responseSlaMinutes === null ? null : addMinutes(now, route.responseSlaMinutes),
798
+ routingRules.id,
799
+ routingAgent?.id ?? null,
800
+ canonicalJson({ reasonCode: route.reasonCode, safeguards: route.safeguardReasons, matchedRuleIds: matchedRules.map((rule) => rule.id), intakeEnrichmentFailures, routingAgentAccepted: agentDecision.accepted }),
801
+ now,
802
+ ),
803
+ db.prepare(`
804
+ INSERT OR IGNORE INTO report_state_history (
805
+ id, report_id, background_job_id, from_state, to_state, reason_code, actor_type, actor_id, created_at
806
+ ) VALUES (?1, ?2, ?3, 'received', ?4, ?5, 'system', ?6, ?7)
807
+ `).bind(crypto.randomUUID(), job.report_id, jobId, route.state, route.reasonCode, routingAgent?.id ?? "queue-policy-router-v2", now),
808
+ db.prepare(`
809
+ INSERT OR IGNORE INTO report_queue_assignments (
810
+ id, report_id, queue_id, queue_version_id, routing_rule_version_id, routing_agent_version_id,
811
+ assignment_kind, reason_json, assigned_by_type, assigned_by_id, assigned_at
812
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'initial', ?7, 'system', ?8, ?9)
813
+ `).bind(
814
+ crypto.randomUUID(), job.report_id, route.queueId, route.queueVersionId,
815
+ routingRules.id, routingAgent?.id ?? null,
816
+ canonicalJson({ reasonCode: route.reasonCode, safeguards: route.safeguardReasons, matchedRuleIds: matchedRules.map((rule) => rule.id), intakeEnrichmentFailures, routingAgentAccepted: agentDecision.accepted }),
817
+ routingAgent?.id ?? routingRules.id, now,
818
+ ),
819
+ db.prepare(`
820
+ UPDATE background_jobs
821
+ SET state = 'complete', last_error_code = NULL, updated_at = ?2
822
+ WHERE id = ?1
823
+ `).bind(jobId, now),
824
+ ...workflowRuns.statements,
825
+ ];
826
+ if (aiJobId && aiConfiguration) {
827
+ statements.push(db.prepare(`
828
+ INSERT OR IGNORE INTO background_jobs (
829
+ id, report_id, job_type, idempotency_key, state, payload_json, available_at,
830
+ created_at, updated_at, ai_config_version_id
831
+ ) VALUES (?1, ?2, 'resume_ai', ?1, 'pending', ?3, ?4, ?4, ?4, ?5)
832
+ `).bind(
833
+ aiJobId, job.report_id,
834
+ canonicalJson({ version: 1, reportId: job.report_id, configVersionId: aiConfiguration.id }),
835
+ now, aiConfiguration.id,
836
+ ));
837
+ }
838
+ await db.batch(statements);
839
+ return [
840
+ ...workflowRuns.jobs,
841
+ ...(aiJobId
842
+ ? [{ version: 1, jobId: aiJobId, type: "process_ai_report", reportId: job.report_id } satisfies AiJob]
843
+ : []),
844
+ ];
845
+ }
846
+
847
+ export type InboxCursor = {
848
+ priority: number;
849
+ receivedAt: string;
850
+ id: string;
851
+ };
852
+
853
+ export async function listInboxReports(
854
+ db: D1Database,
855
+ options: {
856
+ queueId?: string;
857
+ allowedQueueIds?: string[] | null;
858
+ state?: ReportState;
859
+ cursor?: InboxCursor | null;
860
+ limit: number;
861
+ },
862
+ ): Promise<{ reports: InboxReport[]; nextPosition: InboxCursor | null }> {
863
+ const clauses = ["1 = 1"];
864
+ const bindings: unknown[] = [];
865
+ if (options.queueId) {
866
+ bindings.push(options.queueId);
867
+ clauses.push(`r.queue_id = ?${bindings.length}`);
868
+ }
869
+ if (options.allowedQueueIds !== undefined && options.allowedQueueIds !== null) {
870
+ if (!options.allowedQueueIds.length) return { reports: [], nextPosition: null };
871
+ const placeholders = options.allowedQueueIds.map((queueId) => {
872
+ bindings.push(queueId);
873
+ return `?${bindings.length}`;
874
+ });
875
+ clauses.push(`r.queue_id IN (${placeholders.join(",")})`);
876
+ }
877
+ if (options.state) {
878
+ bindings.push(options.state);
879
+ clauses.push(`r.state = ?${bindings.length}`);
880
+ }
881
+ if (options.cursor) {
882
+ bindings.push(options.cursor.priority);
883
+ const priorityBinding = bindings.length;
884
+ bindings.push(options.cursor.receivedAt);
885
+ const receivedAtBinding = bindings.length;
886
+ bindings.push(options.cursor.id);
887
+ const idBinding = bindings.length;
888
+ clauses.push(`(
889
+ r.priority < ?${priorityBinding}
890
+ OR (r.priority = ?${priorityBinding} AND r.received_at > ?${receivedAtBinding})
891
+ OR (r.priority = ?${priorityBinding} AND r.received_at = ?${receivedAtBinding} AND r.id > ?${idBinding})
892
+ )`);
893
+ }
894
+ bindings.push(isoNow());
895
+ const atBinding = bindings.length;
896
+ const limit = Math.max(1, Math.min(100, options.limit));
897
+ bindings.push(limit + 1);
898
+ const limitBinding = bindings.length;
899
+ const result = await db.prepare(`
900
+ SELECT r.id, r.public_reference AS reference, r.state, r.priority, r.reason_code AS reasonCode,
901
+ r.queue_id AS queueId, t.target_type AS targetType, t.target_reference AS targetReference,
902
+ r.submitted_at AS submittedAt, r.received_at AS receivedAt, r.updated_at AS updatedAt, r.due_at AS dueAt,
903
+ c.reviewer_id AS claimReviewerId, c.claimed_at AS claimedAt, c.expires_at AS claimExpiresAt,
904
+ p.display_name AS analystDisplayName, p.avatar_url AS analystAvatarUrl,
905
+ p.avatar_color AS analystAvatarColor
906
+ FROM reports r
907
+ JOIN report_targets t ON t.report_id = r.id
908
+ LEFT JOIN review_claims c ON c.report_id = r.id AND c.expires_at > ?${atBinding}
909
+ LEFT JOIN analyst_profiles p ON p.actor_id = c.reviewer_id
910
+ WHERE ${clauses.join(" AND ")}
911
+ ORDER BY r.priority DESC, r.received_at ASC, r.id ASC
912
+ LIMIT ?${limitBinding}
913
+ `).bind(...bindings).all<InboxReportRow>();
914
+ const reports = result.results.slice(0, limit).map((row): InboxReport => ({
915
+ id: row.id,
916
+ reference: row.reference,
917
+ state: row.state,
918
+ priority: row.priority,
919
+ reasonCode: row.reasonCode,
920
+ queueId: row.queueId,
921
+ targetType: row.targetType,
922
+ targetReference: row.targetReference,
923
+ submittedAt: row.submittedAt,
924
+ receivedAt: row.receivedAt,
925
+ updatedAt: row.updatedAt,
926
+ dueAt: row.dueAt,
927
+ claim: row.claimReviewerId && row.claimedAt && row.claimExpiresAt ? {
928
+ reviewerId: row.claimReviewerId,
929
+ claimedAt: row.claimedAt,
930
+ expiresAt: row.claimExpiresAt,
931
+ analyst: {
932
+ displayName: row.analystDisplayName ?? row.claimReviewerId,
933
+ avatarUrl: row.analystAvatarUrl,
934
+ avatarColor: row.analystAvatarColor ?? analystAvatarColor(row.claimReviewerId),
935
+ },
936
+ } : null,
937
+ }));
938
+ const last = reports.at(-1);
939
+ return {
940
+ reports,
941
+ nextPosition: result.results.length > limit && last
942
+ ? { priority: last.priority, receivedAt: last.receivedAt, id: last.id }
943
+ : null,
944
+ };
945
+ }
946
+
947
+ export async function publicForm(db: D1Database, formVersionId = "general-v1"): Promise<{
948
+ id: string;
949
+ title: string;
950
+ description: string;
951
+ targetTypes: unknown[];
952
+ reasons: unknown[];
953
+ fields: unknown[];
954
+ consentNotice: string;
955
+ } | null> {
956
+ const row = await db.prepare(`
957
+ SELECT id, title, description, target_types_json, reasons_json, fields_json, consent_notice
958
+ FROM report_form_versions
959
+ WHERE id = ?1 AND published_at IS NOT NULL AND retired_at IS NULL
960
+ LIMIT 1
961
+ `).bind(formVersionId).first<FormRow & { title: string; description: string }>();
962
+ if (!row) return null;
963
+ return {
964
+ id: row.id,
965
+ title: row.title,
966
+ description: row.description,
967
+ targetTypes: parseJsonArray(row.target_types_json),
968
+ reasons: parseJsonArray(row.reasons_json),
969
+ fields: parseJsonArray(row.fields_json),
970
+ consentNotice: row.consent_notice,
971
+ };
972
+ }