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,579 @@
1
+ import { canonicalJson } from "./audit";
2
+ import type { DeliveryJob } from "./report-types";
3
+ import { decryptReportContact } from "./report-crypto";
4
+ import { BudgetControlError, reserveBudgets, settleBudgets } from "./budget-control";
5
+
6
+ const MAX_ATTEMPTS = 8;
7
+ const RESPONSE_LIMIT = 16 * 1024;
8
+ const REQUEST_TIMEOUT_MS = 7_500;
9
+ const RETRY_DELAYS = [10, 30, 120, 600, 1_800, 3_600, 7_200, 21_600] as const;
10
+
11
+ interface ActionOutboxRow {
12
+ id: string;
13
+ report_id: string;
14
+ idempotency_key: string;
15
+ payload_json: string;
16
+ status: string;
17
+ attempt_count: number;
18
+ }
19
+
20
+ interface NotificationOutboxRow {
21
+ id: string;
22
+ report_id: string;
23
+ notice_id: string;
24
+ idempotency_key: string;
25
+ payload_json: string;
26
+ status: string;
27
+ attempt_count: number;
28
+ audience: "reporter" | "affected_user";
29
+ recipient_reference: string | null;
30
+ contact_ciphertext: string | null;
31
+ }
32
+
33
+ interface EmailDeliveryRow {
34
+ id: string;
35
+ reportId: string;
36
+ messageId: string;
37
+ audience: "reporter" | "affected_user";
38
+ state: string;
39
+ attemptCount: number;
40
+ recipientReference: string | null;
41
+ contactCiphertext: string | null;
42
+ body: string;
43
+ reportReference: string;
44
+ installationId: string;
45
+ queueId: string | null;
46
+ }
47
+
48
+ export class RetryableDeliveryError extends Error {
49
+ constructor(readonly delaySeconds: number) {
50
+ super("Delivery failed temporarily and was scheduled for retry.");
51
+ this.name = "RetryableDeliveryError";
52
+ }
53
+ }
54
+
55
+ export class DeliveryConfigurationError extends Error {
56
+ constructor(message: string) {
57
+ super(message);
58
+ this.name = "DeliveryConfigurationError";
59
+ }
60
+ }
61
+
62
+ function now(): string {
63
+ return new Date().toISOString();
64
+ }
65
+
66
+ function plusSeconds(timestamp: string, seconds: number): string {
67
+ return new Date(Date.parse(timestamp) + seconds * 1_000).toISOString();
68
+ }
69
+
70
+ function delay(attempt: number): number {
71
+ return RETRY_DELAYS[Math.min(RETRY_DELAYS.length - 1, Math.max(0, attempt - 1))] ?? 21_600;
72
+ }
73
+
74
+ function webhookUrl(raw: string | undefined, variable: string): string {
75
+ try {
76
+ const url = new URL(raw ?? "");
77
+ if (url.protocol !== "https:" || url.username || url.password || url.hash) throw new Error("unsafe webhook URL");
78
+ return url.toString();
79
+ } catch {
80
+ throw new DeliveryConfigurationError(`${variable} is not configured as a safe HTTPS URL.`);
81
+ }
82
+ }
83
+
84
+ async function signature(secret: string, timestamp: string, payload: string): Promise<string> {
85
+ if (new TextEncoder().encode(secret).byteLength < 32) {
86
+ throw new DeliveryConfigurationError("Webhook signing secrets must contain at least 32 bytes.");
87
+ }
88
+ const key = await crypto.subtle.importKey(
89
+ "raw",
90
+ new TextEncoder().encode(secret),
91
+ { name: "HMAC", hash: "SHA-256" },
92
+ false,
93
+ ["sign"],
94
+ );
95
+ const digest = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${timestamp}.${payload}`)));
96
+ return `v1=${[...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
97
+ }
98
+
99
+ export async function boundedResponse(response: Response): Promise<Record<string, unknown> | null> {
100
+ const declared = Number.parseInt(response.headers.get("content-length") ?? "0", 10);
101
+ if (declared > RESPONSE_LIMIT) {
102
+ await response.body?.cancel().catch(() => {});
103
+ return null;
104
+ }
105
+ if (!response.body) return {};
106
+ const reader = response.body.getReader();
107
+ const chunks: Uint8Array[] = [];
108
+ let length = 0;
109
+ while (true) {
110
+ const result = await reader.read();
111
+ if (result.done) break;
112
+ length += result.value.byteLength;
113
+ if (length > RESPONSE_LIMIT) {
114
+ await reader.cancel().catch(() => {});
115
+ return null;
116
+ }
117
+ chunks.push(result.value);
118
+ }
119
+ const bytes = new Uint8Array(length);
120
+ let offset = 0;
121
+ for (const chunk of chunks) {
122
+ bytes.set(chunk, offset);
123
+ offset += chunk.byteLength;
124
+ }
125
+ if (!bytes.length) return {};
126
+ try {
127
+ const value: unknown = JSON.parse(new TextDecoder().decode(bytes));
128
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ export async function sendWebhook(input: {
135
+ url: string;
136
+ urlVariable?: string;
137
+ secret: string;
138
+ event: string;
139
+ idempotencyKey: string;
140
+ payload: string;
141
+ }, fetchImpl: typeof fetch = fetch): Promise<{ status: number; body: Record<string, unknown> | null }> {
142
+ const url = webhookUrl(input.url, input.urlVariable ?? "webhook URL");
143
+ const timestamp = Math.floor(Date.now() / 1_000).toString();
144
+ const controller = new AbortController();
145
+ const timeout = setTimeout(() => controller.abort("webhook_timeout"), REQUEST_TIMEOUT_MS);
146
+ try {
147
+ const response = await fetchImpl(url, {
148
+ method: "POST",
149
+ headers: {
150
+ "content-type": "application/json",
151
+ "user-agent": "Safest-Resolve/1",
152
+ "x-safest-event": input.event,
153
+ "x-safest-idempotency-key": input.idempotencyKey,
154
+ "x-safest-signature": await signature(input.secret, timestamp, input.payload),
155
+ "x-safest-timestamp": timestamp,
156
+ },
157
+ body: input.payload,
158
+ signal: controller.signal,
159
+ });
160
+ return { status: response.status, body: await boundedResponse(response) };
161
+ } finally {
162
+ clearTimeout(timeout);
163
+ }
164
+ }
165
+
166
+ export function responseClass(status: number): "success" | "temporary_failure" | "permanent_failure" {
167
+ if (status >= 200 && status < 300) return "success";
168
+ if (status === 408 || status === 409 || status === 425 || status === 429 || status >= 500) return "temporary_failure";
169
+ return "permanent_failure";
170
+ }
171
+
172
+ async function claimAction(db: D1Database, id: string): Promise<ActionOutboxRow | null> {
173
+ const claimedAt = now();
174
+ const result = await db.prepare(`
175
+ UPDATE decision_action_outbox
176
+ SET status = 'delivering', attempt_count = attempt_count + 1,
177
+ lease_expires_at = ?2, updated_at = ?1
178
+ WHERE id = ?3 AND terminal = 0 AND attempt_count < ?4
179
+ AND (
180
+ status = 'pending'
181
+ OR (status = 'failed' AND next_attempt_at <= ?1)
182
+ OR (status = 'delivering' AND lease_expires_at <= ?1)
183
+ )
184
+ `).bind(claimedAt, plusSeconds(claimedAt, 60), id, MAX_ATTEMPTS).run();
185
+ if ((result.meta.changes ?? 0) < 1) return null;
186
+ return db.prepare(`
187
+ SELECT id, report_id, idempotency_key, payload_json, status, attempt_count
188
+ FROM decision_action_outbox WHERE id = ?1 LIMIT 1
189
+ `).bind(id).first<ActionOutboxRow>();
190
+ }
191
+
192
+ async function claimNotification(db: D1Database, id: string): Promise<NotificationOutboxRow | null> {
193
+ const claimedAt = now();
194
+ const result = await db.prepare(`
195
+ UPDATE notification_outbox
196
+ SET status = 'delivering', attempt_count = attempt_count + 1,
197
+ lease_expires_at = ?2, updated_at = ?1
198
+ WHERE id = ?3 AND terminal = 0 AND attempt_count < ?4
199
+ AND (
200
+ status = 'pending'
201
+ OR (status = 'failed' AND next_attempt_at <= ?1)
202
+ OR (status = 'delivering' AND lease_expires_at <= ?1)
203
+ )
204
+ `).bind(claimedAt, plusSeconds(claimedAt, 60), id, MAX_ATTEMPTS).run();
205
+ if ((result.meta.changes ?? 0) < 1) return null;
206
+ return db.prepare(`
207
+ SELECT o.id, n.report_id, o.notice_id, o.idempotency_key, o.payload_json, o.status, o.attempt_count,
208
+ n.audience, p.reference AS recipient_reference, p.contact_ciphertext
209
+ FROM notification_outbox o JOIN notices n ON n.id = o.notice_id
210
+ LEFT JOIN report_participants p ON p.report_id = n.report_id AND p.audience = n.audience
211
+ WHERE o.id = ?1 LIMIT 1
212
+ `).bind(id).first<NotificationOutboxRow>();
213
+ }
214
+
215
+ async function claimEmail(db: D1Database, id: string): Promise<EmailDeliveryRow | null> {
216
+ const claimedAt = now();
217
+ const staleAt = plusSeconds(claimedAt, -60);
218
+ const result = await db.prepare(`
219
+ UPDATE email_deliveries SET state = 'sending', attempt_count = attempt_count + 1,
220
+ updated_at = ?2 WHERE id = ?1 AND attempt_count < ?3
221
+ AND (state = 'pending' OR (state = 'failed' AND next_attempt_at <= ?2)
222
+ OR (state = 'sending' AND updated_at <= ?4))
223
+ `).bind(id, claimedAt, MAX_ATTEMPTS, staleAt).run();
224
+ if ((result.meta.changes ?? 0) < 1) return null;
225
+ return db.prepare(`
226
+ SELECT e.id, m.report_id AS reportId, e.message_id AS messageId,
227
+ e.audience, e.state, e.attempt_count AS attemptCount,
228
+ p.reference AS recipientReference, p.contact_ciphertext AS contactCiphertext,
229
+ m.body, r.public_reference AS reportReference,
230
+ r.installation_id AS installationId, r.queue_id AS queueId
231
+ FROM email_deliveries e
232
+ JOIN case_messages m ON m.id = e.message_id
233
+ JOIN reports r ON r.id = m.report_id
234
+ LEFT JOIN report_participants p ON p.report_id = m.report_id AND p.audience = e.audience
235
+ WHERE e.id = ?1 LIMIT 1
236
+ `).bind(id).first<EmailDeliveryRow>();
237
+ }
238
+
239
+ function deliveryAudit(
240
+ db: D1Database,
241
+ input: { key: string; action: string; targetType: string; targetId: string; reportId: string; details: unknown; createdAt: string },
242
+ ): D1PreparedStatement {
243
+ return db.prepare(`
244
+ INSERT OR IGNORE INTO audit_events (
245
+ id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at
246
+ ) VALUES (?1, ?2, ?3, 'system', 'delivery-worker', ?4, ?5, ?6, ?7)
247
+ `).bind(
248
+ crypto.randomUUID(), input.key, input.action, input.targetType, input.targetId,
249
+ canonicalJson({ reportId: input.reportId, ...input.details as Record<string, unknown> }), input.createdAt,
250
+ );
251
+ }
252
+
253
+ async function processAction(db: D1Database, outboxId: string, env: Env): Promise<void> {
254
+ const row = await claimAction(db, outboxId);
255
+ if (!row) return;
256
+ const startedAt = now();
257
+ let status: number | null = null;
258
+ let body: Record<string, unknown> | null = null;
259
+ let errorCode: string | null = null;
260
+ try {
261
+ const response = await sendWebhook({
262
+ url: env.ACTION_WEBHOOK_URL,
263
+ urlVariable: "ACTION_WEBHOOK_URL",
264
+ secret: env.ACTION_WEBHOOK_SECRET,
265
+ event: "application.action.requested",
266
+ idempotencyKey: row.idempotency_key,
267
+ payload: row.payload_json,
268
+ });
269
+ status = response.status;
270
+ body = response.body;
271
+ } catch (error) {
272
+ errorCode = error instanceof DeliveryConfigurationError
273
+ ? "webhook_configuration_error"
274
+ : error instanceof DOMException && error.name === "AbortError" ? "webhook_timeout" : "webhook_network_error";
275
+ }
276
+ const finishedAt = now();
277
+ const attemptId = crypto.randomUUID();
278
+ if (status !== null && responseClass(status) === "success") {
279
+ const result = body?.status;
280
+ if (result === "accepted" || result === "already_applied" || result === "rejected") {
281
+ await db.batch([
282
+ db.prepare(`
283
+ UPDATE decision_action_outbox SET status = ?2, terminal = 1, lease_expires_at = NULL,
284
+ last_error_code = NULL, updated_at = ?3 WHERE id = ?1
285
+ `).bind(row.id, result, finishedAt),
286
+ db.prepare(`
287
+ INSERT INTO decision_action_attempts (
288
+ id, action_id, attempt_number, response_class, response_status, started_at, finished_at
289
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
290
+ `).bind(attemptId, row.id, row.attempt_count, result, status, startedAt, finishedAt),
291
+ deliveryAudit(db, {
292
+ key: `action-delivery:${row.id}:${result}`,
293
+ action: "application_action.delivery_completed",
294
+ targetType: "application_action",
295
+ targetId: row.id,
296
+ reportId: row.report_id,
297
+ details: { result, responseStatus: status, attemptCount: row.attempt_count },
298
+ createdAt: finishedAt,
299
+ }),
300
+ ]);
301
+ return;
302
+ }
303
+ errorCode = "invalid_action_response";
304
+ } else if (status !== null) {
305
+ errorCode = `webhook_http_${status}`;
306
+ }
307
+ const configurationFailure = errorCode === "webhook_configuration_error";
308
+ const temporary = !configurationFailure
309
+ && (status === null || responseClass(status) === "temporary_failure" || errorCode === "invalid_action_response");
310
+ const terminal = !temporary || row.attempt_count >= MAX_ATTEMPTS;
311
+ const retryDelay = delay(row.attempt_count);
312
+ const statements = [
313
+ db.prepare(`
314
+ UPDATE decision_action_outbox
315
+ SET status = ?2, terminal = ?3, next_attempt_at = ?4, lease_expires_at = NULL,
316
+ last_error_code = ?5, updated_at = ?6
317
+ WHERE id = ?1
318
+ `).bind(
319
+ row.id,
320
+ terminal && status !== null && responseClass(status) === "permanent_failure" ? "rejected" : "failed",
321
+ terminal ? 1 : 0,
322
+ plusSeconds(finishedAt, retryDelay), errorCode, finishedAt,
323
+ ),
324
+ db.prepare(`
325
+ INSERT INTO decision_action_attempts (
326
+ id, action_id, attempt_number, response_class, response_status, error_code, started_at, finished_at
327
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
328
+ `).bind(attemptId, row.id, row.attempt_count, terminal ? "permanent_failure" : "temporary_failure", status, errorCode, startedAt, finishedAt),
329
+ ];
330
+ if (terminal) statements.push(deliveryAudit(db, {
331
+ key: `action-delivery:${row.id}:failed`,
332
+ action: "application_action.delivery_failed",
333
+ targetType: "application_action",
334
+ targetId: row.id,
335
+ reportId: row.report_id,
336
+ details: { errorCode, responseStatus: status, attemptCount: row.attempt_count },
337
+ createdAt: finishedAt,
338
+ }));
339
+ await db.batch(statements);
340
+ if (!terminal) throw new RetryableDeliveryError(retryDelay);
341
+ }
342
+
343
+ async function updateNoticeDelivery(db: D1Database, noticeId: string, state: "delivered" | "failed", timestamp: string): Promise<void> {
344
+ await db.batch([
345
+ db.prepare(`UPDATE notices SET delivery_state = ?2, updated_at = ?3 WHERE id = ?1`).bind(noticeId, state, timestamp),
346
+ db.prepare(`
347
+ UPDATE case_messages SET delivery_state = ?2
348
+ WHERE id = (SELECT message_id FROM notices WHERE id = ?1)
349
+ `).bind(noticeId, state),
350
+ ]);
351
+ }
352
+
353
+ async function processNotification(db: D1Database, outboxId: string, env: Env): Promise<void> {
354
+ const row = await claimNotification(db, outboxId);
355
+ if (!row) return;
356
+ const startedAt = now();
357
+ let status: number | null = null;
358
+ let errorCode: string | null = null;
359
+ try {
360
+ let payload: Record<string, unknown>;
361
+ try {
362
+ const parsed: unknown = JSON.parse(row.payload_json);
363
+ payload = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
364
+ } catch {
365
+ payload = {};
366
+ }
367
+ const recipientEmail = row.contact_ciphertext
368
+ ? (await decryptReportContact(env.CREDENTIAL_ENCRYPTION_KEY, row.contact_ciphertext, row.report_id, row.audience)).trim().toLowerCase()
369
+ : null;
370
+ const deliveryPayload = canonicalJson({
371
+ ...payload,
372
+ ...(row.recipient_reference ? { recipient_reference: row.recipient_reference } : {}),
373
+ ...(recipientEmail ? { recipient_email: recipientEmail } : {}),
374
+ });
375
+ const response = await sendWebhook({
376
+ url: env.NOTIFICATION_WEBHOOK_URL,
377
+ urlVariable: "NOTIFICATION_WEBHOOK_URL",
378
+ secret: env.NOTIFICATION_WEBHOOK_SECRET,
379
+ event: "participant.notification.requested",
380
+ idempotencyKey: row.idempotency_key,
381
+ payload: deliveryPayload,
382
+ });
383
+ status = response.status;
384
+ } catch (error) {
385
+ errorCode = error instanceof DeliveryConfigurationError
386
+ ? "webhook_configuration_error"
387
+ : error instanceof DOMException && error.name === "AbortError" ? "webhook_timeout" : "webhook_network_error";
388
+ }
389
+ const finishedAt = now();
390
+ const attemptId = crypto.randomUUID();
391
+ if (status !== null && responseClass(status) === "success") {
392
+ await db.batch([
393
+ db.prepare(`
394
+ UPDATE notification_outbox SET status = 'delivered', terminal = 1,
395
+ lease_expires_at = NULL, last_error_code = NULL, updated_at = ?2 WHERE id = ?1
396
+ `).bind(row.id, finishedAt),
397
+ db.prepare(`
398
+ INSERT INTO notification_attempts (
399
+ id, notification_id, attempt_number, response_class, response_status, started_at, finished_at
400
+ ) VALUES (?1, ?2, ?3, 'delivered', ?4, ?5, ?6)
401
+ `).bind(attemptId, row.id, row.attempt_count, status, startedAt, finishedAt),
402
+ deliveryAudit(db, {
403
+ key: `notification-delivery:${row.id}:delivered`,
404
+ action: "notification.delivered",
405
+ targetType: "notification",
406
+ targetId: row.id,
407
+ reportId: row.report_id,
408
+ details: { responseStatus: status, attemptCount: row.attempt_count },
409
+ createdAt: finishedAt,
410
+ }),
411
+ ]);
412
+ await updateNoticeDelivery(db, row.notice_id, "delivered", finishedAt);
413
+ return;
414
+ }
415
+ if (status !== null) errorCode = `webhook_http_${status}`;
416
+ const temporary = errorCode !== "webhook_configuration_error"
417
+ && (status === null || responseClass(status) === "temporary_failure");
418
+ const terminal = !temporary || row.attempt_count >= MAX_ATTEMPTS;
419
+ const retryDelay = delay(row.attempt_count);
420
+ const statements = [
421
+ db.prepare(`
422
+ UPDATE notification_outbox SET status = 'failed', terminal = ?2,
423
+ next_attempt_at = ?3, lease_expires_at = NULL, last_error_code = ?4, updated_at = ?5
424
+ WHERE id = ?1
425
+ `).bind(row.id, terminal ? 1 : 0, plusSeconds(finishedAt, retryDelay), errorCode, finishedAt),
426
+ db.prepare(`
427
+ INSERT INTO notification_attempts (
428
+ id, notification_id, attempt_number, response_class, response_status, error_code, started_at, finished_at
429
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
430
+ `).bind(attemptId, row.id, row.attempt_count, terminal ? "permanent_failure" : "temporary_failure", status, errorCode, startedAt, finishedAt),
431
+ ];
432
+ if (terminal) statements.push(deliveryAudit(db, {
433
+ key: `notification-delivery:${row.id}:failed`,
434
+ action: "notification.delivery_failed",
435
+ targetType: "notification",
436
+ targetId: row.id,
437
+ reportId: row.report_id,
438
+ details: { errorCode, responseStatus: status, attemptCount: row.attempt_count },
439
+ createdAt: finishedAt,
440
+ }));
441
+ await db.batch(statements);
442
+ if (terminal) await updateNoticeDelivery(db, row.notice_id, "failed", finishedAt);
443
+ if (!terminal) throw new RetryableDeliveryError(retryDelay);
444
+ }
445
+
446
+ async function processEmail(db: D1Database, deliveryId: string, env: Env): Promise<void> {
447
+ const row = await claimEmail(db, deliveryId);
448
+ if (!row) return;
449
+ const email = row.contactCiphertext
450
+ ? (await decryptReportContact(env.CREDENTIAL_ENCRYPTION_KEY, row.contactCiphertext, row.reportId, row.audience)).trim().toLowerCase()
451
+ : row.recipientReference?.trim().toLowerCase() ?? "";
452
+ const from = env.EMAIL_FROM_ADDRESS?.trim().toLowerCase() ?? "";
453
+ const binding = (env as unknown as { EMAIL?: { send(message: Record<string, unknown>): Promise<{ messageId?: string }> } }).EMAIL;
454
+ let reservations;
455
+ try {
456
+ reservations = await reserveBudgets(db, {
457
+ installationId: row.installationId,
458
+ scopes: [...(row.queueId ? [{ type: "queue", id: row.queueId }] : [])],
459
+ metric: "emails", amount: 1, operationType: "email_delivery",
460
+ operationId: row.id, attemptId: String(row.attemptCount),
461
+ });
462
+ } catch (error) {
463
+ if (!(error instanceof BudgetControlError)) throw error;
464
+ const timestamp = now();
465
+ if (error.retryable) {
466
+ await db.prepare(`UPDATE email_deliveries SET state = 'failed', last_error_code = 'email_budget_deferred', next_attempt_at = ?2, updated_at = ?3 WHERE id = ?1`)
467
+ .bind(row.id, plusSeconds(timestamp, error.delaySeconds), timestamp).run();
468
+ throw new RetryableDeliveryError(error.delaySeconds);
469
+ }
470
+ await db.batch([
471
+ db.prepare(`UPDATE email_deliveries SET state = 'suppressed', last_error_code = 'email_budget_exhausted', next_attempt_at = NULL, updated_at = ?2 WHERE id = ?1`).bind(row.id, timestamp),
472
+ deliveryAudit(db, {
473
+ key: `email-delivery:${row.id}:budget-suppressed`, action: "email.budget_exhausted",
474
+ targetType: "email_delivery", targetId: row.id, reportId: row.reportId,
475
+ details: { fallback: "customer_webhook", fallbackReference: error.fallbackReference }, createdAt: timestamp,
476
+ }),
477
+ ]);
478
+ return;
479
+ }
480
+ let providerMessageId: string | null = null;
481
+ let errorCode: string | null = null;
482
+ try {
483
+ if (!binding) throw new DeliveryConfigurationError("The Cloudflare Email Service binding is unavailable.");
484
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(email) || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(from)) {
485
+ throw new DeliveryConfigurationError("Email sender or recipient configuration is invalid.");
486
+ }
487
+ const prefix = env.EMAIL_SUBJECT_PREFIX?.trim().slice(0, 120) || "Safest report update";
488
+ const result = await binding.send({
489
+ to: email,
490
+ from,
491
+ subject: `${prefix} — ${row.reportReference}`,
492
+ text: `${row.body}\n\nReport reference: ${row.reportReference}`,
493
+ });
494
+ providerMessageId = typeof result.messageId === "string" ? result.messageId.slice(0, 300) : null;
495
+ } catch (error) {
496
+ errorCode = error instanceof DeliveryConfigurationError
497
+ ? "email_configuration_error"
498
+ : typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
499
+ ? `email_${error.code}`.slice(0, 100) : "email_provider_error";
500
+ }
501
+ const finishedAt = now();
502
+ if (!errorCode) {
503
+ await settleBudgets(db, reservations, true);
504
+ await db.batch([
505
+ db.prepare(`UPDATE email_deliveries SET state = 'delivered', provider_message_id = ?2, last_error_code = NULL, next_attempt_at = NULL, delivered_at = ?3, updated_at = ?3 WHERE id = ?1`)
506
+ .bind(row.id, providerMessageId, finishedAt),
507
+ db.prepare(`UPDATE case_messages SET delivery_state = 'delivered' WHERE id = ?1`).bind(row.messageId),
508
+ db.prepare(`UPDATE notices SET delivery_state = 'delivered', updated_at = ?2 WHERE message_id = ?1`).bind(row.messageId, finishedAt),
509
+ deliveryAudit(db, {
510
+ key: `email-delivery:${row.id}:delivered`, action: "email.delivered",
511
+ targetType: "email_delivery", targetId: row.id, reportId: row.reportId,
512
+ details: { provider: "cloudflare_email_service", attemptCount: row.attemptCount }, createdAt: finishedAt,
513
+ }),
514
+ ]);
515
+ return;
516
+ }
517
+ const terminal = errorCode === "email_configuration_error" || row.attemptCount >= MAX_ATTEMPTS;
518
+ await settleBudgets(db, reservations, errorCode !== "email_configuration_error");
519
+ const retryDelay = delay(row.attemptCount);
520
+ const statements: D1PreparedStatement[] = [
521
+ db.prepare(`UPDATE email_deliveries SET state = 'failed', last_error_code = ?2, next_attempt_at = ?3, updated_at = ?4 WHERE id = ?1`)
522
+ .bind(row.id, errorCode, plusSeconds(finishedAt, retryDelay), finishedAt),
523
+ ];
524
+ if (terminal) {
525
+ statements.push(
526
+ db.prepare(`UPDATE case_messages SET delivery_state = 'failed' WHERE id = ?1 AND delivery_state != 'delivered'`).bind(row.messageId),
527
+ db.prepare(`UPDATE notices SET delivery_state = 'failed', updated_at = ?2 WHERE message_id = ?1 AND delivery_state != 'delivered'`).bind(row.messageId, finishedAt),
528
+ deliveryAudit(db, {
529
+ key: `email-delivery:${row.id}:failed`, action: "email.delivery_failed",
530
+ targetType: "email_delivery", targetId: row.id, reportId: row.reportId,
531
+ details: { errorCode, attemptCount: row.attemptCount }, createdAt: finishedAt,
532
+ }),
533
+ );
534
+ }
535
+ await db.batch(statements);
536
+ if (!terminal) throw new RetryableDeliveryError(retryDelay);
537
+ }
538
+
539
+ export async function processDeliveryJob(db: D1Database, job: DeliveryJob, env: Env): Promise<void> {
540
+ if (job.type === "deliver_action") return processAction(db, job.outboxId, env);
541
+ if (job.type === "deliver_notification") return processNotification(db, job.outboxId, env);
542
+ return processEmail(db, job.outboxId, env);
543
+ }
544
+
545
+ export async function pendingDeliveryJobs(db: D1Database, limit = 50): Promise<DeliveryJob[]> {
546
+ const at = now();
547
+ const boundedLimit = Math.max(1, Math.min(100, limit));
548
+ const [actions, notifications, emails] = await Promise.all([
549
+ db.prepare(`
550
+ SELECT id FROM decision_action_outbox
551
+ WHERE terminal = 0 AND attempt_count < ?2 AND (
552
+ (status IN ('pending', 'failed') AND next_attempt_at <= ?1)
553
+ OR (status = 'delivering' AND lease_expires_at <= ?1)
554
+ )
555
+ ORDER BY next_attempt_at, id LIMIT ?3
556
+ `).bind(at, MAX_ATTEMPTS, boundedLimit).all<{ id: string }>(),
557
+ db.prepare(`
558
+ SELECT id FROM notification_outbox
559
+ WHERE terminal = 0 AND attempt_count < ?2 AND (
560
+ (status IN ('pending', 'failed') AND next_attempt_at <= ?1)
561
+ OR (status = 'delivering' AND lease_expires_at <= ?1)
562
+ )
563
+ ORDER BY next_attempt_at, id LIMIT ?3
564
+ `).bind(at, MAX_ATTEMPTS, boundedLimit).all<{ id: string }>(),
565
+ db.prepare(`
566
+ SELECT id FROM email_deliveries
567
+ WHERE attempt_count < ?2 AND (
568
+ (state IN ('pending', 'failed') AND COALESCE(next_attempt_at, created_at) <= ?1)
569
+ OR (state = 'sending' AND updated_at <= ?3)
570
+ )
571
+ ORDER BY COALESCE(next_attempt_at, created_at), id LIMIT ?4
572
+ `).bind(at, MAX_ATTEMPTS, plusSeconds(at, -60), boundedLimit).all<{ id: string }>(),
573
+ ]);
574
+ return [
575
+ ...actions.results.map((row): DeliveryJob => ({ version: 1, jobId: `action:${row.id}`, type: "deliver_action", outboxId: row.id })),
576
+ ...notifications.results.map((row): DeliveryJob => ({ version: 1, jobId: `notification:${row.id}`, type: "deliver_notification", outboxId: row.id })),
577
+ ...emails.results.map((row): DeliveryJob => ({ version: 1, jobId: `email:${row.id}`, type: "deliver_email", outboxId: row.id })),
578
+ ].slice(0, boundedLimit);
579
+ }
@@ -0,0 +1,51 @@
1
+ import { ApiError } from "./report-http";
2
+
3
+ export function validateAnswersAgainstFormFields(
4
+ answers: Record<string, string | boolean | number | string[]>,
5
+ definitionsValue: unknown[],
6
+ ): void {
7
+ const definitions = definitionsValue
8
+ .filter((value): value is Record<string, unknown> => Boolean(value && typeof value === "object" && !Array.isArray(value)))
9
+ .filter((value) => typeof value.key === "string");
10
+ const byKey = new Map(definitions.map((field) => [String(field.key), field]));
11
+ const unsupported = Object.keys(answers).filter((key) => !byKey.has(key));
12
+ if (unsupported.length) {
13
+ throw new ApiError(400, "answer_field_not_allowed", "The report contains answers that are not part of this form.", { fields: unsupported.slice(0, 10) });
14
+ }
15
+ for (const field of definitions) {
16
+ const key = String(field.key);
17
+ const value = answers[key];
18
+ const missing = value === undefined || value === null || value === "" || Array.isArray(value) && value.length === 0;
19
+ if (field.required === true && missing) {
20
+ throw new ApiError(400, "required_answer_missing", `The ${key} answer is required.`, { field: `answers.${key}` });
21
+ }
22
+ if (missing) continue;
23
+ const type = String(field.type);
24
+ const answerField = `answers.${key}`;
25
+ if (type === "short_text" || type === "long_text" || type === "url") {
26
+ if (typeof value !== "string") throw new ApiError(400, "answer_type_invalid", `${answerField} must be text.`, { field: answerField });
27
+ const defaultMaximum = type === "short_text" ? 512 : type === "url" ? 2_048 : 4_000;
28
+ const maximum = typeof field.max_length === "number" ? Math.min(defaultMaximum, field.max_length) : defaultMaximum;
29
+ if (value.length > maximum) throw new ApiError(400, "answer_length_invalid", `${answerField} exceeds its ${maximum}-character limit.`, { field: answerField, maximum });
30
+ if (type === "url") {
31
+ try {
32
+ const url = new URL(value);
33
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
34
+ if ((url.protocol !== "https:" && !(local && url.protocol === "http:")) || url.username || url.password) throw new Error("unsafe URL");
35
+ } catch { throw new ApiError(400, "answer_url_invalid", `${answerField} must be a valid HTTPS URL.`, { field: answerField }); }
36
+ }
37
+ } else if (type === "number") {
38
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ApiError(400, "answer_type_invalid", `${answerField} must be a number.`, { field: answerField });
39
+ if (typeof field.min_value === "number" && value < field.min_value) throw new ApiError(400, "answer_range_invalid", `${answerField} is below its minimum.`, { field: answerField, minimum: field.min_value });
40
+ if (typeof field.max_value === "number" && value > field.max_value) throw new ApiError(400, "answer_range_invalid", `${answerField} is above its maximum.`, { field: answerField, maximum: field.max_value });
41
+ } else if (type === "boolean") {
42
+ if (typeof value !== "boolean") throw new ApiError(400, "answer_type_invalid", `${answerField} must be yes or no.`, { field: answerField });
43
+ } else if (type === "select" || type === "multi_select") {
44
+ const allowed = new Set(Array.isArray(field.options) ? field.options.map((option) => option && typeof option === "object" && !Array.isArray(option) ? (option as Record<string, unknown>).value : null).filter((option): option is string => typeof option === "string") : []);
45
+ if (type === "select" && (typeof value !== "string" || !allowed.has(value))) throw new ApiError(400, "answer_choice_invalid", `${answerField} contains an unavailable choice.`, { field: answerField });
46
+ if (type === "multi_select" && (!Array.isArray(value) || value.length > 20 || value.some((choice) => !allowed.has(choice)))) throw new ApiError(400, "answer_choice_invalid", `${answerField} contains unavailable choices.`, { field: answerField });
47
+ } else {
48
+ throw new ApiError(400, "form_field_type_invalid", "The published form contains an unsupported field type.", { field: answerField });
49
+ }
50
+ }
51
+ }