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,66 @@
1
+ import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
2
+ import { PageHeader } from "../components/PageHeader";
3
+ import { readable, relativeTime } from "../lib/format";
4
+ import { errorMessage, requestJson } from "../lib/http";
5
+ import { getShellSnapshot, subscribeToShell } from "../shell/store";
6
+ import { configurationEndpoint, configurationLabel } from "./api";
7
+ import { configurationEvents, openConfigurationEditor } from "./events";
8
+ import type { ConfigurationListResponse, ConfigurationSummary, ConfigurationType } from "./types";
9
+
10
+ const tabs: Array<{ type: ConfigurationType; label: string }> = [
11
+ { type: "form", label: "Intake forms" },
12
+ { type: "routing_rules", label: "Routing rules" },
13
+ { type: "policy", label: "Decision policies" },
14
+ { type: "template", label: "Message templates" },
15
+ ];
16
+
17
+ function ConfigurationCard({ item, type }: { item: ConfigurationSummary; type: ConfigurationType }) {
18
+ const updated = item.draft?.updatedAt || item.latestPublishedAt || "";
19
+ const title = item.configurationKey === "default" ? "Workspace routing" : readable(item.configurationKey);
20
+ return <article className="object-card configuration-card">
21
+ <div className="object-card-head"><span className="object-icon" aria-hidden="true">⌗</span><span className={`badge${item.draft?.validationStatus === "invalid" ? " urgent" : ""}`}>{item.draft ? "Draft" : "Published"}</span></div>
22
+ <h3>{title}</h3><p>{item.draft ? `Revision ${item.draft.revision} · ${readable(item.draft.validationStatus)}` : "No unpublished changes."}</p>
23
+ <div className="object-card-meta"><div><span>Type</span><strong>{configurationLabel(type)}</strong></div><div><span>Published</span><strong>{item.latestVersion ? `v${item.latestVersion}` : "Never"}</strong></div><div><span>Updated</span><strong>{relativeTime(updated)}</strong></div></div>
24
+ <div className="object-card-footer"><small>{item.draft ? "Unpublished changes" : "Immutable version"}</small><button className="secondary" type="button" onClick={() => openConfigurationEditor(type, item.configurationKey, true)}>{item.draft ? "Edit draft" : "Create next version"}</button></div>
25
+ </article>;
26
+ }
27
+
28
+ export function ConfigurationWorkspace() {
29
+ const shell = useSyncExternalStore(subscribeToShell, getShellSnapshot, getShellSnapshot);
30
+ const canManage = shell.session?.permissions.manageConfiguration === true;
31
+ const [type, setType] = useState<ConfigurationType>("form");
32
+ const [configurations, setConfigurations] = useState<ConfigurationSummary[]>([]);
33
+ const [loading, setLoading] = useState(false);
34
+ const [refreshing, setRefreshing] = useState(false);
35
+ const [error, setError] = useState("");
36
+
37
+ const load = useCallback(async (refresh = false): Promise<void> => {
38
+ if (!canManage) { setConfigurations([]); return; }
39
+ if (refresh) setRefreshing(true); else setLoading(true);
40
+ setError("");
41
+ try {
42
+ const result = await requestJson<ConfigurationListResponse>(`/v1/admin/${configurationEndpoint(type)}`);
43
+ setConfigurations(result.configurations || []);
44
+ } catch (cause) { setError(errorMessage(cause, "Configuration could not be loaded.")); }
45
+ finally { setLoading(false); setRefreshing(false); }
46
+ }, [canManage, type]);
47
+
48
+ useEffect(() => { if (shell.currentView === "configuration") void load(); }, [shell.currentView, shell.session?.actor.id, load]);
49
+ useEffect(() => {
50
+ const changed = (): void => { if (shell.currentView === "configuration") void load(true); };
51
+ window.addEventListener(configurationEvents.changed, changed);
52
+ return () => window.removeEventListener(configurationEvents.changed, changed);
53
+ }, [shell.currentView, load]);
54
+
55
+ const routing = configurations.find((item) => item.configurationKey === "default");
56
+ const create = (): void => openConfigurationEditor(type, type === "routing_rules" ? "default" : "", type === "routing_rules" && Boolean(routing));
57
+ return <div className="page-stack" data-react-slice="configuration-workspace">
58
+ <PageHeader titleId="configuration-title" title="Configuration" description="Manage intake forms, routing, policies, and messages.">
59
+ <div className="surface-buttons"><button className="secondary" type="button" disabled={loading || refreshing} onClick={() => void load(true)}>{refreshing ? "Refreshing…" : "Refresh"}</button><button className="primary" type="button" disabled={!canManage} onClick={create}>{type === "routing_rules" && routing ? "Open routing" : "Create draft"}</button></div>
60
+ </PageHeader>
61
+ <div className="segmented-tabs configuration-tabs" role="tablist" aria-label="Configuration type">{tabs.map((tab) => <button className={type === tab.type ? "active" : ""} type="button" role="tab" aria-selected={type === tab.type} onClick={() => setType(tab.type)} key={tab.type}>{tab.label}</button>)}</div>
62
+ {type === "routing_rules" ? <div className="safety-boundary routing-boundary"><strong>Safeguards run before AI.</strong><span>The router sees only approved fields and queues. Uncertain results go to General triage.</span></div> : null}
63
+ <div className="object-grid configuration-list" aria-busy={loading || refreshing}>{loading ? <div className="skeleton-card" /> : configurations.length ? configurations.map((item) => <ConfigurationCard item={item} type={type} key={item.configurationKey} />) : error ? null : <div className="empty-product"><span aria-hidden="true">⌗</span><h2>No {configurationLabel(type).toLowerCase()} yet</h2><p>Create, check, and publish an immutable version.</p>{canManage ? <button className="primary" type="button" onClick={create}>Create draft</button> : null}</div>}</div>
64
+ <p className="error surface-error" role="alert">{error}</p>
65
+ </div>;
66
+ }
@@ -0,0 +1,143 @@
1
+ import { createElement, useEffect, useRef, useState } from "react";
2
+ import { readable } from "../lib/format";
3
+ import type { WorkspaceBrand } from "../shell/types";
4
+ import { configurationKeySlug } from "./api";
5
+ import type { FormChoice, FormField, FormFieldType, FormReason, JsonObject, ReportFormContent } from "./types";
6
+
7
+ interface TargetRow { id: string; label: string; value: string }
8
+ interface ReasonRow extends FormReason { id: string; codeEdited: boolean }
9
+ interface ChoiceRow extends FormChoice { id: string; valueEdited: boolean }
10
+ interface FieldRow extends Omit<FormField, "options"> { id: string; keyEdited: boolean; options: ChoiceRow[] }
11
+
12
+ export interface EditableReportForm {
13
+ title: string;
14
+ description: string;
15
+ targets: TargetRow[];
16
+ reasons: ReasonRow[];
17
+ fields: FieldRow[];
18
+ consent: string;
19
+ }
20
+
21
+ function id(): string { return crypto.randomUUID(); }
22
+ function object(value: unknown): Record<string, unknown> { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
23
+ function text(value: unknown): string { return typeof value === "string" ? value : ""; }
24
+ function number(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; }
25
+ function fieldType(value: unknown): FormFieldType {
26
+ return ["short_text", "long_text", "select", "multi_select", "boolean", "number", "url"].includes(String(value)) ? value as FormFieldType : "long_text";
27
+ }
28
+
29
+ export function editableReportForm(value: JsonObject): EditableReportForm {
30
+ const targets = Array.isArray(value.target_types) ? value.target_types.filter((entry): entry is string => typeof entry === "string").map((entry) => ({ id: id(), value: entry, label: readable(entry) })) : [];
31
+ const reasons = Array.isArray(value.reasons) ? value.reasons.map((entry) => object(entry)).map((entry) => ({ id: id(), label: text(entry.label), code: text(entry.code), codeEdited: true })) : [];
32
+ const fields = Array.isArray(value.fields) ? value.fields.map((entry) => object(entry)).map((entry): FieldRow => {
33
+ const type = fieldType(entry.type);
34
+ const options = Array.isArray(entry.options) ? entry.options.map((option) => object(option)).map((option) => ({ id: id(), label: text(option.label), value: text(option.value), valueEdited: true })) : [];
35
+ return {
36
+ id: id(), key: text(entry.key), keyEdited: true, label: text(entry.label), type, required: entry.required === true,
37
+ ...(text(entry.help_text) ? { help_text: text(entry.help_text) } : {}),
38
+ ...(text(entry.placeholder) ? { placeholder: text(entry.placeholder) } : {}),
39
+ ...(["short_text", "long_text", "url"].includes(type) ? { max_length: number(entry.max_length) ?? (type === "short_text" ? 512 : type === "url" ? 2048 : 4000) } : {}),
40
+ ...(type === "number" && number(entry.min_value) !== undefined ? { min_value: number(entry.min_value) } : {}),
41
+ ...(type === "number" && number(entry.max_value) !== undefined ? { max_value: number(entry.max_value) } : {}),
42
+ options,
43
+ };
44
+ }) : [];
45
+ return {
46
+ title: text(value.title), description: text(value.description),
47
+ targets: targets.length ? targets : [{ id: id(), value: "", label: "" }],
48
+ reasons: reasons.length ? reasons : [{ id: id(), label: "", code: "", codeEdited: false }],
49
+ fields,
50
+ consent: text(value.consent_notice),
51
+ };
52
+ }
53
+
54
+ export function reportFormContent(value: EditableReportForm): ReportFormContent {
55
+ const content = {
56
+ title: value.title.trim(), description: value.description.trim(),
57
+ target_types: value.targets.map((target) => target.value || configurationKeySlug(target.label, "")).filter(Boolean),
58
+ reasons: value.reasons.map((reason) => ({ label: reason.label.trim(), code: reason.code.trim() || configurationKeySlug(reason.label) })),
59
+ fields: value.fields.map((field): FormField => {
60
+ const result: FormField = { key: field.key.trim() || configurationKeySlug(field.label), label: field.label.trim(), type: field.type, required: field.required };
61
+ if (field.help_text?.trim()) result.help_text = field.help_text.trim();
62
+ if (field.placeholder?.trim()) result.placeholder = field.placeholder.trim();
63
+ if (["short_text", "long_text", "url"].includes(field.type)) result.max_length = field.max_length ?? (field.type === "short_text" ? 512 : field.type === "url" ? 2048 : 4000);
64
+ if (field.type === "number" && field.min_value !== undefined) result.min_value = field.min_value;
65
+ if (field.type === "number" && field.max_value !== undefined) result.max_value = field.max_value;
66
+ if (field.type === "select" || field.type === "multi_select") result.options = field.options.map((option) => ({ label: option.label.trim(), value: option.value.trim() || configurationKeySlug(option.label) }));
67
+ return result;
68
+ }),
69
+ consent_notice: value.consent.trim(),
70
+ };
71
+ return content as ReportFormContent;
72
+ }
73
+
74
+ function move<Item>(items: Item[], index: number, direction: -1 | 1): Item[] {
75
+ const destination = index + direction;
76
+ if (destination < 0 || destination >= items.length) return items;
77
+ const copy = [...items];
78
+ [copy[index], copy[destination]] = [copy[destination] as Item, copy[index] as Item];
79
+ return copy;
80
+ }
81
+
82
+ function RowActions({ label, first, last, onMove, onRemove }: { label: string; first: boolean; last: boolean; onMove: (direction: -1 | 1) => void; onRemove: () => void }) {
83
+ return <div className="builder-row-actions"><button className="icon-button compact-button" type="button" aria-label={`Move ${label} up`} title="Move up" disabled={first} onClick={() => onMove(-1)}>↑</button><button className="icon-button compact-button" type="button" aria-label={`Move ${label} down`} title="Move down" disabled={last} onClick={() => onMove(1)}>↓</button><button className="icon-button compact-button danger-text" type="button" aria-label={`Remove ${label}`} title="Remove" onClick={onRemove}>×</button></div>;
84
+ }
85
+
86
+ function FieldCard({ field, index, count, onChange, onMove, onRemove }: { field: FieldRow; index: number; count: number; onChange: (field: FieldRow) => void; onMove: (direction: -1 | 1) => void; onRemove: () => void }) {
87
+ const set = <Key extends keyof FieldRow>(key: Key, value: FieldRow[Key]): void => onChange({ ...field, [key]: value });
88
+ const setType = (type: FormFieldType): void => {
89
+ let options = field.options;
90
+ if ((type === "select" || type === "multi_select") && !options.length) options = [{ id: id(), label: "", value: "", valueEdited: false }];
91
+ const maximum = type === "short_text" ? Math.min(field.max_length ?? 512, 512) : type === "url" ? Math.min(field.max_length ?? 2048, 2048) : field.max_length ?? 4000;
92
+ onChange({ ...field, type, options, ...(["short_text", "long_text", "url"].includes(type) ? { max_length: maximum } : {}) });
93
+ };
94
+ const setChoice = (choiceIndex: number, choice: ChoiceRow): void => set("options", field.options.map((candidate, current) => current === choiceIndex ? choice : candidate));
95
+ const choices = field.type === "select" || field.type === "multi_select";
96
+ return <article className="form-field-card">
97
+ <div className="form-field-head"><strong className="field-card-title">{field.label || "New question"}</strong><RowActions label={field.label || "question"} first={index === 0} last={index === count - 1} onMove={onMove} onRemove={onRemove} /></div>
98
+ <div className="form-field-grid">
99
+ <label>Question<input required value={field.label} placeholder="What happened?" onChange={(event) => { const label = event.currentTarget.value; onChange({ ...field, label, key: field.keyEdited ? field.key : configurationKeySlug(label, "") }); }} /></label>
100
+ <label>Internal reference<input pattern="[a-z0-9]+(?:-[a-z0-9]+)*" required value={field.key} placeholder="Generated automatically" onChange={(event) => onChange({ ...field, key: event.currentTarget.value, keyEdited: true })} /></label>
101
+ <label>Answer type<select value={field.type} onChange={(event) => setType(event.currentTarget.value as FormFieldType)}><option value="short_text">Short answer</option><option value="long_text">Long answer</option><option value="select">One choice</option><option value="multi_select">Multiple choices</option><option value="boolean">Yes or no</option><option value="number">Number</option><option value="url">Website address</option></select></label>
102
+ <label className="checkbox builder-checkbox"><input type="checkbox" checked={field.required} onChange={(event) => set("required", event.currentTarget.checked)} />Required</label>
103
+ <label>Help text<textarea maxLength={500} value={field.help_text || ""} placeholder="Optional guidance shown below the question" onChange={(event) => set("help_text", event.currentTarget.value)} /></label>
104
+ <label>Placeholder<input maxLength={300} value={field.placeholder || ""} placeholder="Optional example answer" onChange={(event) => set("placeholder", event.currentTarget.value)} /></label>
105
+ {["short_text", "long_text", "url"].includes(field.type) ? <label className="field-length-label">Character limit<input type="number" min={1} max={4000} required value={field.max_length || ""} onChange={(event) => set("max_length", event.currentTarget.value ? Number(event.currentTarget.value) : undefined)} /></label> : null}
106
+ {field.type === "number" ? <div className="field-number-bounds"><label>Minimum<input type="number" value={field.min_value ?? ""} placeholder="No minimum" onChange={(event) => set("min_value", event.currentTarget.value === "" ? undefined : Number(event.currentTarget.value))} /></label><label>Maximum<input type="number" value={field.max_value ?? ""} placeholder="No maximum" onChange={(event) => set("max_value", event.currentTarget.value === "" ? undefined : Number(event.currentTarget.value))} /></label></div> : null}
107
+ </div>
108
+ {choices ? <section className="field-options-section"><div className="builder-section-head"><div><strong>Choices</strong><small>The label is shown to reporters; the reference is stored.</small></div><button className="secondary compact-button" type="button" onClick={() => set("options", [...field.options, { id: id(), label: "", value: "", valueEdited: false }])}>Add choice</button></div><div className="field-options">{field.options.map((option, optionIndex) => <div className="field-option-row" key={option.id}><input aria-label={`Choice ${optionIndex + 1} label`} required value={option.label} placeholder="Choice label" onChange={(event) => { const label = event.currentTarget.value; setChoice(optionIndex, { ...option, label, value: option.valueEdited ? option.value : configurationKeySlug(label, "") }); }} /><input aria-label={`Choice ${optionIndex + 1} reference`} required value={option.value} placeholder="Reference" onChange={(event) => setChoice(optionIndex, { ...option, value: event.currentTarget.value, valueEdited: true })} /><button className="icon-button compact-button danger-text" type="button" aria-label={`Remove choice ${optionIndex + 1}`} onClick={() => set("options", field.options.filter((_, current) => current !== optionIndex))}>×</button></div>)}</div></section> : null}
109
+ </article>;
110
+ }
111
+
112
+ export function FormBuilder({ value, onChange, disabled = false }: { value: EditableReportForm; onChange: (value: EditableReportForm) => void; disabled?: boolean }) {
113
+ const previewRef = useRef<HTMLElement & { previewConfig?: unknown; previewBrand?: WorkspaceBrand }>(null);
114
+ const [brand, setBrand] = useState<WorkspaceBrand | undefined>(() => window.SafestBrand?.defaults);
115
+ const content = reportFormContent(value);
116
+ useEffect(() => {
117
+ const preview = previewRef.current;
118
+ if (!preview) return;
119
+ preview.previewConfig = { id: "preview", ...content };
120
+ if (brand) preview.previewBrand = brand;
121
+ }, [content, brand]);
122
+ useEffect(() => {
123
+ const applied = (event: Event): void => {
124
+ if (event instanceof CustomEvent && event.detail?.brand) setBrand(event.detail.brand as WorkspaceBrand);
125
+ };
126
+ window.addEventListener("safest-brand-applied", applied);
127
+ return () => window.removeEventListener("safest-brand-applied", applied);
128
+ }, []);
129
+ const set = <Key extends keyof EditableReportForm>(key: Key, next: EditableReportForm[Key]): void => onChange({ ...value, [key]: next });
130
+ return <fieldset className="form-builder" disabled={disabled}>
131
+ <legend className="visually-hidden">Intake form fields</legend>
132
+ <div className="form-builder-layout">
133
+ <div className="form-builder-controls">
134
+ <section><h3>Form introduction</h3><label>Title<input maxLength={160} required value={value.title} onChange={(event) => set("title", event.currentTarget.value)} /></label><label>Description<textarea maxLength={2000} value={value.description} onChange={(event) => set("description", event.currentTarget.value)} /></label></section>
135
+ <section><div className="builder-section-head"><div><h3>What can be reported?</h3><p>Add the object types this form accepts.</p></div><button className="secondary compact-button" type="button" onClick={() => set("targets", [...value.targets, { id: id(), label: "", value: "" }])}>Add type</button></div><div className="builder-rows">{value.targets.map((target, index) => <div className="builder-row target-type-row" key={target.id}><label>Type name<input required value={target.label} placeholder="Message, listing, account…" onChange={(event) => { const label = event.currentTarget.value; set("targets", value.targets.map((candidate, current) => current === index ? { ...candidate, label, value: configurationKeySlug(label, "") } : candidate)); }} /></label><RowActions label={target.label || "type"} first={index === 0} last={index === value.targets.length - 1} onMove={(direction) => set("targets", move(value.targets, index, direction))} onRemove={() => set("targets", value.targets.filter((_, current) => current !== index))} /></div>)}</div></section>
136
+ <section><div className="builder-section-head"><div><h3>Reasons</h3><p>These appear in the first dropdown.</p></div><button className="secondary compact-button" type="button" onClick={() => set("reasons", [...value.reasons, { id: id(), label: "", code: "", codeEdited: false }])}>Add reason</button></div><div className="builder-rows">{value.reasons.map((reason, index) => <div className="builder-row reason-row" key={reason.id}><label>Reason shown to reporters<input required value={reason.label} placeholder="Spam or scam" onChange={(event) => { const label = event.currentTarget.value; set("reasons", value.reasons.map((candidate, current) => current === index ? { ...candidate, label, code: candidate.codeEdited ? candidate.code : configurationKeySlug(label, "") } : candidate)); }} /></label><label>Internal reference<input pattern="[A-Za-z0-9][A-Za-z0-9._:-]*" required value={reason.code} placeholder="Generated automatically" onChange={(event) => set("reasons", value.reasons.map((candidate, current) => current === index ? { ...candidate, code: event.currentTarget.value, codeEdited: true } : candidate))} /></label><RowActions label={reason.label || "reason"} first={index === 0} last={index === value.reasons.length - 1} onMove={(direction) => set("reasons", move(value.reasons, index, direction))} onRemove={() => set("reasons", value.reasons.filter((_, current) => current !== index))} /></div>)}</div></section>
137
+ <section><div className="builder-section-head"><div><h3>Questions</h3><p>Questions appear in this order.</p></div><button className="secondary compact-button" type="button" onClick={() => set("fields", [...value.fields, { id: id(), key: "", keyEdited: false, label: "", type: "long_text", required: false, max_length: 4000, options: [] }])}>Add question</button></div><div className="form-field-list">{value.fields.map((field, index) => <FieldCard field={field} index={index} count={value.fields.length} onChange={(next) => set("fields", value.fields.map((candidate, current) => current === index ? next : candidate))} onMove={(direction) => set("fields", move(value.fields, index, direction))} onRemove={() => set("fields", value.fields.filter((_, current) => current !== index))} key={field.id} />)}</div></section>
138
+ <section><h3>Consent</h3><label>Consent notice<textarea maxLength={4000} required value={value.consent} onChange={(event) => set("consent", event.currentTarget.value)} /></label></section>
139
+ </div>
140
+ <aside className="form-builder-preview"><div><p className="preview-label">Preview</p><span>Updates while you edit</span></div>{createElement("safest-report", { "integration-id": "default", preview: "", ref: previewRef })}</aside>
141
+ </div>
142
+ </fieldset>;
143
+ }
@@ -0,0 +1,40 @@
1
+ import type { ConfigurationType, JsonObject, QueueSummary } from "./types";
2
+
3
+ export function configurationEndpoint(type: ConfigurationType): string {
4
+ if (type === "form") return "forms";
5
+ if (type === "policy") return "policies";
6
+ if (type === "template") return "templates";
7
+ return "routing-rules";
8
+ }
9
+
10
+ export function configurationActionPath(type: ConfigurationType, key: string, action: "draft" | "validate" | "publish"): string {
11
+ const root = `/v1/admin/${configurationEndpoint(type)}`;
12
+ return type === "routing_rules" ? `${root}/${action}` : `${root}/${encodeURIComponent(key)}/${action}`;
13
+ }
14
+
15
+ export function configurationLabel(type: ConfigurationType): string {
16
+ if (type === "form") return "Intake form";
17
+ if (type === "routing_rules") return "Routing rules";
18
+ if (type === "policy") return "Decision policy";
19
+ return "Message template";
20
+ }
21
+
22
+ export function configurationDefault(type: ConfigurationType, queues: QueueSummary[] = []): JsonObject {
23
+ const general = queues.find((queue) => /general/iu.test(queue.name)) || queues[0];
24
+ const safeguard = queues.find((queue) => /urgent|protect/iu.test(queue.name)) || general;
25
+ if (type === "form") return {
26
+ title: "Report a concern",
27
+ description: "Tell our safety team what happened.",
28
+ target_types: ["message"],
29
+ reasons: [{ code: "other", label: "Something else" }],
30
+ fields: [{ key: "details", label: "What happened?", type: "long_text", required: true, max_length: 4000 }],
31
+ consent_notice: "Your report will be reviewed according to the service’s safety policy.",
32
+ };
33
+ if (type === "policy") return { title: "General safety policy", rules: [{ code: "manual-review", title: "Manual review required" }] };
34
+ if (type === "template") return { audience: "reporter", purpose: "follow_up", locale: "en", body_template: "We need a little more information about report {{report_reference}}.", automation_allowed: false, variables_schema: { report_reference: { type: "string" } } };
35
+ return { rules: [], default_queue_id: general?.id || "general-triage", safeguard_queue_id: safeguard?.id || "urgent-human", intake_enrichments: [], fixtures: [] };
36
+ }
37
+
38
+ export function configurationKeySlug(value: string, fallback = "item"): string {
39
+ return value.toLowerCase().trim().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "").slice(0, 64) || fallback;
40
+ }
@@ -0,0 +1,14 @@
1
+ import type { ConfigurationType } from "./types";
2
+
3
+ export const configurationEvents = {
4
+ openEditor: "safest:configuration:editor-open",
5
+ changed: "safest:configuration:changed",
6
+ } as const;
7
+
8
+ export function openConfigurationEditor(type: ConfigurationType, key = "", exists = false): void {
9
+ window.dispatchEvent(new CustomEvent(configurationEvents.openEditor, { detail: { type, key, exists } }));
10
+ }
11
+
12
+ export function announceConfigurationChanged(): void {
13
+ window.dispatchEvent(new Event(configurationEvents.changed));
14
+ }
@@ -0,0 +1,79 @@
1
+ export type ConfigurationType = "form" | "routing_rules" | "policy" | "template";
2
+ export type JsonPrimitive = string | number | boolean | null;
3
+ export type JsonValue = JsonPrimitive | JsonValue[] | JsonObject;
4
+ export interface JsonObject { [key: string]: JsonValue }
5
+
6
+ export interface ConfigurationValidationIssue {
7
+ code: string;
8
+ message: string;
9
+ path?: string;
10
+ }
11
+
12
+ export interface ConfigurationValidation {
13
+ valid: boolean;
14
+ errors: ConfigurationValidationIssue[];
15
+ warnings: ConfigurationValidationIssue[];
16
+ }
17
+
18
+ export interface ConfigurationDraftSummary {
19
+ id: string;
20
+ configurationKey: string;
21
+ revision: number;
22
+ validationStatus: string;
23
+ lastPublishedVersionId: string | null;
24
+ updatedAt: string;
25
+ }
26
+
27
+ export interface ConfigurationSummary {
28
+ configurationKey: string;
29
+ latestVersion: number | null;
30
+ latestPublishedAt: string | null;
31
+ draft: ConfigurationDraftSummary | null;
32
+ }
33
+
34
+ export interface ConfigurationDraft extends ConfigurationDraftSummary {
35
+ content: JsonObject;
36
+ validation: ConfigurationValidation;
37
+ }
38
+
39
+ export interface ConfigurationVersion {
40
+ id: string;
41
+ version: number;
42
+ content: JsonObject;
43
+ published_at?: string;
44
+ publishedAt?: string;
45
+ }
46
+
47
+ export interface ConfigurationListResponse { configurations: ConfigurationSummary[] }
48
+ export interface ConfigurationDetailResponse { draft: ConfigurationDraft | null; versions: ConfigurationVersion[] }
49
+ export interface ConfigurationSaveResponse { draftId: string; revision: number; validation: ConfigurationValidation; idempotentReplay?: boolean }
50
+ export interface ConfigurationValidationResponse { validation: ConfigurationValidation }
51
+ export interface ConfigurationPublishResponse { versionId: string; version: number; idempotentReplay?: boolean }
52
+
53
+ export type FormFieldType = "short_text" | "long_text" | "select" | "multi_select" | "boolean" | "number" | "url";
54
+ export interface FormChoice { label: string; value: string }
55
+ export interface FormReason { label: string; code: string }
56
+ export interface FormField {
57
+ key: string;
58
+ label: string;
59
+ type: FormFieldType;
60
+ required: boolean;
61
+ help_text?: string;
62
+ placeholder?: string;
63
+ max_length?: number;
64
+ min_value?: number;
65
+ max_value?: number;
66
+ options?: FormChoice[];
67
+ }
68
+
69
+ export interface ReportFormContent {
70
+ title: string;
71
+ description: string;
72
+ target_types: string[];
73
+ reasons: FormReason[];
74
+ fields: FormField[];
75
+ consent_notice: string;
76
+ }
77
+
78
+ export interface QueueSummary { id: string; name: string; status: string }
79
+ export interface QueueListResponse { queues: QueueSummary[] }
@@ -0,0 +1,49 @@
1
+ const acronyms: Record<string, string> = {
2
+ ai: "AI",
3
+ api: "API",
4
+ csv: "CSV",
5
+ d1: "D1",
6
+ http: "HTTP",
7
+ https: "HTTPS",
8
+ id: "ID",
9
+ json: "JSON",
10
+ r2: "R2",
11
+ sla: "SLA",
12
+ url: "URL",
13
+ };
14
+
15
+ const acronymPattern = /\b(ai|api|csv|d1|http|https|id|json|r2|sla|url)\b/giu;
16
+
17
+ export function readable(value: unknown): string {
18
+ const copy = String(value || "").replaceAll("_", " ");
19
+ if (!copy) return "";
20
+ const sentence = `${copy.charAt(0).toUpperCase()}${copy.slice(1)}`;
21
+ return sentence.replace(acronymPattern, (match) => acronyms[match.toLowerCase()] ?? match);
22
+ }
23
+
24
+ export function relativeTime(value: string, now = Date.now()): string {
25
+ const milliseconds = now - Date.parse(value);
26
+ if (!Number.isFinite(milliseconds)) return "Unknown";
27
+ const minutes = Math.max(0, Math.floor(milliseconds / 60_000));
28
+ if (minutes < 1) return "Just now";
29
+ if (minutes < 60) return `${minutes}m ago`;
30
+ const hours = Math.floor(minutes / 60);
31
+ if (hours < 48) return `${hours}h ago`;
32
+ return `${Math.floor(hours / 24)}d ago`;
33
+ }
34
+
35
+ export function dateTime(value: string): string {
36
+ const parsed = new Date(value);
37
+ return Number.isFinite(parsed.getTime()) ? parsed.toLocaleString() : "Unknown";
38
+ }
39
+
40
+ export function safeCustomerUrl(value: string | null | undefined): string | null {
41
+ if (!value) return null;
42
+ try {
43
+ const url = new URL(value);
44
+ const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
45
+ return url.protocol === "https:" || local && url.protocol === "http:" ? url.toString() : null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
@@ -0,0 +1,96 @@
1
+ type ApiErrorBody = {
2
+ error?: {
3
+ code?: string;
4
+ message?: string;
5
+ };
6
+ };
7
+
8
+ export class ApiRequestError extends Error {
9
+ readonly body: unknown;
10
+ readonly code?: string;
11
+ readonly status: number;
12
+
13
+ constructor(message: string, status: number, code?: string, body?: unknown) {
14
+ super(message);
15
+ this.name = "ApiRequestError";
16
+ this.status = status;
17
+ this.code = code;
18
+ this.body = body;
19
+ }
20
+ }
21
+
22
+ function cookieValue(name: string): string {
23
+ const prefix = `${name}=`;
24
+ const part = document.cookie.split(";").map((value) => value.trim()).find((value) => value.startsWith(prefix));
25
+ return part ? part.slice(prefix.length) : "";
26
+ }
27
+
28
+ function csrfHeaders(): Record<string, string> {
29
+ const name = window.location.protocol === "https:" ? "__Host-safest_csrf" : "safest_csrf";
30
+ const token = cookieValue(name);
31
+ return token ? { "x-csrf-token": token } : {};
32
+ }
33
+
34
+ function authenticationHeaders(): Record<string, string> {
35
+ return csrfHeaders();
36
+ }
37
+
38
+ export function hasSessionCredentials(): boolean {
39
+ return Boolean(Object.keys(csrfHeaders()).length);
40
+ }
41
+
42
+ function notifyExpiredSession(path: string, status: number): void {
43
+ if (status !== 401 || (path !== "/v1/admin/session" && !path.startsWith("/v1/admin/"))) return;
44
+ if (!Object.keys(csrfHeaders()).length) return;
45
+ window.dispatchEvent(new CustomEvent("safest:auth:expired", { detail: { message: "Your session expired. Sign in again." } }));
46
+ }
47
+
48
+ export function authorizedFetch(path: string, options: RequestInit = {}): Promise<Response> {
49
+ return fetch(path, {
50
+ ...options,
51
+ credentials: "same-origin",
52
+ headers: { ...authenticationHeaders(), ...(options.headers || {}) },
53
+ });
54
+ }
55
+
56
+ export async function requestJson<TResponse>(path: string, options: RequestInit = {}): Promise<TResponse> {
57
+ const response = await authorizedFetch(path, options);
58
+ const value = (response.status === 204 ? {} : await response.json().catch(() => ({}))) as TResponse & ApiErrorBody;
59
+ if (!response.ok) {
60
+ notifyExpiredSession(path, response.status);
61
+ throw new ApiRequestError(
62
+ value.error?.message || `Request failed (${response.status})`,
63
+ response.status,
64
+ value.error?.code,
65
+ value,
66
+ );
67
+ }
68
+ return value;
69
+ }
70
+
71
+ export function postJson<TResponse>(path: string, body: unknown): Promise<TResponse> {
72
+ return requestJson<TResponse>(path, {
73
+ method: "POST",
74
+ headers: { "content-type": "application/json" },
75
+ body: JSON.stringify(body),
76
+ });
77
+ }
78
+
79
+ export function mutationHeaders(json = true): Record<string, string> {
80
+ return {
81
+ ...(json ? { "content-type": "application/json" } : {}),
82
+ "idempotency-key": crypto.randomUUID(),
83
+ };
84
+ }
85
+
86
+ export function putBinary<TResponse>(path: string, body: Blob, contentType: string): Promise<TResponse> {
87
+ return requestJson<TResponse>(path, {
88
+ method: "PUT",
89
+ headers: { "content-type": contentType, ...csrfHeaders() },
90
+ body,
91
+ });
92
+ }
93
+
94
+ export function errorMessage(error: unknown, fallback: string): string {
95
+ return error instanceof Error && error.message ? error.message : fallback;
96
+ }