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,155 @@
1
+ import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
2
+ import { PageHeader } from "../components/PageHeader";
3
+ import { errorMessage, requestJson } from "../lib/http";
4
+ import { getShellSnapshot, subscribeToShell } from "../shell/store";
5
+ import type { WorkspaceBrand } from "../shell/types";
6
+ import { BrandEditor } from "./BrandEditor";
7
+ import { dispatchSetupUpdated, openChannelDialog, settingsEvents } from "./events";
8
+ import type {
9
+ ApplicationIntegration,
10
+ BrandResponse,
11
+ InstallationDetails,
12
+ InstallationResponse,
13
+ IntegrationsResponse,
14
+ ReportingChannel,
15
+ ReportingChannelsResponse,
16
+ SetupResponse,
17
+ SetupStatus,
18
+ } from "./types";
19
+
20
+ const featureNames: Record<string, string> = {
21
+ database: "Database",
22
+ email: "Email delivery",
23
+ queues: "Queues",
24
+ r2: "Private evidence storage",
25
+ storage: "Private evidence storage",
26
+ turnstile: "Human verification",
27
+ workflows: "Workflows",
28
+ };
29
+
30
+ function featureName(value: string): string {
31
+ return featureNames[value] ?? value.replaceAll("_", " ").replace(/^./u, (letter) => letter.toUpperCase());
32
+ }
33
+
34
+ function installationFacts(installation: InstallationDetails): Array<[string, string]> {
35
+ return [
36
+ ["Workspace", installation.displayName || "Safety workspace"],
37
+ ["Default language", installation.defaultLocale || "en"],
38
+ ["Time zone", installation.timezone || "UTC"],
39
+ ["Anonymous reporting", installation.anonymousReportingEnabled ? "Enabled" : "Disabled"],
40
+ ];
41
+ }
42
+
43
+ function SettingsLoading() {
44
+ return <div className="settings-card-loading" aria-label="Loading settings" role="status"><div className="skeleton-block" /></div>;
45
+ }
46
+
47
+ function ChannelRow({ channel }: { channel: ReportingChannel }) {
48
+ const active = channel.enabled !== false;
49
+ return <article className="integration-row"><span className={`setup-state${active ? "" : " warning"}`}>{active ? "✓" : "!"}</span><div><strong>{channel.name || "Hosted report form"}</strong><small>{channel.url}</small></div><div className="integration-actions"><span className={`badge${active ? "" : " urgent"}`}>{active ? "Hosted" : "Needs setup"}</span><a className="text-button" href={channel.url} target="_blank" rel="noopener">Open</a></div></article>;
50
+ }
51
+
52
+ function IntegrationRow({ integration }: { integration: ApplicationIntegration }) {
53
+ const active = integration.enabled !== false;
54
+ const contextual = integration.channelMode === "contextual";
55
+ const ready = active && (!contextual || integration.activeKeyCount > 0);
56
+ const detail = integration.allowedOrigins.length ? integration.allowedOrigins.join(", ") : "No website origin";
57
+ const status = !active ? "Disabled" : contextual ? integration.activeKeyCount > 0 ? "Signed-in" : "Needs key" : "Anonymous";
58
+ return <article className="integration-row"><span className={`setup-state${ready ? "" : " warning"}`}>{ready ? "✓" : "!"}</span><div><strong>{integration.name || integration.id}</strong><small>{detail}</small></div><span className={`badge${ready ? "" : " urgent"}`}>{status}</span></article>;
59
+ }
60
+
61
+ export function SettingsWorkspace() {
62
+ const shell = useSyncExternalStore(subscribeToShell, getShellSnapshot, getShellSnapshot);
63
+ const canManageInstallation = shell.session?.permissions.manageInstallation === true;
64
+ const canManageConfiguration = shell.session?.permissions.manageConfiguration === true;
65
+ const canManageIntegrations = shell.session?.permissions.manageIntegrations === true;
66
+ const [setup, setSetup] = useState<SetupStatus | null>(null);
67
+ const [installation, setInstallation] = useState<InstallationDetails | null>(null);
68
+ const [brand, setBrand] = useState<WorkspaceBrand | null>(null);
69
+ const [integrations, setIntegrations] = useState<ApplicationIntegration[]>([]);
70
+ const [channels, setChannels] = useState<ReportingChannel[]>([]);
71
+ const [loading, setLoading] = useState(false);
72
+ const [verifying, setVerifying] = useState("");
73
+ const [error, setError] = useState("");
74
+
75
+ const loadChannels = useCallback(async (): Promise<void> => {
76
+ if (!canManageIntegrations) { setIntegrations([]); setChannels([]); return; }
77
+ const [integrationResponse, channelResponse] = await Promise.all([
78
+ requestJson<IntegrationsResponse>("/v1/admin/integrations"),
79
+ requestJson<ReportingChannelsResponse>("/v1/admin/reporting-channels"),
80
+ ]);
81
+ setIntegrations(Array.isArray(integrationResponse.integrations) ? integrationResponse.integrations : []);
82
+ setChannels(Array.isArray(channelResponse.channels) ? channelResponse.channels : []);
83
+ }, [canManageIntegrations]);
84
+
85
+ const load = useCallback(async (): Promise<void> => {
86
+ if (!shell.session) return;
87
+ setLoading(true);
88
+ setError("");
89
+ try {
90
+ const tasks: Promise<void>[] = [];
91
+ if (canManageInstallation) tasks.push(Promise.all([
92
+ requestJson<SetupResponse>("/v1/admin/setup"),
93
+ requestJson<InstallationResponse>("/v1/admin/installation"),
94
+ ]).then(([setupResponse, installationResponse]) => {
95
+ setSetup(setupResponse.setup);
96
+ setInstallation(installationResponse.installation);
97
+ dispatchSetupUpdated(setupResponse.setup);
98
+ }));
99
+ if (canManageConfiguration) tasks.push(requestJson<BrandResponse>("/v1/admin/brand").then((response) => setBrand(response.brand)));
100
+ if (canManageIntegrations) tasks.push(loadChannels());
101
+ await Promise.all(tasks);
102
+ } catch (cause) {
103
+ setError(errorMessage(cause, "Workspace settings could not be loaded."));
104
+ } finally {
105
+ setLoading(false);
106
+ }
107
+ }, [shell.session?.actor.id, canManageInstallation, canManageConfiguration, canManageIntegrations, loadChannels]);
108
+
109
+ useEffect(() => { void load(); }, [load]);
110
+ useEffect(() => {
111
+ const reloadChannels = (): void => { void loadChannels().catch((cause) => setError(errorMessage(cause, "Reporting channels could not be refreshed."))); };
112
+ window.addEventListener(settingsEvents.channelsChanged, reloadChannels);
113
+ return () => window.removeEventListener(settingsEvents.channelsChanged, reloadChannels);
114
+ }, [loadChannels]);
115
+
116
+ const verify = async (featureKey: string): Promise<void> => {
117
+ setVerifying(featureKey);
118
+ setError("");
119
+ try {
120
+ await requestJson(`/v1/admin/setup/${encodeURIComponent(featureKey)}/verify`, { method: "POST" });
121
+ const response = await requestJson<SetupResponse>("/v1/admin/setup");
122
+ setSetup(response.setup);
123
+ dispatchSetupUpdated(response.setup);
124
+ } catch (cause) {
125
+ setError(errorMessage(cause, "The setup item could not be verified."));
126
+ } finally {
127
+ setVerifying("");
128
+ }
129
+ };
130
+
131
+ const optional = new Set(setup?.optional ?? []);
132
+ const requiredRemaining = setup?.features.filter((feature) => feature.state !== "ready" && !optional.has(feature.featureKey)).length ?? 0;
133
+ const noAccess = !canManageInstallation && !canManageConfiguration && !canManageIntegrations;
134
+
135
+ return (
136
+ <div className="page-stack settings-react-workspace" data-react-slice="settings-workspace">
137
+ <PageHeader titleId="settings-title" title="Settings" description="Update branding, reporting channels, and deployment checks.">
138
+ {!noAccess ? <div className="surface-buttons"><button className="secondary" type="button" disabled={loading} onClick={() => void load()}>{loading ? "Refreshing…" : "Refresh"}</button></div> : null}
139
+ </PageHeader>
140
+ {noAccess ? <section className="surface settings-no-access"><div className="empty-product"><h2>Settings unavailable</h2><p>Your role does not include workspace settings access.</p></div></section> : <div className="settings-grid">
141
+ {canManageInstallation ? <>
142
+ <section className="surface settings-card" aria-labelledby="setup-checklist-title"><div className="surface-head"><h2 id="setup-checklist-title">Setup checklist</h2><span className={`badge${requiredRemaining ? " urgent" : ""}`}>{loading && !setup ? "Checking" : requiredRemaining ? `${requiredRemaining} required` : "Ready"}</span></div>{loading && !setup ? <SettingsLoading /> : <div className="setup-list">{setup?.features.length ? setup.features.map((feature) => {
143
+ const ready = feature.state === "ready";
144
+ const isOptional = optional.has(feature.featureKey);
145
+ return <article className="setup-row" key={feature.featureKey}><span className={`setup-state${ready ? "" : " warning"}`}>{ready ? "✓" : "!"}</span><div><strong>{featureName(feature.featureKey)}</strong><small>{feature.message || feature.detail || (ready ? "Verified in this deployment" : isOptional ? "Optional setup" : "Verification required")}</small></div>{ready ? <span className="badge">Ready</span> : <button className="secondary compact-button" type="button" disabled={Boolean(verifying)} onClick={() => void verify(feature.featureKey)}>{verifying === feature.featureKey ? "Checking…" : isOptional ? "Check optional" : "Verify"}</button>}</article>;
146
+ }) : <div className="settings-empty-row"><strong>No setup items</strong><small>There is nothing to verify for this deployment.</small></div>}</div>}</section>
147
+ <section className="surface settings-card" aria-labelledby="workspace-details-title"><div className="surface-head"><h2 id="workspace-details-title">Workspace details</h2></div>{loading && !installation ? <SettingsLoading /> : installation ? <dl className="settings-facts">{installationFacts(installation).map(([label, value]) => <span className="settings-fact" key={label}><dt>{label}</dt><dd>{value}</dd></span>)}</dl> : <div className="settings-empty-row">Workspace details are unavailable.</div>}</section>
148
+ </> : null}
149
+ {canManageConfiguration ? <BrandEditor brand={brand} displayName={installation?.displayName ?? "Safest Resolve"} loading={loading && !brand} onSaved={setBrand} /> : null}
150
+ {canManageIntegrations ? <section className="surface settings-card settings-wide" aria-labelledby="reporting-channels-title"><div className="surface-head"><div><h2 id="reporting-channels-title">Reporting channels</h2><p>Choose where people open the form and what context can be trusted.</p></div><button className="primary" type="button" onClick={openChannelDialog}>Add channel</button></div>{loading && !channels.length && !integrations.length ? <SettingsLoading /> : <div className="integration-list">{channels.map((channel) => <ChannelRow channel={channel} key={channel.id} />)}{integrations.map((integration) => <IntegrationRow integration={integration} key={integration.id} />)}{!channels.length && !integrations.length ? <div className="settings-empty-row"><strong>No reporting channels yet</strong><small>Add a hosted form or connect the widget to your application.</small></div> : null}</div>}</section> : null}
151
+ </div>}
152
+ <p className="error surface-error" role="alert">{error}</p>
153
+ </div>
154
+ );
155
+ }
@@ -0,0 +1,19 @@
1
+ import type { SetupStatus } from "./types";
2
+
3
+ export const settingsEvents = {
4
+ openChannel: "safest:settings:open-channel",
5
+ channelsChanged: "safest:settings:channels-changed",
6
+ setupUpdated: "safest:settings:setup-updated",
7
+ } as const;
8
+
9
+ export function openChannelDialog(): void {
10
+ window.dispatchEvent(new Event(settingsEvents.openChannel));
11
+ }
12
+
13
+ export function dispatchChannelsChanged(): void {
14
+ window.dispatchEvent(new Event(settingsEvents.channelsChanged));
15
+ }
16
+
17
+ export function dispatchSetupUpdated(setup: SetupStatus): void {
18
+ window.dispatchEvent(new CustomEvent(settingsEvents.setupUpdated, { detail: { setup } }));
19
+ }
@@ -0,0 +1,22 @@
1
+ import type { ContextSchema, WidgetChannelResponse } from "./types";
2
+
3
+ export function widgetHandoff(
4
+ result: WidgetChannelResponse,
5
+ origin: string,
6
+ formVersion: string,
7
+ mode: "contextual" | "anonymous",
8
+ contextSchema: ContextSchema,
9
+ ): string {
10
+ const serviceBase = result.publicBaseUrl || window.location.origin;
11
+ const tag = `<script src="${serviceBase}/widget.js" defer></script>\n<safest-report\n api-base="${serviceBase}"\n integration-id="${result.integrationId}"\n form-version="${formVersion}"${mode === "contextual" ? '\n context-endpoint="/api/safest/report-context"' : ""}\n target-type="<object type>"\n target-reference="<object id>">\n</safest-report>`;
12
+
13
+ if (mode === "anonymous") {
14
+ return `Safest Resolve anonymous widget\n\nThis channel has no API key and does not identify the reporter. Values in target-type and target-reference are reporter-provided hints, not verified facts.\n\nAdd this component:\n${tag}\n\nContent Security Policy\nAllow ${serviceBase} in script-src, style-src, and frame-src. The human check runs inside the isolated Resolve frame.`;
15
+ }
16
+
17
+ const registered = contextSchema.fields.length
18
+ ? contextSchema.fields.map((field) => ` "${field.key}": <${field.type}>`).join(",\n")
19
+ : " // no extra trusted fields registered";
20
+
21
+ return `Safest Resolve signed-in widget\n\n1. Store the one-time backend key in your secret manager. Never put it in HTML, JavaScript, logs, or a URL.\n\n2. Add this component:\n${tag}\n\nThe component automatically asks /api/safest/report-context for a short-lived opaque token. Your endpoint must authenticate the current session and verify that the user may report the requested object. Do not trust a user ID or object ownership sent by the browser.\n\n3. From that backend endpoint, POST ${serviceBase}/v1/context-tokens with:\nAuthorization: Bearer <BACKEND KEY>\nX-Safest-Integration-Id: ${result.integrationId}\nContent-Type: application/json\n\n{\n "origin": "${origin}",\n "form_version": "${formVersion}",\n "reporter": { "reference": "<ID from authenticated server session>" },\n "target": { "type": "<verified object type>", "reference": "<verified object id>" },\n "trusted_facts": {\n${registered}\n }\n}\n\nReturn only Resolve's context_token and expires_at to the component. Trusted facts stay in Resolve; the token itself contains only random identifiers.\n\nContent Security Policy\nAllow ${serviceBase} in script-src, style-src, and frame-src. The human check runs inside the isolated Resolve frame.`;
22
+ }
@@ -0,0 +1,93 @@
1
+ import type { WorkspaceBrand } from "../shell/types";
2
+
3
+ export type ChannelMode = "hosted" | "contextual" | "anonymous";
4
+ export type ContextFieldType = "string" | "number" | "boolean";
5
+ export type ContextFieldSensitivity = "standard" | "sensitive";
6
+
7
+ export interface SetupFeature {
8
+ featureKey: string;
9
+ state: string;
10
+ message?: string;
11
+ detail?: string;
12
+ errorCode?: string | null;
13
+ }
14
+
15
+ export interface SetupStatus {
16
+ ready: boolean;
17
+ features: SetupFeature[];
18
+ optional: string[];
19
+ }
20
+
21
+ export interface InstallationDetails {
22
+ id: string;
23
+ displayName: string;
24
+ defaultLocale: string;
25
+ timezone: string;
26
+ anonymousReportingEnabled: boolean;
27
+ createdAt: string;
28
+ updatedAt: string;
29
+ }
30
+
31
+ export interface ReportingChannel {
32
+ id: string;
33
+ mode: "hosted";
34
+ name: string;
35
+ formVersionId: string;
36
+ url: string;
37
+ enabled: boolean;
38
+ status: string;
39
+ }
40
+
41
+ export interface ApplicationIntegration {
42
+ id: string;
43
+ name: string;
44
+ allowedOrigins: string[];
45
+ scopes: string[];
46
+ channelMode: "contextual" | "anonymous";
47
+ formVersionId: string | null;
48
+ enabled: boolean;
49
+ activeKeyCount: number;
50
+ }
51
+
52
+ export interface PublishedForm {
53
+ id: string;
54
+ formKey: string;
55
+ version: number;
56
+ title: string;
57
+ description: string;
58
+ publishedAt: string;
59
+ }
60
+
61
+ export interface ContextField {
62
+ id: string;
63
+ key: string;
64
+ label: string;
65
+ type: ContextFieldType;
66
+ sensitivity: ContextFieldSensitivity;
67
+ required: boolean;
68
+ rule_eligible: boolean;
69
+ }
70
+
71
+ export interface ContextSchema {
72
+ version: 1;
73
+ allow_unknown: false;
74
+ fields: Omit<ContextField, "id">[];
75
+ }
76
+
77
+ export interface SetupResponse { setup: SetupStatus }
78
+ export interface InstallationResponse { installation: InstallationDetails }
79
+ export interface BrandResponse { brand: WorkspaceBrand }
80
+ export interface IntegrationsResponse { integrations: ApplicationIntegration[] }
81
+ export interface ReportingChannelsResponse { channels: ReportingChannel[] }
82
+ export interface PublishedFormsResponse { forms: PublishedForm[] }
83
+
84
+ export interface HostedChannelResponse {
85
+ channel: { mode: "hosted"; formVersionId: string; url: string };
86
+ }
87
+
88
+ export interface WidgetChannelResponse {
89
+ integrationId: string;
90
+ keyId: string | null;
91
+ apiKey: string | null;
92
+ publicBaseUrl: string;
93
+ }
@@ -0,0 +1,160 @@
1
+ import { Fragment, useEffect, useMemo, useState, useSyncExternalStore, type MouseEvent } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { AnalystIdentity } from "../components/AnalystIdentity";
4
+ import { dispatchNavigation, dispatchSignOut } from "./events";
5
+ import { visibleNavigationGroups } from "./navigation";
6
+ import { getShellSnapshot, subscribeToShell } from "./store";
7
+ import type { ConsoleAnalystProfile, ConsoleSession, CustomerBrand, WorkspaceView } from "./types";
8
+
9
+ const fallbackBrand: CustomerBrand = {
10
+ organization_name: "Safest Resolve",
11
+ logo_url: "",
12
+ logo_alt: "",
13
+ };
14
+
15
+ const roleLabels: Record<string, string> = {
16
+ reviewer: "Analyst",
17
+ appeal_reviewer: "Appeal analyst",
18
+ administrator: "Administrator",
19
+ owner: "Infrastructure owner",
20
+ auditor: "Auditor",
21
+ };
22
+
23
+ function initials(value: string): string {
24
+ return value.split(/\s+/u).slice(0, 2).map((part) => part[0] ?? "").join("").toUpperCase() || "SR";
25
+ }
26
+
27
+ function useCustomerBrand(): CustomerBrand {
28
+ const [brand, setBrand] = useState<CustomerBrand>(() => window.SafestBrand?.defaults ?? fallbackBrand);
29
+
30
+ useEffect(() => {
31
+ const receiveBrand = (event: Event): void => {
32
+ if (!(event instanceof CustomEvent) || !event.detail?.brand) return;
33
+ setBrand((current) => ({ ...current, ...(event.detail.brand as CustomerBrand) }));
34
+ };
35
+ window.addEventListener("safest-brand-applied", receiveBrand);
36
+ return () => window.removeEventListener("safest-brand-applied", receiveBrand);
37
+ }, []);
38
+
39
+ return brand;
40
+ }
41
+
42
+ function BrandLogo({ brand, size }: { brand: CustomerBrand; size: "brand" | "workspace" }) {
43
+ const isDefaultBrand = brand.organization_name.trim().toLowerCase() === "safest resolve";
44
+ const logoUrl = brand.logo_url || (isDefaultBrand ? "/brand-icon.svg" : "");
45
+ const alt = brand.logo_alt || (isDefaultBrand ? "Safest Resolve logo" : `${brand.organization_name} logo`);
46
+ if (logoUrl) {
47
+ return <img className={size === "workspace" ? "workspace-logo" : undefined} src={logoUrl} alt={alt} width={size === "workspace" ? 30 : 34} height={size === "workspace" ? 30 : 34} />;
48
+ }
49
+ return <span className={size === "workspace" ? "workspace-mark" : "customer-brand-fallback"} aria-hidden="true">{initials(brand.organization_name)}</span>;
50
+ }
51
+
52
+ function analystProfile(session: ConsoleSession): ConsoleAnalystProfile {
53
+ return session.profile ?? {
54
+ actorId: session.actor.id,
55
+ displayName: session.displayName,
56
+ email: session.email ?? null,
57
+ avatarUrl: session.avatarUrl ?? null,
58
+ avatarColor: "#315d4d",
59
+ roles: session.roles,
60
+ };
61
+ }
62
+
63
+ function accessLabel(session: ConsoleSession): string {
64
+ return session.roles.map((role) => roleLabels[role] ?? role.replaceAll("_", " ")).join(", ") || "Team member";
65
+ }
66
+
67
+ function Navigation({ session, currentView, reportCount, open, onClose }: {
68
+ session: ConsoleSession;
69
+ currentView: WorkspaceView;
70
+ reportCount: number;
71
+ open: boolean;
72
+ onClose: () => void;
73
+ }) {
74
+ const groups = useMemo(() => visibleNavigationGroups(session), [session]);
75
+ const navigate = (event: MouseEvent<HTMLAnchorElement>, view: WorkspaceView): void => {
76
+ event.preventDefault();
77
+ dispatchNavigation(view);
78
+ onClose();
79
+ };
80
+ return (
81
+ <>
82
+ <aside id="workspace-navigation" className={`sidebar${open ? " open" : ""}`} aria-label="Workspace navigation">
83
+ <nav>
84
+ {groups.map((group) => (
85
+ <Fragment key={group.label}>
86
+ <small>{group.label}</small>
87
+ {group.items.map((item) => (
88
+ <a className={item.view === currentView ? "active" : undefined} data-nav={item.view} href={`#${item.view}`} key={item.view} aria-current={item.view === currentView ? "page" : undefined} onClick={(event) => navigate(event, item.view)}>
89
+ <svg aria-hidden="true"><use href={`#${item.icon}`} /></svg>
90
+ <span>{item.label}</span>
91
+ {item.count === "reports" ? <b id="nav-report-count" className="nav-count">{reportCount}</b> : null}
92
+ </a>
93
+ ))}
94
+ </Fragment>
95
+ ))}
96
+ </nav>
97
+ <div className="principle">
98
+ <span className="principle-dot" aria-hidden="true" />
99
+ <div><strong>Customer-owned records</strong><p>Reports and decisions stay in this deployment.</p></div>
100
+ </div>
101
+ </aside>
102
+ <button className="nav-scrim" type="button" aria-label="Close navigation" hidden={!open} onClick={onClose} />
103
+ </>
104
+ );
105
+ }
106
+
107
+ export function WorkspaceShell({ navigationTarget }: { navigationTarget: HTMLElement }) {
108
+ const snapshot = useSyncExternalStore(subscribeToShell, getShellSnapshot, getShellSnapshot);
109
+ const [navigationOpen, setNavigationOpen] = useState(false);
110
+ const brand = useCustomerBrand();
111
+ const session = snapshot.session;
112
+
113
+ useEffect(() => setNavigationOpen(false), [snapshot.currentView, session]);
114
+ useEffect(() => {
115
+ document.body.classList.toggle("mobile-navigation-open", navigationOpen && Boolean(session));
116
+ return () => document.body.classList.remove("mobile-navigation-open");
117
+ }, [navigationOpen, session]);
118
+ useEffect(() => {
119
+ const closeOnWideViewport = (): void => {
120
+ if (window.innerWidth > 760) setNavigationOpen(false);
121
+ };
122
+ const closeOnEscape = (event: KeyboardEvent): void => {
123
+ if (event.key === "Escape") setNavigationOpen(false);
124
+ };
125
+ window.addEventListener("resize", closeOnWideViewport);
126
+ window.addEventListener("keydown", closeOnEscape);
127
+ return () => {
128
+ window.removeEventListener("resize", closeOnWideViewport);
129
+ window.removeEventListener("keydown", closeOnEscape);
130
+ };
131
+ }, []);
132
+
133
+ const canUseAssistant = session?.permissions.useAssistant === true;
134
+ return (
135
+ <>
136
+ <header className="topbar" data-react-slice="workspace-shell">
137
+ <button className="nav-toggle" type="button" aria-label={navigationOpen ? "Close navigation" : "Open navigation"} aria-controls="workspace-navigation" aria-expanded={navigationOpen} hidden={!session} onClick={() => setNavigationOpen((open) => !open)}>
138
+ <svg aria-hidden="true"><use href="#i-menu" /></svg>
139
+ </button>
140
+ <a className="brand" href="/" aria-label="Workspace home">
141
+ <BrandLogo brand={brand} size="brand" />
142
+ <span><strong>{brand.organization_name}</strong><small>Reports and workflows</small></span>
143
+ </a>
144
+ <div className="top-workspace" aria-label="Current workspace" hidden={!session}>
145
+ <BrandLogo brand={brand} size="workspace" />
146
+ <span><strong>{brand.organization_name}</strong><small>Reports and workflows</small></span>
147
+ </div>
148
+ <button className="global-command" type="button" hidden={!session || !canUseAssistant} onClick={() => dispatchNavigation("assistant")}>
149
+ <svg aria-hidden="true"><use href="#i-search" /></svg><span>Search or ask Safest</span><kbd>⌘ K</kbd>
150
+ </button>
151
+ <div className="top-actions">
152
+ <span className={`health ${snapshot.health.status === "checking" ? "" : snapshot.health.status}`.trim()}><i aria-hidden="true" /><span>{snapshot.health.label}</span></span>
153
+ {session ? <div className="current-analyst"><AnalystIdentity analyst={analystProfile(session)} detail={accessLabel(session)} /></div> : null}
154
+ {session ? <button className="sign-out-button" type="button" onClick={dispatchSignOut}>Sign out</button> : null}
155
+ </div>
156
+ </header>
157
+ {session ? createPortal(<Navigation session={session} currentView={snapshot.currentView} reportCount={snapshot.reportCount} open={navigationOpen} onClose={() => setNavigationOpen(false)} />, navigationTarget) : null}
158
+ </>
159
+ );
160
+ }
@@ -0,0 +1,182 @@
1
+ import { authEvents } from "../auth/events";
2
+ import { errorMessage, hasSessionCredentials, requestJson } from "../lib/http";
3
+ import { shellEvents } from "./events";
4
+ import { visibleNavigationGroups } from "./navigation";
5
+ import { getShellSnapshot, setShellSnapshot } from "./store";
6
+ import type { ConsoleSession, CustomerBrand, ShellHealthStatus, WorkspaceView } from "./types";
7
+
8
+ interface SessionResponse {
9
+ session: ConsoleSession;
10
+ configuration?: {
11
+ allowed_action_codes?: string[];
12
+ action_delivery_enabled?: boolean;
13
+ } | null;
14
+ }
15
+
16
+ const viewTitles: Partial<Record<WorkspaceView, string>> = {
17
+ command: "Command centre",
18
+ queues: "Queues and policies",
19
+ assistant: "Ask Safest",
20
+ "ai-quality": "AI quality",
21
+ analytics: "Reports and outcomes",
22
+ settings: "Settings",
23
+ };
24
+
25
+ let brand: CustomerBrand = { organization_name: "Safest Resolve", logo_url: "", logo_alt: "" };
26
+ let expired = false;
27
+ let started = false;
28
+
29
+ function element(id: "login" | "workspace"): HTMLElement | null {
30
+ return document.querySelector<HTMLElement>(`#${id}`);
31
+ }
32
+
33
+ function readable(value: string): string {
34
+ const copy = value.replaceAll("_", " ");
35
+ if (!copy) return "";
36
+ const sentence = `${copy.charAt(0).toUpperCase()}${copy.slice(1)}`;
37
+ const acronyms: Record<string, string> = { ai: "AI", api: "API", csv: "CSV", d1: "D1", http: "HTTP", https: "HTTPS", id: "ID", json: "JSON", r2: "R2", sla: "SLA", url: "URL" };
38
+ return sentence.replace(/\b(ai|api|csv|d1|http|https|id|json|r2|sla|url)\b/giu, (match) => acronyms[match.toLowerCase()] ?? match);
39
+ }
40
+
41
+ function setWorkspaceMode(authenticated: boolean): void {
42
+ document.body.classList.toggle("workspace-mode", authenticated);
43
+ document.body.classList.toggle("login-mode", !authenticated);
44
+ document.body.classList.remove("mobile-navigation-open", "report-open");
45
+ window.scrollTo({ top: 0, left: 0, behavior: "auto" });
46
+ }
47
+
48
+ function showWorkspace(authenticated: boolean): void {
49
+ const login = element("login");
50
+ const workspace = element("workspace");
51
+ if (login) login.hidden = authenticated;
52
+ if (workspace) workspace.hidden = !authenticated;
53
+ setWorkspaceMode(authenticated);
54
+ if (!authenticated) setDocumentTitle("command");
55
+ }
56
+
57
+ function allowedViews(session: ConsoleSession): Set<WorkspaceView> {
58
+ return new Set(visibleNavigationGroups(session).flatMap((group) => group.items.map((item) => item.view)));
59
+ }
60
+
61
+ function setDocumentTitle(view: WorkspaceView): void {
62
+ const suffix = getShellSnapshot().session ? viewTitles[view] || readable(view) : "Reports and workflows";
63
+ document.title = `${brand.organization_name || "Safest Resolve"} — ${suffix}`;
64
+ }
65
+
66
+ function setView(requested: string): void {
67
+ const snapshot = getShellSnapshot();
68
+ const session = snapshot.session;
69
+ if (!session) return;
70
+ const available = [...document.querySelectorAll<HTMLElement>("[data-view]")];
71
+ const allowed = allowedViews(session);
72
+ const requestedView = requested as WorkspaceView;
73
+ const selected: WorkspaceView = allowed.has(requestedView) && available.some((section) => section.dataset.view === requestedView)
74
+ ? requestedView
75
+ : "command";
76
+ for (const section of available) section.hidden = section.dataset.view !== selected;
77
+ if (window.location.hash !== `#${selected}`) history.replaceState(null, "", `#${selected}`);
78
+ setShellSnapshot((current) => ({ ...current, currentView: selected }));
79
+ setDocumentTitle(selected);
80
+ }
81
+
82
+ function expireWorkspaceSession(message = ""): void {
83
+ if (expired && !getShellSnapshot().session) return;
84
+ expired = true;
85
+ setShellSnapshot((snapshot) => ({ ...snapshot, session: null, configuration: null, currentView: "command", reportCount: 0 }));
86
+ showWorkspace(false);
87
+ if (message) window.dispatchEvent(new CustomEvent(authEvents.error, { detail: { message } }));
88
+ else window.dispatchEvent(new Event(authEvents.focusEmail));
89
+ }
90
+
91
+ async function openWorkspace(destination?: WorkspaceView, silent = false): Promise<void> {
92
+ try {
93
+ const response = await requestJson<SessionResponse>("/v1/admin/session");
94
+ expired = false;
95
+ setShellSnapshot((snapshot) => ({
96
+ ...snapshot,
97
+ session: response.session,
98
+ configuration: response.configuration ?? null,
99
+ reportCount: 0,
100
+ }));
101
+ showWorkspace(true);
102
+ const hashView = window.location.hash.slice(1);
103
+ setView(destination || (hashView && hashView !== "invite" ? hashView : "command"));
104
+ } catch (cause) {
105
+ expireWorkspaceSession(silent ? "" : errorMessage(cause, "Authentication failed."));
106
+ }
107
+ }
108
+
109
+ async function checkHealth(): Promise<void> {
110
+ let status: ShellHealthStatus = "ready";
111
+ let label = "Durable storage ready";
112
+ try {
113
+ await requestJson("/health");
114
+ } catch {
115
+ status = "failed";
116
+ label = "Storage unavailable";
117
+ }
118
+ setShellSnapshot((snapshot) => ({ ...snapshot, health: { status, label } }));
119
+ }
120
+
121
+ async function signOut(): Promise<void> {
122
+ try { await requestJson("/v1/auth/logout", { method: "POST" }); } catch { /* Local state is still cleared. */ }
123
+ expired = false;
124
+ sessionStorage.setItem("safest-reports-force-login", "true");
125
+ setShellSnapshot((snapshot) => ({ ...snapshot, session: null, configuration: null, currentView: "command", reportCount: 0 }));
126
+ showWorkspace(false);
127
+ window.dispatchEvent(new Event(authEvents.focusEmail));
128
+ }
129
+
130
+ async function initialize(): Promise<void> {
131
+ try {
132
+ brand = await window.SafestBrand?.loadBrand("/v1/brand", { title: "Reports and workflows" }) || brand;
133
+ } catch {
134
+ brand = window.SafestBrand?.defaults || brand;
135
+ }
136
+ await checkHealth();
137
+ const parameters = new URLSearchParams(window.location.hash.slice(1));
138
+ if (parameters.has("invite") || parameters.has("reset")) return;
139
+ if (sessionStorage.getItem("safest-reports-force-login") !== "true") {
140
+ await openWorkspace(undefined, !hasSessionCredentials());
141
+ } else {
142
+ showWorkspace(false);
143
+ window.dispatchEvent(new Event(authEvents.focusEmail));
144
+ }
145
+ }
146
+
147
+ export function startShellController(): void {
148
+ if (started) return;
149
+ started = true;
150
+ window.addEventListener("safest-brand-applied", (event) => {
151
+ if (!(event instanceof CustomEvent) || !event.detail?.brand) return;
152
+ brand = { ...brand, ...(event.detail.brand as CustomerBrand) };
153
+ setDocumentTitle(getShellSnapshot().currentView);
154
+ });
155
+ window.addEventListener(authEvents.authenticated, (event) => {
156
+ const destination = event instanceof CustomEvent && event.detail?.destination === "profile" ? "profile" : undefined;
157
+ void openWorkspace(destination);
158
+ });
159
+ window.addEventListener("safest:auth:expired", (event) => {
160
+ const message = event instanceof CustomEvent && typeof event.detail?.message === "string"
161
+ ? event.detail.message
162
+ : "Your session expired. Sign in again.";
163
+ expireWorkspaceSession(message);
164
+ });
165
+ window.addEventListener(shellEvents.signOut, () => { void signOut(); });
166
+ window.addEventListener(shellEvents.navigate, (event) => {
167
+ const view = event instanceof CustomEvent && typeof event.detail?.view === "string" ? event.detail.view : "command";
168
+ setView(view);
169
+ if (view === "assistant") window.dispatchEvent(new Event("safest:assistant:focus"));
170
+ });
171
+ window.addEventListener("keydown", (event) => {
172
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k" && getShellSnapshot().session?.permissions.useAssistant) {
173
+ event.preventDefault();
174
+ setView("assistant");
175
+ window.dispatchEvent(new Event("safest:assistant:focus"));
176
+ }
177
+ });
178
+ window.addEventListener("hashchange", () => {
179
+ if (getShellSnapshot().session && !window.location.hash.startsWith("#invite=")) setView(window.location.hash.slice(1) || "reports");
180
+ });
181
+ void initialize();
182
+ }
@@ -0,0 +1,25 @@
1
+ import type { ShellSnapshot, WorkspaceView } from "./types";
2
+
3
+ export const shellEvents = {
4
+ state: "safest:shell:state",
5
+ navigate: "safest:shell:navigate",
6
+ signOut: "safest:shell:sign-out",
7
+ } as const;
8
+
9
+ export function dispatchNavigation(view: WorkspaceView): void {
10
+ window.dispatchEvent(new CustomEvent(shellEvents.navigate, { detail: { view } }));
11
+ }
12
+
13
+ export function dispatchSignOut(): void {
14
+ window.dispatchEvent(new Event(shellEvents.signOut));
15
+ }
16
+
17
+ export function isShellSnapshot(value: unknown): value is ShellSnapshot {
18
+ if (!value || typeof value !== "object") return false;
19
+ const candidate = value as Partial<ShellSnapshot>;
20
+ return (candidate.session === null || typeof candidate.session === "object")
21
+ && typeof candidate.currentView === "string"
22
+ && typeof candidate.reportCount === "number"
23
+ && typeof candidate.health?.status === "string"
24
+ && typeof candidate.health?.label === "string";
25
+ }