portal-mojo 0.1.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 (369) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +55 -0
  3. package/docs/DetailView.md +173 -0
  4. package/docs/ModelTable.md +128 -0
  5. package/docs/README.md +98 -0
  6. package/docs/admin-assistant.md +72 -0
  7. package/docs/admin-bouncer.md +120 -0
  8. package/docs/admin-cloudwatch.md +38 -0
  9. package/docs/admin-credentials.md +116 -0
  10. package/docs/admin-dashboard.md +15 -0
  11. package/docs/admin-devices-geoip.md +284 -0
  12. package/docs/admin-dns-certificates.md +122 -0
  13. package/docs/admin-dns-records.md +65 -0
  14. package/docs/admin-dns-registrar.md +33 -0
  15. package/docs/admin-dns.md +80 -0
  16. package/docs/admin-identity-users.md +103 -0
  17. package/docs/admin-incidents.md +71 -0
  18. package/docs/admin-jobs.md +426 -0
  19. package/docs/admin-members.md +112 -0
  20. package/docs/admin-messaging.md +56 -0
  21. package/docs/admin-metrics-explorer.md +63 -0
  22. package/docs/admin-monitoring.md +99 -0
  23. package/docs/admin-network-security.md +393 -0
  24. package/docs/admin-phonehub.md +27 -0
  25. package/docs/admin-push.md +36 -0
  26. package/docs/admin-rules.md +87 -0
  27. package/docs/admin-security-tickets.md +40 -0
  28. package/docs/admin-settings.md +78 -0
  29. package/docs/admin-shortlinks.md +32 -0
  30. package/docs/admin-storage.md +141 -0
  31. package/docs/auth-pages.md +212 -0
  32. package/docs/auth.md +58 -0
  33. package/docs/calendar.md +122 -0
  34. package/docs/charts.md +303 -0
  35. package/docs/client.md +206 -0
  36. package/docs/collection-multiselect.md +117 -0
  37. package/docs/collection-select.md +132 -0
  38. package/docs/combobox.md +105 -0
  39. package/docs/dataview.md +226 -0
  40. package/docs/datepicker.md +151 -0
  41. package/docs/daterange.md +170 -0
  42. package/docs/datetimepicker.md +249 -0
  43. package/docs/defineModel.md +72 -0
  44. package/docs/detail-primitives.md +174 -0
  45. package/docs/feedback.md +85 -0
  46. package/docs/file-upload-ux.md +146 -0
  47. package/docs/forms.md +435 -0
  48. package/docs/grouping-and-fmt.md +98 -0
  49. package/docs/idioms.md +112 -0
  50. package/docs/image-editor.md +101 -0
  51. package/docs/loading.md +242 -0
  52. package/docs/location-address.md +76 -0
  53. package/docs/markdown.md +203 -0
  54. package/docs/menus-and-access.md +89 -0
  55. package/docs/multiselect-dropdown.md +153 -0
  56. package/docs/params.md +73 -0
  57. package/docs/popover.md +130 -0
  58. package/docs/realtime.md +61 -0
  59. package/docs/record-feed.md +238 -0
  60. package/docs/right-panel.md +42 -0
  61. package/docs/tabs-and-form-wizard.md +60 -0
  62. package/docs/taginput.md +120 -0
  63. package/docs/timepicker.md +173 -0
  64. package/docs/timezone-select.md +147 -0
  65. package/docs/worldmap.md +302 -0
  66. package/package.json +55 -0
  67. package/src/admin/assistant/AssistantFeed.tsx +29 -0
  68. package/src/admin/assistant/AssistantPanel.tsx +65 -0
  69. package/src/admin/assistant/api.ts +17 -0
  70. package/src/admin/assistant/data.ts +63 -0
  71. package/src/admin/assistant/index.ts +21 -0
  72. package/src/admin/assistant/launchers.tsx +33 -0
  73. package/src/admin/assistant/pages.tsx +78 -0
  74. package/src/admin/assistant/streaming.ts +238 -0
  75. package/src/admin/assistant/types.ts +30 -0
  76. package/src/admin/bouncer/devices.tsx +230 -0
  77. package/src/admin/bouncer/index.ts +34 -0
  78. package/src/admin/bouncer/models.ts +199 -0
  79. package/src/admin/bouncer/signals.tsx +182 -0
  80. package/src/admin/bouncer/signatures.tsx +220 -0
  81. package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
  82. package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
  83. package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
  84. package/src/admin/cloudwatch/data.ts +163 -0
  85. package/src/admin/cloudwatch/index.ts +16 -0
  86. package/src/admin/credentials/group-api-keys.tsx +433 -0
  87. package/src/admin/credentials/index.ts +4 -0
  88. package/src/admin/credentials/models.ts +244 -0
  89. package/src/admin/credentials/secret-dialog.tsx +77 -0
  90. package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
  91. package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
  92. package/src/admin/dashboard/data.ts +60 -0
  93. package/src/admin/dashboard/index.ts +2 -0
  94. package/src/admin/dns/CertificateDetail.tsx +115 -0
  95. package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
  96. package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
  97. package/src/admin/dns/CertificatesPage.tsx +85 -0
  98. package/src/admin/dns/DnsRecordEditor.tsx +110 -0
  99. package/src/admin/dns/DnsRecordsPage.tsx +36 -0
  100. package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
  101. package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
  102. package/src/admin/dns/DomainDetail.tsx +41 -0
  103. package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
  104. package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
  105. package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
  106. package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
  107. package/src/admin/dns/DomainsPage.tsx +27 -0
  108. package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
  109. package/src/admin/dns/RegistrantContactPage.tsx +42 -0
  110. package/src/admin/dns/api.ts +515 -0
  111. package/src/admin/dns/certificate-data.ts +221 -0
  112. package/src/admin/dns/data.ts +32 -0
  113. package/src/admin/dns/dns-data.ts +266 -0
  114. package/src/admin/dns/dns-integration.ts +24 -0
  115. package/src/admin/dns/domain-links.ts +37 -0
  116. package/src/admin/dns/index.ts +86 -0
  117. package/src/admin/dns/models.ts +418 -0
  118. package/src/admin/dns/purchase-data.ts +67 -0
  119. package/src/admin/dns/registrant-data.ts +52 -0
  120. package/src/admin/identity/index.ts +5 -0
  121. package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
  122. package/src/admin/identity/members/MemberDetail.tsx +226 -0
  123. package/src/admin/identity/members/MembersPage.tsx +144 -0
  124. package/src/admin/identity/members/index.ts +24 -0
  125. package/src/admin/identity/members/member-flows.tsx +132 -0
  126. package/src/admin/identity/members/models.ts +204 -0
  127. package/src/admin/identity/users/UserDetail.tsx +257 -0
  128. package/src/admin/identity/users/UsersPage.tsx +163 -0
  129. package/src/admin/identity/users/index.ts +27 -0
  130. package/src/admin/identity/users/models.ts +232 -0
  131. package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
  132. package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
  133. package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
  134. package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
  135. package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
  136. package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
  137. package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
  138. package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
  139. package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
  140. package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
  141. package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
  142. package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
  143. package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
  144. package/src/admin/identity/users/sections/actions.tsx +752 -0
  145. package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
  146. package/src/admin/identity/users/sections/queries.ts +74 -0
  147. package/src/admin/identity/users/sections/shared.tsx +299 -0
  148. package/src/admin/incidents/EventDetail.tsx +43 -0
  149. package/src/admin/incidents/EventsPage.tsx +52 -0
  150. package/src/admin/incidents/IncidentDetail.tsx +68 -0
  151. package/src/admin/incidents/IncidentsPage.tsx +102 -0
  152. package/src/admin/incidents/forensics.tsx +73 -0
  153. package/src/admin/incidents/index.ts +16 -0
  154. package/src/admin/incidents/models.ts +113 -0
  155. package/src/admin/incidents/sanitize.ts +71 -0
  156. package/src/admin/index.ts +383 -0
  157. package/src/admin/jobs/JobDashboardPage.tsx +111 -0
  158. package/src/admin/jobs/JobDetail.tsx +721 -0
  159. package/src/admin/jobs/JobRunnersPage.tsx +216 -0
  160. package/src/admin/jobs/JobsTablePage.tsx +122 -0
  161. package/src/admin/jobs/RunnerDetail.tsx +576 -0
  162. package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
  163. package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
  164. package/src/admin/jobs/columns.tsx +297 -0
  165. package/src/admin/jobs/control.ts +488 -0
  166. package/src/admin/jobs/index.ts +85 -0
  167. package/src/admin/jobs/models.ts +599 -0
  168. package/src/admin/jobs/queries.ts +272 -0
  169. package/src/admin/jobs/scheduled-task-form.tsx +493 -0
  170. package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
  171. package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
  172. package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
  173. package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
  174. package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
  175. package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
  176. package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
  177. package/src/admin/messaging/MailboxesPage.tsx +15 -0
  178. package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
  179. package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
  180. package/src/admin/messaging/SentMessagesPage.tsx +11 -0
  181. package/src/admin/messaging/api.ts +17 -0
  182. package/src/admin/messaging/data.ts +10 -0
  183. package/src/admin/messaging/index.ts +23 -0
  184. package/src/admin/messaging/models.ts +76 -0
  185. package/src/admin/messaging/push/PushPage.tsx +44 -0
  186. package/src/admin/messaging/push/api.ts +17 -0
  187. package/src/admin/messaging/push/index.ts +9 -0
  188. package/src/admin/messaging/push/models.ts +43 -0
  189. package/src/admin/messaging/sanitize.ts +44 -0
  190. package/src/admin/monitoring/LogInspector.tsx +160 -0
  191. package/src/admin/monitoring/LogsPage.tsx +135 -0
  192. package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
  193. package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
  194. package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
  195. package/src/admin/monitoring/index.ts +55 -0
  196. package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
  197. package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
  198. package/src/admin/monitoring/models.ts +155 -0
  199. package/src/admin/network/BlockedIPsPage.tsx +194 -0
  200. package/src/admin/network/BlockedIpDetail.tsx +262 -0
  201. package/src/admin/network/FirewallLogPage.tsx +115 -0
  202. package/src/admin/network/IPSetDetail.tsx +280 -0
  203. package/src/admin/network/IPSetEditor.tsx +191 -0
  204. package/src/admin/network/IPSetsPage.tsx +177 -0
  205. package/src/admin/network/geofence/BlocksTab.tsx +289 -0
  206. package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
  207. package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
  208. package/src/admin/network/geofence/PostureHeader.tsx +184 -0
  209. package/src/admin/network/geofence/RuleEditor.tsx +176 -0
  210. package/src/admin/network/geofence/RulesTab.tsx +254 -0
  211. package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
  212. package/src/admin/network/geofence/geofence-data.ts +674 -0
  213. package/src/admin/network/index.ts +68 -0
  214. package/src/admin/network/models.ts +607 -0
  215. package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
  216. package/src/admin/phonehub/api.ts +47 -0
  217. package/src/admin/phonehub/data.ts +6 -0
  218. package/src/admin/phonehub/index.ts +10 -0
  219. package/src/admin/phonehub/models.ts +68 -0
  220. package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
  221. package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
  222. package/src/admin/rules/RuleSetsPage.tsx +33 -0
  223. package/src/admin/rules/editors.tsx +54 -0
  224. package/src/admin/rules/handler-dsl.ts +213 -0
  225. package/src/admin/rules/index.ts +13 -0
  226. package/src/admin/rules/models.ts +112 -0
  227. package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
  228. package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
  229. package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
  230. package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
  231. package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
  232. package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
  233. package/src/admin/security/devices/index.ts +40 -0
  234. package/src/admin/security/devices/models.ts +470 -0
  235. package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
  236. package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
  237. package/src/admin/security/geoip/geoip-forms.ts +102 -0
  238. package/src/admin/security/geoip/index.ts +27 -0
  239. package/src/admin/security/geoip/models.ts +397 -0
  240. package/src/admin/security/index.ts +31 -0
  241. package/src/admin/security/models.ts +137 -0
  242. package/src/admin/security/tickets.tsx +562 -0
  243. package/src/admin/security-permissions.ts +5 -0
  244. package/src/admin/settings/SettingDetail.tsx +57 -0
  245. package/src/admin/settings/SettingEditor.tsx +157 -0
  246. package/src/admin/settings/SettingsPage.tsx +72 -0
  247. package/src/admin/settings/index.ts +19 -0
  248. package/src/admin/settings/model.ts +143 -0
  249. package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
  250. package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
  251. package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
  252. package/src/admin/shortlinks/index.ts +18 -0
  253. package/src/admin/shortlinks/models.ts +246 -0
  254. package/src/admin/storage/BackendsPage.tsx +43 -0
  255. package/src/admin/storage/BucketDetail.tsx +77 -0
  256. package/src/admin/storage/BucketsPage.tsx +64 -0
  257. package/src/admin/storage/FileManagerDetail.tsx +36 -0
  258. package/src/admin/storage/FilePreview.tsx +54 -0
  259. package/src/admin/storage/FileUploadSurface.tsx +250 -0
  260. package/src/admin/storage/FileView.tsx +109 -0
  261. package/src/admin/storage/FilesPage.tsx +53 -0
  262. package/src/admin/storage/file-renditions.ts +99 -0
  263. package/src/admin/storage/index.ts +31 -0
  264. package/src/admin/storage/models.ts +585 -0
  265. package/src/admin/storage/storage-dialogs.tsx +80 -0
  266. package/src/charts/CircularProgress.tsx +281 -0
  267. package/src/charts/KPIStrip.tsx +162 -0
  268. package/src/charts/KPITile.tsx +125 -0
  269. package/src/charts/MetricsChart.tsx +459 -0
  270. package/src/charts/MetricsMiniWidget.tsx +588 -0
  271. package/src/charts/MiniChart.tsx +283 -0
  272. package/src/charts/PieChart.tsx +354 -0
  273. package/src/charts/SeriesChart.tsx +378 -0
  274. package/src/charts/chart-dialogs.tsx +149 -0
  275. package/src/charts/exportChart.ts +144 -0
  276. package/src/charts/index.ts +28 -0
  277. package/src/charts/pie-math.ts +127 -0
  278. package/src/charts/stats.ts +265 -0
  279. package/src/charts/worldmap/WorldMap.tsx +649 -0
  280. package/src/charts/worldmap/countryCentroids.ts +304 -0
  281. package/src/charts/worldmap/geo.ts +293 -0
  282. package/src/charts/worldmap/use-world-land.ts +47 -0
  283. package/src/charts/worldmap/world-land.ts +18 -0
  284. package/src/charts/worldmap/worldmap-data.ts +285 -0
  285. package/src/client/auth.ts +737 -0
  286. package/src/client/client.ts +268 -0
  287. package/src/client/duid.ts +31 -0
  288. package/src/client/errors.ts +33 -0
  289. package/src/client/group-context.ts +28 -0
  290. package/src/client/group.tsx +126 -0
  291. package/src/client/hooks.ts +50 -0
  292. package/src/client/index.ts +37 -0
  293. package/src/client/jwt.ts +75 -0
  294. package/src/client/location.ts +285 -0
  295. package/src/client/lookups.ts +41 -0
  296. package/src/client/markdown-fallback.tsx +115 -0
  297. package/src/client/markdown-parse.ts +386 -0
  298. package/src/client/markdown-sanitize.ts +217 -0
  299. package/src/client/markdown.ts +122 -0
  300. package/src/client/me.ts +188 -0
  301. package/src/client/mock.ts +9984 -0
  302. package/src/client/model.ts +227 -0
  303. package/src/client/params.ts +292 -0
  304. package/src/client/realtime-mock.ts +221 -0
  305. package/src/client/realtime.ts +561 -0
  306. package/src/client/record-feed.ts +329 -0
  307. package/src/client/safe-export.ts +71 -0
  308. package/src/client/types.ts +278 -0
  309. package/src/client/upload.ts +421 -0
  310. package/src/ui/AddressField.tsx +273 -0
  311. package/src/ui/AttachmentQueue.tsx +92 -0
  312. package/src/ui/CollectionMultiSelect.tsx +622 -0
  313. package/src/ui/CollectionSelect.tsx +517 -0
  314. package/src/ui/ComboBox.tsx +336 -0
  315. package/src/ui/DetailView.tsx +438 -0
  316. package/src/ui/FileDrop.tsx +194 -0
  317. package/src/ui/FileField.tsx +369 -0
  318. package/src/ui/FilterBar.tsx +269 -0
  319. package/src/ui/FormFields.tsx +77 -0
  320. package/src/ui/FormView.tsx +360 -0
  321. package/src/ui/FormWizard.tsx +336 -0
  322. package/src/ui/GroupSwitcher.tsx +202 -0
  323. package/src/ui/Guarded.tsx +16 -0
  324. package/src/ui/MarkdownView.tsx +89 -0
  325. package/src/ui/ModelTable.tsx +848 -0
  326. package/src/ui/MultiSelectDropdown.tsx +390 -0
  327. package/src/ui/Popover.tsx +204 -0
  328. package/src/ui/RecordFeed.tsx +627 -0
  329. package/src/ui/RequiresGroup.tsx +32 -0
  330. package/src/ui/RightPanel.tsx +107 -0
  331. package/src/ui/SidebarNav.tsx +330 -0
  332. package/src/ui/Tabs.tsx +161 -0
  333. package/src/ui/TagInput.tsx +381 -0
  334. package/src/ui/ThemeProvider.tsx +52 -0
  335. package/src/ui/UploadQueue.tsx +603 -0
  336. package/src/ui/armed-button.tsx +91 -0
  337. package/src/ui/date/Calendar.tsx +498 -0
  338. package/src/ui/date/DatePicker.tsx +289 -0
  339. package/src/ui/date/DateRangePicker.tsx +280 -0
  340. package/src/ui/date/DateTimePicker.tsx +587 -0
  341. package/src/ui/date/PresetRail.tsx +165 -0
  342. package/src/ui/date/TimePicker.tsx +624 -0
  343. package/src/ui/date/TimezoneSelect.tsx +267 -0
  344. package/src/ui/date/fns.ts +679 -0
  345. package/src/ui/detail/DataView.tsx +692 -0
  346. package/src/ui/detail/FlowStrip.tsx +80 -0
  347. package/src/ui/detail/KnownFieldsCard.tsx +121 -0
  348. package/src/ui/detail/MetadataSection.tsx +219 -0
  349. package/src/ui/detail/StackTraceView.tsx +150 -0
  350. package/src/ui/detail/StatusPanel.tsx +73 -0
  351. package/src/ui/detail/Timeline.tsx +52 -0
  352. package/src/ui/detail/index.ts +12 -0
  353. package/src/ui/field-registry.tsx +428 -0
  354. package/src/ui/field-wire.ts +322 -0
  355. package/src/ui/form-autosave.ts +583 -0
  356. package/src/ui/format.ts +444 -0
  357. package/src/ui/grouping.ts +150 -0
  358. package/src/ui/image-editor/ImageEditor.tsx +484 -0
  359. package/src/ui/image-editor/canvas.ts +254 -0
  360. package/src/ui/image-editor/index.ts +3 -0
  361. package/src/ui/image-editor/math.ts +416 -0
  362. package/src/ui/index.ts +58 -0
  363. package/src/ui/loading.tsx +604 -0
  364. package/src/ui/menu-registry.ts +213 -0
  365. package/src/ui/modal.tsx +297 -0
  366. package/src/ui/password.tsx +302 -0
  367. package/src/ui/schema-form-core.tsx +315 -0
  368. package/src/ui/toast.tsx +219 -0
  369. package/src/ui/ui.tsx +45 -0
