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,142 @@
1
+ import { sha256 } from "./report-crypto";
2
+ import { ApiError } from "./report-http";
3
+
4
+ const SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
5
+ const TEST_SITE_KEY = "1x00000000000000000000AA";
6
+ const TEST_SECRET = "1x0000000000000000000000000000000AA";
7
+ const MAX_RESPONSE_BYTES = 16 * 1024;
8
+
9
+ export interface TurnstileConfig {
10
+ siteKey: string;
11
+ secret: string;
12
+ hostname: string;
13
+ testing: boolean;
14
+ }
15
+
16
+ export interface TurnstileExpectation {
17
+ action: string;
18
+ cdata?: string;
19
+ }
20
+
21
+ export interface VerifiedTurnstile {
22
+ hostname: string;
23
+ action: string;
24
+ cdata: string | null;
25
+ challengeTimestamp: string | null;
26
+ }
27
+
28
+ export function turnstileConfig(env: Env): TurnstileConfig {
29
+ const siteKey = env.TURNSTILE_SITE_KEY?.trim() ?? "";
30
+ const secret = env.TURNSTILE_SECRET_KEY?.trim() ?? "";
31
+ if (!siteKey || !secret) throw new ApiError(503, "public_reporting_not_configured", "Public reporting is unavailable until Turnstile is configured.");
32
+ const base = new URL(env.PUBLIC_BASE_URL);
33
+ const local = base.hostname === "localhost" || base.hostname === "127.0.0.1";
34
+ const testing = siteKey === TEST_SITE_KEY && secret === TEST_SECRET;
35
+ if ((siteKey === TEST_SITE_KEY) !== (secret === TEST_SECRET) || testing && !local) {
36
+ throw new ApiError(503, "turnstile_misconfigured", "Turnstile test keys may only be used together on localhost.");
37
+ }
38
+ return { siteKey, secret, hostname: base.hostname.toLowerCase(), testing };
39
+ }
40
+
41
+ async function boundedJson(response: Response): Promise<Record<string, unknown> | null> {
42
+ const declared = Number.parseInt(response.headers.get("content-length") ?? "0", 10);
43
+ if (declared > MAX_RESPONSE_BYTES) {
44
+ await response.body?.cancel().catch(() => {});
45
+ return null;
46
+ }
47
+ const bytes = new Uint8Array(await response.arrayBuffer());
48
+ if (bytes.byteLength > MAX_RESPONSE_BYTES) return null;
49
+ try {
50
+ const value: unknown = JSON.parse(new TextDecoder().decode(bytes));
51
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ export async function verifyTurnstile(
58
+ token: unknown,
59
+ remoteIp: string | null,
60
+ config: TurnstileConfig,
61
+ expectation: TurnstileExpectation,
62
+ fetchImpl: typeof fetch = fetch,
63
+ ): Promise<VerifiedTurnstile> {
64
+ if (typeof token !== "string" || !token.trim() || token.length > 2_048) {
65
+ throw new ApiError(400, "turnstile_token_required", "Complete the anti-abuse check before submitting.");
66
+ }
67
+ const controller = new AbortController();
68
+ const timeout = setTimeout(() => controller.abort("turnstile_timeout"), 5_000);
69
+ try {
70
+ const body = new URLSearchParams({
71
+ secret: config.secret,
72
+ response: token,
73
+ idempotency_key: crypto.randomUUID(),
74
+ });
75
+ if (remoteIp) body.set("remoteip", remoteIp);
76
+ const response = await fetchImpl(SITEVERIFY_URL, {
77
+ method: "POST",
78
+ headers: { "content-type": "application/x-www-form-urlencoded" },
79
+ body,
80
+ signal: controller.signal,
81
+ });
82
+ if (!response.ok) throw new ApiError(503, "turnstile_unavailable", "The anti-abuse check is temporarily unavailable.");
83
+ const result = await boundedJson(response);
84
+ if (!result) throw new ApiError(503, "turnstile_unavailable", "The anti-abuse check returned an invalid response.");
85
+ if (result.success !== true) throw new ApiError(400, "turnstile_rejected", "The anti-abuse check was not accepted.");
86
+ const observedHostname = typeof result.hostname === "string" ? result.hostname.toLowerCase() : "";
87
+ const acceptedTestHostname = config.testing && observedHostname === "example.com";
88
+ if (observedHostname !== config.hostname && !acceptedTestHostname) {
89
+ throw new ApiError(400, "turnstile_hostname_mismatch", "The anti-abuse check was completed for a different site.");
90
+ }
91
+ if (!config.testing && result.action !== expectation.action) {
92
+ throw new ApiError(400, "turnstile_action_mismatch", "The anti-abuse check was completed for a different action.");
93
+ }
94
+ if (!config.testing && expectation.cdata !== undefined && result.cdata !== expectation.cdata) {
95
+ throw new ApiError(400, "turnstile_context_mismatch", "The anti-abuse check does not match this report window.");
96
+ }
97
+ return {
98
+ hostname: observedHostname,
99
+ action: typeof result.action === "string" ? result.action : expectation.action,
100
+ cdata: typeof result.cdata === "string" ? result.cdata : null,
101
+ challengeTimestamp: typeof result.challenge_ts === "string" ? result.challenge_ts : null,
102
+ };
103
+ } catch (error) {
104
+ if (error instanceof ApiError) throw error;
105
+ const timedOut = controller.signal.aborted || error instanceof DOMException && error.name === "AbortError";
106
+ throw new ApiError(503, timedOut ? "turnstile_timeout" : "turnstile_unavailable", "The anti-abuse check is temporarily unavailable.");
107
+ } finally {
108
+ clearTimeout(timeout);
109
+ }
110
+ }
111
+
112
+ export async function verifyPublicTurnstile(
113
+ token: unknown,
114
+ remoteIp: string | null,
115
+ config: TurnstileConfig,
116
+ fetchImpl: typeof fetch = fetch,
117
+ ): Promise<void> {
118
+ await verifyTurnstile(token, remoteIp, config, { action: "report_submit" }, fetchImpl);
119
+ }
120
+
121
+ export async function enforcePublicRateLimit(
122
+ db: D1Database,
123
+ remoteIp: string | null,
124
+ pepper: string,
125
+ maximum: number,
126
+ timestamp = Date.now(),
127
+ ): Promise<void> {
128
+ if (new TextEncoder().encode(pepper).byteLength < 32) throw new ApiError(503, "rate_limit_misconfigured", "Public reporting rate limits are not configured.");
129
+ const bucket = new Date(Math.floor(timestamp / 600_000) * 600_000).toISOString();
130
+ const bucketKey = await sha256(`public-report-v1:${pepper}:${remoteIp ?? "unavailable"}`);
131
+ const at = new Date(timestamp).toISOString();
132
+ const row = await db.prepare(`
133
+ INSERT INTO intake_rate_limits (bucket_key, window_start, request_count, updated_at)
134
+ VALUES (?1, ?2, 1, ?3)
135
+ ON CONFLICT(bucket_key, window_start) DO UPDATE SET
136
+ request_count = intake_rate_limits.request_count + 1,
137
+ updated_at = excluded.updated_at
138
+ WHERE intake_rate_limits.request_count < ?4
139
+ RETURNING request_count
140
+ `).bind(bucketKey, bucket, at, Math.max(1, Math.min(100, maximum))).first<{ request_count: number }>();
141
+ if (!row) throw new ApiError(429, "public_report_rate_limited", "Too many public reports were submitted from this network. Try again later.");
142
+ }
@@ -0,0 +1,224 @@
1
+ export type ReportSource = "signed_widget" | "public_page" | "server_api";
2
+ export type ReportState =
3
+ | "received"
4
+ | "human_review"
5
+ | "ai_review"
6
+ | "awaiting_reporter"
7
+ | "resolved_by_human"
8
+ | "resolved_by_ai"
9
+ | "human_review_requested"
10
+ | "appealed"
11
+ | "closed";
12
+
13
+ export type ParticipantAudience = "reporter" | "affected_user";
14
+ export type OperatorRole = "owner" | "administrator" | "workflow_author" | "reviewer" | "appeal_reviewer" | "quality_reviewer" | "operations" | "auditor";
15
+
16
+ export interface OperatorPermissions {
17
+ readReports: boolean;
18
+ readEvidence: boolean;
19
+ readSensitiveEvidence: boolean;
20
+ manageEvidence: boolean;
21
+ claimReports: boolean;
22
+ reviewTasks: boolean;
23
+ assignTasks: boolean;
24
+ messageReporters: boolean;
25
+ messageParticipants: boolean;
26
+ decideReports: boolean;
27
+ reassignReports: boolean;
28
+ approveActions: boolean;
29
+ operateActions: boolean;
30
+ reviewAppeals: boolean;
31
+ decideAppeals: boolean;
32
+ reviewQuality: boolean;
33
+ runShadow: boolean;
34
+ runEnrichments: boolean;
35
+ manageForms: boolean;
36
+ managePolicies: boolean;
37
+ manageTemplates: boolean;
38
+ manageQueues: boolean;
39
+ manageRouting: boolean;
40
+ manageWorkflowPins: boolean;
41
+ manageConnections: boolean;
42
+ publishConnections: boolean;
43
+ manageComponents: boolean;
44
+ publishComponents: boolean;
45
+ manageWorkflows: boolean;
46
+ publishWorkflows: boolean;
47
+ manageAi: boolean;
48
+ manageBudgets: boolean;
49
+ useAssistant: boolean;
50
+ readRuns: boolean;
51
+ operateRuns: boolean;
52
+ searchWorkspace: boolean;
53
+ readAnalytics: boolean;
54
+ readAi: boolean;
55
+ readOperations: boolean;
56
+ operateSystem: boolean;
57
+ manageConfiguration: boolean;
58
+ managePeople: boolean;
59
+ manageInstallation: boolean;
60
+ viewAudit: boolean;
61
+ exportData: boolean;
62
+ manageGovernance: boolean;
63
+ manageIntegrations: boolean;
64
+ }
65
+
66
+ export interface OperatorSession {
67
+ actor: { type: "cloudflare_access" | "admin_key" | "operator_user"; id: string };
68
+ authMethod: "cloudflare_access" | "admin_key" | "better_auth";
69
+ verified: boolean;
70
+ displayName: string;
71
+ email?: string;
72
+ avatarUrl?: string;
73
+ roles: OperatorRole[];
74
+ permissions: OperatorPermissions;
75
+ queueScope?: string[] | null;
76
+ fieldScope?: Record<string, string[]> | null;
77
+ }
78
+
79
+ export interface ReportTargetInput {
80
+ type: string;
81
+ reference: string;
82
+ ownerReference?: string;
83
+ customerUrl?: string;
84
+ }
85
+
86
+ export interface ReportReporterInput {
87
+ reference?: string;
88
+ email?: string;
89
+ contactMode: "customer_notification" | "none";
90
+ }
91
+
92
+ export interface EvidenceInput {
93
+ mode: "reference_only";
94
+ references: string[];
95
+ }
96
+
97
+ export interface ReportSubmission {
98
+ schemaVersion: "1";
99
+ idempotencyKey: string;
100
+ source: ReportSource;
101
+ integrationId: string;
102
+ formVersionId: string;
103
+ reporter: ReportReporterInput;
104
+ target: ReportTargetInput;
105
+ reasonCode: string;
106
+ answers: Record<string, string | boolean | number | string[]>;
107
+ evidence: EvidenceInput;
108
+ locale: string;
109
+ consentNotice: string;
110
+ consentedAt: string;
111
+ submittedAt: string;
112
+ contextToken?: string;
113
+ trustedFacts: Record<string, string | number | boolean | null>;
114
+ routingFacts: Record<string, string | number | boolean | null>;
115
+ contextJti?: string;
116
+ contextId?: string;
117
+ }
118
+
119
+ export interface ContextClaims {
120
+ version: 2;
121
+ iss: string;
122
+ aud: "safest-resolve-reports";
123
+ integrationId: string;
124
+ contextId: string;
125
+ formVersionId: string;
126
+ jti: string;
127
+ iat: number;
128
+ exp: number;
129
+ origin: string;
130
+ }
131
+
132
+ export interface StoredReportContext {
133
+ id: string;
134
+ integrationId: string;
135
+ formVersionId: string;
136
+ origin: string;
137
+ reporter: { reference?: string };
138
+ target: ReportTargetInput;
139
+ trustedFacts: Record<string, string | number | boolean | null>;
140
+ routingFacts: Record<string, string | number | boolean | null>;
141
+ contextSchemaVersion: number;
142
+ assertionSource: "customer_backend" | "resolve_hosted";
143
+ createdAt: string;
144
+ expiresAt: string;
145
+ usedAt: string | null;
146
+ }
147
+
148
+ export interface IntakeReceipt {
149
+ reportId: string;
150
+ reference: string;
151
+ state: ReportState;
152
+ receivedAt: string;
153
+ idempotentReplay: boolean;
154
+ }
155
+
156
+ export interface ReportJob {
157
+ version: 1;
158
+ jobId: string;
159
+ type: "route_report";
160
+ reportId: string;
161
+ }
162
+
163
+ export interface AiJob {
164
+ version: 1;
165
+ jobId: string;
166
+ type: "process_ai_report";
167
+ reportId: string;
168
+ }
169
+
170
+ export interface WorkflowDispatchJob {
171
+ version: 1;
172
+ jobId: string;
173
+ type: "dispatch_workflow";
174
+ runId: string;
175
+ outboxId: string;
176
+ }
177
+
178
+ export interface EvidenceFetchJob {
179
+ version: 1;
180
+ jobId: string;
181
+ type: "fetch_evidence";
182
+ reportId: string;
183
+ requestId: string;
184
+ }
185
+
186
+ export interface ActionProposalJob {
187
+ version: 1;
188
+ jobId: string;
189
+ type: "apply_action_proposal";
190
+ proposalId: string;
191
+ runId: string;
192
+ }
193
+
194
+ export interface DeliveryJob {
195
+ version: 1;
196
+ jobId: string;
197
+ type: "deliver_action" | "deliver_notification" | "deliver_email";
198
+ outboxId: string;
199
+ }
200
+
201
+ export interface InboxReport {
202
+ id: string;
203
+ reference: string;
204
+ state: ReportState;
205
+ priority: number;
206
+ reasonCode: string;
207
+ queueId: string | null;
208
+ targetType: string;
209
+ targetReference: string;
210
+ submittedAt: string;
211
+ receivedAt: string;
212
+ updatedAt: string;
213
+ dueAt: string | null;
214
+ claim: {
215
+ reviewerId: string;
216
+ claimedAt: string;
217
+ expiresAt: string;
218
+ analyst: {
219
+ displayName: string;
220
+ avatarUrl: string | null;
221
+ avatarColor: string;
222
+ };
223
+ } | null;
224
+ }
@@ -0,0 +1,279 @@
1
+ import { ApiError } from "./report-http";
2
+ import type { ContextClaims, ReportSource, ReportSubmission, ReportTargetInput, StoredReportContext } from "./report-types";
3
+
4
+ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/u;
5
+ const LOCALE_PATTERN = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/u;
6
+ const SOURCES = new Set<ReportSource>(["signed_widget", "public_page", "server_api"]);
7
+ const CONTACT_MODES = new Set(["customer_notification", "none"] as const);
8
+ const EMAIL_PATTERN = /^[A-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?(?:\.[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?)+$/iu;
9
+ const ANSWER_KEYS = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/u;
10
+ const MAX_ANSWER_BYTES = 16 * 1024;
11
+
12
+ function object(value: unknown, field: string): Record<string, unknown> {
13
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new ApiError(400, "invalid_field", `${field} must be an object.`, { field });
14
+ return value as Record<string, unknown>;
15
+ }
16
+
17
+ function knownFields(value: Record<string, unknown>, allowed: readonly string[], field = "body"): void {
18
+ const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
19
+ if (unknown.length) throw new ApiError(400, "unknown_field", `${field} contains unsupported fields.`, { field, fields: unknown.slice(0, 10) });
20
+ }
21
+
22
+ function text(value: unknown, field: string, maximum: number, minimum = 1): string {
23
+ if (typeof value !== "string") throw new ApiError(400, "invalid_field", `${field} must be text.`, { field });
24
+ const normalized = value.trim();
25
+ if (normalized.length < minimum || normalized.length > maximum) {
26
+ throw new ApiError(400, "invalid_field", `${field} must contain ${minimum}-${maximum} characters.`, { field });
27
+ }
28
+ return normalized;
29
+ }
30
+
31
+ function identifier(value: unknown, field: string, maximum = 200): string {
32
+ const normalized = text(value, field, maximum);
33
+ if (!ID_PATTERN.test(normalized)) throw new ApiError(400, "invalid_field", `${field} contains unsupported characters.`, { field });
34
+ return normalized;
35
+ }
36
+
37
+ function optionalIdentifier(value: unknown, field: string, maximum = 200): string | undefined {
38
+ return value === undefined || value === null || value === "" ? undefined : identifier(value, field, maximum);
39
+ }
40
+
41
+ function isoTimestamp(value: unknown, field: string, now: number, maximumSkewMs: number): string {
42
+ const normalized = text(value, field, 40);
43
+ const timestamp = Date.parse(normalized);
44
+ if (!Number.isFinite(timestamp) || Math.abs(timestamp - now) > maximumSkewMs) {
45
+ throw new ApiError(400, "invalid_field", `${field} is outside the accepted time window.`, { field });
46
+ }
47
+ return new Date(timestamp).toISOString();
48
+ }
49
+
50
+ function safeUrl(value: unknown, field: string): string | undefined {
51
+ if (value === undefined || value === null || value === "") return undefined;
52
+ const normalized = text(value, field, 2_048);
53
+ try {
54
+ const url = new URL(normalized);
55
+ const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
56
+ if ((url.protocol !== "https:" && !(local && url.protocol === "http:")) || url.username || url.password) throw new Error("unsafe URL");
57
+ return url.toString();
58
+ } catch {
59
+ throw new ApiError(400, "invalid_field", `${field} must be an HTTPS URL without embedded credentials (localhost HTTP is allowed for development).`, { field });
60
+ }
61
+ }
62
+
63
+ function safeOrigin(value: unknown, field: string): string | undefined {
64
+ if (value === undefined || value === null || value === "") return undefined;
65
+ const normalized = text(value, field, 2_048);
66
+ try {
67
+ const url = new URL(normalized);
68
+ const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
69
+ if ((url.protocol !== "https:" && !(local && url.protocol === "http:")) || url.username || url.password
70
+ || url.pathname !== "/" || url.search || url.hash) throw new Error("unsafe origin");
71
+ return url.origin;
72
+ } catch {
73
+ throw new ApiError(400, "invalid_field", `${field} must be an HTTPS origin (HTTP is allowed for localhost).`, { field });
74
+ }
75
+ }
76
+
77
+ function target(value: unknown, field: string): ReportTargetInput {
78
+ const input = object(value, field);
79
+ knownFields(input, ["type", "reference", "owner_reference", "customer_url"], field);
80
+ return {
81
+ type: identifier(input.type, `${field}.type`, 64),
82
+ reference: identifier(input.reference, `${field}.reference`, 500),
83
+ ...(optionalIdentifier(input.owner_reference, `${field}.owner_reference`, 500) ? { ownerReference: optionalIdentifier(input.owner_reference, `${field}.owner_reference`, 500) } : {}),
84
+ ...(safeUrl(input.customer_url, `${field}.customer_url`) ? { customerUrl: safeUrl(input.customer_url, `${field}.customer_url`) } : {}),
85
+ };
86
+ }
87
+
88
+ function email(value: unknown, field: string): string {
89
+ const normalized = text(value, field, 254).toLowerCase();
90
+ const [local = "", domain = ""] = normalized.split("@");
91
+ if (!EMAIL_PATTERN.test(normalized) || local.length > 64 || domain.length > 253) {
92
+ throw new ApiError(400, "invalid_field", `${field} must be a valid email address.`, { field });
93
+ }
94
+ return normalized;
95
+ }
96
+
97
+ function reporter(
98
+ value: unknown,
99
+ field: string,
100
+ options: { requireEmail?: boolean } = {},
101
+ ): { reference?: string; email?: string; contactMode: "customer_notification" | "none" } {
102
+ const input = object(value, field);
103
+ knownFields(input, ["reference", "email", "contact_mode"], field);
104
+ const contactMode = text(input.contact_mode ?? "customer_notification", `${field}.contact_mode`, 32) as "customer_notification" | "none";
105
+ if (!CONTACT_MODES.has(contactMode)) throw new ApiError(400, "invalid_field", `${field}.contact_mode is unsupported.`, { field: `${field}.contact_mode` });
106
+ const reference = optionalIdentifier(input.reference, `${field}.reference`, 500);
107
+ const emailAddress = input.email === undefined || input.email === null || input.email === "" ? undefined : email(input.email, `${field}.email`);
108
+ if (options.requireEmail && !emailAddress) {
109
+ throw new ApiError(400, "reporter_email_required", "Enter an email address so the safety team can contact you about this report.", { field: `${field}.email` });
110
+ }
111
+ if (emailAddress && contactMode === "none") {
112
+ throw new ApiError(400, "invalid_field", `${field}.contact_mode must allow notifications when an email is provided.`, { field: `${field}.contact_mode` });
113
+ }
114
+ return { ...(reference ? { reference } : {}), ...(emailAddress ? { email: emailAddress } : {}), contactMode };
115
+ }
116
+
117
+ function facts(value: unknown, field: string): Record<string, string | number | boolean | null> {
118
+ const input = value === undefined ? {} : object(value, field);
119
+ if (Object.keys(input).length > 50) throw new ApiError(400, "invalid_field", `${field} has too many values.`, { field });
120
+ const result: Record<string, string | number | boolean | null> = {};
121
+ for (const [key, child] of Object.entries(input)) {
122
+ if (!ANSWER_KEYS.test(key) || !["string", "number", "boolean"].includes(typeof child) && child !== null) {
123
+ throw new ApiError(400, "invalid_field", `${field}.${key} has an unsupported key or value.`, { field: `${field}.${key}` });
124
+ }
125
+ if (typeof child === "string" && child.length > 500) throw new ApiError(400, "invalid_field", `${field}.${key} is too long.`, { field: `${field}.${key}` });
126
+ if (typeof child === "number" && !Number.isFinite(child)) throw new ApiError(400, "invalid_field", `${field}.${key} must be finite.`, { field: `${field}.${key}` });
127
+ result[key] = child as string | number | boolean | null;
128
+ }
129
+ return result;
130
+ }
131
+
132
+ function answers(value: unknown): Record<string, string | boolean | number | string[]> {
133
+ const input = object(value, "answers");
134
+ if (Object.keys(input).length > 50) throw new ApiError(400, "invalid_field", "answers has too many fields.", { field: "answers" });
135
+ const result: Record<string, string | boolean | number | string[]> = {};
136
+ for (const [key, child] of Object.entries(input)) {
137
+ if (!ANSWER_KEYS.test(key)) throw new ApiError(400, "invalid_field", `answers.${key} has an unsupported key.`, { field: `answers.${key}` });
138
+ const valid = typeof child === "boolean"
139
+ || typeof child === "number" && Number.isFinite(child)
140
+ || typeof child === "string" && child.length <= 4_000
141
+ || Array.isArray(child) && child.length <= 20 && child.every((item) => typeof item === "string" && item.length <= 200);
142
+ if (!valid) throw new ApiError(400, "invalid_field", `answers.${key} has an unsupported value.`, { field: `answers.${key}` });
143
+ result[key] = child as string | boolean | number | string[];
144
+ }
145
+ if (new TextEncoder().encode(JSON.stringify(result)).byteLength > MAX_ANSWER_BYTES) {
146
+ throw new ApiError(400, "invalid_field", "answers is too large.", { field: "answers" });
147
+ }
148
+ return result;
149
+ }
150
+
151
+ function evidence(value: unknown): { mode: "reference_only"; references: string[] } {
152
+ const input = object(value ?? { mode: "reference_only", references: [] }, "evidence");
153
+ knownFields(input, ["mode", "references"], "evidence");
154
+ if (input.mode !== "reference_only") throw new ApiError(400, "invalid_field", "Only reference_only evidence is enabled.", { field: "evidence.mode" });
155
+ if (!Array.isArray(input.references) || input.references.length > 25) {
156
+ throw new ApiError(400, "invalid_field", "evidence.references must contain at most 25 references.", { field: "evidence.references" });
157
+ }
158
+ const references = input.references.map((item, index) => {
159
+ if (typeof item !== "string") throw new ApiError(400, "invalid_field", `evidence.references[${index}] must be text.`, { field: `evidence.references[${index}]` });
160
+ const normalized = item.trim();
161
+ if (!normalized || normalized.length > 2_048) throw new ApiError(400, "invalid_field", `evidence.references[${index}] is invalid.`, { field: `evidence.references[${index}]` });
162
+ if (normalized.includes("://")) {
163
+ const url = safeUrl(normalized, `evidence.references[${index}]`);
164
+ if (!url) throw new ApiError(400, "invalid_field", `evidence.references[${index}] is invalid.`, { field: `evidence.references[${index}]` });
165
+ return url;
166
+ }
167
+ return identifier(normalized, `evidence.references[${index}]`, 500);
168
+ });
169
+ if (new Set(references).size !== references.length) throw new ApiError(400, "invalid_field", "evidence.references contains duplicates.", { field: "evidence.references" });
170
+ return { mode: "reference_only", references };
171
+ }
172
+
173
+ function source(value: unknown, expected: ReportSource): ReportSource {
174
+ const normalized = text(value, "source", 32) as ReportSource;
175
+ if (!SOURCES.has(normalized) || normalized !== expected) throw new ApiError(400, "invalid_source", `source must be ${expected}.`, { field: "source" });
176
+ return normalized;
177
+ }
178
+
179
+ export interface ContextMintInput {
180
+ reporter: { reference?: string };
181
+ target: ReportTargetInput;
182
+ trustedFacts: Record<string, string | number | boolean | null>;
183
+ origin?: string;
184
+ formVersionId?: string;
185
+ }
186
+
187
+ export function parseContextMintInput(body: Record<string, unknown>): ContextMintInput {
188
+ knownFields(body, ["reporter", "target", "trusted_facts", "origin", "form_version"]);
189
+ const reporterInput = reporter(body.reporter ?? { contact_mode: "customer_notification" }, "reporter");
190
+ const origin = safeOrigin(body.origin, "origin");
191
+ const formVersionId = body.form_version === undefined ? undefined : identifier(body.form_version, "form_version", 100);
192
+ return {
193
+ reporter: { ...(reporterInput.reference ? { reference: reporterInput.reference } : {}) },
194
+ target: target(body.target, "target"),
195
+ trustedFacts: facts(body.trusted_facts, "trusted_facts"),
196
+ ...(origin ? { origin } : {}),
197
+ ...(formVersionId ? { formVersionId } : {}),
198
+ };
199
+ }
200
+
201
+ function common(
202
+ body: Record<string, unknown>,
203
+ expectedSource: ReportSource,
204
+ now: number,
205
+ ): Pick<ReportSubmission, "schemaVersion" | "idempotencyKey" | "source" | "formVersionId" | "reasonCode" | "answers" | "evidence" | "locale" | "consentNotice" | "consentedAt" | "submittedAt"> {
206
+ if (body.schema_version !== "1") throw new ApiError(400, "unsupported_schema_version", "schema_version must be 1.", { field: "schema_version" });
207
+ const locale = text(body.locale ?? "en", "locale", 35);
208
+ if (!LOCALE_PATTERN.test(locale)) throw new ApiError(400, "invalid_field", "locale is invalid.", { field: "locale" });
209
+ const consent = object(body.consent, "consent");
210
+ knownFields(consent, ["notice", "accepted_at"], "consent");
211
+ return {
212
+ schemaVersion: "1",
213
+ idempotencyKey: identifier(body.idempotency_key, "idempotency_key", 200),
214
+ source: source(body.source, expectedSource),
215
+ formVersionId: identifier(body.form_version, "form_version", 100),
216
+ reasonCode: identifier(body.reason_code, "reason_code", 100),
217
+ answers: answers(body.answers),
218
+ evidence: evidence(body.evidence),
219
+ locale,
220
+ consentNotice: text(consent.notice, "consent.notice", 2_000),
221
+ consentedAt: isoTimestamp(consent.accepted_at, "consent.accepted_at", now, 24 * 60 * 60 * 1_000),
222
+ submittedAt: isoTimestamp(body.submitted_at, "submitted_at", now, 24 * 60 * 60 * 1_000),
223
+ };
224
+ }
225
+
226
+ export function parseWidgetReport(
227
+ body: Record<string, unknown>,
228
+ claims: ContextClaims,
229
+ context: StoredReportContext,
230
+ now = Date.now(),
231
+ ): ReportSubmission {
232
+ knownFields(body, ["schema_version", "idempotency_key", "source", "form_version", "reason_code", "answers", "evidence", "locale", "consent", "submitted_at", "context_token", "turnstile_token"]);
233
+ const parsed = common(body, "signed_widget", now);
234
+ if (parsed.formVersionId !== claims.formVersionId || parsed.formVersionId !== context.formVersionId) {
235
+ throw new ApiError(400, "form_context_mismatch", "The report form does not match its trusted context.", { field: "form_version" });
236
+ }
237
+ return {
238
+ ...parsed,
239
+ integrationId: claims.integrationId,
240
+ reporter: { ...(context.reporter.reference ? { reference: context.reporter.reference } : {}), contactMode: "customer_notification" },
241
+ target: context.target,
242
+ trustedFacts: context.trustedFacts,
243
+ routingFacts: context.routingFacts,
244
+ contextJti: claims.jti,
245
+ contextId: claims.contextId,
246
+ contextToken: text(body.context_token, "context_token", 16 * 1024),
247
+ };
248
+ }
249
+
250
+ export function parseServerReport(body: Record<string, unknown>, integrationId: string, now = Date.now()): ReportSubmission {
251
+ knownFields(body, ["schema_version", "idempotency_key", "source", "form_version", "reporter", "target", "reason_code", "answers", "evidence", "locale", "consent", "submitted_at", "trusted_facts"]);
252
+ return {
253
+ ...common(body, "server_api", now),
254
+ integrationId,
255
+ reporter: reporter(body.reporter, "reporter"),
256
+ target: target(body.target, "target"),
257
+ trustedFacts: facts(body.trusted_facts, "trusted_facts"),
258
+ routingFacts: facts(body.trusted_facts, "trusted_facts"),
259
+ };
260
+ }
261
+
262
+ export function parsePublicReport(body: Record<string, unknown>, integrationId: string, now = Date.now()): ReportSubmission {
263
+ knownFields(body, [
264
+ "schema_version", "idempotency_key", "source", "form_version", "reporter", "target", "reason_code",
265
+ "answers", "evidence", "locale", "consent", "submitted_at", "turnstile_token",
266
+ ]);
267
+ const reportTarget = target(body.target, "target");
268
+ if (reportTarget.ownerReference) {
269
+ throw new ApiError(400, "untrusted_owner_reference", "Public reports cannot assert who owns the target.", { field: "target.owner_reference" });
270
+ }
271
+ return {
272
+ ...common(body, "public_page", now),
273
+ integrationId,
274
+ reporter: reporter(body.reporter, "reporter", { requireEmail: true }),
275
+ target: reportTarget,
276
+ trustedFacts: {},
277
+ routingFacts: {},
278
+ };
279
+ }