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,510 @@
1
+ import { AUDIT_GENESIS_HASH, canonicalJson, hashAuditEntry } from "./audit";
2
+ import { ApiError } from "./report-http";
3
+
4
+ function now(): string {
5
+ return new Date().toISOString();
6
+ }
7
+
8
+ function subtractDays(timestamp: string, days: number): string {
9
+ return new Date(Date.parse(timestamp) - days * 86_400_000).toISOString();
10
+ }
11
+
12
+ function auditEvent(
13
+ db: D1Database,
14
+ input: { key: string; action: string; actorType: string; actorId: string; targetType: string; targetId: string; details: unknown; createdAt: string },
15
+ ): D1PreparedStatement {
16
+ return db.prepare(`
17
+ INSERT OR IGNORE INTO audit_events (
18
+ id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at
19
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
20
+ `).bind(
21
+ crypto.randomUUID(), input.key, input.action, input.actorType, input.actorId,
22
+ input.targetType, input.targetId, canonicalJson(input.details), input.createdAt,
23
+ );
24
+ }
25
+
26
+ export async function refreshDailyAggregates(db: D1Database, lookbackDays = 90): Promise<void> {
27
+ const refreshedAt = now();
28
+ const fromDate = subtractDays(refreshedAt, Math.max(1, Math.min(366, lookbackDays))).slice(0, 10);
29
+ const statements: D1PreparedStatement[] = [
30
+ db.prepare(`DELETE FROM daily_aggregates WHERE aggregate_date >= ?1`).bind(fromDate),
31
+ db.prepare(`
32
+ INSERT INTO daily_aggregates (aggregate_date, metric_key, dimension_key, dimension_value, value, updated_at)
33
+ SELECT substr(received_at, 1, 10), 'reports_received', 'source', source, COUNT(*), ?2
34
+ FROM reports WHERE received_at >= ?1 GROUP BY substr(received_at, 1, 10), source
35
+ `).bind(fromDate, refreshedAt),
36
+ db.prepare(`
37
+ INSERT INTO daily_aggregates (aggregate_date, metric_key, dimension_key, dimension_value, value, updated_at)
38
+ SELECT substr(received_at, 1, 10), 'reports_received', 'reason', reason_code, COUNT(*), ?2
39
+ FROM reports WHERE received_at >= ?1 GROUP BY substr(received_at, 1, 10), reason_code
40
+ `).bind(fromDate, refreshedAt),
41
+ db.prepare(`
42
+ INSERT INTO daily_aggregates (aggregate_date, metric_key, dimension_key, dimension_value, value, updated_at)
43
+ SELECT substr(r.received_at, 1, 10), 'reports_received', 'target_type', t.target_type, COUNT(*), ?2
44
+ FROM reports r JOIN report_targets t ON t.report_id = r.id
45
+ WHERE r.received_at >= ?1 GROUP BY substr(r.received_at, 1, 10), t.target_type
46
+ `).bind(fromDate, refreshedAt),
47
+ db.prepare(`
48
+ INSERT INTO daily_aggregates (aggregate_date, metric_key, dimension_key, dimension_value, value, updated_at)
49
+ SELECT substr(d.created_at, 1, 10), 'decisions', 'outcome', d.decision_code, COUNT(*), ?2
50
+ FROM decisions d WHERE d.created_at >= ?1 GROUP BY substr(d.created_at, 1, 10), d.decision_code
51
+ `).bind(fromDate, refreshedAt),
52
+ db.prepare(`
53
+ INSERT INTO daily_aggregates (aggregate_date, metric_key, dimension_key, dimension_value, value, updated_at)
54
+ SELECT substr(d.created_at, 1, 10), 'decisions', 'maker_type', d.maker_type, COUNT(*), ?2
55
+ FROM decisions d WHERE d.created_at >= ?1 GROUP BY substr(d.created_at, 1, 10), d.maker_type
56
+ `).bind(fromDate, refreshedAt),
57
+ db.prepare(`
58
+ INSERT INTO daily_aggregates (aggregate_date, metric_key, dimension_key, dimension_value, value, updated_at)
59
+ SELECT substr(submitted_at, 1, 10), 'appeals_filed', 'audience', appellant_audience, COUNT(*), ?2
60
+ FROM appeals WHERE submitted_at >= ?1 GROUP BY substr(submitted_at, 1, 10), appellant_audience
61
+ `).bind(fromDate, refreshedAt),
62
+ db.prepare(`
63
+ INSERT INTO daily_aggregates (aggregate_date, metric_key, dimension_key, dimension_value, value, updated_at)
64
+ SELECT substr(created_at, 1, 10), 'appeal_decisions', 'outcome', outcome, COUNT(*), ?2
65
+ FROM appeal_decisions WHERE created_at >= ?1 GROUP BY substr(created_at, 1, 10), outcome
66
+ `).bind(fromDate, refreshedAt),
67
+ db.prepare(`
68
+ INSERT INTO daily_aggregates (aggregate_date, metric_key, dimension_key, dimension_value, value, updated_at)
69
+ SELECT substr(created_at, 1, 10), 'application_actions', 'status', status, COUNT(*), ?2
70
+ FROM decision_action_outbox WHERE created_at >= ?1 GROUP BY substr(created_at, 1, 10), status
71
+ `).bind(fromDate, refreshedAt),
72
+ db.prepare(`
73
+ INSERT INTO daily_aggregates (aggregate_date, metric_key, dimension_key, dimension_value, value, updated_at)
74
+ SELECT substr(created_at, 1, 10), 'notices', 'delivery_state', delivery_state, COUNT(*), ?2
75
+ FROM notices WHERE created_at >= ?1 GROUP BY substr(created_at, 1, 10), delivery_state
76
+ `).bind(fromDate, refreshedAt),
77
+ db.prepare(`
78
+ INSERT INTO daily_aggregates (aggregate_date, metric_key, dimension_key, dimension_value, value, updated_at)
79
+ SELECT substr(started_at, 1, 10), 'ai_runs', 'status', status, COUNT(*), ?2
80
+ FROM ai_runs WHERE started_at >= ?1 GROUP BY substr(started_at, 1, 10), status
81
+ `).bind(fromDate, refreshedAt),
82
+ db.prepare(`
83
+ INSERT INTO daily_aggregates (aggregate_date, metric_key, dimension_key, dimension_value, value, updated_at)
84
+ SELECT substr(started_at, 1, 10), 'ai_runs', 'mode', mode, COUNT(*), ?2
85
+ FROM ai_runs WHERE started_at >= ?1 GROUP BY substr(started_at, 1, 10), mode
86
+ `).bind(fromDate, refreshedAt),
87
+ ];
88
+ await db.batch(statements);
89
+ }
90
+
91
+ export async function refreshDailyAggregatesIfStale(db: D1Database): Promise<void> {
92
+ const latest = await db.prepare(`SELECT MAX(updated_at) AS updatedAt FROM daily_aggregates`).first<{ updatedAt: string | null }>();
93
+ if (latest?.updatedAt && Date.parse(latest.updatedAt) > Date.now() - 15 * 60_000) return;
94
+ await refreshDailyAggregates(db, 90);
95
+ }
96
+
97
+ function percentile(values: number[], ratio: number): number | null {
98
+ if (!values.length) return null;
99
+ const index = Math.max(0, Math.min(values.length - 1, Math.ceil(values.length * ratio) - 1));
100
+ return Math.round(values[index] ?? 0);
101
+ }
102
+
103
+ export async function loadAnalytics(db: D1Database, days = 30): Promise<Record<string, unknown>> {
104
+ const generatedAt = now();
105
+ const boundedDays = Math.max(1, Math.min(366, days));
106
+ const fromDate = subtractDays(generatedAt, boundedDays).slice(0, 10);
107
+ const [series, durations, backlog, aiUsage] = await Promise.all([
108
+ db.prepare(`
109
+ SELECT aggregate_date AS aggregateDate, metric_key AS metricKey, dimension_key AS dimensionKey,
110
+ dimension_value AS dimensionValue, value
111
+ FROM daily_aggregates WHERE aggregate_date >= ?1
112
+ ORDER BY aggregate_date, metric_key, dimension_key, dimension_value LIMIT 10000
113
+ `).bind(fromDate).all<Record<string, unknown>>(),
114
+ db.prepare(`
115
+ SELECT CAST((julianday(resolved_at) - julianday(received_at)) * 86400 AS INTEGER) AS seconds
116
+ FROM reports WHERE resolved_at IS NOT NULL AND received_at >= ?1
117
+ ORDER BY seconds LIMIT 10000
118
+ `).bind(`${fromDate}T00:00:00.000Z`).all<{ seconds: number }>(),
119
+ db.prepare(`
120
+ SELECT queue_id AS queueId, state, COUNT(*) AS count,
121
+ CAST(MAX((julianday(?1) - julianday(received_at)) * 86400) AS INTEGER) AS oldestAgeSeconds
122
+ FROM reports WHERE state IN ('received', 'human_review', 'ai_review', 'awaiting_reporter', 'human_review_requested', 'appealed')
123
+ GROUP BY queue_id, state ORDER BY queue_id, state
124
+ `).bind(generatedAt).all<Record<string, unknown>>(),
125
+ db.prepare(`
126
+ SELECT usage_date AS usageDate, SUM(reports_started) AS reportsStarted,
127
+ SUM(reports_resolved) AS reportsResolved, SUM(reports_escalated) AS reportsEscalated,
128
+ SUM(failures) AS failures, SUM(estimated_cost_microusd) AS estimatedCostMicrousd,
129
+ SUM(eligible_reports) AS eligibleReports, SUM(waiting_for_reporter) AS waitingForReporter,
130
+ SUM(human_review_requests) AS humanReviewRequests, SUM(appeals_filed) AS appealsFiled,
131
+ SUM(appeal_reversals) AS appealReversals, SUM(invalid_outputs) AS invalidOutputs,
132
+ SUM(provider_errors) AS providerErrors, SUM(timeouts) AS timeouts
133
+ FROM ai_usage_daily WHERE usage_date >= ?1 GROUP BY usage_date ORDER BY usage_date
134
+ `).bind(fromDate).all<Record<string, unknown>>(),
135
+ ]);
136
+ const seconds = durations.results.map((row) => row.seconds).filter((value) => Number.isFinite(value));
137
+ return {
138
+ generatedAt,
139
+ fromDate,
140
+ days: boundedDays,
141
+ series: series.results,
142
+ backlog: backlog.results,
143
+ resolutionSeconds: {
144
+ count: seconds.length,
145
+ median: percentile(seconds, 0.5),
146
+ p90: percentile(seconds, 0.9),
147
+ p95: percentile(seconds, 0.95),
148
+ },
149
+ aiUsage: aiUsage.results,
150
+ limitations: [
151
+ "Counts describe reports and decisions, not verified real-world incidents.",
152
+ "No rate denominator is calculated unless the customer supplies one.",
153
+ ...(durations.results.length >= 10000 ? ["Resolution percentiles are bounded to the first 10,000 records in the selected period."] : []),
154
+ ],
155
+ };
156
+ }
157
+
158
+ type DeletionScope = "participant_data" | "all_permitted_data";
159
+
160
+ async function anonymizeReport(
161
+ db: D1Database,
162
+ reportId: string,
163
+ at: string,
164
+ actorId: string,
165
+ reason: string,
166
+ scope: DeletionScope,
167
+ ): Promise<void> {
168
+ await db.batch([
169
+ db.prepare(`
170
+ UPDATE case_messages SET body = '[removed participant message]', sender_id = 'redacted', redacted_at = ?2
171
+ WHERE report_id = ?1 AND direction = 'inbound' AND sender_type IN ('reporter', 'affected_user') AND redacted_at IS NULL
172
+ `).bind(reportId, at),
173
+ db.prepare(`UPDATE report_answers SET value_json = 'null', redacted_at = ?2 WHERE report_id = ?1 AND redacted_at IS NULL`).bind(reportId, at),
174
+ db.prepare(`UPDATE report_participants SET reference = NULL, contact_ciphertext = NULL, reference_redacted_at = ?2 WHERE report_id = ?1 AND reference_redacted_at IS NULL`).bind(reportId, at),
175
+ db.prepare(`UPDATE appeals SET reason = '[removed participant appeal]', reason_redacted_at = ?2 WHERE report_id = ?1 AND reason_redacted_at IS NULL`).bind(reportId, at),
176
+ db.prepare(`UPDATE report_state_history SET actor_id = 'redacted' WHERE report_id = ?1 AND actor_type IN ('reporter', 'affected_user')`).bind(reportId),
177
+ db.prepare(`UPDATE audit_events SET actor_id = 'redacted' WHERE target_id = ?1 AND actor_type IN ('reporter', 'affected_user') AND chained_at IS NULL`).bind(reportId),
178
+ db.prepare(`DELETE FROM context_token_uses WHERE report_id = ?1`).bind(reportId),
179
+ ]);
180
+ if (scope === "all_permitted_data") {
181
+ await db.batch([
182
+ db.prepare(`UPDATE case_messages SET body = '[removed by retention]', sender_id = 'redacted', redacted_at = COALESCE(redacted_at, ?2) WHERE report_id = ?1`).bind(reportId, at),
183
+ db.prepare(`
184
+ UPDATE report_targets SET target_reference = 'redacted:' || id, owner_reference = NULL,
185
+ customer_url = NULL, signed_facts_json = '{}', sensitive_redacted_at = ?2
186
+ WHERE report_id = ?1 AND sensitive_redacted_at IS NULL
187
+ `).bind(reportId, at),
188
+ db.prepare(`UPDATE evidence_references SET reference = 'redacted:' || id, availability = 'deleted', retention_class = 'redacted' WHERE report_id = ?1 AND retention_class != 'redacted'`).bind(reportId),
189
+ db.prepare(`UPDATE decisions SET rationale = '[removed by retention]', evidence_references_json = '[]', sensitive_redacted_at = ?2 WHERE report_id = ?1 AND sensitive_redacted_at IS NULL`).bind(reportId, at),
190
+ db.prepare(`UPDATE notices SET rendered_body = '[removed by retention]', redacted_at = ?2 WHERE report_id = ?1 AND redacted_at IS NULL`).bind(reportId, at),
191
+ db.prepare(`
192
+ UPDATE appeal_decisions SET rationale = '[removed by retention]', rationale_redacted_at = ?2
193
+ WHERE appeal_id IN (SELECT id FROM appeals WHERE report_id = ?1) AND rationale_redacted_at IS NULL
194
+ `).bind(reportId, at),
195
+ db.prepare(`
196
+ UPDATE decision_action_outbox SET target_reference = 'redacted:' || id, payload_json = '{}', payload_redacted_at = ?2
197
+ WHERE report_id = ?1 AND terminal = 1 AND payload_redacted_at IS NULL
198
+ `).bind(reportId, at),
199
+ db.prepare(`
200
+ UPDATE notification_outbox SET payload_json = '{}', payload_redacted_at = ?2
201
+ WHERE notice_id IN (SELECT id FROM notices WHERE report_id = ?1) AND terminal = 1 AND payload_redacted_at IS NULL
202
+ `).bind(reportId, at),
203
+ db.prepare(`UPDATE ai_runs SET output_json = NULL, output_redacted_at = ?2 WHERE report_id = ?1 AND output_redacted_at IS NULL`).bind(reportId, at),
204
+ db.prepare(`
205
+ UPDATE ai_results SET evidence_references_json = '[]', summary = '[removed by retention]',
206
+ message_variables_json = NULL, redacted_at = ?2
207
+ WHERE ai_run_id IN (SELECT id FROM ai_runs WHERE report_id = ?1) AND redacted_at IS NULL
208
+ `).bind(reportId, at),
209
+ db.prepare(`UPDATE ai_tool_calls SET input_references_json = '{}' WHERE ai_run_id IN (SELECT id FROM ai_runs WHERE report_id = ?1)`).bind(reportId),
210
+ db.prepare(`
211
+ UPDATE quality_reviews SET notes = '[removed by retention]', notes_redacted_at = ?2
212
+ WHERE sample_id IN (SELECT id FROM ai_quality_samples WHERE report_id = ?1) AND notes_redacted_at IS NULL
213
+ `).bind(reportId, at),
214
+ ]);
215
+ }
216
+ await db.batch([
217
+ auditEvent(db, {
218
+ key: `retention:${reportId}:${reason}`,
219
+ action: "retention.report_anonymized",
220
+ actorType: "system",
221
+ actorId,
222
+ targetType: "report",
223
+ targetId: reportId,
224
+ details: { reason, scope },
225
+ createdAt: at,
226
+ }),
227
+ ]);
228
+ }
229
+
230
+ export async function runScheduledRetention(db: D1Database, messageRetentionDays: number, reportRetentionDays: number): Promise<void> {
231
+ const startedAt = now();
232
+ const jobId = crypto.randomUUID();
233
+ const messageCutoff = subtractDays(startedAt, Math.max(1, Math.min(3_650, messageRetentionDays)));
234
+ const reportCutoff = subtractDays(startedAt, Math.max(30, Math.min(3_650, reportRetentionDays)));
235
+ await db.prepare(`
236
+ INSERT INTO retention_jobs (id, job_kind, state, cutoff_at, started_at)
237
+ VALUES (?1, 'scheduled_retention', 'running', ?2, ?3)
238
+ `).bind(jobId, reportCutoff, startedAt).run();
239
+ try {
240
+ const messages = await db.prepare(`
241
+ UPDATE case_messages SET body = '[removed by retention]', sender_id = 'redacted', redacted_at = ?2
242
+ WHERE redacted_at IS NULL AND created_at < ?1 AND NOT EXISTS (
243
+ SELECT 1 FROM legal_holds h WHERE h.report_id = case_messages.report_id AND h.state = 'active'
244
+ )
245
+ `).bind(messageCutoff, startedAt).run();
246
+ await db.prepare(`
247
+ UPDATE notices SET rendered_body = '[removed by retention]', redacted_at = ?2
248
+ WHERE redacted_at IS NULL AND created_at < ?1 AND NOT EXISTS (
249
+ SELECT 1 FROM legal_holds h WHERE h.report_id = notices.report_id AND h.state = 'active'
250
+ )
251
+ `).bind(messageCutoff, startedAt).run();
252
+ const candidates = await db.prepare(`
253
+ SELECT id FROM reports r
254
+ WHERE r.updated_at < ?1 AND r.state IN ('resolved_by_human', 'resolved_by_ai', 'closed')
255
+ AND NOT EXISTS (SELECT 1 FROM legal_holds h WHERE h.report_id = r.id AND h.state = 'active')
256
+ AND NOT EXISTS (SELECT 1 FROM decision_action_outbox o WHERE o.report_id = r.id AND o.terminal = 0)
257
+ AND NOT EXISTS (
258
+ SELECT 1 FROM notification_outbox o JOIN notices n ON n.id = o.notice_id
259
+ WHERE n.report_id = r.id AND o.terminal = 0
260
+ )
261
+ AND EXISTS (SELECT 1 FROM report_participants p WHERE p.report_id = r.id AND p.reference_redacted_at IS NULL)
262
+ ORDER BY r.updated_at, r.id LIMIT 100
263
+ `).bind(reportCutoff).all<{ id: string }>();
264
+ for (const report of candidates.results) {
265
+ await anonymizeReport(db, report.id, startedAt, "retention-worker", "retention_period_expired", "all_permitted_data");
266
+ }
267
+ await db.prepare(`
268
+ UPDATE retention_jobs SET state = 'complete', messages_redacted = ?2, reports_anonymized = ?3,
269
+ portal_sessions_revoked = ?4, finished_at = ?5 WHERE id = ?1
270
+ `).bind(jobId, messages.meta.changes ?? 0, candidates.results.length, 0, now()).run();
271
+ } catch (error) {
272
+ await db.prepare(`UPDATE retention_jobs SET state = 'failed', error_code = 'retention_failed', finished_at = ?2 WHERE id = ?1`)
273
+ .bind(jobId, now()).run();
274
+ throw error;
275
+ }
276
+ }
277
+
278
+ export async function runRetentionIfDue(db: D1Database, messageRetentionDays: number, reportRetentionDays: number): Promise<void> {
279
+ const latest = await db.prepare(`
280
+ SELECT started_at AS startedAt FROM retention_jobs
281
+ WHERE job_kind = 'scheduled_retention' ORDER BY started_at DESC LIMIT 1
282
+ `).first<{ startedAt: string }>();
283
+ if (latest?.startedAt && Date.parse(latest.startedAt) > Date.now() - 23 * 60 * 60_000) return;
284
+ await runScheduledRetention(db, messageRetentionDays, reportRetentionDays);
285
+ }
286
+
287
+ export async function createLegalHold(
288
+ db: D1Database,
289
+ reportId: string,
290
+ reason: string,
291
+ actorId: string,
292
+ idempotencyKey: string,
293
+ ): Promise<{ id: string; idempotentReplay: boolean }> {
294
+ const existing = await db.prepare(`SELECT id FROM legal_holds WHERE idempotency_key = ?1 LIMIT 1`).bind(idempotencyKey).first<{ id: string }>();
295
+ if (existing) return { id: existing.id, idempotentReplay: true };
296
+ const report = await db.prepare(`SELECT id FROM reports WHERE id = ?1 LIMIT 1`).bind(reportId).first<{ id: string }>();
297
+ if (!report) throw new ApiError(404, "report_not_found", "The report does not exist.");
298
+ const createdAt = now();
299
+ const id = crypto.randomUUID();
300
+ try {
301
+ await db.batch([
302
+ db.prepare(`
303
+ INSERT INTO legal_holds (id, report_id, reason, state, placed_by, idempotency_key, created_at)
304
+ VALUES (?1, ?2, ?3, 'active', ?4, ?5, ?6)
305
+ `).bind(id, reportId, reason, actorId, idempotencyKey, createdAt),
306
+ auditEvent(db, {
307
+ key: `legal-hold:${idempotencyKey}`,
308
+ action: "retention.legal_hold_placed",
309
+ actorType: "operator",
310
+ actorId,
311
+ targetType: "report",
312
+ targetId: reportId,
313
+ details: { legalHoldId: id, reason },
314
+ createdAt,
315
+ }),
316
+ ]);
317
+ } catch {
318
+ throw new ApiError(409, "legal_hold_exists", "This report already has an active legal hold.");
319
+ }
320
+ return { id, idempotentReplay: false };
321
+ }
322
+
323
+ export async function releaseLegalHold(db: D1Database, holdId: string, actorId: string, idempotencyKey: string): Promise<boolean> {
324
+ const releasedAt = now();
325
+ const result = await db.prepare(`
326
+ UPDATE legal_holds SET state = 'released', released_by = ?2, release_idempotency_key = ?3, released_at = ?4
327
+ WHERE id = ?1 AND (state = 'active' OR release_idempotency_key = ?3)
328
+ `).bind(holdId, actorId, idempotencyKey, releasedAt).run();
329
+ if ((result.meta.changes ?? 0) < 1) throw new ApiError(404, "legal_hold_not_found", "The active legal hold does not exist.");
330
+ await db.batch([auditEvent(db, {
331
+ key: `legal-hold-release:${idempotencyKey}`,
332
+ action: "retention.legal_hold_released",
333
+ actorType: "operator",
334
+ actorId,
335
+ targetType: "legal_hold",
336
+ targetId: holdId,
337
+ details: {},
338
+ createdAt: releasedAt,
339
+ })]);
340
+ return true;
341
+ }
342
+
343
+ export async function createDeletionRequest(
344
+ db: D1Database,
345
+ input: { reportId: string; scope: "participant_data" | "all_permitted_data"; reason: string },
346
+ actorId: string,
347
+ idempotencyKey: string,
348
+ ): Promise<{ id: string; state: string; idempotentReplay: boolean }> {
349
+ const existing = await db.prepare(`SELECT id, state FROM deletion_requests WHERE idempotency_key = ?1 LIMIT 1`)
350
+ .bind(idempotencyKey).first<{ id: string; state: string }>();
351
+ if (existing) return { ...existing, idempotentReplay: true };
352
+ const report = await db.prepare(`SELECT id FROM reports WHERE id = ?1 LIMIT 1`).bind(input.reportId).first<{ id: string }>();
353
+ if (!report) throw new ApiError(404, "report_not_found", "The report does not exist.");
354
+ const hold = await db.prepare(`SELECT id FROM legal_holds WHERE report_id = ?1 AND state = 'active' LIMIT 1`)
355
+ .bind(input.reportId).first<{ id: string }>();
356
+ const state = hold ? "blocked_by_hold" : "pending";
357
+ const id = crypto.randomUUID();
358
+ const requestedAt = now();
359
+ await db.batch([
360
+ db.prepare(`
361
+ INSERT INTO deletion_requests (
362
+ id, report_id, scope, reason, state, requested_by, idempotency_key, requested_at
363
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
364
+ `).bind(id, input.reportId, input.scope, input.reason, state, actorId, idempotencyKey, requestedAt),
365
+ auditEvent(db, {
366
+ key: `deletion-request:${idempotencyKey}`,
367
+ action: "retention.deletion_requested",
368
+ actorType: "operator",
369
+ actorId,
370
+ targetType: "report",
371
+ targetId: input.reportId,
372
+ details: { deletionRequestId: id, scope: input.scope, state },
373
+ createdAt: requestedAt,
374
+ }),
375
+ ]);
376
+ return { id, state, idempotentReplay: false };
377
+ }
378
+
379
+ export async function processDeletionRequests(db: D1Database): Promise<void> {
380
+ const requests = await db.prepare(`
381
+ SELECT id, report_id, scope FROM deletion_requests WHERE state IN ('pending', 'blocked_by_hold')
382
+ ORDER BY requested_at, id LIMIT 25
383
+ `).all<{ id: string; report_id: string; scope: DeletionScope }>();
384
+ for (const request of requests.results) {
385
+ const hold = await db.prepare(`SELECT id FROM legal_holds WHERE report_id = ?1 AND state = 'active' LIMIT 1`)
386
+ .bind(request.report_id).first<{ id: string }>();
387
+ if (hold) {
388
+ await db.prepare(`UPDATE deletion_requests SET state = 'blocked_by_hold' WHERE id = ?1`).bind(request.id).run();
389
+ continue;
390
+ }
391
+ const report = await db.prepare(`SELECT state FROM reports WHERE id = ?1 LIMIT 1`).bind(request.report_id).first<{ state: string }>();
392
+ if (!report || !["resolved_by_human", "resolved_by_ai", "closed"].includes(report.state)) {
393
+ await db.prepare(`UPDATE deletion_requests SET state = 'pending', last_error_code = 'active_case' WHERE id = ?1`).bind(request.id).run();
394
+ continue;
395
+ }
396
+ const outstandingDelivery = await db.prepare(`
397
+ SELECT 1 AS pending FROM decision_action_outbox WHERE report_id = ?1 AND terminal = 0
398
+ UNION ALL
399
+ SELECT 1 AS pending FROM notification_outbox o JOIN notices n ON n.id = o.notice_id
400
+ WHERE n.report_id = ?1 AND o.terminal = 0 LIMIT 1
401
+ `).bind(request.report_id).first<{ pending: number }>();
402
+ if (outstandingDelivery) {
403
+ await db.prepare(`UPDATE deletion_requests SET state = 'pending', last_error_code = 'delivery_pending' WHERE id = ?1`).bind(request.id).run();
404
+ continue;
405
+ }
406
+ const completedAt = now();
407
+ try {
408
+ await anonymizeReport(db, request.report_id, completedAt, "deletion-worker", `deletion_request:${request.id}`, request.scope);
409
+ await db.prepare(`UPDATE deletion_requests SET state = 'complete', completed_at = ?2, last_error_code = NULL WHERE id = ?1`)
410
+ .bind(request.id, completedAt).run();
411
+ } catch {
412
+ await db.prepare(`UPDATE deletion_requests SET state = 'failed', last_error_code = 'anonymization_failed' WHERE id = ?1`).bind(request.id).run();
413
+ }
414
+ }
415
+ }
416
+
417
+ export async function listAuditEntries(
418
+ db: D1Database,
419
+ afterSequence: number,
420
+ limit: number,
421
+ ): Promise<{ entries: Record<string, unknown>[]; nextSequence: number | null }> {
422
+ const boundedLimit = Math.max(1, Math.min(500, limit));
423
+ const result = await db.prepare(`
424
+ SELECT sequence, id, action, actor_type AS actorType, actor_id AS actorId,
425
+ target_type AS targetType, target_id AS targetId, details_json AS detailsJson,
426
+ previous_hash AS previousHash, entry_hash AS entryHash, created_at AS createdAt
427
+ FROM audit_entries WHERE sequence > ?1 ORDER BY sequence LIMIT ?2
428
+ `).bind(Math.max(0, afterSequence), boundedLimit + 1).all<Record<string, unknown>>();
429
+ const entries = result.results.slice(0, boundedLimit)
430
+ .map<Record<string, unknown>>((row) => ({ ...row, details: JSON.parse(String(row.detailsJson)), detailsJson: undefined }));
431
+ const last = entries.at(-1);
432
+ return {
433
+ entries,
434
+ nextSequence: result.results.length > boundedLimit && last ? Number(last.sequence) : null,
435
+ };
436
+ }
437
+
438
+ export async function verifyAuditChain(db: D1Database): Promise<Record<string, unknown>> {
439
+ let cursor = 0;
440
+ let previousHash = AUDIT_GENESIS_HASH;
441
+ let verified = 0;
442
+ while (true) {
443
+ const page = await db.prepare(`
444
+ SELECT sequence, action, actor_type, actor_id, target_type, target_id,
445
+ details_json, previous_hash, entry_hash, created_at
446
+ FROM audit_entries WHERE sequence > ?1 ORDER BY sequence LIMIT 500
447
+ `).bind(cursor).all<{
448
+ sequence: number; action: string; actor_type: string; actor_id: string; target_type: string;
449
+ target_id: string; details_json: string; previous_hash: string; entry_hash: string; created_at: string;
450
+ }>();
451
+ if (!page.results.length) break;
452
+ for (const entry of page.results) {
453
+ if (entry.previous_hash !== previousHash) return { valid: false, verifiedEntries: verified, failedSequence: entry.sequence, reason: "previous_hash_mismatch" };
454
+ const expected = await hashAuditEntry({
455
+ sequence: entry.sequence, action: entry.action, actorType: entry.actor_type, actorId: entry.actor_id,
456
+ targetType: entry.target_type, targetId: entry.target_id, detailsJson: entry.details_json,
457
+ previousHash: entry.previous_hash, createdAt: entry.created_at,
458
+ });
459
+ if (expected !== entry.entry_hash) return { valid: false, verifiedEntries: verified, failedSequence: entry.sequence, reason: "entry_hash_mismatch" };
460
+ previousHash = entry.entry_hash;
461
+ cursor = entry.sequence;
462
+ verified += 1;
463
+ if (verified > 100_000) return { valid: false, verifiedEntries: verified, reason: "verification_limit_exceeded" };
464
+ }
465
+ }
466
+ return { valid: true, verifiedEntries: verified, tipHash: previousHash, verifiedAt: now() };
467
+ }
468
+
469
+ function csvCell(value: unknown): string {
470
+ const raw = String(value ?? "");
471
+ const text = /^[=+\-@\t\r]/u.test(raw) ? `'${raw}` : raw;
472
+ return /[",\r\n]/u.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
473
+ }
474
+
475
+ export async function reportsCsv(db: D1Database, days: number, allowedQueueIds?: string[] | null): Promise<string> {
476
+ const from = subtractDays(now(), Math.max(1, Math.min(366, days)));
477
+ const result = await db.prepare(`
478
+ SELECT r.public_reference AS reference, r.source, r.reason_code AS allegation,
479
+ t.target_type AS targetType, r.state, r.queue_id AS queueId, r.priority,
480
+ r.received_at AS receivedAt, r.resolved_at AS resolvedAt,
481
+ d.decision_code AS decisionOutcome, d.maker_type AS decisionMakerType,
482
+ a.state AS appealState
483
+ FROM reports r JOIN report_targets t ON t.report_id = r.id
484
+ LEFT JOIN decisions d ON d.id = (SELECT id FROM decisions WHERE report_id = r.id ORDER BY created_at DESC, id DESC LIMIT 1)
485
+ LEFT JOIN appeals a ON a.id = (SELECT id FROM appeals WHERE report_id = r.id ORDER BY submitted_at DESC, id DESC LIMIT 1)
486
+ WHERE r.received_at >= ?1
487
+ AND (?2 = 1 OR r.queue_id IN (SELECT value FROM json_each(?3)))
488
+ ORDER BY r.received_at DESC, r.id DESC LIMIT 10000
489
+ `).bind(
490
+ from,
491
+ allowedQueueIds === null || allowedQueueIds === undefined ? 1 : 0,
492
+ canonicalJson(allowedQueueIds ?? []),
493
+ ).all<Record<string, unknown>>();
494
+ const columns = ["reference", "source", "allegation", "targetType", "state", "queueId", "priority", "receivedAt", "resolvedAt", "decisionOutcome", "decisionMakerType", "appealState"];
495
+ return `${columns.join(",")}\r\n${result.results.map((row) => columns.map((column) => csvCell(row[column])).join(",")).join("\r\n")}\r\n`;
496
+ }
497
+
498
+ export async function recordExportAudit(db: D1Database, actorId: string, days: number): Promise<void> {
499
+ const createdAt = now();
500
+ await db.batch([auditEvent(db, {
501
+ key: `reports-export:${actorId}:${crypto.randomUUID()}`,
502
+ action: "export.reports_created",
503
+ actorType: "operator",
504
+ actorId,
505
+ targetType: "report_export",
506
+ targetId: crypto.randomUUID(),
507
+ details: { format: "csv", days: Math.max(1, Math.min(366, days)), includesMessageBodies: false },
508
+ createdAt,
509
+ })]);
510
+ }
@@ -0,0 +1,90 @@
1
+ export class ApiError extends Error {
2
+ constructor(
3
+ readonly status: number,
4
+ readonly code: string,
5
+ message: string,
6
+ readonly details?: Record<string, unknown>,
7
+ ) {
8
+ super(message);
9
+ this.name = "ApiError";
10
+ }
11
+ }
12
+
13
+ const securityHeaders = {
14
+ "content-security-policy": "default-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
15
+ "referrer-policy": "no-referrer",
16
+ "x-content-type-options": "nosniff",
17
+ "x-frame-options": "DENY",
18
+ } as const;
19
+
20
+ export function json(data: unknown, init: ResponseInit = {}): Response {
21
+ const headers = new Headers(init.headers);
22
+ headers.set("content-type", "application/json; charset=utf-8");
23
+ headers.set("cache-control", "no-store");
24
+ for (const [name, value] of Object.entries(securityHeaders)) headers.set(name, value);
25
+ return new Response(JSON.stringify(data), { ...init, headers });
26
+ }
27
+
28
+ export function apiErrorResponse(error: unknown): Response {
29
+ if (error instanceof ApiError) {
30
+ return json({
31
+ error: {
32
+ code: error.code,
33
+ message: error.message,
34
+ ...(error.details ? { details: error.details } : {}),
35
+ },
36
+ }, { status: error.status });
37
+ }
38
+ console.error("unhandled reports request error", error instanceof Error
39
+ ? { name: error.name, message: error.message }
40
+ : { valueType: typeof error });
41
+ return json({ error: { code: "internal_error", message: "The request could not be completed." } }, { status: 500 });
42
+ }
43
+
44
+ export async function readJsonObject(request: Request, maximumBytes = 64 * 1024): Promise<Record<string, unknown>> {
45
+ const contentType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
46
+ if (contentType !== "application/json") throw new ApiError(415, "unsupported_media_type", "Content-Type must be application/json.");
47
+ const declared = Number.parseInt(request.headers.get("content-length") ?? "0", 10);
48
+ if (Number.isFinite(declared) && declared > maximumBytes) throw new ApiError(413, "request_too_large", "The request body is too large.");
49
+ const bytes = new Uint8Array(await request.arrayBuffer());
50
+ if (bytes.byteLength > maximumBytes) throw new ApiError(413, "request_too_large", "The request body is too large.");
51
+ let value: unknown;
52
+ try {
53
+ value = JSON.parse(new TextDecoder().decode(bytes));
54
+ } catch {
55
+ throw new ApiError(400, "invalid_json", "The request body must contain valid JSON.");
56
+ }
57
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
58
+ throw new ApiError(400, "invalid_body", "The request body must be a JSON object.");
59
+ }
60
+ return value as Record<string, unknown>;
61
+ }
62
+
63
+ export function allowedOrigin(origin: string | null, configured: string): string | null {
64
+ if (!origin) return null;
65
+ const allowed = new Set(configured.split(",").map((value) => value.trim()).filter(Boolean));
66
+ return allowed.has(origin) ? origin : null;
67
+ }
68
+
69
+ export function withCors(response: Response, origin: string | null): Response {
70
+ if (!origin) return response;
71
+ const headers = new Headers(response.headers);
72
+ headers.set("access-control-allow-origin", origin);
73
+ headers.set("access-control-allow-methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS");
74
+ headers.set("access-control-allow-headers", "authorization, content-type, idempotency-key, if-match, x-csrf-token, x-safest-integration-id");
75
+ headers.set("access-control-max-age", "600");
76
+ headers.append("vary", "Origin");
77
+ return new Response(response.body, {
78
+ status: response.status,
79
+ statusText: response.statusText,
80
+ headers,
81
+ webSocket: response.webSocket,
82
+ });
83
+ }
84
+
85
+ export function methodNotAllowed(allowed: string[]): Response {
86
+ return json({ error: { code: "method_not_allowed", message: "This method is not supported for the route." } }, {
87
+ status: 405,
88
+ headers: { allow: allowed.join(", ") },
89
+ });
90
+ }