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,211 @@
1
+ import { canonicalJson } from "./audit";
2
+ import { evaluateInputMapping, evaluateWorkflowExpression } from "./workflow-expressions";
3
+ import type { JsonObject, JsonValue, WorkflowGraphV1, WorkflowNodeV1 } from "./workflow-platform-types";
4
+
5
+ export interface WorkflowSimulationFixture {
6
+ input: JsonObject;
7
+ componentResults: JsonObject;
8
+ humanResponses: JsonObject;
9
+ failureInjection: JsonObject;
10
+ }
11
+
12
+ export interface WorkflowSimulationStep {
13
+ nodeId: string;
14
+ kind: string;
15
+ branchKey: string;
16
+ state: "succeeded" | "failed" | "timed_out" | "simulated";
17
+ port: string;
18
+ output: JsonObject;
19
+ }
20
+
21
+ export interface WorkflowSimulationResult {
22
+ status: "passed" | "failed";
23
+ terminalCode: string | null;
24
+ output: JsonObject | null;
25
+ steps: WorkflowSimulationStep[];
26
+ error: { code: string; nodeId?: string } | null;
27
+ canonicalResult: string;
28
+ }
29
+
30
+ class SimulationFailure extends Error {
31
+ constructor(readonly code: string, readonly nodeId?: string) {
32
+ super(code);
33
+ }
34
+ }
35
+
36
+ function object(value: JsonValue | undefined): JsonObject {
37
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
38
+ }
39
+
40
+ function conditionCases(node: WorkflowNodeV1): { port: string; when: JsonValue }[] {
41
+ const configured = node.config.cases;
42
+ if (Array.isArray(configured)) {
43
+ return configured.flatMap((entry) => {
44
+ if (!entry || typeof entry !== "object" || Array.isArray(entry) || typeof entry.port !== "string" || !("when" in entry)) return [];
45
+ return [{ port: entry.port, when: entry.when }];
46
+ });
47
+ }
48
+ return configured && typeof configured === "object"
49
+ ? Object.entries(configured).map(([port, when]) => ({ port, when })) : [];
50
+ }
51
+
52
+ function selectedFixtureResult(fixture: WorkflowSimulationFixture, node: WorkflowNodeV1): JsonObject | null {
53
+ const componentId = typeof node.config.component_version_id === "string" ? node.config.component_version_id : null;
54
+ const agentId = typeof node.config.agent_version_id === "string" ? node.config.agent_version_id : null;
55
+ const selected = fixture.componentResults[node.id]
56
+ ?? (componentId ? fixture.componentResults[componentId] : undefined)
57
+ ?? (agentId ? fixture.componentResults[agentId] : undefined);
58
+ return selected === undefined ? null : object(selected);
59
+ }
60
+
61
+ export function simulateWorkflow(graph: WorkflowGraphV1, fixture: WorkflowSimulationFixture): WorkflowSimulationResult {
62
+ const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
63
+ const outgoing = new Map(graph.nodes.map((node) => [node.id, graph.edges.filter((edge) => edge.from.nodeId === node.id)]));
64
+ const steps: WorkflowSimulationStep[] = [];
65
+ const stepOutputs: JsonObject = {};
66
+ const baseContext: JsonObject = { input: fixture.input, steps: stepOutputs, findings: {} };
67
+
68
+ const record = (node: WorkflowNodeV1, branchKey: string, state: WorkflowSimulationStep["state"], port: string, output: JsonObject) => {
69
+ const entry = { nodeId: node.id, kind: node.kind, branchKey, state, port, output };
70
+ steps.push(entry);
71
+ stepOutputs[node.id] = output;
72
+ return entry;
73
+ };
74
+ const next = (node: WorkflowNodeV1, port: string): string | null => {
75
+ const edge = (outgoing.get(node.id) ?? []).find((candidate) => candidate.from.port === port);
76
+ if (!edge) throw new SimulationFailure("simulation_port_unresolved", node.id);
77
+ return edge.to.nodeId;
78
+ };
79
+ const executePath = (initialNodeId: string, stopNodeId: string | null, branchKey: string): JsonObject => {
80
+ let nodeId: string | null = initialNodeId;
81
+ let last: JsonObject = {};
82
+ while (nodeId && nodeId !== stopNodeId) {
83
+ const node: WorkflowNodeV1 | undefined = nodeById.get(nodeId);
84
+ if (!node) throw new SimulationFailure("simulation_node_missing", nodeId);
85
+ const injected = fixture.failureInjection[node.id];
86
+ if (typeof injected === "string") {
87
+ const port = node.errorPort ?? (injected === "timeout" ? "timeout" : "error");
88
+ last = { port, error_code: injected };
89
+ record(node, branchKey, injected === "timeout" ? "timed_out" : "failed", port, last);
90
+ nodeId = next(node, port);
91
+ continue;
92
+ }
93
+ if (node.kind === "parallel_group") {
94
+ const joinNodeId = typeof node.config.join_node_id === "string" ? node.config.join_node_id : null;
95
+ if (!joinNodeId) throw new SimulationFailure("simulation_join_missing", node.id);
96
+ const branchPorts = Array.isArray(node.config.branch_ports) ? node.config.branch_ports.filter((port): port is string => typeof port === "string") : [];
97
+ const branchOutput: JsonObject = {};
98
+ for (const port of branchPorts) {
99
+ const edge = (outgoing.get(node.id) ?? []).find((candidate) => candidate.from.port === port);
100
+ if (!edge) throw new SimulationFailure("simulation_branch_missing", node.id);
101
+ branchOutput[port] = executePath(edge.to.nodeId, joinNodeId, port);
102
+ }
103
+ const join = nodeById.get(joinNodeId);
104
+ if (!join) throw new SimulationFailure("simulation_join_missing", node.id);
105
+ last = { port: "success", branches: branchOutput };
106
+ record(join, branchKey, "succeeded", "success", last);
107
+ nodeId = next(join, "success");
108
+ continue;
109
+ }
110
+ const mapped = evaluateInputMapping(node.inputMapping, baseContext);
111
+ let state: WorkflowSimulationStep["state"] = "succeeded";
112
+ if (node.kind === "start" || node.kind === "join") last = { port: "success", input: mapped };
113
+ else if (node.kind === "enrichment") {
114
+ const result = selectedFixtureResult(fixture, node);
115
+ if (!result) throw new SimulationFailure("simulation_component_result_missing", node.id);
116
+ last = { port: "success", finding_id: `fixture:${node.id}`, data: result };
117
+ } else if (node.kind === "condition") {
118
+ const context = { ...baseContext, node_input: mapped };
119
+ const matches = conditionCases(node).filter((candidate) => evaluateWorkflowExpression(candidate.when, context) === true);
120
+ if (matches.length > 1) throw new SimulationFailure("condition_not_mutually_exclusive", node.id);
121
+ const port = matches[0]?.port ?? (typeof node.config.default_port === "string" ? node.config.default_port : null);
122
+ if (!port) throw new SimulationFailure("condition_no_match", node.id);
123
+ last = { port, matched: matches[0]?.port ?? null, input: mapped };
124
+ } else if (node.kind === "ai_proposal") {
125
+ const result = selectedFixtureResult(fixture, node);
126
+ if (!result) throw new SimulationFailure("simulation_ai_result_missing", node.id);
127
+ const resultCode = [result.result_code, result.outcome, result.conclusion, result.classification]
128
+ .find((value): value is string => typeof value === "string");
129
+ if (!resultCode) throw new SimulationFailure("simulation_ai_result_port_missing", node.id);
130
+ last = { port: resultCode, data: result };
131
+ } else if (node.kind === "human_task") {
132
+ const response = fixture.humanResponses[node.id];
133
+ if (response === undefined) {
134
+ state = "timed_out";
135
+ last = { port: "timeout", status: "timed_out" };
136
+ } else {
137
+ last = { port: "completed", status: "received", response };
138
+ }
139
+ } else if (node.kind === "wait_for_event") {
140
+ const response = fixture.humanResponses[node.id];
141
+ if (response === undefined) {
142
+ state = "timed_out";
143
+ last = { port: "timeout", status: "timed_out" };
144
+ } else last = { port: "received", event: response };
145
+ } else if (node.kind === "delay" || node.kind === "delay_until") {
146
+ state = "simulated";
147
+ last = { port: "completed", simulated: true };
148
+ } else if (node.kind === "send_message") {
149
+ state = "simulated";
150
+ last = { port: "sent", simulated: true, audience: node.config.audience ?? null };
151
+ } else if (node.kind === "decision") {
152
+ state = "simulated";
153
+ last = { port: "recorded", simulated: true, decision: mapped };
154
+ } else if (node.kind === "action") {
155
+ state = "simulated";
156
+ last = { port: "applied", simulated: true, action: mapped };
157
+ } else if (node.kind === "handoff") {
158
+ state = "simulated";
159
+ last = { port: "handed_off", simulated: true, handoff: mapped };
160
+ } else if (node.kind === "start_linked_workflow") {
161
+ state = "simulated";
162
+ last = { port: "started", simulated: true };
163
+ } else if (node.kind === "end") {
164
+ const terminalCode = typeof node.config.terminal_code === "string" ? node.config.terminal_code : null;
165
+ if (!terminalCode) throw new SimulationFailure("simulation_terminal_missing", node.id);
166
+ last = { port: "terminal", terminal_code: terminalCode, output: mapped };
167
+ record(node, branchKey, state, "terminal", last);
168
+ return last;
169
+ } else throw new SimulationFailure("simulation_node_unsupported", node.id);
170
+ const port = typeof last.port === "string" ? last.port : "success";
171
+ record(node, branchKey, state, port, last);
172
+ nodeId = next(node, port);
173
+ }
174
+ return last;
175
+ };
176
+
177
+ let terminalCode: string | null = null;
178
+ let output: JsonObject | null = null;
179
+ let error: WorkflowSimulationResult["error"] = null;
180
+ try {
181
+ output = executePath(graph.entryNodeId, null, "");
182
+ terminalCode = typeof output.terminal_code === "string" ? output.terminal_code : null;
183
+ if (!terminalCode) throw new SimulationFailure("simulation_did_not_terminate");
184
+ } catch (caught) {
185
+ const failure = caught instanceof SimulationFailure ? caught : new SimulationFailure("simulation_failed");
186
+ error = { code: failure.code, ...(failure.nodeId ? { nodeId: failure.nodeId } : {}) };
187
+ }
188
+ const material = { terminalCode, output, steps, error };
189
+ return {
190
+ status: error ? "failed" : "passed",
191
+ terminalCode,
192
+ output,
193
+ steps,
194
+ error,
195
+ canonicalResult: canonicalJson(material),
196
+ };
197
+ }
198
+
199
+ export function simulationMatchesExpected(result: WorkflowSimulationResult, expected: JsonObject): boolean {
200
+ if (typeof expected.terminal_code === "string" && result.terminalCode !== expected.terminal_code) return false;
201
+ if (typeof expected.status === "string" && result.status !== expected.status) return false;
202
+ if (Array.isArray(expected.required_nodes)) {
203
+ const seen = new Set(result.steps.map((step) => step.nodeId));
204
+ if (expected.required_nodes.some((nodeId) => typeof nodeId !== "string" || !seen.has(nodeId))) return false;
205
+ }
206
+ if (Array.isArray(expected.forbidden_nodes)) {
207
+ const seen = new Set(result.steps.map((step) => step.nodeId));
208
+ if (expected.forbidden_nodes.some((nodeId) => typeof nodeId === "string" && seen.has(nodeId))) return false;
209
+ }
210
+ return true;
211
+ }
@@ -0,0 +1,128 @@
1
+ import { ApiError } from "./report-http";
2
+
3
+ export interface WorkspaceBrand {
4
+ organization_name: string;
5
+ logo_url: string;
6
+ logo_alt: string;
7
+ primary_color: string;
8
+ primary_contrast_color: string;
9
+ accent_color: string;
10
+ background_color: string;
11
+ surface_color: string;
12
+ text_color: string;
13
+ muted_color: string;
14
+ border_color: string;
15
+ body_font_family: string;
16
+ heading_font_family: string;
17
+ font_css_url: string;
18
+ border_radius: number;
19
+ }
20
+
21
+ const DEFAULT_BRAND: Omit<WorkspaceBrand, "organization_name"> = {
22
+ logo_url: "",
23
+ logo_alt: "",
24
+ primary_color: "#154e3a",
25
+ primary_contrast_color: "#ffffff",
26
+ accent_color: "#cbef68",
27
+ background_color: "#f5f6f1",
28
+ surface_color: "#ffffff",
29
+ text_color: "#14231c",
30
+ muted_color: "#637168",
31
+ border_color: "#dfe6e0",
32
+ body_font_family: '"Safest Sans", Manrope, ui-sans-serif, system-ui, sans-serif',
33
+ heading_font_family: '"Safest Serif", Newsreader, Georgia, serif',
34
+ font_css_url: "",
35
+ border_radius: 12,
36
+ };
37
+
38
+ function object(value: unknown): Record<string, unknown> {
39
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
40
+ throw new ApiError(400, "brand_invalid", "Brand settings must be an object.");
41
+ }
42
+ return value as Record<string, unknown>;
43
+ }
44
+
45
+ function color(value: unknown, field: string, fallback: string): string {
46
+ const resolved = value === undefined || value === "" ? fallback : value;
47
+ if (typeof resolved !== "string" || !/^#[0-9a-f]{6}$/iu.test(resolved)) {
48
+ throw new ApiError(400, "brand_color_invalid", `${field} must be a six-digit hexadecimal color.`, { field: `settings.brand.${field}` });
49
+ }
50
+ return resolved.toLowerCase();
51
+ }
52
+
53
+ function text(value: unknown, field: string, fallback: string, minimum: number, maximum: number): string {
54
+ const resolved = value === undefined ? fallback : value;
55
+ if (typeof resolved !== "string" || resolved.trim().length < minimum || resolved.length > maximum) {
56
+ throw new ApiError(400, "brand_field_invalid", `${field} must contain ${minimum} to ${maximum} characters.`, { field: `settings.brand.${field}` });
57
+ }
58
+ return resolved.trim();
59
+ }
60
+
61
+ function optionalHttpsUrl(value: unknown, field: string): string {
62
+ if (value === undefined || value === null || value === "") return "";
63
+ if (typeof value !== "string" || value.length > 2_048) {
64
+ throw new ApiError(400, "brand_url_invalid", `${field} must be a valid HTTPS URL.`, { field: `settings.brand.${field}` });
65
+ }
66
+ try {
67
+ const url = new URL(value);
68
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
69
+ if ((url.protocol !== "https:" && !(local && url.protocol === "http:")) || url.username || url.password) throw new Error("unsafe URL");
70
+ return url.toString();
71
+ } catch {
72
+ throw new ApiError(400, "brand_url_invalid", `${field} must be a valid HTTPS URL.`, { field: `settings.brand.${field}` });
73
+ }
74
+ }
75
+
76
+ function fontFamily(value: unknown, field: string, fallback: string): string {
77
+ const resolved = text(value, field, fallback, 2, 180);
78
+ if (!/^[A-Za-z0-9\s"'(),._-]+$/u.test(resolved)) {
79
+ throw new ApiError(400, "brand_font_invalid", `${field} contains unsupported characters.`, { field: `settings.brand.${field}` });
80
+ }
81
+ return resolved;
82
+ }
83
+
84
+ export function parseWorkspaceBrand(value: unknown, displayName: string): WorkspaceBrand {
85
+ const input = value === undefined || value === null ? {} : object(value);
86
+ const allowed = new Set([
87
+ "organization_name", "logo_url", "logo_alt", "primary_color", "primary_contrast_color", "accent_color",
88
+ "background_color", "surface_color", "text_color", "muted_color", "border_color", "body_font_family",
89
+ "heading_font_family", "font_css_url", "border_radius",
90
+ ]);
91
+ if (Object.keys(input).some((key) => !allowed.has(key))) {
92
+ throw new ApiError(400, "brand_field_unsupported", "Brand settings contain unsupported fields.");
93
+ }
94
+ const borderRadius = input.border_radius === undefined ? DEFAULT_BRAND.border_radius : input.border_radius;
95
+ if (!Number.isInteger(borderRadius) || Number(borderRadius) < 0 || Number(borderRadius) > 32) {
96
+ throw new ApiError(400, "brand_radius_invalid", "border_radius must be an integer from 0 to 32.", { field: "settings.brand.border_radius" });
97
+ }
98
+ return {
99
+ organization_name: text(input.organization_name, "organization_name", displayName || "Safest Resolve", 2, 120),
100
+ logo_url: optionalHttpsUrl(input.logo_url, "logo_url"),
101
+ logo_alt: text(input.logo_alt, "logo_alt", "", 0, 160),
102
+ primary_color: color(input.primary_color, "primary_color", DEFAULT_BRAND.primary_color),
103
+ primary_contrast_color: color(input.primary_contrast_color, "primary_contrast_color", DEFAULT_BRAND.primary_contrast_color),
104
+ accent_color: color(input.accent_color, "accent_color", DEFAULT_BRAND.accent_color),
105
+ background_color: color(input.background_color, "background_color", DEFAULT_BRAND.background_color),
106
+ surface_color: color(input.surface_color, "surface_color", DEFAULT_BRAND.surface_color),
107
+ text_color: color(input.text_color, "text_color", DEFAULT_BRAND.text_color),
108
+ muted_color: color(input.muted_color, "muted_color", DEFAULT_BRAND.muted_color),
109
+ border_color: color(input.border_color, "border_color", DEFAULT_BRAND.border_color),
110
+ body_font_family: fontFamily(input.body_font_family, "body_font_family", DEFAULT_BRAND.body_font_family),
111
+ heading_font_family: fontFamily(input.heading_font_family, "heading_font_family", DEFAULT_BRAND.heading_font_family),
112
+ font_css_url: optionalHttpsUrl(input.font_css_url, "font_css_url"),
113
+ border_radius: Number(borderRadius),
114
+ };
115
+ }
116
+
117
+ export async function loadWorkspaceBrand(db: D1Database): Promise<WorkspaceBrand> {
118
+ const row = await db.prepare(`SELECT display_name AS displayName, settings_json AS settingsJson FROM installations WHERE id = 'default' LIMIT 1`)
119
+ .first<{ displayName: string; settingsJson: string }>();
120
+ if (!row) return parseWorkspaceBrand({}, "Safest Resolve");
121
+ let settings: Record<string, unknown> = {};
122
+ try {
123
+ const parsed: unknown = JSON.parse(row.settingsJson || "{}");
124
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) settings = parsed as Record<string, unknown>;
125
+ } catch { /* Invalid legacy settings fall back to the workspace name. */ }
126
+ try { return parseWorkspaceBrand(settings.brand, row.displayName); }
127
+ catch { return parseWorkspaceBrand({}, row.displayName); }
128
+ }
@@ -0,0 +1,279 @@
1
+ import { canonicalJson } from "./audit";
2
+ import { sha256 } from "./report-crypto";
3
+ import { ApiError } from "./report-http";
4
+ import { reportsCsv } from "./report-governance";
5
+ import type { OperatorSession } from "./report-types";
6
+
7
+ function now(): string { return new Date().toISOString(); }
8
+
9
+ function object(value: unknown, field = "filters"): Record<string, unknown> {
10
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new ApiError(400, "governance_input_invalid", `${field} must be an object.`);
11
+ return value as Record<string, unknown>;
12
+ }
13
+
14
+ function parsed(value: unknown, fallback: unknown): unknown {
15
+ if (typeof value !== "string") return fallback;
16
+ try { return JSON.parse(value) as unknown; } catch { return fallback; }
17
+ }
18
+
19
+ export async function searchWorkspace(
20
+ db: D1Database,
21
+ term: string,
22
+ allowedQueueIds: string[] | null | undefined,
23
+ limit: number,
24
+ ): Promise<Record<string, unknown>[]> {
25
+ const normalized = term.trim().slice(0, 120);
26
+ if (normalized.length < 2) throw new ApiError(400, "search_term_required", "q must contain at least two characters.");
27
+ const escaped = normalized.replace(/[\\%_]/gu, (value) => `\\${value}`);
28
+ const pattern = `%${escaped}%`;
29
+ const rows = await db.prepare(`
30
+ SELECT r.id, r.public_reference AS reference, r.state, r.queue_id AS queueId,
31
+ r.reason_code AS reasonCode, r.priority, r.received_at AS receivedAt,
32
+ t.target_type AS targetType, t.target_reference AS targetReference,
33
+ d.decision_code AS latestDecisionCode, d.policy_code AS latestPolicyCode,
34
+ CASE
35
+ WHEN r.public_reference LIKE ?1 ESCAPE '\\' THEN 'reference'
36
+ WHEN t.target_reference LIKE ?1 ESCAPE '\\' THEN 'target'
37
+ WHEN r.reason_code LIKE ?1 ESCAPE '\\' THEN 'reason'
38
+ ELSE 'decision'
39
+ END AS matchKind
40
+ FROM reports r JOIN report_targets t ON t.report_id = r.id
41
+ LEFT JOIN decisions d ON d.id = (
42
+ SELECT id FROM decisions WHERE report_id = r.id ORDER BY created_at DESC, id DESC LIMIT 1
43
+ )
44
+ WHERE (r.public_reference LIKE ?1 ESCAPE '\\' OR t.target_reference LIKE ?1 ESCAPE '\\'
45
+ OR r.reason_code LIKE ?1 ESCAPE '\\' OR d.decision_code LIKE ?1 ESCAPE '\\'
46
+ OR d.policy_code LIKE ?1 ESCAPE '\\')
47
+ AND (?2 = 1 OR r.queue_id IN (SELECT value FROM json_each(?3)))
48
+ ORDER BY r.updated_at DESC, r.id DESC LIMIT ?4
49
+ `).bind(
50
+ pattern,
51
+ allowedQueueIds === null || allowedQueueIds === undefined ? 1 : 0,
52
+ canonicalJson(allowedQueueIds ?? []),
53
+ Math.max(1, Math.min(100, limit)),
54
+ ).all<Record<string, unknown>>();
55
+ return rows.results;
56
+ }
57
+
58
+ export async function listSavedViews(db: D1Database, principalId: string): Promise<Record<string, unknown>[]> {
59
+ const rows = await db.prepare(`SELECT id, name, filters_json AS filtersJson, created_at AS createdAt, updated_at AS updatedAt FROM saved_views WHERE installation_id = 'default' AND principal_id = ?1 ORDER BY updated_at DESC, id DESC`)
60
+ .bind(principalId).all<Record<string, unknown>>();
61
+ return rows.results.map((row) => ({ ...row, filters: parsed(row.filtersJson, {}), filtersJson: undefined }));
62
+ }
63
+
64
+ export async function createSavedView(
65
+ db: D1Database,
66
+ principalId: string,
67
+ value: Record<string, unknown>,
68
+ ): Promise<Record<string, unknown>> {
69
+ const name = typeof value.name === "string" ? value.name.trim() : "";
70
+ if (name.length < 2 || name.length > 100) throw new ApiError(400, "saved_view_name_invalid", "name must contain 2 to 100 characters.");
71
+ const filters = object(value.filters);
72
+ const filtersJson = canonicalJson(filters);
73
+ if (new TextEncoder().encode(filtersJson).byteLength > 32_768) throw new ApiError(413, "saved_view_too_large", "The saved filters are too large.");
74
+ const id = crypto.randomUUID();
75
+ const timestamp = now();
76
+ try {
77
+ await db.prepare(`INSERT INTO saved_views (id, installation_id, principal_id, name, filters_json, created_at, updated_at) VALUES (?1, 'default', ?2, ?3, ?4, ?5, ?5)`)
78
+ .bind(id, principalId, name, filtersJson, timestamp).run();
79
+ } catch {
80
+ throw new ApiError(409, "saved_view_name_exists", "You already have a saved view with this name.");
81
+ }
82
+ return { id, name, filters, createdAt: timestamp, updatedAt: timestamp };
83
+ }
84
+
85
+ export async function updateSavedView(
86
+ db: D1Database,
87
+ principalId: string,
88
+ viewId: string,
89
+ value: Record<string, unknown>,
90
+ ): Promise<Record<string, unknown>> {
91
+ const current = await db.prepare(`SELECT name, filters_json AS filtersJson, created_at AS createdAt FROM saved_views WHERE id = ?1 AND principal_id = ?2 LIMIT 1`)
92
+ .bind(viewId, principalId).first<{ name: string; filtersJson: string; createdAt: string }>();
93
+ if (!current) throw new ApiError(404, "saved_view_not_found", "The saved view does not exist.");
94
+ const name = value.name === undefined ? current.name : typeof value.name === "string" ? value.name.trim() : "";
95
+ if (name.length < 2 || name.length > 100) throw new ApiError(400, "saved_view_name_invalid", "name must contain 2 to 100 characters.");
96
+ const filters = value.filters === undefined ? object(parsed(current.filtersJson, {})) : object(value.filters);
97
+ const timestamp = now();
98
+ try {
99
+ await db.prepare(`UPDATE saved_views SET name = ?3, filters_json = ?4, updated_at = ?5 WHERE id = ?1 AND principal_id = ?2`)
100
+ .bind(viewId, principalId, name, canonicalJson(filters), timestamp).run();
101
+ } catch {
102
+ throw new ApiError(409, "saved_view_name_exists", "You already have a saved view with this name.");
103
+ }
104
+ return { id: viewId, name, filters, createdAt: current.createdAt, updatedAt: timestamp };
105
+ }
106
+
107
+ export async function deleteSavedView(db: D1Database, principalId: string, viewId: string): Promise<void> {
108
+ const result = await db.prepare(`DELETE FROM saved_views WHERE id = ?1 AND principal_id = ?2`).bind(viewId, principalId).run();
109
+ if (!result.meta.changes) throw new ApiError(404, "saved_view_not_found", "The saved view does not exist.");
110
+ }
111
+
112
+ export async function listLegalHolds(db: D1Database, limit: number): Promise<Record<string, unknown>[]> {
113
+ const rows = await db.prepare(`SELECT h.id, h.report_id AS reportId, r.public_reference AS reportReference, h.reason, h.state, h.placed_by AS placedBy, h.created_at AS createdAt, h.released_by AS releasedBy, h.released_at AS releasedAt FROM legal_holds h JOIN reports r ON r.id = h.report_id ORDER BY h.created_at DESC, h.id DESC LIMIT ?1`)
114
+ .bind(Math.max(1, Math.min(200, limit))).all<Record<string, unknown>>();
115
+ return rows.results;
116
+ }
117
+
118
+ export async function listDeletionRequests(db: D1Database, limit: number): Promise<Record<string, unknown>[]> {
119
+ const rows = await db.prepare(`SELECT d.id, d.report_id AS reportId, r.public_reference AS reportReference, d.scope, d.reason, d.state, d.requested_by AS requestedBy, d.requested_at AS requestedAt, d.completed_at AS completedAt, d.last_error_code AS errorCode FROM deletion_requests d JOIN reports r ON r.id = d.report_id ORDER BY d.requested_at DESC, d.id DESC LIMIT ?1`)
120
+ .bind(Math.max(1, Math.min(200, limit))).all<Record<string, unknown>>();
121
+ return rows.results;
122
+ }
123
+
124
+ export async function retentionOverview(
125
+ db: D1Database,
126
+ messageRetentionDays: number,
127
+ reportRetentionDays: number,
128
+ ): Promise<Record<string, unknown>> {
129
+ const latest = await db.prepare(`SELECT id, state, cutoff_at AS cutoffAt, messages_redacted AS messagesRedacted, reports_anonymized AS reportsAnonymized, error_code AS errorCode, started_at AS startedAt, finished_at AS finishedAt FROM retention_jobs WHERE job_kind = 'scheduled_retention' ORDER BY started_at DESC, id DESC LIMIT 1`).first<Record<string, unknown>>();
130
+ return { policy: { messageRetentionDays, reportRetentionDays }, latestRun: latest ?? null };
131
+ }
132
+
133
+ export async function previewRetention(
134
+ db: D1Database,
135
+ messageRetentionDays: number,
136
+ reportRetentionDays: number,
137
+ ): Promise<Record<string, unknown>> {
138
+ const timestamp = now();
139
+ const messageCutoff = new Date(Date.now() - messageRetentionDays * 86_400_000).toISOString();
140
+ const reportCutoff = new Date(Date.now() - reportRetentionDays * 86_400_000).toISOString();
141
+ const [messages, reports, holds] = await Promise.all([
142
+ db.prepare(`SELECT COUNT(*) AS count FROM case_messages WHERE created_at < ?1 AND redacted_at IS NULL`).bind(messageCutoff).first(),
143
+ db.prepare(`SELECT COUNT(*) AS count FROM reports r WHERE r.resolved_at < ?1 AND r.state IN ('resolved_by_human','resolved_by_ai','closed') AND NOT EXISTS (SELECT 1 FROM legal_holds h WHERE h.report_id = r.id AND h.state = 'active')`).bind(reportCutoff).first(),
144
+ db.prepare(`SELECT COUNT(*) AS count FROM legal_holds WHERE state = 'active'`).first(),
145
+ ]);
146
+ return { generatedAt: timestamp, cutoffs: { messages: messageCutoff, reports: reportCutoff }, impact: { messages, reports, activeLegalHolds: holds }, destructive: false };
147
+ }
148
+
149
+ type ExportType = "reports_csv" | "reports_ndjson" | "audit_ndjson" | "configuration";
150
+
151
+ async function exportBody(
152
+ db: D1Database,
153
+ type: ExportType,
154
+ filters: Record<string, unknown>,
155
+ allowedQueueIds?: string[] | null,
156
+ ): Promise<{ body: string; contentType: string; rows: number }> {
157
+ const days = Number.isInteger(filters.days) ? Math.max(1, Math.min(366, Number(filters.days))) : 30;
158
+ if (type === "reports_csv") {
159
+ const body = await reportsCsv(db, days, allowedQueueIds);
160
+ return { body, contentType: "text/csv; charset=utf-8", rows: Math.max(0, body.split("\r\n").length - 2) };
161
+ }
162
+ if (type === "reports_ndjson") {
163
+ const since = new Date(Date.now() - days * 86_400_000).toISOString();
164
+ const rows = await db.prepare(`SELECT r.id, r.public_reference AS reference, r.source, r.state, r.queue_id AS queueId, r.reason_code AS reasonCode, r.priority, r.received_at AS receivedAt, r.resolved_at AS resolvedAt, t.target_type AS targetType FROM reports r JOIN report_targets t ON t.report_id = r.id WHERE r.received_at >= ?1 AND (?2 = 1 OR r.queue_id IN (SELECT value FROM json_each(?3))) ORDER BY r.received_at DESC, r.id DESC LIMIT 10000`).bind(
165
+ since,
166
+ allowedQueueIds === null || allowedQueueIds === undefined ? 1 : 0,
167
+ canonicalJson(allowedQueueIds ?? []),
168
+ ).all<Record<string, unknown>>();
169
+ return { body: rows.results.map(canonicalJson).join("\n") + (rows.results.length ? "\n" : ""), contentType: "application/x-ndjson", rows: rows.results.length };
170
+ }
171
+ if (type === "audit_ndjson") {
172
+ const rows = await db.prepare(`SELECT sequence, id, action, actor_type AS actorType, actor_id AS actorId, target_type AS targetType, target_id AS targetId, details_json AS detailsJson, previous_hash AS previousHash, entry_hash AS entryHash, created_at AS createdAt FROM audit_entries ORDER BY sequence DESC LIMIT 10000`).all<Record<string, unknown>>();
173
+ const values = rows.results.reverse().map((row) => ({ ...row, details: parsed(row.detailsJson, {}), detailsJson: undefined }));
174
+ return { body: values.map(canonicalJson).join("\n") + (values.length ? "\n" : ""), contentType: "application/x-ndjson", rows: values.length };
175
+ }
176
+ const [queues, workflows, components, connections, policies] = await Promise.all([
177
+ db.prepare(`SELECT id, queue_key AS key, name, purpose, status, active_version_id AS activeVersionId FROM queue_definitions ORDER BY id`).all(),
178
+ db.prepare(`SELECT id, workflow_key AS key, name, purpose, status, active_version_id AS activeVersionId FROM workflow_definitions ORDER BY id`).all(),
179
+ db.prepare(`SELECT id, component_key AS key, name, component_kind AS kind, effect_class AS effectClass, status, active_version_id AS activeVersionId FROM component_definitions ORDER BY id`).all(),
180
+ db.prepare(`SELECT id, connection_key AS key, name, kind, status, active_version_id AS activeVersionId FROM connection_definitions ORDER BY id`).all(),
181
+ db.prepare(`SELECT id, policy_key AS key, version, title, published_at AS publishedAt FROM policy_versions ORDER BY policy_key, version`).all(),
182
+ ]);
183
+ const body = canonicalJson({ schema_version: "1", queues: queues.results, workflows: workflows.results, components: components.results, connections: connections.results, policies: policies.results });
184
+ return { body, contentType: "application/json", rows: queues.results.length + workflows.results.length + components.results.length + connections.results.length + policies.results.length };
185
+ }
186
+
187
+ export async function listExports(db: D1Database, principalId: string, limit: number): Promise<Record<string, unknown>[]> {
188
+ const rows = await db.prepare(`SELECT id, requested_by AS requestedBy, export_type AS exportType, filters_json AS filtersJson, state, digest, size_bytes AS sizeBytes, row_count AS rowCount, error_code AS errorCode, requested_at AS requestedAt, ready_at AS readyAt, expires_at AS expiresAt FROM export_jobs WHERE installation_id = 'default' AND requested_by = ?1 AND deleted_at IS NULL ORDER BY requested_at DESC, id DESC LIMIT ?2`)
189
+ .bind(principalId, Math.max(1, Math.min(100, limit))).all<Record<string, unknown>>();
190
+ return rows.results.map((row) => ({ ...row, filters: parsed(row.filtersJson, {}), filtersJson: undefined }));
191
+ }
192
+
193
+ export async function createExport(
194
+ env: Env,
195
+ value: Record<string, unknown>,
196
+ session: OperatorSession,
197
+ idempotencyKey: string,
198
+ ): Promise<{ exportId: string; state: string; idempotentReplay: boolean }> {
199
+ const type = value.export_type;
200
+ if (!(["reports_csv", "reports_ndjson", "audit_ndjson", "configuration"] as unknown[]).includes(type)) throw new ApiError(400, "export_type_invalid", "export_type is unsupported.");
201
+ const filters = value.filters === undefined ? {} : object(value.filters);
202
+ if (session.queueScope !== null && session.queueScope !== undefined && type !== "reports_csv" && type !== "reports_ndjson") {
203
+ throw new ApiError(403, "export_scope_denied", "Queue-scoped operators may export only the report rows they are authorized to review.");
204
+ }
205
+ const existing = await env.DB.prepare(`SELECT id, state FROM export_jobs WHERE installation_id = 'default' AND requested_by = ?1 AND idempotency_key = ?2 LIMIT 1`)
206
+ .bind(session.actor.id, idempotencyKey).first<{ id: string; state: string }>();
207
+ if (existing) return { exportId: existing.id, state: existing.state, idempotentReplay: true };
208
+ const exportId = crypto.randomUUID();
209
+ const timestamp = now();
210
+ await env.DB.prepare(`INSERT INTO export_jobs (id, installation_id, requested_by, export_type, filters_json, redaction_policy_json, state, requested_at, idempotency_key) VALUES (?1, 'default', ?2, ?3, ?4, ?5, 'running', ?6, ?7)`)
211
+ .bind(exportId, session.actor.id, type, canonicalJson(filters), canonicalJson({ omitSecrets: true, omitMessageBodies: true, queueScope: session.queueScope ?? null }), timestamp, idempotencyKey).run();
212
+ let objectKey: string | null = null;
213
+ try {
214
+ const generated = await exportBody(env.DB, type as ExportType, filters, session.queueScope ?? null);
215
+ const digest = `sha256:${await sha256(generated.body)}`;
216
+ objectKey = `exports/default/${exportId}`;
217
+ await env.EXPORTS.put(objectKey, generated.body, { httpMetadata: { contentType: generated.contentType }, customMetadata: { digest, exportType: String(type) } });
218
+ const readyAt = now();
219
+ const expiresAt = new Date(Date.now() + 60 * 60_000).toISOString();
220
+ await env.DB.prepare(`UPDATE export_jobs SET state = 'ready', r2_object_key = ?2, digest = ?3, size_bytes = ?4, row_count = ?5, ready_at = ?6, expires_at = ?7 WHERE id = ?1`)
221
+ .bind(exportId, objectKey, digest, new TextEncoder().encode(generated.body).byteLength, generated.rows, readyAt, expiresAt).run();
222
+ return { exportId, state: "ready", idempotentReplay: false };
223
+ } catch (error) {
224
+ if (objectKey) await env.EXPORTS.delete(objectKey).catch(() => {});
225
+ await env.DB.prepare(`UPDATE export_jobs SET state = 'failed', error_code = 'export_generation_failed' WHERE id = ?1`).bind(exportId).run();
226
+ throw error;
227
+ }
228
+ }
229
+
230
+ export async function loadExport(db: D1Database, exportId: string, principalId: string): Promise<Record<string, unknown> | null> {
231
+ const row = await db.prepare(`SELECT id, requested_by AS requestedBy, export_type AS exportType, filters_json AS filtersJson, state, r2_object_key AS objectKey, digest, size_bytes AS sizeBytes, row_count AS rowCount, error_code AS errorCode, requested_at AS requestedAt, ready_at AS readyAt, expires_at AS expiresAt FROM export_jobs WHERE id = ?1 AND installation_id = 'default' AND requested_by = ?2 AND deleted_at IS NULL LIMIT 1`)
232
+ .bind(exportId, principalId).first<Record<string, unknown>>();
233
+ return row ? { ...row, filters: parsed(row.filtersJson, {}), filtersJson: undefined } : null;
234
+ }
235
+
236
+ export async function downloadExport(env: Env, exportId: string, principalId: string): Promise<Response> {
237
+ const job = await loadExport(env.DB, exportId, principalId);
238
+ if (!job) throw new ApiError(404, "export_not_found", "The export does not exist.");
239
+ if (job.state !== "ready" || typeof job.objectKey !== "string" || typeof job.expiresAt !== "string" || job.expiresAt <= now()) {
240
+ throw new ApiError(409, "export_not_ready", "The export is not ready or has expired.");
241
+ }
242
+ const objectValue = await env.EXPORTS.get(job.objectKey);
243
+ if (!objectValue) throw new ApiError(404, "export_object_missing", "The export object is unavailable.");
244
+ const headers = new Headers();
245
+ objectValue.writeHttpMetadata(headers);
246
+ headers.set("cache-control", "private, no-store");
247
+ headers.set("content-disposition", `attachment; filename="safest-${job.exportType}-${exportId}.data"`);
248
+ if (typeof job.digest === "string") headers.set("etag", `"${job.digest}"`);
249
+ return new Response(objectValue.body, { headers });
250
+ }
251
+
252
+ export async function expireExports(env: Env, limit = 100): Promise<number> {
253
+ const timestamp = now();
254
+ const rows = await env.DB.prepare(`
255
+ SELECT id, r2_object_key AS objectKey FROM export_jobs
256
+ WHERE installation_id = 'default' AND state = 'ready' AND expires_at <= ?1 AND deleted_at IS NULL
257
+ ORDER BY expires_at, id LIMIT ?2
258
+ `).bind(timestamp, Math.max(1, Math.min(500, limit))).all<{ id: string; objectKey: string | null }>();
259
+ let expired = 0;
260
+ for (const row of rows.results) {
261
+ if (row.objectKey) await env.EXPORTS.delete(row.objectKey);
262
+ const result = await env.DB.prepare(`
263
+ UPDATE export_jobs SET state = 'expired', r2_object_key = NULL, deleted_at = ?2
264
+ WHERE id = ?1 AND state = 'ready' AND expires_at <= ?2
265
+ `).bind(row.id, timestamp).run();
266
+ expired += Number(result.meta.changes ?? 0);
267
+ }
268
+ return expired;
269
+ }
270
+
271
+ export async function loadWorkflowAnalytics(db: D1Database, days: number): Promise<Record<string, unknown>> {
272
+ const since = new Date(Date.now() - Math.max(1, Math.min(366, days)) * 86_400_000).toISOString();
273
+ const [states, steps, components] = await Promise.all([
274
+ db.prepare(`SELECT state, run_kind AS runKind, COUNT(*) AS count, AVG((julianday(COALESCE(finished_at, ?2)) - julianday(COALESCE(started_at, created_at))) * 86400000) AS averageDurationMs FROM workflow_runs WHERE created_at >= ?1 GROUP BY state, run_kind ORDER BY run_kind, state`).bind(since, now()).all(),
275
+ db.prepare(`SELECT node_type AS nodeType, state, COUNT(*) AS count, AVG((julianday(COALESCE(finished_at, ?2)) - julianday(started_at)) * 86400000) AS averageDurationMs FROM workflow_step_runs WHERE updated_at >= ?1 GROUP BY node_type, state ORDER BY node_type, state`).bind(since, now()).all(),
276
+ db.prepare(`SELECT d.component_key AS componentKey, r.state, COUNT(*) AS count, AVG((julianday(COALESCE(r.finished_at, ?2)) - julianday(r.started_at)) * 86400000) AS averageDurationMs FROM component_runs r JOIN component_versions v ON v.id = r.component_version_id JOIN component_definitions d ON d.id = v.definition_id WHERE r.created_at >= ?1 GROUP BY d.component_key, r.state ORDER BY d.component_key, r.state`).bind(since, now()).all(),
277
+ ]);
278
+ return { since, runStates: states.results, stepPerformance: steps.results, componentPerformance: components.results };
279
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
6
+ "allowJs": false,
7
+ "skipLibCheck": true,
8
+ "esModuleInterop": true,
9
+ "allowSyntheticDefaultImports": true,
10
+ "strict": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "module": "ESNext",
13
+ "moduleResolution": "Bundler",
14
+ "resolveJsonModule": true,
15
+ "isolatedModules": true,
16
+ "noEmit": true,
17
+ "jsx": "react-jsx",
18
+ "noUnusedLocals": true,
19
+ "noUnusedParameters": true,
20
+ "types": ["vite/client"]
21
+ },
22
+ "include": ["console/**/*.ts", "console/**/*.tsx"]
23
+ }