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,221 @@
1
+ import type { CertificateRow, DnsCapabilities, DnsCredentialRow, DomainRow } from './models';
2
+
3
+ export const CERTIFICATE_STATUSES = ['pending', 'issuing', 'active', 'failed', 'revoked'] as const;
4
+ export type CertificateStatus = (typeof CERTIFICATE_STATUSES)[number];
5
+
6
+ export interface AcmeDelegationStatus {
7
+ id: number;
8
+ created: string | null;
9
+ modified: string | null;
10
+ domain: number | null;
11
+ domain_name: string;
12
+ source: string | null;
13
+ target: string | null;
14
+ state: 'pending' | 'verified' | 'broken' | 'retired' | 'unknown';
15
+ verified_at: string | null;
16
+ last_error_code: string | null;
17
+ }
18
+
19
+ export type CertificateRenewalHealth =
20
+ | 'pending' | 'issuing' | 'healthy' | 'due' | 'renewal-error'
21
+ | 'failed' | 'revoked' | 'unknown';
22
+
23
+ export interface CertificateReadiness {
24
+ ready: boolean;
25
+ mode: 'direct' | 'delegated' | 'blocked';
26
+ label: string;
27
+ reason: string | null;
28
+ }
29
+
30
+ function object(value: unknown): Record<string, unknown> {
31
+ return value != null && typeof value === 'object' && !Array.isArray(value)
32
+ ? value as Record<string, unknown>
33
+ : {};
34
+ }
35
+
36
+ function finite(value: unknown): number | null {
37
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
38
+ }
39
+
40
+ function nullableText(value: unknown, limit: number): string | null {
41
+ if (value == null) return null;
42
+ if (typeof value !== 'string') return null;
43
+ const normalized = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim();
44
+ if (!normalized) return null;
45
+ return normalized.length <= limit ? normalized : `${normalized.slice(0, limit)}…`;
46
+ }
47
+
48
+ /** Bound and redact provider/ACME exceptions before they enter Query cache. */
49
+ export function sanitizeCertificateLastError(value: unknown): string | null {
50
+ const bounded = nullableText(value, 2_000);
51
+ if (!bounded) return null;
52
+ return bounded
53
+ .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, '$1 [redacted]')
54
+ .replace(/([?&;](?:token|api_key|secret|password|credential)=)[^&#;\s]*/gi, '$1[redacted]')
55
+ .replace(/(^|[&;\s])((?:token|api_key|secret|password|credential)=)[^&;\s]*/gi, '$1$2[redacted]')
56
+ .replace(/\b(token|api[ _-]?key|client[ _-]?secret|password|credential)(\s*(?:is|:|=)?\s+)[A-Za-z0-9._~+/=-]{8,}/gi, '$1$2[redacted]');
57
+ }
58
+
59
+ export function normalizeCertificateStatus(value: unknown): CertificateStatus {
60
+ const status = String(value ?? '').trim().toLowerCase();
61
+ return (CERTIFICATE_STATUSES as readonly string[]).includes(status)
62
+ ? status as CertificateStatus
63
+ : 'failed';
64
+ }
65
+
66
+ /** DNS names are lowercase, ASCII, and have no trailing dot. */
67
+ export function normalizeCertificateName(value: unknown): string {
68
+ const input = String(value ?? '').trim().toLowerCase().replace(/\.+$/, '');
69
+ if (!input) return '';
70
+ const wildcard = input.startsWith('*.');
71
+ const host = wildcard ? input.slice(2) : input;
72
+ try {
73
+ const ascii = new URL(`https://${host}`).hostname.toLowerCase().replace(/\.+$/, '');
74
+ return wildcard ? `*.${ascii}` : ascii;
75
+ } catch {
76
+ return input;
77
+ }
78
+ }
79
+
80
+ export function normalizeCertificateSans(value: unknown): string[] {
81
+ if (!Array.isArray(value)) return [];
82
+ const out: string[] = [];
83
+ for (const candidate of value) {
84
+ if (typeof candidate !== 'string') continue;
85
+ const name = normalizeCertificateName(candidate);
86
+ if (name && !out.includes(name)) out.push(name);
87
+ if (out.length >= 100) break;
88
+ }
89
+ return out;
90
+ }
91
+
92
+ function validDnsName(name: string): boolean {
93
+ if (!name || name.length > 253 || name.includes('/') || name.includes(':')) return false;
94
+ const wildcard = name.startsWith('*.');
95
+ if (name.includes('*') && !wildcard) return false;
96
+ const host = wildcard ? name.slice(2) : name;
97
+ return host.split('.').every((label) => label.length > 0 && label.length <= 63
98
+ && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label));
99
+ }
100
+
101
+ /** Normalize, dedupe and require every requested SAN to remain in-zone. */
102
+ export function validateCertificateNames(zoneValue: string, values: readonly string[]): { names: string[]; errors: string[] } {
103
+ const zone = normalizeCertificateName(zoneValue).replace(/^\*\./, '');
104
+ const names = normalizeCertificateSans(values);
105
+ const effective = names.length ? names : [zone, `*.${zone}`];
106
+ const errors: string[] = [];
107
+ for (const name of effective) {
108
+ const host = name.startsWith('*.') ? name.slice(2) : name;
109
+ if (!validDnsName(name)) errors.push(`${name || 'Empty name'} is not a valid DNS name.`);
110
+ else if (host !== zone && !host.endsWith(`.${zone}`)) errors.push(`${name} is outside the ${zone} zone.`);
111
+ }
112
+ return { names: effective, errors };
113
+ }
114
+
115
+ export function sanitizeCertificateRow(row: unknown): CertificateRow {
116
+ const raw = object(row);
117
+ const domainRaw = typeof raw.domain === 'number' ? raw.domain : object(raw.domain);
118
+ const status = normalizeCertificateStatus(raw.status);
119
+ const commonName = normalizeCertificateName(raw.common_name);
120
+ return {
121
+ id: finite(raw.id) ?? 0,
122
+ created: finite(raw.created) ?? 0,
123
+ modified: finite(raw.modified) ?? 0,
124
+ common_name: commonName,
125
+ sans: normalizeCertificateSans(raw.sans),
126
+ status,
127
+ issuer: nullableText(raw.issuer, 255),
128
+ serial: nullableText(raw.serial, 128),
129
+ not_before: finite(raw.not_before),
130
+ not_after: finite(raw.not_after),
131
+ renew_after: finite(raw.renew_after),
132
+ last_error: sanitizeCertificateLastError(raw.last_error),
133
+ attempts: Math.max(0, Math.trunc(finite(raw.attempts) ?? 0)),
134
+ days_remaining: finite(raw.days_remaining),
135
+ domain: typeof domainRaw === 'number' ? domainRaw : {
136
+ id: finite(domainRaw.id) ?? 0,
137
+ name: normalizeCertificateName(domainRaw.name),
138
+ provider: nullableText(domainRaw.provider, 32) ?? 'unknown',
139
+ status: nullableText(domainRaw.status, 32) ?? 'unknown',
140
+ expires: finite(domainRaw.expires),
141
+ },
142
+ };
143
+ }
144
+
145
+ export function sanitizeAcmeDelegationStatus(value: unknown): AcmeDelegationStatus {
146
+ const raw = object(value);
147
+ const state = String(raw.state ?? '').toLowerCase();
148
+ return {
149
+ id: finite(raw.id) ?? 0,
150
+ created: nullableText(raw.created, 64),
151
+ modified: nullableText(raw.modified, 64),
152
+ domain: finite(raw.domain),
153
+ domain_name: normalizeCertificateName(raw.domain_name),
154
+ source: nullableText(raw.source, 253),
155
+ target: nullableText(raw.target, 253),
156
+ state: ['pending', 'verified', 'broken', 'retired'].includes(state)
157
+ ? state as AcmeDelegationStatus['state']
158
+ : 'unknown',
159
+ verified_at: nullableText(raw.verified_at, 64),
160
+ last_error_code: nullableText(raw.last_error_code, 64)?.replace(/[^a-z0-9_.-]/gi, '') || null,
161
+ };
162
+ }
163
+
164
+ /** `renew_after` is authoritative; days remaining is display-only. */
165
+ export function deriveCertificateRenewalHealth(row: CertificateRow, nowSeconds = Date.now() / 1000): CertificateRenewalHealth {
166
+ if (row.status === 'pending') return 'pending';
167
+ if (row.status === 'issuing') return 'issuing';
168
+ if (row.status === 'failed') return 'failed';
169
+ if (row.status === 'revoked') return 'revoked';
170
+ if (row.status !== 'active') return 'unknown';
171
+ if (row.last_error) return 'renewal-error';
172
+ if (row.renew_after == null) return 'unknown';
173
+ return row.renew_after <= nowSeconds ? 'due' : 'healthy';
174
+ }
175
+
176
+ export function isHouseDomain(domain: Pick<DomainRow, 'group'>): boolean {
177
+ return domain.group == null;
178
+ }
179
+
180
+ export function canInspectHouseCertificate(domain: Pick<DomainRow, 'group'>, isSuperuser: boolean): boolean {
181
+ return !isHouseDomain(domain) || isSuperuser;
182
+ }
183
+
184
+ /**
185
+ * Readiness mirrors certs.py routing: once a delegation was verified it is
186
+ * sticky. A broken sticky delegation must be repaired; it never falls back to
187
+ * direct provider DNS.
188
+ */
189
+ export function deriveCertificateReadiness(input: {
190
+ domain: DomainRow;
191
+ capabilities: DnsCapabilities;
192
+ delegations?: readonly AcmeDelegationStatus[];
193
+ credential?: Pick<DnsCredentialRow, 'is_active' | 'verified'> | null;
194
+ }): CertificateReadiness {
195
+ const { domain, capabilities } = input;
196
+ if (!capabilities.acme.configured) return { ready: false, mode: 'blocked', label: 'ACME unavailable', reason: 'ACME is not configured on this deployment.' };
197
+ if (domain.status !== 'active') return { ready: false, mode: 'blocked', label: 'Domain unavailable', reason: 'Certificates can be requested only for active domains.' };
198
+ const delegations = input.delegations ?? [];
199
+ const sticky = delegations.find((row) => row.verified_at != null && row.state !== 'retired');
200
+ if (sticky) {
201
+ if (sticky.state !== 'verified') return { ready: false, mode: 'blocked', label: 'Delegation broken', reason: 'This verified ACME delegation is sticky and must be repaired; direct DNS fallback is intentionally disabled.' };
202
+ if (!capabilities.delegated_acme.available) return { ready: false, mode: 'blocked', label: 'Delegation unavailable', reason: 'The delegated ACME service is unavailable.' };
203
+ return { ready: true, mode: 'delegated', label: 'Delegated DNS-01', reason: null };
204
+ }
205
+ if (domain.provider === 'mojo') return { ready: false, mode: 'blocked', label: 'Delegation not verified', reason: 'Certificate-only domains require a verified ACME delegation.' };
206
+ if (domain.provider === 'godaddy') {
207
+ const relation = input.credential ?? (typeof domain.credential === 'object' ? domain.credential : null);
208
+ if (!relation?.is_active || !relation.verified) return { ready: false, mode: 'blocked', label: 'Credential unavailable', reason: 'Direct GoDaddy DNS-01 requires an active, verified provider credential.' };
209
+ } else if (domain.provider !== 'route53') {
210
+ return { ready: false, mode: 'blocked', label: 'Provider unsupported', reason: `Certificate issuance is unavailable for ${domain.provider}.` };
211
+ }
212
+ return { ready: true, mode: 'direct', label: 'Direct DNS-01', reason: null };
213
+ }
214
+
215
+ export function certificateStatusTone(value: string): 'success' | 'warning' | 'danger' | 'muted' {
216
+ const status = normalizeCertificateStatus(value);
217
+ if (status === 'active') return 'success';
218
+ if (status === 'failed' || status === 'revoked') return 'danger';
219
+ if (status === 'pending' || status === 'issuing') return 'warning';
220
+ return 'muted';
221
+ }
@@ -0,0 +1,32 @@
1
+ import type { DnsCapabilities, DnsProviderCapability, DnsRecordRow, DomainRow } from './models';
2
+ import { recordKey } from './dns-data';
3
+
4
+ export function providerLabel(provider: string | null | undefined): string {
5
+ const key = String(provider ?? '').trim().toLowerCase();
6
+ if (key === 'route53') return 'Route 53';
7
+ if (key === 'godaddy') return 'GoDaddy';
8
+ return key ? key.charAt(0).toUpperCase() + key.slice(1) : '—';
9
+ }
10
+
11
+ export function providerCapability(caps: DnsCapabilities, provider: string): DnsProviderCapability | null {
12
+ const key = provider.trim().toLowerCase();
13
+ return caps.providers.find((entry) => entry.name.toLowerCase() === key) ?? null;
14
+ }
15
+
16
+ export function linkableProviders(caps: DnsCapabilities): DnsProviderCapability[] {
17
+ return caps.providers.filter((provider) => provider.requires_credential);
18
+ }
19
+
20
+ export function requiresCredential(provider: string, caps: DnsCapabilities): boolean {
21
+ return providerCapability(caps, provider)?.requires_credential === true;
22
+ }
23
+
24
+ export function isManagementOnly(domain: Pick<DomainRow, 'provider'> | string, caps: DnsCapabilities): boolean {
25
+ const provider = typeof domain === 'string' ? domain : domain.provider;
26
+ return providerCapability(caps, provider)?.purchase === false;
27
+ }
28
+
29
+ /** Provider records are live and id-less; type+normalized name is the stable set identity. */
30
+ export function dnsRecordKey(record: Pick<DnsRecordRow, 'type' | 'name'>): string {
31
+ return recordKey(record);
32
+ }
@@ -0,0 +1,266 @@
1
+ import type { DnsCapabilities, DnsRecordRow } from './models';
2
+
3
+ export const DNS_RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'CAA', 'NS'] as const;
4
+ export type DnsRecordType = (typeof DNS_RECORD_TYPES)[number];
5
+ export const CAA_TAGS = ['issue', 'issuewild', 'iodef'] as const;
6
+
7
+ export type RecordFieldKind = 'ipv4' | 'ipv6' | 'hostname' | 'uint16' | 'uint8' | 'port' | 'enum' | 'quoted' | 'text';
8
+ export interface RecordFieldSpec { key: string; label: string; kind: RecordFieldKind; default?: string; options?: readonly string[]; width?: string; grow?: boolean }
9
+ export interface RecordSpec { multi: boolean; valuesLabel: string; fields: readonly RecordFieldSpec[] }
10
+ export type StructuredRecordValue = Record<string, string>;
11
+
12
+ export const RECORD_SPECS: Readonly<Record<DnsRecordType, RecordSpec>> = {
13
+ A: { multi: true, valuesLabel: 'IPv4 addresses', fields: [{ key: 'ip', label: 'IPv4 address', kind: 'ipv4', grow: true }] },
14
+ AAAA: { multi: true, valuesLabel: 'IPv6 addresses', fields: [{ key: 'ip', label: 'IPv6 address', kind: 'ipv6', grow: true }] },
15
+ CNAME: { multi: false, valuesLabel: 'Alias target', fields: [{ key: 'target', label: 'Target', kind: 'hostname', grow: true }] },
16
+ TXT: { multi: true, valuesLabel: 'Text values', fields: [{ key: 'text', label: 'Value', kind: 'text', grow: true }] },
17
+ MX: { multi: true, valuesLabel: 'Mail servers', fields: [{ key: 'priority', label: 'Priority', kind: 'uint16', default: '10', width: '96px' }, { key: 'target', label: 'Target', kind: 'hostname', grow: true }] },
18
+ SRV: { multi: true, valuesLabel: 'Service targets', fields: [{ key: 'priority', label: 'Priority', kind: 'uint16', default: '10', width: '96px' }, { key: 'weight', label: 'Weight', kind: 'uint16', default: '10', width: '96px' }, { key: 'port', label: 'Port', kind: 'port', width: '96px' }, { key: 'target', label: 'Target', kind: 'hostname', grow: true }] },
19
+ CAA: { multi: true, valuesLabel: 'Certificate authorities', fields: [{ key: 'flags', label: 'Flags', kind: 'uint8', default: '0', width: '86px' }, { key: 'tag', label: 'Tag', kind: 'enum', options: CAA_TAGS, default: 'issue', width: '130px' }, { key: 'value', label: 'Value', kind: 'quoted', grow: true }] },
20
+ NS: { multi: true, valuesLabel: 'Nameservers', fields: [{ key: 'target', label: 'Nameserver', kind: 'hostname', grow: true }] },
21
+ };
22
+
23
+ const text = (value: unknown): string => value == null ? '' : String(value);
24
+ const stripDot = (value: string): string => value.replace(/\.+$/, '');
25
+ const stripQuotes = (value: string): string => value.length >= 2 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
26
+ const LABEL_RE = /^[a-z0-9_](?:[a-z0-9_-]*[a-z0-9_])?$/i;
27
+
28
+ export function specFor(type: unknown): RecordSpec | null {
29
+ return RECORD_SPECS[text(type).toUpperCase() as DnsRecordType] ?? null;
30
+ }
31
+ export function blankRecordValue(type: unknown): StructuredRecordValue {
32
+ return Object.fromEntries((specFor(type)?.fields ?? []).map((field) => [field.key, field.default ?? '']));
33
+ }
34
+ /** Compatibility with the completed web-mojo contract. */
35
+ export const blankValue = blankRecordValue;
36
+
37
+ export function recordKey(record: Pick<DnsRecordRow, 'type' | 'name'>): string {
38
+ return `${text(record.type).trim().toUpperCase()}|${stripDot(text(record.name).trim().toLowerCase())}`;
39
+ }
40
+ export function isInZone(recordName: unknown, zone: unknown): boolean {
41
+ const name = stripDot(text(recordName).trim().toLowerCase());
42
+ const root = stripDot(text(zone).trim().toLowerCase());
43
+ return !!root && (name === root || name.endsWith(`.${root}`));
44
+ }
45
+ export function toFqdn(name: unknown, zone: unknown): string {
46
+ const raw = stripDot(text(name).trim().toLowerCase());
47
+ const root = stripDot(text(zone).trim().toLowerCase());
48
+ if (!raw || raw === '@') return root;
49
+ if (raw.includes('.')) return raw;
50
+ return root ? `${raw}.${root}` : raw;
51
+ }
52
+ export function relativeRecordName(name: unknown, zone: unknown): string {
53
+ const fqdn = stripDot(text(name).trim().toLowerCase());
54
+ const root = stripDot(text(zone).trim().toLowerCase());
55
+ if (fqdn === root) return '@';
56
+ return fqdn.endsWith(`.${root}`) ? fqdn.slice(0, -(root.length + 1)) : fqdn;
57
+ }
58
+ export function hasValidLabels(fqdn: unknown): boolean {
59
+ const raw = stripDot(text(fqdn));
60
+ return !!raw && raw.length <= 253 && raw.split('.').every((label, index) => label.length <= 63 && (label === '*' ? index === 0 : LABEL_RE.test(label)));
61
+ }
62
+
63
+ export function isIPv4(value: unknown): boolean {
64
+ const parts = text(value).trim().split('.');
65
+ return parts.length === 4 && parts.every((part) => /^(0|[1-9]\d{0,2})$/.test(part) && Number(part) <= 255);
66
+ }
67
+ function ipv6Words(part: string): number | null {
68
+ if (!part) return 0;
69
+ const groups = part.split(':');
70
+ let words = 0;
71
+ for (let i = 0; i < groups.length; i += 1) {
72
+ const group = groups[i]!;
73
+ if (/^[0-9a-f]{1,4}$/i.test(group)) { words += 1; continue; }
74
+ if (i === groups.length - 1 && isIPv4(group)) { words += 2; continue; }
75
+ return null;
76
+ }
77
+ return words;
78
+ }
79
+ export function isIPv6(value: unknown): boolean {
80
+ const raw = text(value).trim();
81
+ if (!raw || raw.includes('%') || !raw.includes(':') || (raw.match(/::/g) ?? []).length > 1) return false;
82
+ if (raw.includes(':::')) return false;
83
+ const compressed = raw.includes('::');
84
+ const [left = '', right = ''] = compressed ? raw.split('::') : [raw, ''];
85
+ const leftWords = ipv6Words(left);
86
+ const rightWords = compressed ? ipv6Words(right) : 0;
87
+ if (leftWords == null || rightWords == null) return false;
88
+ const total = leftWords + rightWords;
89
+ return compressed ? total < 8 : total === 8;
90
+ }
91
+ export const isIP = (value: unknown): boolean => isIPv4(value) || isIPv6(value);
92
+ export function isHostname(value: unknown): boolean {
93
+ const raw = stripDot(text(value).trim());
94
+ return !!raw && raw.length <= 253 && !isIP(raw) && raw.split('.').length >= 2 && raw.split('.').every((label) => label.length <= 63 && LABEL_RE.test(label));
95
+ }
96
+ const isUint = (value: unknown, max: number): boolean => /^\d+$/.test(text(value).trim()) && Number(text(value).trim()) <= max;
97
+
98
+ export function isSpentAcmeChallenge(provider: unknown, record: Pick<DnsRecordRow, 'type' | 'name' | 'record_values'>): boolean {
99
+ return text(provider).toLowerCase() === 'godaddy'
100
+ && text(record.type).toUpperCase() === 'TXT'
101
+ && (text(record.name).toLowerCase() === '_acme-challenge' || text(record.name).toLowerCase().startsWith('_acme-challenge.'))
102
+ && record.record_values.length === 1 && text(record.record_values[0]).trim() === 'retired';
103
+ }
104
+ export const isAcmeChallenge = (record: Pick<DnsRecordRow, 'type' | 'name'>): boolean => text(record.type).toUpperCase() === 'TXT' && text(record.name).toLowerCase().startsWith('_acme-challenge');
105
+
106
+ export function parseRecordValue(type: unknown, wire: unknown): StructuredRecordValue {
107
+ const spec = specFor(type);
108
+ const raw = text(type).toUpperCase() === 'TXT' ? text(wire) : text(wire).trim();
109
+ if (!spec) return { text: raw };
110
+ if (spec.fields.length === 1) return { [spec.fields[0]!.key]: spec.fields[0]!.kind === 'quoted' ? stripQuotes(raw) : raw };
111
+ const parts = raw ? raw.split(/\s+/) : [];
112
+ return Object.fromEntries(spec.fields.map((field, index) => {
113
+ const part = index === spec.fields.length - 1 ? parts.slice(index).join(' ') : parts[index] ?? '';
114
+ return [field.key, field.kind === 'quoted' ? stripQuotes(part) : part];
115
+ }));
116
+ }
117
+ export function formatRecordValue(type: unknown, value: StructuredRecordValue): string {
118
+ const spec = specFor(type);
119
+ if (!spec) return text(value.text).trim();
120
+ if (text(type).toUpperCase() === 'TXT') return text(value.text);
121
+ return spec.fields.map((field) => {
122
+ const raw = text(value[field.key]).trim();
123
+ return field.kind === 'quoted' && raw ? `"${stripQuotes(raw)}"` : raw;
124
+ }).filter(Boolean).join(' ');
125
+ }
126
+ /** Shape conversion only: validation, never this helper, detects blanks/duplicates. */
127
+ export function normalizeRecordValues(input: unknown): string[] {
128
+ if (Array.isArray(input)) return input.map(text);
129
+ if (input == null) return [];
130
+ return text(input).split('\n');
131
+ }
132
+
133
+ const ZERO_WIDTH_RE = /[\u200b\u200c\u200d\ufeff]/g;
134
+ const SMART_REPLACEMENTS: Record<string, string> = { '\u2018': "'", '\u2019': "'", '\u201c': '"', '\u201d': '"' };
135
+ export interface DnsCorrection { field: string; row: number | null; before: string; after: string; message: string }
136
+ function correct(step: DnsCorrection[], field: string, row: number | null, before: string, after: string, message: string): string {
137
+ if (before !== after) step.push({ field, row, before, after, message });
138
+ return after;
139
+ }
140
+ export function autofixFieldValue(kind: RecordFieldKind, raw: unknown, field = 'value', row: number | null = null): { value: string; fixes: string[]; corrections: DnsCorrection[] } {
141
+ const corrections: DnsCorrection[] = [];
142
+ let value = text(raw);
143
+ let next = value.replace(ZERO_WIDTH_RE, ''); value = correct(corrections, field, row, value, next, 'Removed invisible characters');
144
+ next = value.replace(/\u00a0/g, ' '); value = correct(corrections, field, row, value, next, 'Replaced non-breaking spaces');
145
+ next = value.replace(/[\u2018\u2019\u201c\u201d]/g, (char) => SMART_REPLACEMENTS[char] ?? char); value = correct(corrections, field, row, value, next, 'Replaced curly quotes with plain quotes');
146
+ next = value.trim(); value = correct(corrections, field, row, value, next, 'Trimmed surrounding whitespace');
147
+ if (kind === 'hostname') {
148
+ next = value.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ''); value = correct(corrections, field, row, value, next, 'Removed the URL scheme');
149
+ next = value.split('/')[0]!; value = correct(corrections, field, row, value, next, 'Removed the URL path');
150
+ next = stripDot(value); value = correct(corrections, field, row, value, next, 'Removed the trailing dot');
151
+ next = value.toLowerCase(); value = correct(corrections, field, row, value, next, 'Lowercased the hostname');
152
+ } else if (kind === 'ipv4' || kind === 'ipv6') {
153
+ next = value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value;
154
+ value = correct(corrections, field, row, value, next, 'Removed square brackets');
155
+ } else if (kind === 'text' || kind === 'quoted') {
156
+ next = stripQuotes(value); value = correct(corrections, field, row, value, next, 'Removed surrounding quotes');
157
+ } else if (kind === 'enum') {
158
+ next = value.toLowerCase(); value = correct(corrections, field, row, value, next, 'Lowercased the value');
159
+ }
160
+ return { value, fixes: corrections.map((entry) => entry.message), corrections };
161
+ }
162
+ export function autofixRecordValue(type: unknown, raw: unknown, row: number | null = null): { value: string; parts: StructuredRecordValue; fixes: string[]; corrections: DnsCorrection[] } {
163
+ const parsed = parseRecordValue(type, raw);
164
+ const spec = specFor(type);
165
+ if (!spec) {
166
+ const result = autofixFieldValue('text', raw, 'text', row);
167
+ return { value: result.value, parts: { text: result.value }, fixes: result.fixes, corrections: result.corrections };
168
+ }
169
+ const parts: StructuredRecordValue = {};
170
+ const corrections: DnsCorrection[] = [];
171
+ for (const field of spec.fields) {
172
+ const result = autofixFieldValue(field.kind, parsed[field.key], field.key, row);
173
+ parts[field.key] = result.value; corrections.push(...result.corrections);
174
+ }
175
+ return { value: formatRecordValue(type, parts), parts, fixes: [...new Set(corrections.map((entry) => entry.message))], corrections };
176
+ }
177
+
178
+ export interface DnsValidationFix { action: 'change-type' | 'set-name'; type?: string; name?: string; label: string }
179
+ export interface DnsValidationError { index: number | null; field: string; message: string; fix?: DnsValidationFix }
180
+ export interface ValidateRecordSetOptions { type: unknown; name: unknown; values: unknown; ttl: unknown; zone: unknown; existingRecords?: readonly DnsRecordRow[]; caps?: Pick<DnsCapabilities, 'allowed_record_types'> | null; original?: Pick<DnsRecordRow, 'type' | 'name'> | null }
181
+ const validationError = (index: number | null, field: string, message: string, fix?: DnsValidationFix): DnsValidationError => ({ index, field, message, ...(fix ? { fix } : {}) });
182
+
183
+ export function validateRecordSet(options: ValidateRecordSetOptions): { ok: boolean; errors: DnsValidationError[]; fqdn: string } {
184
+ const type = text(options.type).toUpperCase();
185
+ const zone = stripDot(text(options.zone).trim().toLowerCase());
186
+ const fqdn = toFqdn(options.name, zone);
187
+ const errors: DnsValidationError[] = [];
188
+ const allowed = options.caps?.allowed_record_types?.map((entry) => entry.toUpperCase()) ?? [...DNS_RECORD_TYPES];
189
+ if (!type) errors.push(validationError(null, 'type', 'A record type is required.'));
190
+ else if (!allowed.includes(type)) errors.push(validationError(null, 'type', `${type} is not an allowed record type (${allowed.join(', ')}).`));
191
+ if (!fqdn) errors.push(validationError(null, 'name', 'A record name is required.'));
192
+ else if (zone && !isInZone(fqdn, zone)) errors.push(validationError(null, 'name', `"${fqdn}" is not inside the ${zone} zone.`, { action: 'set-name', name: `${fqdn}.${zone}`, label: `Use ${fqdn}.${zone}` }));
193
+ else if (!hasValidLabels(fqdn)) errors.push(validationError(null, 'name', fqdn.split('.').includes('*') ? 'A wildcard may only be the leftmost label.' : `"${fqdn}" is not a valid record name.`));
194
+ const apex = !!zone && fqdn === zone;
195
+ if (apex && ['NS', 'SOA'].includes(type)) errors.push(validationError(null, 'type', `The apex ${type} record set cannot be changed.`));
196
+ if (apex && type === 'CNAME') errors.push(validationError(null, 'name', "A CNAME can't sit at the apex."));
197
+ if (options.original && (recordKey({ type, name: fqdn }) !== recordKey(options.original))) errors.push(validationError(null, 'identity', 'Existing record type and name are immutable.'));
198
+
199
+ const originalKey = options.original ? recordKey(options.original) : null;
200
+ const other = (options.existingRecords ?? []).filter((record) => recordKey(record) !== originalKey && stripDot(record.name.toLowerCase()) === fqdn);
201
+ if (type === 'CNAME' && other.length) errors.push(validationError(null, 'name', `${fqdn} already has ${other.map((record) => record.type.toUpperCase()).join(', ')}. A CNAME cannot coexist with another record.`));
202
+ if (type !== 'CNAME' && other.some((record) => record.type.toUpperCase() === 'CNAME')) errors.push(validationError(null, 'name', `${fqdn} is a CNAME. No other record type can share that name.`));
203
+
204
+ const values = normalizeRecordValues(options.values);
205
+ const spec = specFor(type);
206
+ if (!values.length) errors.push(validationError(null, 'values', 'At least one value is required.'));
207
+ if (spec && !spec.multi && values.length > 1) errors.push(validationError(null, 'values', `A ${type} record holds exactly one value.`));
208
+ const seen = new Set<string>();
209
+ values.forEach((wire, index) => {
210
+ if (!wire || wire.trim() === '') errors.push(validationError(index, 'value', 'A record value is required.'));
211
+ else if (type !== 'TXT' && wire.trim() !== wire) errors.push(validationError(index, 'value', 'Apply the visible correction before saving.'));
212
+ if (seen.has(wire)) errors.push(validationError(index, 'value', 'Duplicate values are not allowed.'));
213
+ seen.add(wire);
214
+ const parts = parseRecordValue(type, wire);
215
+ for (const field of spec?.fields ?? []) {
216
+ const value = parts[field.key] ?? '';
217
+ if (!value || (type === 'TXT' && value.trim() === '')) { errors.push(validationError(index, field.key, `${field.label} is required.`)); continue; }
218
+ if (field.kind === 'ipv4' && !isIPv4(value)) errors.push(validationError(index, field.key, isIPv6(value) ? "That's IPv6 — use AAAA." : `"${value}" is not a valid IPv4 address.`, isIPv6(value) ? { action: 'change-type', type: 'AAAA', label: 'Change type to AAAA' } : undefined));
219
+ if (field.kind === 'ipv6' && !isIPv6(value)) errors.push(validationError(index, field.key, isIPv4(value) ? "That's IPv4 — use A." : `"${value}" is not a valid IPv6 address.`, isIPv4(value) ? { action: 'change-type', type: 'A', label: 'Change type to A' } : undefined));
220
+ if (field.kind === 'hostname' && !isHostname(value)) errors.push(validationError(index, field.key, isIP(value) ? `${field.label} must be a hostname, not an IP address.` : `"${value}" is not a valid hostname.`));
221
+ if (field.kind === 'uint16' && !isUint(value, 65535)) errors.push(validationError(index, field.key, `${field.label} must be 0 to 65535.`));
222
+ if (field.kind === 'uint8' && !isUint(value, 255)) errors.push(validationError(index, field.key, `${field.label} must be 0 to 255.`));
223
+ if (field.kind === 'port' && (!isUint(value, 65535) || Number(value) === 0)) errors.push(validationError(index, field.key, 'Port must be 1 to 65535.'));
224
+ if (field.kind === 'enum' && field.options && !field.options.includes(value.toLowerCase())) errors.push(validationError(index, field.key, `${field.label} must be one of: ${field.options.join(', ')}.`));
225
+ }
226
+ });
227
+ const ttl = Number(options.ttl);
228
+ if (!Number.isInteger(ttl) || ttl < 60 || ttl > 86400) errors.push(validationError(null, 'ttl', 'TTL must be a whole number of seconds from 60 to 86400.'));
229
+ return { ok: errors.length === 0, errors, fqdn };
230
+ }
231
+
232
+ export interface RecordValueDiff { added: string[]; removed: string[]; unchanged: string[] }
233
+ export function diffRecordValues(before: readonly string[] | unknown, after: readonly string[] | unknown): RecordValueDiff {
234
+ const from = normalizeRecordValues(before); const to = normalizeRecordValues(after);
235
+ return { added: to.filter((value) => !from.includes(value)), removed: from.filter((value) => !to.includes(value)), unchanged: from.filter((value) => to.includes(value)) };
236
+ }
237
+ export interface RecordSetDiff extends RecordValueDiff { key: string; type: string; name: string; ttl: { before: number | null; after: number | null } }
238
+ export function diffRecordSet(before: DnsRecordRow | null, after: DnsRecordRow | null): RecordSetDiff {
239
+ const identity = after ?? before ?? { type: '', name: '', record_values: [], ttl: 0 };
240
+ return { key: recordKey(identity), type: identity.type, name: identity.name, ...diffRecordValues(before?.record_values ?? [], after?.record_values ?? []), ttl: { before: before?.ttl ?? null, after: after?.ttl ?? null } };
241
+ }
242
+ export function recordWarnings(options: { provider?: string; type: unknown; name: unknown; values?: unknown; ttl?: unknown; zone: unknown; existingRecords?: readonly DnsRecordRow[]; before?: unknown; deleting?: boolean }): string[] {
243
+ const type = text(options.type).toUpperCase(); const fqdn = toFqdn(options.name, options.zone); const values = normalizeRecordValues(options.values); const warnings: string[] = [];
244
+ const current = (options.existingRecords ?? []).find((record) => recordKey(record) === recordKey({ type, name: fqdn }));
245
+ if (current && isAcmeChallenge(current) && !isSpentAcmeChallenge(options.provider, current)) warnings.push('This is a live _acme-challenge record. Changing it can fail certificate issuance.');
246
+ if (['A', 'AAAA'].includes(type) && (fqdn === stripDot(text(options.zone)) || fqdn === `www.${stripDot(text(options.zone))}`) && (options.deleting || !values.length)) warnings.push(`Removing the ${type} record for ${fqdn} will take that address offline.`);
247
+ if (type === 'MX') warnings.push('Changing MX records changes where mail for this domain is delivered.');
248
+ if (fqdn.startsWith('*.')) warnings.push('This wildcard record answers for every name without a more specific record.');
249
+ if (Number(options.ttl) >= 60 && Number(options.ttl) < 300) warnings.push(`A TTL of ${options.ttl}s is very short.`);
250
+ if (type === 'SRV' && !/^_[^.]+\._[^.]+\./.test(fqdn)) warnings.push('SRV names are normally _service._protocol.name.');
251
+ const before = normalizeRecordValues(options.before); const removed = diffRecordValues(before, values).removed;
252
+ if (!options.deleting && removed.length && values.length < before.length) warnings.push(`${before.length - values.length} existing ${before.length - values.length === 1 ? 'value' : 'values'} will be removed.`);
253
+ return warnings;
254
+ }
255
+
256
+ export interface RecordOwnerSnapshot { key: string; owner: string; exact: DnsRecordRow | null; ownerRecords: DnsRecordRow[] }
257
+ const cloneRecord = (record: DnsRecordRow): DnsRecordRow => ({ type: record.type, name: record.name, record_values: [...record.record_values], ttl: record.ttl });
258
+ export function snapshotRecordOwner(records: readonly DnsRecordRow[], type: string, name: string): RecordOwnerSnapshot {
259
+ const key = recordKey({ type, name }); const owner = stripDot(name.toLowerCase());
260
+ const ownerRecords = records.filter((record) => stripDot(record.name.toLowerCase()) === owner).map(cloneRecord);
261
+ return { key, owner, exact: ownerRecords.find((record) => recordKey(record) === key) ?? null, ownerRecords };
262
+ }
263
+ export function sameRecordOwnerSnapshot(a: RecordOwnerSnapshot, b: RecordOwnerSnapshot): boolean {
264
+ const canonical = (snapshot: RecordOwnerSnapshot) => JSON.stringify(snapshot.ownerRecords.map((record) => ({ ...record, type: record.type.toUpperCase(), name: stripDot(record.name.toLowerCase()) })).sort((x, y) => recordKey(x).localeCompare(recordKey(y))));
265
+ return a.key === b.key && a.owner === b.owner && canonical(a) === canonical(b);
266
+ }
@@ -0,0 +1,24 @@
1
+ /** Dependency-free DNS composition seam. Keep this safe for client/mock import order. */
2
+ export interface ManagedDnsRecordInput { type: string; name: string; record_values: string[]; ttl: number }
3
+ export interface DnsAdminIntegration {
4
+ resolveDomainByName?: (normalizedName: string) => Promise<number | null>;
5
+ recordsHref?: (domainId: number) => string;
6
+ applyManagedDnsRecords?: (domainId: number, records: readonly ManagedDnsRecordInput[]) => void | Promise<void>;
7
+ }
8
+
9
+ let integration: DnsAdminIntegration = {};
10
+ export function registerDnsAdminIntegration(adapter: DnsAdminIntegration): () => void {
11
+ const previous: DnsAdminIntegration = {};
12
+ for (const key of Object.keys(adapter) as (keyof DnsAdminIntegration)[]) {
13
+ Object.assign(previous, { [key]: integration[key] });
14
+ Object.assign(integration, { [key]: adapter[key] });
15
+ }
16
+ return () => {
17
+ for (const key of Object.keys(adapter) as (keyof DnsAdminIntegration)[]) {
18
+ if (integration[key] === adapter[key]) Object.assign(integration, { [key]: previous[key] });
19
+ }
20
+ };
21
+ }
22
+ export function getDnsAdminIntegration(): DnsAdminIntegration | null {
23
+ return Object.keys(integration).length ? integration : null;
24
+ }
@@ -0,0 +1,37 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ import type { DnsCapabilities, DomainRow } from './models';
3
+
4
+ export interface DnsDomainLink {
5
+ key: string;
6
+ label: string;
7
+ icon: string;
8
+ route: string | ((domain: DomainRow) => string);
9
+ permissions?: string | string[];
10
+ when?: (domain: DomainRow, capabilities: DnsCapabilities) => boolean;
11
+ order?: number;
12
+ }
13
+ export const DNS_DOMAIN_LINK_ORDER: Readonly<Record<string, number>> = {
14
+ domains: 10,
15
+ records: 20,
16
+ certificates: 30,
17
+ purchases: 40,
18
+ registrant: 50,
19
+ credentials: 60,
20
+ };
21
+ const links = new Map<string, DnsDomainLink>();
22
+ const listeners = new Set<() => void>();
23
+ let snapshot: readonly DnsDomainLink[] = [];
24
+ const emit = () => {
25
+ snapshot = [...links.values()].sort((a, b) => (a.order ?? DNS_DOMAIN_LINK_ORDER[a.key] ?? 100) - (b.order ?? DNS_DOMAIN_LINK_ORDER[b.key] ?? 100));
26
+ listeners.forEach((listener) => listener());
27
+ };
28
+ export function registerDnsDomainLinks(...entries: DnsDomainLink[]): () => void {
29
+ const prior = new Map<string, DnsDomainLink | undefined>();
30
+ entries.forEach((entry) => { prior.set(entry.key, links.get(entry.key)); links.set(entry.key, entry); });
31
+ emit();
32
+ return () => { entries.forEach((entry) => { if (links.get(entry.key) !== entry) return; const old = prior.get(entry.key); if (old) links.set(entry.key, old); else links.delete(entry.key); }); emit(); };
33
+ }
34
+ export function getDnsDomainLinks(): readonly DnsDomainLink[] { return snapshot; }
35
+ export function useDnsDomainLinks(): readonly DnsDomainLink[] {
36
+ return useSyncExternalStore((listener) => { listeners.add(listener); return () => listeners.delete(listener); }, getDnsDomainLinks, getDnsDomainLinks);
37
+ }