@@ -0,0 +1,674 @@
1
+ // geofence-data — pure data + mapping helpers for BOTH geofence surfaces:
2
+ // the platform page (admin/network/geofence/*) and the group-scoped panel
3
+ // (apps/portal/src/pages/group-sections/GeofenceSection.tsx).
4
+ //
5
+ // PROVENANCE. This is the promotion of web-mojo
6
+ // `src/extensions/admin/security/geofence/geofenceData.js` (463 lines, read in
7
+ // full 2026-08-06) plus the country/US-state tables from
8
+ // `GeofenceRuleForm.js`. Wave 4 had already lifted ~13 of those exports into
9
+ // `apps/portal/src/pages/group-sections/geofence-data.ts`; that file is now a
10
+ // re-export shim over THIS module, so the lossy rule↔form projection is
11
+ // defined exactly once (the source file's own docstring warns about precisely
12
+ // that duplication).
13
+ //
14
+ // The module stays framework-free on purpose — no React, no query client, no
15
+ // client transport. Its only import is the country table (#1426), which is
16
+ // plain data.
17
+ //
18
+ // Rule DSL (django-mojo services/geofence/dsl.py:20-22 — top keys
19
+ // {country, region, abuse}, operators {in, not_in, eq}, abuse flags
20
+ // {tor, vpn, datacenter, proxy}):
21
+ // {
22
+ // country: { in: [..] } | { not_in: [..] } | { eq: 'US' },
23
+ // region: { in: [..] } | { not_in: [..] } | { eq: 'US-CA' }, // ISO 3166-2
24
+ // abuse: { tor: false, vpn: false, datacenter: false, proxy: false }
25
+ // }
26
+ // Abuse flag semantics: false → block when detected; true → require the flag
27
+ // (rare — not expressible in the friendly editor); absent/null → don't care.
28
+ import { COUNTRY_OPTIONS, countryName } from '../../../charts/worldmap/countryCentroids';
29
+ import { SECURITY_VIEW_PERMS } from '../../security-permissions';
30
+
31
+ // The country table is #1426's (`portal-mojo/charts`). web-mojo derived its
32
+ // picker from the same centroid table; the inline 249-entry copy that wave 4
33
+ // carried is DELETED rather than moved — one country-name source, package-wide.
34
+ export { COUNTRY_OPTIONS, countryName };
35
+
36
+ // ── Permissions ───────────────────────────────────────────────────────
37
+ // Geofence config is PLATFORM-WIDE. `@md.requires_global_perms` on every
38
+ // `/api/geo/*` route authorizes against global `User.permissions` (or
39
+ // superuser) ONLY — no group fallback, and a GroupScopedToken is refused
40
+ // outright. So every clause here is `sys.`-pinned: a member grant must never
41
+ // open platform enforcement config.
42
+
43
+ /** `@md.requires_global_perms("view_geofence","manage_geofence","security")`. */
44
+ export const GEOFENCE_VIEW_PERMS = ['sys.view_geofence', 'sys.manage_geofence', 'sys.security'];
45
+
46
+ /** `@md.requires_global_perms("manage_geofence","security")` — POST/DELETE. */
47
+ export const GEOFENCE_MANAGE_PERMS = ['sys.manage_geofence', 'sys.security'];
48
+
49
+ /**
50
+ * Change history, the blocks log and the posture header's Last-change chip all
51
+ * read `incident.Event`, whose `RestMeta.VIEW_PERMS` is `view_security|security`.
52
+ * A geofence-only grant passes the page gate and must NOT see events.
53
+ */
54
+ export const SECURITY_EVENTS_PERMS = SECURITY_VIEW_PERMS;
55
+
56
+ /**
57
+ * Editing the GROUP layer needs either the global geofence grant or group
58
+ * management rights (the group REST enforces its own save perms — a 403 from
59
+ * it still surfaces inline). GroupGeofenceSection.js:36.
60
+ */
61
+ export const GROUP_GEOFENCE_EDIT_PERMS = ['sys.manage_geofence', 'sys.security', 'sys.manage_groups', 'sys.groups'];
62
+
63
+ // ── US states (ISO 3166-2 region codes) ───────────────────────────────
64
+
65
+ export const US_STATES: { value: string; label: string }[] = [
66
+ { value: 'US-AL', label: 'Alabama' },
67
+ { value: 'US-AK', label: 'Alaska' },
68
+ { value: 'US-AZ', label: 'Arizona' },
69
+ { value: 'US-AR', label: 'Arkansas' },
70
+ { value: 'US-CA', label: 'California' },
71
+ { value: 'US-CO', label: 'Colorado' },
72
+ { value: 'US-CT', label: 'Connecticut' },
73
+ { value: 'US-DE', label: 'Delaware' },
74
+ { value: 'US-DC', label: 'District of Columbia' },
75
+ { value: 'US-FL', label: 'Florida' },
76
+ { value: 'US-GA', label: 'Georgia' },
77
+ { value: 'US-HI', label: 'Hawaii' },
78
+ { value: 'US-ID', label: 'Idaho' },
79
+ { value: 'US-IL', label: 'Illinois' },
80
+ { value: 'US-IN', label: 'Indiana' },
81
+ { value: 'US-IA', label: 'Iowa' },
82
+ { value: 'US-KS', label: 'Kansas' },
83
+ { value: 'US-KY', label: 'Kentucky' },
84
+ { value: 'US-LA', label: 'Louisiana' },
85
+ { value: 'US-ME', label: 'Maine' },
86
+ { value: 'US-MD', label: 'Maryland' },
87
+ { value: 'US-MA', label: 'Massachusetts' },
88
+ { value: 'US-MI', label: 'Michigan' },
89
+ { value: 'US-MN', label: 'Minnesota' },
90
+ { value: 'US-MS', label: 'Mississippi' },
91
+ { value: 'US-MO', label: 'Missouri' },
92
+ { value: 'US-MT', label: 'Montana' },
93
+ { value: 'US-NE', label: 'Nebraska' },
94
+ { value: 'US-NV', label: 'Nevada' },
95
+ { value: 'US-NH', label: 'New Hampshire' },
96
+ { value: 'US-NJ', label: 'New Jersey' },
97
+ { value: 'US-NM', label: 'New Mexico' },
98
+ { value: 'US-NY', label: 'New York' },
99
+ { value: 'US-NC', label: 'North Carolina' },
100
+ { value: 'US-ND', label: 'North Dakota' },
101
+ { value: 'US-OH', label: 'Ohio' },
102
+ { value: 'US-OK', label: 'Oklahoma' },
103
+ { value: 'US-OR', label: 'Oregon' },
104
+ { value: 'US-PA', label: 'Pennsylvania' },
105
+ { value: 'US-RI', label: 'Rhode Island' },
106
+ { value: 'US-SC', label: 'South Carolina' },
107
+ { value: 'US-SD', label: 'South Dakota' },
108
+ { value: 'US-TN', label: 'Tennessee' },
109
+ { value: 'US-TX', label: 'Texas' },
110
+ { value: 'US-UT', label: 'Utah' },
111
+ { value: 'US-VT', label: 'Vermont' },
112
+ { value: 'US-VA', label: 'Virginia' },
113
+ { value: 'US-WA', label: 'Washington' },
114
+ { value: 'US-WV', label: 'West Virginia' },
115
+ { value: 'US-WI', label: 'Wisconsin' },
116
+ { value: 'US-WY', label: 'Wyoming' },
117
+ ];
118
+
119
+ const US_STATE_NAMES: Record<string, string> = Object.fromEntries(US_STATES.map((s) => [s.value, s.label]));
120
+ const US_STATE_CODES = new Set(US_STATES.map((s) => s.value));
121
+
122
+ /** State/region code → display name ('US-WA' → 'Washington'; unknown → code). */
123
+ export function regionName(code: string | null | undefined): string {
124
+ if (!code) return '';
125
+ return US_STATE_NAMES[String(code).toUpperCase()] ?? String(code);
126
+ }
127
+
128
+ export const COUNTRY_MODE_OPTS = [
129
+ { value: '', label: 'No country rule' },
130
+ { value: 'block', label: 'Block the listed countries' },
131
+ { value: 'allow', label: 'Allow only the listed countries' },
132
+ ];
133
+
134
+ // ── Abuse flags ───────────────────────────────────────────────────────
135
+
136
+ export const ABUSE_FLAGS = [
137
+ { key: 'vpn', label: 'VPN connections', help: 'Commercial VPN exit addresses.' },
138
+ { key: 'tor', label: 'Tor connections', help: 'Tor exit nodes.' },
139
+ { key: 'proxy', label: 'Open proxies', help: 'Open/anonymous proxies.' },
140
+ { key: 'datacenter', label: 'Datacenter IPs', help: 'Cloud-hosted addresses (AWS, GCP, …) — can affect legitimate automation.' },
141
+ ] as const;
142
+
143
+ export type AbuseFlagKey = (typeof ABUSE_FLAGS)[number]['key'];
144
+
145
+ // ── Endpoint scopes ───────────────────────────────────────────────────
146
+ // Scopes are deployment-defined strings — django-mojo core ships only 'auth'.
147
+ // Friendly labels for the ones we know; raw name for everything else.
148
+
149
+ const SCOPE_LABELS: Record<string, string> = { auth: 'Sign-in endpoints (auth)' };
150
+
151
+ export function scopeLabel(scope: string | null | undefined): string {
152
+ if (!scope) return 'All endpoints';
153
+ return SCOPE_LABELS[scope] ?? `${scope} endpoints`;
154
+ }
155
+
156
+ // ── GET /api/geo/rules — the config-plane payload, key for key ────────
157
+ // mojo/apps/account/rest/geofence.py:191-218. Every field below is written by
158
+ // the backend; nothing is invented, and nothing the backend writes is dropped.
159
+
160
+ /** A geofence rule as stored (unknown shapes flip the editor to advanced). */
161
+ export type GeofenceRule = Record<string, unknown>;
162
+
163
+ export interface GeoPosture {
164
+ /** GEOFENCE_ENABLED — the "is it enforcing" answer. Default true. */
165
+ enabled?: boolean;
166
+ /** GEOFENCE_FAIL_CLOSED — global fail posture. Default false. */
167
+ fail_closed?: boolean;
168
+ /** GEOFENCE_FAIL_CLOSED_SCOPES — per-scope override list. */
169
+ fail_closed_scopes?: string[];
170
+ /** GEOFENCE_ALLOW_PRIVATE_IPS. Default true. */
171
+ allow_private_ips?: boolean;
172
+ /**
173
+ * GEOFENCE_STRICT_POSTURE. When true, an empty ruleset DENIES
174
+ * (`no_rules_strict`) rather than passing through.
175
+ */
176
+ strict_posture?: boolean;
177
+ /** GEOFENCE_CACHE_TTL, seconds. Default 300. */
178
+ cache_ttl?: number | null;
179
+ }
180
+
181
+ export interface GeoEnforcedEndpoint {
182
+ /** `<module>.<func>` of the decorated view. */
183
+ endpoint: string;
184
+ scope?: string | null;
185
+ /** Present (and true) only for endpoints gated AFTER credentials. */
186
+ after_auth?: boolean;
187
+ }
188
+
189
+ export interface GeoAllowlistSummary {
190
+ setting_entries?: number;
191
+ geoip_active?: number;
192
+ }
193
+
194
+ /** The platform floor, this group's rule (when asked for), posture, endpoints. */
195
+ export interface GeoRulesConfig {
196
+ system?: { rule?: GeofenceRule | null; source?: string | null; modified?: string | null } | null;
197
+ /** Present ONLY when the request carried `group_uuid`. */
198
+ group?: {
199
+ id?: number;
200
+ uuid?: string;
201
+ is_active?: boolean;
202
+ rule?: GeofenceRule | null;
203
+ strict_posture?: boolean | null;
204
+ strict_posture_effective?: boolean;
205
+ } | null;
206
+ posture?: GeoPosture | null;
207
+ allowlist_summary?: GeoAllowlistSummary | null;
208
+ evaluation_order?: string[];
209
+ enforced_endpoints?: GeoEnforcedEndpoint[];
210
+ }
211
+
212
+ /** Unique scope list from a GET /api/geo/rules payload (enforced_endpoints ∪ fail_closed_scopes). */
213
+ export function collectScopes(config: GeoRulesConfig | null | undefined): string[] {
214
+ const scopes = new Set<string>();
215
+ for (const entry of config?.enforced_endpoints ?? []) {
216
+ if (entry?.scope) scopes.add(entry.scope);
217
+ }
218
+ for (const scope of config?.posture?.fail_closed_scopes ?? []) {
219
+ if (scope) scopes.add(scope);
220
+ }
221
+ return [...scopes].sort();
222
+ }
223
+
224
+ // ── The decision shape (engine._build_decision + simulate) ────────────
225
+
226
+ export interface GeoDecision {
227
+ allowed?: boolean;
228
+ reason?: string;
229
+ detail?: string;
230
+ ip?: string | null;
231
+ /** `country` and `country_code` carry the SAME value (engine.py:317-318). */
232
+ country?: string | null;
233
+ country_code?: string | null;
234
+ region?: string | null;
235
+ region_code?: string | null;
236
+ abuse?: Partial<Record<AbuseFlagKey, boolean>>;
237
+ checked_at?: string;
238
+ rule_level?: string | null;
239
+ strict_posture?: boolean;
240
+ /**
241
+ * BACKEND CORRECTION. `enabled` sits at the TOP level of a simulate
242
+ * decision (`engine.py:473 — dec.enabled = enabled`), and is absent from
243
+ * `check()` decisions entirely. web-mojo read `decision.posture.enabled`,
244
+ * a key the decision has never carried, so its "geofencing is currently
245
+ * off" notice could never fire.
246
+ */
247
+ enabled?: boolean;
248
+ /** The next five appear ONLY when the IP matched the allowlist. */
249
+ would_block?: boolean | null;
250
+ would_block_reason?: string | null;
251
+ allowlist_source?: string | null;
252
+ allowlist_reason?: string | null;
253
+ allowlist_until?: string | null;
254
+ }
255
+
256
+ export interface AllowlistEntry {
257
+ /** `norm.cidr || norm.ip` — a bare-string entry normalizes to `{cidr}`. */
258
+ cidr: string;
259
+ reason?: string | null;
260
+ until?: string | null;
261
+ /** Expired entries are LISTED with active=false, never hidden. */
262
+ active?: boolean;
263
+ }
264
+
265
+ export interface GeoIpAllowlistEntry {
266
+ ip: string;
267
+ reason?: string | null;
268
+ until?: string | null;
269
+ active?: boolean;
270
+ }
271
+
272
+ export interface AllowlistPayload {
273
+ setting?: AllowlistEntry[];
274
+ geoip?: GeoIpAllowlistEntry[];
275
+ }
276
+
277
+ export interface BypassHolder {
278
+ id: number;
279
+ username: string;
280
+ is_active: boolean;
281
+ /** 'permission' | 'superuser'. */
282
+ source: string;
283
+ /** The raw `permissions.bypass_geofence` value; not rendered (PII-adjacent). */
284
+ value?: unknown;
285
+ }
286
+
287
+ export interface BypassHoldersPayload {
288
+ holders?: BypassHolder[];
289
+ count?: number;
290
+ capped?: boolean;
291
+ }
292
+
293
+ // ── Decision reasons → plain language ─────────────────────────────────
294
+
295
+ function countryPhrase(decision: GeoDecision | null | undefined): string {
296
+ return decision?.country || decision?.country_code || 'this country';
297
+ }
298
+
299
+ function regionPhrase(decision: GeoDecision | null | undefined): string {
300
+ const code = decision?.region_code;
301
+ if (code && US_STATE_NAMES[String(code).toUpperCase()]) {
302
+ return US_STATE_NAMES[String(code).toUpperCase()]!;
303
+ }
304
+ return decision?.region || code || 'this region';
305
+ }
306
+
307
+ /**
308
+ * Every key of `GeoFenceEngine._DETAIL_MAP` (engine.py:328-345), in the
309
+ * backend's own order. BACKEND CORRECTION: `no_rules_strict` is the fifth
310
+ * entry there and was MISSING from web-mojo's REASON_TEXT — a strict-posture
311
+ * denial (the one denial an operator most needs explained) fell through to the
312
+ * generic "reason: no rules strict." fallback.
313
+ */
314
+ const REASON_TEXT: Record<string, (d: GeoDecision) => string> = {
315
+ no_rules: () => 'Allowed — no geofence rules are configured.',
316
+ disabled: () => 'Allowed — geofencing is turned off.',
317
+ bypass: () => 'Allowed — this user bypasses geofencing.',
318
+ ip_allowlisted: (d) => `Allowed by exemption${d?.allowlist_reason ? ` (${d.allowlist_reason})` : ''}.`,
319
+ no_rules_strict: () => 'Blocked — geofencing is required but no rules are configured (strict posture).',
320
+ passed: () => 'Allowed — no rules match this location.',
321
+ lookup_failed: (d) => (d && d.allowed === false)
322
+ ? 'Blocked — location lookup unavailable (fail-closed endpoint).'
323
+ : 'Allowed while location lookup was unavailable (fail-open).',
324
+ private_ip: () => 'Allowed — private/internal network address.',
325
+ country_not_allowed: (d) => `Blocked — ${countryPhrase(d)} is not allowed by the rules.`,
326
+ region_not_allowed: (d) => `Blocked — ${regionPhrase(d)} is not allowed by the rules.`,
327
+ tor_detected: () => 'Blocked — Tor connection detected.',
328
+ vpn_detected: () => 'Blocked — VPN connection detected.',
329
+ proxy_detected: () => 'Blocked — open proxy detected.',
330
+ datacenter_detected: () => 'Blocked — datacenter IP detected.',
331
+ rule_invalid: () => 'Request denied — an invalid rule reached the engine. Check recent rule edits.',
332
+ group_inactive: () => 'This group is inactive — platform rules were applied.',
333
+ };
334
+
335
+ /** Every reason code this projection knows — the verifier asserts parity. */
336
+ export const GEOFENCE_REASON_CODES: readonly string[] = Object.keys(REASON_TEXT);
337
+
338
+ const warnedReasons = new Set<string>();
339
+
340
+ /**
341
+ * Plain-language sentence for a decision-ish object `{reason, allowed, …}`.
342
+ * Also used for blocks-log rows, where the event metadata carries the same
343
+ * reason codes. An unknown code degrades to a readable fallback AND warns
344
+ * once — house rule 4: never "render nothing", never silence.
345
+ */
346
+ export function describeDecision(decision: GeoDecision | null | undefined): string {
347
+ const reason = decision?.reason;
348
+ const fn = reason ? REASON_TEXT[reason] : null;
349
+ if (fn) return fn(decision ?? {});
350
+ if (reason && !warnedReasons.has(reason)) {
351
+ warnedReasons.add(reason);
352
+ console.warn(`geofence: unknown decision reason "${reason}" — rendering the generic fallback. Add it to REASON_TEXT if the backend introduced it.`);
353
+ }
354
+ const verb = decision?.allowed === false ? 'Blocked' : (decision?.allowed ? 'Allowed' : 'Decision');
355
+ return reason ? `${verb} — reason: ${String(reason).replace(/_/g, ' ')}.` : `${verb}.`;
356
+ }
357
+
358
+ /**
359
+ * "Would otherwise block" line for exempted decisions; '' when not applicable.
360
+ * `would_block` is deliberately NULLABLE on the wire: when the shadow decision
361
+ * was `lookup_failed` the backend sets both shadow fields to null because it
362
+ * genuinely does not know (engine.py:370-373).
363
+ */
364
+ export function describeWouldBlock(decision: GeoDecision | null | undefined): string {
365
+ if (!decision || decision.reason !== 'ip_allowlisted' || !decision.would_block) return '';
366
+ const why = decision.would_block_reason
367
+ ? describeDecision({ ...decision, reason: decision.would_block_reason, allowed: false })
368
+ : 'Blocked.';
369
+ return `Without this exemption the request would be blocked: ${why.replace(/^Blocked — /, '').replace(/\.$/, '')}.`;
370
+ }
371
+
372
+ // ── Rule ↔ friendly-form mapping ──────────────────────────────────────
373
+
374
+ const RULE_TOP_KEYS = ['country', 'region', 'abuse'];
375
+ const MATCHER_OPS = ['in', 'not_in', 'eq'];
376
+
377
+ export interface RuleForm {
378
+ country_mode: '' | 'allow' | 'block';
379
+ countries: string[]; // ISO2 codes
380
+ blocked_states: string[]; // 'US-XX' codes
381
+ block_vpn: boolean;
382
+ block_tor: boolean;
383
+ block_proxy: boolean;
384
+ block_datacenter: boolean;
385
+ }
386
+
387
+ export const EMPTY_RULE_FORM: RuleForm = Object.freeze({
388
+ country_mode: '',
389
+ countries: [],
390
+ blocked_states: [],
391
+ block_vpn: false,
392
+ block_tor: false,
393
+ block_proxy: false,
394
+ block_datacenter: false,
395
+ });
396
+
397
+ function isDict(v: unknown): v is Record<string, unknown> {
398
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
399
+ }
400
+
401
+ /**
402
+ * True when a rule uses DSL shapes the friendly editor cannot represent —
403
+ * the editor must flip to advanced-JSON mode instead of silently rewriting:
404
+ * - unknown top-level keys / non-dict bodies
405
+ * - country: eq, multiple operators, or an allow+block mix
406
+ * - region: anything other than a single not_in of US state codes
407
+ * - abuse: a `true` (require-flag) value or unknown flags
408
+ */
409
+ export function isAdvancedRule(rule: unknown): boolean {
410
+ if (rule === null || rule === undefined) return false;
411
+ if (!isDict(rule)) return true;
412
+ for (const key of Object.keys(rule)) {
413
+ if (!RULE_TOP_KEYS.includes(key)) return true;
414
+ const body = rule[key];
415
+ if (!isDict(body)) return true;
416
+ if (key === 'abuse') {
417
+ for (const [flag, val] of Object.entries(body)) {
418
+ if (!ABUSE_FLAGS.some((f) => f.key === flag)) return true;
419
+ if (val !== false && val !== null) return true; // `true` = require-flag → advanced
420
+ }
421
+ continue;
422
+ }
423
+ const ops = Object.keys(body);
424
+ if (ops.some((op) => !MATCHER_OPS.includes(op))) return true;
425
+ if (ops.length > 1) return true;
426
+ if (key === 'country') {
427
+ if (ops[0] === 'eq') return true;
428
+ if (ops.length && !Array.isArray(body[ops[0]!])) return true;
429
+ }
430
+ if (key === 'region') {
431
+ if (ops[0] !== 'not_in') return true;
432
+ if (!Array.isArray(body.not_in)) return true;
433
+ if ((body.not_in as unknown[]).some((c) => !US_STATE_CODES.has(String(c).toUpperCase()))) return true;
434
+ }
435
+ }
436
+ return false;
437
+ }
438
+
439
+ /**
440
+ * Project a representable rule onto the friendly form values. Call
441
+ * `isAdvancedRule` first — unrepresentable shapes project best-effort.
442
+ */
443
+ export function ruleToForm(rule: unknown): RuleForm {
444
+ const form: RuleForm = { ...EMPTY_RULE_FORM, countries: [], blocked_states: [] };
445
+ if (!isDict(rule)) return form;
446
+
447
+ const country = rule.country;
448
+ if (isDict(country)) {
449
+ if (Array.isArray(country.in)) {
450
+ form.country_mode = 'allow';
451
+ form.countries = country.in.map((c) => String(c).toUpperCase());
452
+ } else if (Array.isArray(country.not_in)) {
453
+ form.country_mode = 'block';
454
+ form.countries = country.not_in.map((c) => String(c).toUpperCase());
455
+ }
456
+ }
457
+
458
+ const region = rule.region;
459
+ if (isDict(region) && Array.isArray(region.not_in)) {
460
+ form.blocked_states = region.not_in.map((c) => String(c).toUpperCase());
461
+ }
462
+
463
+ const abuse = rule.abuse;
464
+ if (isDict(abuse)) {
465
+ for (const f of ABUSE_FLAGS) {
466
+ form[`block_${f.key}`] = abuse[f.key] === false;
467
+ }
468
+ }
469
+ return form;
470
+ }
471
+
472
+ /** Assemble the canonical rule object from friendly form values. */
473
+ export function formToRule(form: RuleForm): GeofenceRule {
474
+ const rule: GeofenceRule = {};
475
+ const countries = (form.countries || []).map((c) => String(c).toUpperCase()).filter(Boolean);
476
+ if (form.country_mode === 'allow' && countries.length) {
477
+ rule.country = { in: countries };
478
+ } else if (form.country_mode === 'block' && countries.length) {
479
+ rule.country = { not_in: countries };
480
+ }
481
+
482
+ const states = (form.blocked_states || []).map((c) => String(c).toUpperCase()).filter(Boolean);
483
+ if (states.length) {
484
+ rule.region = { not_in: states };
485
+ }
486
+
487
+ const abuse: Record<string, false> = {};
488
+ for (const f of ABUSE_FLAGS) {
489
+ if (form[`block_${f.key}`]) abuse[f.key] = false;
490
+ }
491
+ if (Object.keys(abuse).length) rule.abuse = abuse;
492
+ return rule;
493
+ }
494
+
495
+ // ── Plain-language rule summary ───────────────────────────────────────
496
+
497
+ export interface RuleClause {
498
+ tone: 'block' | 'warn' | 'ok';
499
+ text: string;
500
+ }
501
+
502
+ function nameList(codes: unknown[], toName: (code: string) => string): string {
503
+ return (codes || []).map((c) => toName(String(c))).join(', ');
504
+ }
505
+
506
+ /**
507
+ * Plain-language clauses for a rule, for read-only display.
508
+ * Returns [] for an empty/absent rule (caller renders its own empty state).
509
+ */
510
+ export function describeRule(rule: unknown): RuleClause[] {
511
+ const clauses: RuleClause[] = [];
512
+ if (!isDict(rule)) return clauses;
513
+
514
+ const country = isDict(rule.country) ? rule.country : {};
515
+ if (Array.isArray(country.in)) {
516
+ clauses.push({ tone: 'block', text: `Only these countries are allowed: ${nameList(country.in, countryName)}.` });
517
+ } else if (Array.isArray(country.not_in)) {
518
+ clauses.push({ tone: 'block', text: `Blocked countries: ${nameList(country.not_in, countryName)}.` });
519
+ } else if (typeof country.eq === 'string') {
520
+ clauses.push({ tone: 'block', text: `Only allowed country: ${countryName(country.eq)}.` });
521
+ }
522
+
523
+ const region = isDict(rule.region) ? rule.region : {};
524
+ if (Array.isArray(region.not_in)) {
525
+ clauses.push({ tone: 'block', text: `Blocked US states / regions: ${nameList(region.not_in, regionName)}.` });
526
+ } else if (Array.isArray(region.in)) {
527
+ clauses.push({ tone: 'block', text: `Only these regions are allowed: ${nameList(region.in, regionName)}.` });
528
+ } else if (typeof region.eq === 'string') {
529
+ clauses.push({ tone: 'block', text: `Only allowed region: ${regionName(region.eq)}.` });
530
+ }
531
+
532
+ const abuse = isDict(rule.abuse) ? rule.abuse : {};
533
+ const blockedFlags = ABUSE_FLAGS.filter((f) => abuse[f.key] === false).map((f) => f.label);
534
+ if (blockedFlags.length) {
535
+ clauses.push({ tone: 'block', text: `${blockedFlags.join(', ')} are blocked.` });
536
+ }
537
+ const requiredFlags = ABUSE_FLAGS.filter((f) => abuse[f.key] === true).map((f) => f.label);
538
+ if (requiredFlags.length) {
539
+ clauses.push({ tone: 'warn', text: `Required (unusual): ${requiredFlags.join(', ')}.` });
540
+ }
541
+ return clauses;
542
+ }
543
+
544
+ /**
545
+ * The plain-language DIFF between two rules, for the platform save's armed
546
+ * label — an operator must see what a fleet-wide replace adds and removes
547
+ * before the second click, not after.
548
+ */
549
+ export function diffRules(oldRule: unknown, newRule: unknown): { added: string[]; removed: string[] } {
550
+ const before = describeRule(oldRule).map((c) => c.text);
551
+ const after = describeRule(newRule).map((c) => c.text);
552
+ return {
553
+ added: after.filter((text) => !before.includes(text)),
554
+ removed: before.filter((text) => !after.includes(text)),
555
+ };
556
+ }
557
+
558
+ // ── Group-rule save payload (merge-safe) ──────────────────────────────
559
+
560
+ function deepEqual(a: unknown, b: unknown): boolean {
561
+ return JSON.stringify(a) === JSON.stringify(b);
562
+ }
563
+
564
+ /** Stable ordering so deepEqual doesn't depend on key insertion order. */
565
+ function normalizeRule(rule: unknown): unknown {
566
+ if (!isDict(rule)) return rule;
567
+ const out: Record<string, unknown> = {};
568
+ for (const key of Object.keys(rule).sort()) {
569
+ out[key] = normalizeRule(rule[key]);
570
+ }
571
+ return out;
572
+ }
573
+
574
+ /**
575
+ * Build the `metadata.geofence` payload that makes the stored group rule
576
+ * equal `newRule` under django's JSONField deep merge (objict.merge_dicts):
577
+ * nested dicts merge recursively and a `null` value DELETES the key — nested
578
+ * `__replace` is NOT supported and must never be sent.
579
+ *
580
+ * So: send the new value for every kept key, plus explicit nulls for any
581
+ * top-level constraint or matcher-operator/abuse-flag that existed in
582
+ * `oldRule` but not in `newRule`. Returns null when nothing changed.
583
+ *
584
+ * Sub-keys are ALLOWLISTED (matcher operators for country/region, the four
585
+ * abuse flags for abuse): `newRule` can come from the advanced JSON editor,
586
+ * so anything outside the DSL — a nested `__replace`, `protected`, etc. —
587
+ * is dropped here instead of riding the PATCH into stored metadata.
588
+ *
589
+ * GROUP LAYER ONLY. The platform rule is written by `POST /api/geo/rules`,
590
+ * which is a full replace and never merges — the two surfaces do not share a
591
+ * save path, and the group surface can never write platform rules.
592
+ */
593
+ export function buildGroupRulePayload(oldRule: unknown, newRule: unknown): Record<string, unknown> | null {
594
+ const oldR = isDict(oldRule) ? oldRule : {};
595
+ const newR = isDict(newRule) ? newRule : {};
596
+ if (deepEqual(normalizeRule(oldR), normalizeRule(newR))) return null;
597
+
598
+ const payload: Record<string, unknown> = {};
599
+ for (const key of RULE_TOP_KEYS) {
600
+ const allowedSubs: readonly string[] = key === 'abuse' ? ABUSE_FLAGS.map((f) => f.key) : MATCHER_OPS;
601
+ const oldBody = isDict(oldR[key]) ? (oldR[key] as Record<string, unknown>) : null;
602
+ const newBody = isDict(newR[key]) ? (newR[key] as Record<string, unknown>) : null;
603
+ if (newBody) {
604
+ const body: Record<string, unknown> = {};
605
+ for (const sub of allowedSubs) {
606
+ if (sub in newBody) body[sub] = newBody[sub];
607
+ }
608
+ // Null out stale sub-keys (a switched operator / cleared abuse
609
+ // flag) so the recursive merge can't leave both behind.
610
+ for (const sub of Object.keys(oldBody ?? {})) {
611
+ if (allowedSubs.includes(sub) && !(sub in body)) body[sub] = null;
612
+ }
613
+ payload[key] = body;
614
+ } else if (oldBody) {
615
+ payload[key] = null;
616
+ }
617
+ }
618
+ return payload;
619
+ }
620
+
621
+ /**
622
+ * Coerce the advanced editor's value into a rule object, or null when
623
+ * invalid. Accepts a parsed object, a JSON string, or ''/null as an empty
624
+ * rule.
625
+ */
626
+ export function coerceRuleInput(raw: unknown): GeofenceRule | null {
627
+ if (raw === '' || raw === null || raw === undefined) return {};
628
+ if (isDict(raw)) return raw;
629
+ if (typeof raw === 'string') {
630
+ try {
631
+ const parsed: unknown = JSON.parse(raw);
632
+ return isDict(parsed) ? parsed : null;
633
+ } catch {
634
+ return null;
635
+ }
636
+ }
637
+ return null;
638
+ }
639
+
640
+ // ── Simulate request body ─────────────────────────────────────────────
641
+
642
+ export interface SimulateInput {
643
+ mode: 'ip' | 'geo';
644
+ ip?: string | null;
645
+ country?: string | null;
646
+ state?: string | null;
647
+ flags?: Partial<Record<AbuseFlagKey, boolean>>;
648
+ group_uuid?: string | null;
649
+ scope?: string | null;
650
+ }
651
+
652
+ /**
653
+ * Build the `POST /api/geo/simulate` body. IP mode sends `ip` (the allowlist
654
+ * is consulted); geo mode sends a geo dict with `is_<flag>` keys — the engine
655
+ * reads `geo.is_vpn` etc. The backend accepts exactly four top-level keys:
656
+ * `ip`, `geo`, `scope`, `group_uuid` (rest/geofence.py:257-275).
657
+ */
658
+ export function buildSimulateBody(input: SimulateInput | null | undefined): Record<string, unknown> {
659
+ const body: Record<string, unknown> = {};
660
+ if (input?.mode === 'ip') {
661
+ body.ip = String(input.ip ?? '').trim();
662
+ } else {
663
+ const geo: Record<string, unknown> = {};
664
+ if (input?.country) geo.country_code = String(input.country).toUpperCase();
665
+ if (input?.state) geo.region_code = String(input.state).toUpperCase();
666
+ for (const f of ABUSE_FLAGS) {
667
+ if (input?.flags?.[f.key]) geo[`is_${f.key}`] = true;
668
+ }
669
+ body.geo = geo;
670
+ }
671
+ if (input?.group_uuid) body.group_uuid = input.group_uuid;
672
+ if (input?.scope) body.scope = input.scope;
673
+ return body;
674
+ }