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,517 @@
1
+ import { errorMessage, requestJson } from "../lib/http";
2
+ import { getQueueWorkspaceSnapshot, subscribeToQueueWorkspace } from "../queues/store";
3
+ import type { QueueRecord } from "../queues/types";
4
+ import { getShellSnapshot, setShellSnapshot, subscribeToShell } from "../shell/store";
5
+ import { reportEvents } from "./events";
6
+ import { getReportWorkspaceSnapshot, setReportWorkspaceSnapshot } from "./store";
7
+ import type {
8
+ HumanDecisionInput,
9
+ PresenceAnalyst,
10
+ ReportDetailRecord,
11
+ ReportFilters,
12
+ ReporterMessageInput,
13
+ ReportSummary,
14
+ ReportWorkspaceSnapshot,
15
+ WorkflowActionRecord,
16
+ WorkflowRunRecord,
17
+ } from "./types";
18
+
19
+ interface RawReport {
20
+ id: string;
21
+ reference: string;
22
+ targetType: string;
23
+ targetReference: string;
24
+ reasonCode: string;
25
+ queueId?: string;
26
+ state: string;
27
+ priority: number;
28
+ receivedAt: string;
29
+ claim?: {
30
+ reviewerId: string;
31
+ expiresAt: string;
32
+ analyst?: { displayName?: string; avatarUrl?: string | null; avatarColor?: string | null };
33
+ } | null;
34
+ }
35
+
36
+ interface RawPresenceAnalyst {
37
+ actorId: string;
38
+ displayName: string;
39
+ avatarUrl?: string | null;
40
+ avatarColor?: string | null;
41
+ activity: string;
42
+ activeReportId?: string | null;
43
+ }
44
+
45
+ interface ReportsResponse { reports?: RawReport[] }
46
+ interface ReportResponse { report: ReportDetailRecord }
47
+ interface TimelineResponse { events?: ReportDetailRecord["timeline"] }
48
+ interface WorkflowRunsResponse { runs?: WorkflowRunRecord[] }
49
+ interface WorkflowActionsResponse { actions?: WorkflowActionRecord[] }
50
+ interface PresenceResponse { analysts?: RawPresenceAnalyst[] }
51
+
52
+ const defaultFilters = (): ReportFilters => ({ search: "", queueId: "", priority: "", age: "", state: "", sort: "oldest" });
53
+ const initialSnapshot = (): ReportWorkspaceSnapshot => ({
54
+ loading: true,
55
+ refreshing: false,
56
+ error: "",
57
+ generatedAt: new Date().toISOString(),
58
+ actorId: "",
59
+ filters: defaultFilters(),
60
+ queues: [],
61
+ reports: [],
62
+ presence: [],
63
+ actionCodes: [],
64
+ permissions: { claimReports: false, messageReporters: false, decideReports: false, readRuns: false },
65
+ detail: { reportId: null, loading: false, error: "", pendingAction: null, messageMutationVersion: 0, report: null },
66
+ });
67
+
68
+ let actorId = "";
69
+ let rawReports: RawReport[] = [];
70
+ let rawPresence: RawPresenceAnalyst[] = [];
71
+ let detailRequestVersion = 0;
72
+ let presenceSessionId = crypto.randomUUID();
73
+ let presenceSocket: WebSocket | null = null;
74
+ let presenceReconnectTimer: ReturnType<typeof setTimeout> | null = null;
75
+ let presenceHeartbeatTimer: ReturnType<typeof setInterval> | null = null;
76
+ let presenceRunning = false;
77
+ let started = false;
78
+
79
+ function readable(value: string): string {
80
+ const copy = value.replaceAll("_", " ");
81
+ if (!copy) return "";
82
+ const sentence = `${copy.charAt(0).toUpperCase()}${copy.slice(1)}`;
83
+ const acronyms: Record<string, string> = { ai: "AI", api: "API", csv: "CSV", d1: "D1", http: "HTTP", https: "HTTPS", id: "ID", json: "JSON", r2: "R2", sla: "SLA", url: "URL" };
84
+ return sentence.replace(/\b(ai|api|csv|d1|http|https|id|json|r2|sla|url)\b/giu, (match) => acronyms[match.toLowerCase()] ?? match);
85
+ }
86
+
87
+ function priorityName(priority: number): "high" | "medium" | "low" {
88
+ if (priority >= 70) return "high";
89
+ if (priority >= 40) return "medium";
90
+ return "low";
91
+ }
92
+
93
+ function queues(): QueueRecord[] {
94
+ return getQueueWorkspaceSnapshot().queues;
95
+ }
96
+
97
+ function visibleReports(filters: ReportFilters): RawReport[] {
98
+ const query = filters.search.trim().toLowerCase();
99
+ const now = Date.now();
100
+ return rawReports.filter((report) => {
101
+ if (filters.priority && priorityName(report.priority) !== filters.priority) return false;
102
+ const milliseconds = now - Date.parse(report.receivedAt);
103
+ if (filters.age === "hour" && milliseconds > 3_600_000) return false;
104
+ if (filters.age === "day" && milliseconds > 86_400_000) return false;
105
+ if (filters.age === "older" && milliseconds <= 86_400_000) return false;
106
+ if (!query) return true;
107
+ return [report.reference, report.targetReference, report.reasonCode, report.queueId, report.state]
108
+ .some((value) => String(value || "").toLowerCase().includes(query));
109
+ }).sort((left, right) => {
110
+ if (filters.sort === "newest") return Date.parse(right.receivedAt) - Date.parse(left.receivedAt);
111
+ if (filters.sort === "priority") return right.priority - left.priority || Date.parse(left.receivedAt) - Date.parse(right.receivedAt);
112
+ return Date.parse(left.receivedAt) - Date.parse(right.receivedAt);
113
+ });
114
+ }
115
+
116
+ function reportSummaries(filters: ReportFilters): ReportSummary[] {
117
+ const session = getShellSnapshot().session;
118
+ const queueList = queues();
119
+ return visibleReports(filters).map((report) => ({
120
+ id: report.id,
121
+ reference: report.reference,
122
+ queueId: report.queueId || "",
123
+ targetType: report.targetType,
124
+ targetReference: report.targetReference,
125
+ reasonCode: report.reasonCode,
126
+ queueName: queueList.find((queue) => queue.id === report.queueId)?.name || readable(report.queueId || "Unassigned"),
127
+ state: report.state,
128
+ priority: Number(report.priority || 0),
129
+ receivedAt: report.receivedAt,
130
+ owner: report.claim ? {
131
+ reviewerId: report.claim.reviewerId,
132
+ mine: report.claim.reviewerId === session?.actor.id,
133
+ displayName: report.claim.analyst?.displayName || "Unknown analyst",
134
+ avatarUrl: report.claim.analyst?.avatarUrl || null,
135
+ avatarColor: report.claim.analyst?.avatarColor || "#315d4d",
136
+ } : null,
137
+ }));
138
+ }
139
+
140
+ function presenceAnalysts(): PresenceAnalyst[] {
141
+ return rawPresence.map((analyst) => {
142
+ const report = analyst.activeReportId ? rawReports.find((candidate) => candidate.id === analyst.activeReportId) : null;
143
+ const activityLabel = report
144
+ ? `${readable(analyst.activity)} ${report.reference}`
145
+ : analyst.activeReportId
146
+ ? `${readable(analyst.activity)} report ${analyst.activeReportId.slice(0, 8)}`
147
+ : readable(analyst.activity);
148
+ return {
149
+ actorId: analyst.actorId,
150
+ displayName: analyst.displayName,
151
+ avatarUrl: analyst.avatarUrl || null,
152
+ avatarColor: analyst.avatarColor || "#315d4d",
153
+ activity: analyst.activity,
154
+ activeReportId: analyst.activeReportId || null,
155
+ activityLabel,
156
+ };
157
+ });
158
+ }
159
+
160
+ function publish(next?: Partial<ReportWorkspaceSnapshot>): void {
161
+ const current = getReportWorkspaceSnapshot();
162
+ const shell = getShellSnapshot();
163
+ const configuration = shell.configuration;
164
+ const availableQueues = queues().filter((queue) => queue.status !== "archived");
165
+ const filters = next?.filters || current.filters;
166
+ const validQueueId = !filters.queueId || availableQueues.some((queue) => queue.id === filters.queueId);
167
+ const reconciledFilters = validQueueId ? filters : { ...filters, queueId: "" };
168
+ setReportWorkspaceSnapshot({
169
+ ...current,
170
+ ...next,
171
+ generatedAt: new Date().toISOString(),
172
+ actorId: shell.session?.actor.id || "",
173
+ filters: reconciledFilters,
174
+ queues: availableQueues.map((queue) => ({ id: queue.id, name: queue.name })),
175
+ reports: reportSummaries(reconciledFilters),
176
+ presence: presenceAnalysts(),
177
+ actionCodes: Array.isArray(configuration?.allowed_action_codes) && configuration?.action_delivery_enabled
178
+ ? configuration.allowed_action_codes
179
+ : [],
180
+ permissions: {
181
+ claimReports: shell.session?.permissions.claimReports === true,
182
+ messageReporters: shell.session?.permissions.messageReporters === true,
183
+ decideReports: shell.session?.permissions.decideReports === true,
184
+ readRuns: shell.session?.permissions.readRuns === true,
185
+ },
186
+ });
187
+ }
188
+
189
+ async function loadReports(): Promise<void> {
190
+ const session = getShellSnapshot().session;
191
+ if (!session?.permissions.readReports) {
192
+ rawReports = [];
193
+ setShellSnapshot((shell) => ({ ...shell, reportCount: 0 }));
194
+ publish({ loading: false, refreshing: false, error: "" });
195
+ return;
196
+ }
197
+ const snapshot = getReportWorkspaceSnapshot();
198
+ publish({ error: "", refreshing: !snapshot.loading });
199
+ const params = new URLSearchParams({ limit: "100" });
200
+ if (snapshot.filters.queueId) params.set("queue_id", snapshot.filters.queueId);
201
+ if (snapshot.filters.state) params.set("state", snapshot.filters.state);
202
+ try {
203
+ const response = await requestJson<ReportsResponse>(`/v1/admin/reports?${params}`);
204
+ rawReports = response.reports || [];
205
+ setShellSnapshot((shell) => ({
206
+ ...shell,
207
+ reportCount: rawReports.filter((report) => !["resolved_by_human", "resolved_by_ai", "closed"].includes(report.state)).length,
208
+ }));
209
+ } catch (cause) {
210
+ publish({ error: errorMessage(cause, "Reports could not be loaded.") });
211
+ } finally {
212
+ publish({ loading: false, refreshing: false });
213
+ }
214
+ }
215
+
216
+ async function openReport(reportId: string): Promise<void> {
217
+ const version = ++detailRequestVersion;
218
+ const snapshot = getReportWorkspaceSnapshot();
219
+ document.body.classList.add("report-open");
220
+ publish({
221
+ detail: {
222
+ ...snapshot.detail,
223
+ reportId,
224
+ loading: true,
225
+ error: "",
226
+ report: snapshot.detail.report?.id === reportId ? snapshot.detail.report : null,
227
+ },
228
+ });
229
+ try {
230
+ const encodedId = encodeURIComponent(reportId);
231
+ const canReadRuns = getShellSnapshot().session?.permissions.readRuns === true;
232
+ const [reportResponse, timelineResponse, runsResponse, actionsResponse] = await Promise.all([
233
+ requestJson<ReportResponse>(`/v1/admin/reports/${encodedId}`),
234
+ requestJson<TimelineResponse>(`/v1/admin/reports/${encodedId}/timeline?limit=50`).catch(() => ({ events: [] })),
235
+ canReadRuns ? requestJson<WorkflowRunsResponse>(`/v1/admin/reports/${encodedId}/workflow-runs`).catch(() => ({ runs: [] })) : Promise.resolve({ runs: [] }),
236
+ requestJson<WorkflowActionsResponse>(`/v1/admin/reports/${encodedId}/actions`).catch(() => ({ actions: [] })),
237
+ ]);
238
+ if (version !== detailRequestVersion || getReportWorkspaceSnapshot().detail.reportId !== reportId) return;
239
+ publish({
240
+ detail: {
241
+ ...getReportWorkspaceSnapshot().detail,
242
+ report: {
243
+ ...reportResponse.report,
244
+ timeline: timelineResponse.events || [],
245
+ workflowRuns: runsResponse.runs || [],
246
+ workflowActions: actionsResponse.actions || [],
247
+ },
248
+ },
249
+ });
250
+ sendPresenceActivity();
251
+ } catch (cause) {
252
+ if (version !== detailRequestVersion || getReportWorkspaceSnapshot().detail.reportId !== reportId) return;
253
+ publish({ detail: { ...getReportWorkspaceSnapshot().detail, report: null, error: errorMessage(cause, "The report could not be opened.") } });
254
+ } finally {
255
+ if (version === detailRequestVersion && getReportWorkspaceSnapshot().detail.reportId === reportId) {
256
+ publish({ detail: { ...getReportWorkspaceSnapshot().detail, loading: false } });
257
+ }
258
+ }
259
+ }
260
+
261
+ function closeReport(): void {
262
+ detailRequestVersion += 1;
263
+ const messageMutationVersion = getReportWorkspaceSnapshot().detail.messageMutationVersion;
264
+ document.body.classList.remove("report-open");
265
+ publish({ detail: { reportId: null, loading: false, error: "", pendingAction: null, messageMutationVersion, report: null } });
266
+ sendPresenceActivity();
267
+ }
268
+
269
+ async function claimReport(): Promise<void> {
270
+ const snapshot = getReportWorkspaceSnapshot();
271
+ const reportId = snapshot.detail.report?.id;
272
+ if (!reportId || !snapshot.permissions.claimReports || snapshot.detail.pendingAction) return;
273
+ publish({ detail: { ...snapshot.detail, error: "", pendingAction: "claim" } });
274
+ try {
275
+ await requestJson(`/v1/admin/reports/${encodeURIComponent(reportId)}/claim`, { method: "POST" });
276
+ await Promise.all([openReport(reportId), loadReports()]);
277
+ } catch (cause) {
278
+ publish({ detail: { ...getReportWorkspaceSnapshot().detail, error: errorMessage(cause, "The report could not be claimed.") } });
279
+ } finally {
280
+ publish({ detail: { ...getReportWorkspaceSnapshot().detail, pendingAction: null } });
281
+ }
282
+ }
283
+
284
+ async function releaseReport(): Promise<void> {
285
+ const snapshot = getReportWorkspaceSnapshot();
286
+ const reportId = snapshot.detail.report?.id;
287
+ if (!reportId || !snapshot.permissions.claimReports || snapshot.detail.pendingAction) return;
288
+ publish({ detail: { ...snapshot.detail, error: "", pendingAction: "release" } });
289
+ try {
290
+ await requestJson(`/v1/admin/reports/${encodeURIComponent(reportId)}/claim`, { method: "DELETE" });
291
+ await Promise.all([openReport(reportId), loadReports()]);
292
+ } catch (cause) {
293
+ publish({ detail: { ...getReportWorkspaceSnapshot().detail, error: errorMessage(cause, "The claim could not be released.") } });
294
+ } finally {
295
+ publish({ detail: { ...getReportWorkspaceSnapshot().detail, pendingAction: null } });
296
+ }
297
+ }
298
+
299
+ async function sendMessage(input: ReporterMessageInput): Promise<void> {
300
+ const snapshot = getReportWorkspaceSnapshot();
301
+ const reportId = snapshot.detail.report?.id;
302
+ if (!reportId || !snapshot.permissions.messageReporters || snapshot.detail.pendingAction) return;
303
+ const idempotencyKey = crypto.randomUUID();
304
+ publish({ detail: { ...snapshot.detail, error: "", pendingAction: "message" } });
305
+ try {
306
+ await requestJson(`/v1/admin/reports/${encodeURIComponent(reportId)}/messages`, {
307
+ method: "POST",
308
+ headers: { "content-type": "application/json", "idempotency-key": idempotencyKey },
309
+ body: JSON.stringify({ idempotency_key: idempotencyKey, body: input.body, await_reporter: input.awaitReporter }),
310
+ });
311
+ const version = getReportWorkspaceSnapshot().detail.messageMutationVersion + 1;
312
+ publish({ detail: { ...getReportWorkspaceSnapshot().detail, messageMutationVersion: version } });
313
+ await Promise.all([openReport(reportId), loadReports()]);
314
+ } catch (cause) {
315
+ publish({ detail: { ...getReportWorkspaceSnapshot().detail, error: errorMessage(cause, "The message could not be created.") } });
316
+ } finally {
317
+ publish({ detail: { ...getReportWorkspaceSnapshot().detail, pendingAction: null } });
318
+ }
319
+ }
320
+
321
+ async function recordDecision(input: HumanDecisionInput): Promise<void> {
322
+ const snapshot = getReportWorkspaceSnapshot();
323
+ const reportId = snapshot.detail.report?.id;
324
+ if (!reportId || !snapshot.permissions.decideReports || snapshot.detail.pendingAction) return;
325
+ const idempotencyKey = crypto.randomUUID();
326
+ publish({ detail: { ...snapshot.detail, error: "", pendingAction: "decision" } });
327
+ try {
328
+ await requestJson(`/v1/admin/reports/${encodeURIComponent(reportId)}/decisions`, {
329
+ method: "POST",
330
+ headers: { "content-type": "application/json", "idempotency-key": idempotencyKey },
331
+ body: JSON.stringify({
332
+ idempotency_key: idempotencyKey,
333
+ decision_code: input.decisionCode,
334
+ policy_code: input.policyCode,
335
+ rationale: input.rationale,
336
+ evidence_references: input.evidenceReferences,
337
+ reporter_notice: input.reporterNotice,
338
+ affected_user_notice: input.affectedUserNotice,
339
+ ...(input.actionCode ? { action_code: input.actionCode } : {}),
340
+ }),
341
+ });
342
+ closeReport();
343
+ await loadReports();
344
+ } catch (cause) {
345
+ publish({ detail: { ...getReportWorkspaceSnapshot().detail, error: errorMessage(cause, "The decision could not be recorded.") } });
346
+ } finally {
347
+ publish({ detail: { ...getReportWorkspaceSnapshot().detail, pendingAction: null } });
348
+ }
349
+ }
350
+
351
+ function currentPresenceActivity(): { activity: string; reportId: string | null } {
352
+ const snapshot = getReportWorkspaceSnapshot();
353
+ const report = snapshot.detail.report;
354
+ if (!report || !snapshot.detail.reportId) return { activity: "online", reportId: null };
355
+ const claim = report.claim;
356
+ const mine = Boolean(claim && claim.reviewerId === getShellSnapshot().session?.actor.id && Date.parse(claim.expiresAt) > Date.now());
357
+ return { activity: mine ? "editing" : "viewing", reportId: report.id };
358
+ }
359
+
360
+ async function heartbeatPresence(): Promise<void> {
361
+ if (!getShellSnapshot().session) return;
362
+ const current = currentPresenceActivity();
363
+ try {
364
+ const response = await requestJson<PresenceResponse>("/v1/admin/presence", {
365
+ method: "POST",
366
+ headers: { "content-type": "application/json" },
367
+ body: JSON.stringify({ session_id: presenceSessionId, activity: current.activity, active_report_id: current.reportId }),
368
+ });
369
+ rawPresence = response.analysts || [];
370
+ publish();
371
+ } catch {
372
+ // Presence is advisory. Durable claims remain authoritative.
373
+ }
374
+ }
375
+
376
+ function sendPresenceActivity(): void {
377
+ if (!getShellSnapshot().session) return;
378
+ const current = currentPresenceActivity();
379
+ if (presenceSocket?.readyState === WebSocket.OPEN) {
380
+ presenceSocket.send(JSON.stringify(current.reportId
381
+ ? { type: "focus_report", report_id: current.reportId, editing: current.activity === "editing" }
382
+ : { type: "blur_report" }));
383
+ } else {
384
+ void heartbeatPresence();
385
+ }
386
+ }
387
+
388
+ function openPresenceSocket(): void {
389
+ const session = getShellSnapshot().session;
390
+ if (!session || presenceSocket) return;
391
+ const url = new URL("/v1/admin/presence/socket", window.location.href);
392
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
393
+ url.searchParams.set("session_id", presenceSessionId);
394
+ const socket = new WebSocket(url);
395
+ presenceSocket = socket;
396
+ socket.addEventListener("open", sendPresenceActivity);
397
+ socket.addEventListener("message", (event) => {
398
+ try {
399
+ const message = JSON.parse(String(event.data)) as { type?: string; analysts?: RawPresenceAnalyst[]; report_id?: string };
400
+ if (message.type === "presence_snapshot" && Array.isArray(message.analysts)) {
401
+ rawPresence = message.analysts;
402
+ publish();
403
+ }
404
+ if (message.type === "claim_changed" || message.type === "reports_changed") {
405
+ void loadReports();
406
+ const report = getReportWorkspaceSnapshot().detail.report;
407
+ if (report && (!message.report_id || report.id === message.report_id)) void openReport(report.id);
408
+ }
409
+ } catch {
410
+ // Ignore messages outside the bounded presence protocol.
411
+ }
412
+ });
413
+ socket.addEventListener("close", () => {
414
+ if (presenceSocket === socket) presenceSocket = null;
415
+ if (presenceRunning && getShellSnapshot().session) {
416
+ void heartbeatPresence();
417
+ if (presenceReconnectTimer) clearTimeout(presenceReconnectTimer);
418
+ presenceReconnectTimer = setTimeout(openPresenceSocket, 3_000);
419
+ }
420
+ });
421
+ socket.addEventListener("error", () => socket.close());
422
+ }
423
+
424
+ function stopPresence(): void {
425
+ presenceRunning = false;
426
+ if (presenceHeartbeatTimer) clearInterval(presenceHeartbeatTimer);
427
+ if (presenceReconnectTimer) clearTimeout(presenceReconnectTimer);
428
+ presenceHeartbeatTimer = null;
429
+ presenceReconnectTimer = null;
430
+ const socket = presenceSocket;
431
+ presenceSocket = null;
432
+ if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "workspace closed");
433
+ rawPresence = [];
434
+ }
435
+
436
+ async function startPresence(): Promise<void> {
437
+ stopPresence();
438
+ presenceSessionId = crypto.randomUUID();
439
+ presenceRunning = true;
440
+ try {
441
+ const response = await requestJson<PresenceResponse>("/v1/admin/presence");
442
+ rawPresence = response.analysts || [];
443
+ } catch {
444
+ rawPresence = [];
445
+ }
446
+ publish();
447
+ presenceHeartbeatTimer = setInterval(() => {
448
+ if (presenceSocket?.readyState === WebSocket.OPEN) {
449
+ const current = currentPresenceActivity();
450
+ presenceSocket.send(JSON.stringify({ type: "heartbeat", activity: current.activity, report_id: current.reportId }));
451
+ } else {
452
+ void heartbeatPresence();
453
+ }
454
+ }, 15_000);
455
+ openPresenceSocket();
456
+ }
457
+
458
+ function validFilters(value: unknown): value is ReportFilters {
459
+ if (!value || typeof value !== "object") return false;
460
+ const filters = value as Partial<ReportFilters>;
461
+ return typeof filters.search === "string"
462
+ && typeof filters.queueId === "string"
463
+ && ["", "high", "medium", "low"].includes(filters.priority ?? "invalid")
464
+ && ["", "hour", "day", "older"].includes(filters.age ?? "invalid")
465
+ && typeof filters.state === "string"
466
+ && ["oldest", "newest", "priority"].includes(filters.sort ?? "invalid");
467
+ }
468
+
469
+ export function startReportController(): void {
470
+ if (started) return;
471
+ started = true;
472
+ subscribeToShell(() => {
473
+ const session = getShellSnapshot().session;
474
+ const nextActorId = session?.actor.id || "";
475
+ if (nextActorId === actorId) {
476
+ publish();
477
+ return;
478
+ }
479
+ actorId = nextActorId;
480
+ detailRequestVersion += 1;
481
+ stopPresence();
482
+ rawReports = [];
483
+ setShellSnapshot((shell) => ({ ...shell, reportCount: 0 }));
484
+ document.body.classList.remove("report-open");
485
+ setReportWorkspaceSnapshot(initialSnapshot());
486
+ if (session) {
487
+ void loadReports();
488
+ void startPresence();
489
+ }
490
+ });
491
+ subscribeToQueueWorkspace(() => publish());
492
+ window.addEventListener(reportEvents.open, (event) => {
493
+ if (event instanceof CustomEvent && typeof event.detail?.reportId === "string") void openReport(event.detail.reportId);
494
+ });
495
+ window.addEventListener(reportEvents.close, closeReport);
496
+ window.addEventListener(reportEvents.refresh, () => { void loadReports(); });
497
+ window.addEventListener(reportEvents.filters, (event) => {
498
+ if (!(event instanceof CustomEvent) || !validFilters(event.detail?.filters)) return;
499
+ const previous = getReportWorkspaceSnapshot().filters;
500
+ const filters = event.detail.filters;
501
+ publish({ filters });
502
+ if (filters.queueId !== previous.queueId || filters.state !== previous.state) void loadReports();
503
+ });
504
+ window.addEventListener(reportEvents.claim, () => { void claimReport(); });
505
+ window.addEventListener(reportEvents.release, () => { void releaseReport(); });
506
+ window.addEventListener(reportEvents.message, (event) => {
507
+ if (!(event instanceof CustomEvent) || typeof event.detail?.body !== "string" || typeof event.detail?.awaitReporter !== "boolean") return;
508
+ void sendMessage(event.detail as ReporterMessageInput);
509
+ });
510
+ window.addEventListener(reportEvents.decision, (event) => {
511
+ if (!(event instanceof CustomEvent) || !Array.isArray(event.detail?.evidenceReferences)) return;
512
+ void recordDecision(event.detail as HumanDecisionInput);
513
+ });
514
+ window.addEventListener("keydown", (event) => {
515
+ if (event.key === "Escape" && getReportWorkspaceSnapshot().detail.reportId) closeReport();
516
+ });
517
+ }
@@ -0,0 +1,42 @@
1
+ import type { HumanDecisionInput, ReportFilters, ReporterMessageInput, ReportWorkspaceSnapshot } from "./types";
2
+
3
+ export const reportEvents = {
4
+ state: "safest:reports:state",
5
+ refresh: "safest:reports:refresh",
6
+ filters: "safest:reports:filters",
7
+ open: "safest:reports:open",
8
+ close: "safest:reports:close",
9
+ claim: "safest:reports:claim",
10
+ release: "safest:reports:release",
11
+ message: "safest:reports:message",
12
+ decision: "safest:reports:decision",
13
+ } as const;
14
+
15
+ function dispatch<TDetail>(type: string, detail?: TDetail): void {
16
+ window.dispatchEvent(detail === undefined ? new Event(type) : new CustomEvent(type, { detail }));
17
+ }
18
+
19
+ export function dispatchReportRefresh(): void { dispatch(reportEvents.refresh); }
20
+ export function dispatchReportFilters(filters: ReportFilters): void { dispatch(reportEvents.filters, { filters }); }
21
+ export function dispatchOpenReport(reportId: string): void { dispatch(reportEvents.open, { reportId }); }
22
+ export function dispatchCloseReport(): void { dispatch(reportEvents.close); }
23
+ export function dispatchClaimReport(): void { dispatch(reportEvents.claim); }
24
+ export function dispatchReleaseReport(): void { dispatch(reportEvents.release); }
25
+ export function dispatchReporterMessage(input: ReporterMessageInput): void { dispatch(reportEvents.message, input); }
26
+ export function dispatchHumanDecision(input: HumanDecisionInput): void { dispatch(reportEvents.decision, input); }
27
+
28
+ export function isReportWorkspaceSnapshot(value: unknown): value is ReportWorkspaceSnapshot {
29
+ if (!value || typeof value !== "object") return false;
30
+ const candidate = value as Partial<ReportWorkspaceSnapshot>;
31
+ return typeof candidate.loading === "boolean"
32
+ && typeof candidate.refreshing === "boolean"
33
+ && typeof candidate.error === "string"
34
+ && typeof candidate.generatedAt === "string"
35
+ && typeof candidate.filters === "object"
36
+ && Array.isArray(candidate.queues)
37
+ && Array.isArray(candidate.reports)
38
+ && Array.isArray(candidate.presence)
39
+ && Array.isArray(candidate.actionCodes)
40
+ && typeof candidate.permissions === "object"
41
+ && typeof candidate.detail === "object";
42
+ }
@@ -0,0 +1,39 @@
1
+ import { reportEvents, isReportWorkspaceSnapshot } from "./events";
2
+ import type { ReportWorkspaceSnapshot } from "./types";
3
+
4
+ const subscribers = new Set<() => void>();
5
+
6
+ let snapshot: ReportWorkspaceSnapshot = {
7
+ loading: true,
8
+ refreshing: false,
9
+ error: "",
10
+ generatedAt: new Date().toISOString(),
11
+ actorId: "",
12
+ filters: { search: "", queueId: "", priority: "", age: "", state: "", sort: "oldest" },
13
+ queues: [],
14
+ reports: [],
15
+ presence: [],
16
+ actionCodes: [],
17
+ permissions: { claimReports: false, messageReporters: false, decideReports: false, readRuns: false },
18
+ detail: { reportId: null, loading: false, error: "", pendingAction: null, messageMutationVersion: 0, report: null },
19
+ };
20
+
21
+ window.addEventListener(reportEvents.state, (event) => {
22
+ if (!(event instanceof CustomEvent) || !isReportWorkspaceSnapshot(event.detail)) return;
23
+ snapshot = event.detail;
24
+ for (const subscriber of subscribers) subscriber();
25
+ });
26
+
27
+ export function getReportWorkspaceSnapshot(): ReportWorkspaceSnapshot {
28
+ return snapshot;
29
+ }
30
+
31
+ export function setReportWorkspaceSnapshot(next: ReportWorkspaceSnapshot | ((current: ReportWorkspaceSnapshot) => ReportWorkspaceSnapshot)): void {
32
+ snapshot = typeof next === "function" ? next(snapshot) : next;
33
+ for (const subscriber of subscribers) subscriber();
34
+ }
35
+
36
+ export function subscribeToReportWorkspace(callback: () => void): () => void {
37
+ subscribers.add(callback);
38
+ return () => subscribers.delete(callback);
39
+ }