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,585 @@
1
+ import { useQuery, type QueryClient } from '@tanstack/react-query';
2
+ import {
3
+ MojoError, defineModel, mojoCall, mojoDelete, mojoGet, mojoSave, useCan, withFreshAuth,
4
+ type Envelope, type Params,
5
+ } from '../../client';
6
+ import { createSafeExporter } from '../../client/safe-export';
7
+ import { USER_VIEW_PERMISSIONS } from '../identity/users/models';
8
+
9
+ export const STORAGE_VIEW_PERMS = ['sys.view_fileman', 'sys.manage_files', 'sys.files'];
10
+ export const STORAGE_MANAGE_PERMS = ['sys.manage_files', 'sys.files'];
11
+ export const BUCKET_MANAGE_PERMS = ['sys.manage_aws', 'sys.files'];
12
+ export const GROUP_DIRECTORY_PERMS = ['sys.view_groups', 'sys.manage_groups', 'sys.manage_group', 'sys.groups'];
13
+ export const USER_DIRECTORY_PERMS = USER_VIEW_PERMISSIONS;
14
+
15
+ export interface RelationRow { id: number; name?: string | null; display_name?: string | null; email?: string | null }
16
+
17
+ export interface FileManagerRow {
18
+ id: number;
19
+ created: number;
20
+ name: string;
21
+ use: string | null;
22
+ backend_type: string;
23
+ backend_url: string;
24
+ is_active: boolean;
25
+ is_default: boolean;
26
+ is_public: boolean;
27
+ aws_region?: string | null;
28
+ aws_key_masked?: string | null;
29
+ aws_secret_masked?: string | null;
30
+ allowed_origins?: string[];
31
+ assume_role_arn?: string | null;
32
+ has_external_id?: boolean;
33
+ group?: RelationRow | number | null;
34
+ user?: RelationRow | number | null;
35
+ }
36
+
37
+ /** Capability-free projection used only to choose an upload destination. */
38
+ export interface FileManagerUploadPolicyRow {
39
+ id: number;
40
+ name: string;
41
+ use: string | null;
42
+ is_active: boolean;
43
+ max_file_size: number;
44
+ allowed_extensions: string[];
45
+ allowed_mime_types: string[];
46
+ supports_direct_upload: boolean;
47
+ }
48
+
49
+ export interface FileRenditionRow {
50
+ id: number;
51
+ created: number;
52
+ modified: number;
53
+ filename: string;
54
+ file_size: number | null;
55
+ content_type: string;
56
+ category?: string | null;
57
+ role: string;
58
+ upload_status: string;
59
+ width?: number | null;
60
+ height?: number | null;
61
+ url?: string | null;
62
+ }
63
+
64
+ export interface FileRow {
65
+ id: number;
66
+ created: number;
67
+ modified: number;
68
+ filename: string;
69
+ file_size: number | null;
70
+ content_type: string;
71
+ category: string | null;
72
+ upload_status: 'pending' | 'uploading' | 'completed' | 'failed' | 'expired' | string;
73
+ is_active: boolean;
74
+ is_public: boolean;
75
+ group?: RelationRow | number | null;
76
+ user?: RelationRow | number | null;
77
+ file_manager?: RelationRow | number | null;
78
+ metadata?: Record<string, unknown>;
79
+ url?: string | null;
80
+ thumbnail?: string | null;
81
+ renditions?: Record<string, FileRenditionRow>;
82
+ }
83
+
84
+ export interface ShortLinkShareRow {
85
+ id: number;
86
+ code: string;
87
+ url: string;
88
+ source: string;
89
+ hit_count: number;
90
+ expires_at: number | string | null;
91
+ is_active: boolean;
92
+ track_clicks?: boolean;
93
+ metadata?: Record<string, unknown>;
94
+ note?: string | null;
95
+ created: number;
96
+ modified?: number;
97
+ user?: RelationRow | number | null;
98
+ group?: RelationRow | number | null;
99
+ }
100
+
101
+ export interface S3BucketRow { id: string; name: string; created: number }
102
+ export type MutationState = 'none' | 'partial' | 'unknown' | 'complete';
103
+ export interface S3FailureEvidence {
104
+ name?: string;
105
+ action?: string;
106
+ complete: false;
107
+ mutation_state: Exclude<MutationState, 'complete'>;
108
+ counts?: Record<string, number | null>;
109
+ failed?: Record<string, number | null>;
110
+ remaining?: Record<string, number | null> | null;
111
+ failure?: { operation?: string; provider_code?: string; retryable?: boolean };
112
+ requested_public?: boolean;
113
+ created_new?: boolean | null;
114
+ is_public?: boolean | null;
115
+ configured_public?: boolean | null;
116
+ safety_lock?: 'applied' | 'failed' | 'not_needed';
117
+ }
118
+ export interface BucketCreateResult { id: string; name: string; created_new: boolean | null }
119
+ export interface BucketAccessResult {
120
+ name: string;
121
+ is_public: boolean;
122
+ configured_public?: boolean;
123
+ complete: true;
124
+ mutation_state: 'complete';
125
+ }
126
+ export interface BucketEmptyResult {
127
+ name: string;
128
+ complete: true;
129
+ mutation_state: 'complete';
130
+ deleted_objects: number;
131
+ deleted_versions: number;
132
+ deleted_markers: number;
133
+ aborted_uploads: number;
134
+ }
135
+
136
+ const COMMON_LIST = new Set(['start', 'size', 'sort', 'search', 'dr_field', 'dr_start', 'dr_end']);
137
+ const MANAGER_FILTERS = new Set(['id', 'id__in', 'backend_type', 'backend_type__in', 'is_active', 'is_default', 'is_public', 'group', 'group__isnull', 'user', 'user__isnull']);
138
+ const FILE_FILTERS = new Set(['id', 'id__in', 'file_manager', 'group', 'group__isnull', 'user', 'category', 'category__in', 'content_type', 'content_type__icontains', 'upload_status', 'upload_status__in', 'is_active', 'is_public', 'created__gte', 'created__lte']);
139
+
140
+ function allowListParams(params: Params, filters: Set<string>, graph: string): Params {
141
+ const out: Params = { graph };
142
+ for (const [key, value] of Object.entries(params)) {
143
+ if ((COMMON_LIST.has(key) || filters.has(key)) && value != null && value !== '') out[key] = value;
144
+ }
145
+ out.sort = String(out.sort ?? '-created');
146
+ return out;
147
+ }
148
+
149
+ function relation(value: unknown): RelationRow | number | null {
150
+ if (typeof value === 'number') return value;
151
+ if (!value || typeof value !== 'object') return null;
152
+ const raw = value as Record<string, unknown>;
153
+ if (typeof raw.id !== 'number') return null;
154
+ return {
155
+ id: raw.id,
156
+ ...(typeof raw.name === 'string' ? { name: raw.name } : {}),
157
+ ...(typeof raw.display_name === 'string' ? { display_name: raw.display_name } : {}),
158
+ ...(typeof raw.email === 'string' ? { email: raw.email } : {}),
159
+ };
160
+ }
161
+
162
+ export function storageRelationId(value: RelationRow | number | null | undefined): number | null {
163
+ return typeof value === 'number' ? value : value?.id ?? null;
164
+ }
165
+
166
+ /** Allowlist before any FileManager row can enter TanStack Query state. */
167
+ export function sanitizeFileManagerRow(input: FileManagerRow): FileManagerRow {
168
+ const raw = input as unknown as Record<string, unknown>;
169
+ return {
170
+ id: Number(raw.id),
171
+ created: Number(raw.created ?? 0),
172
+ name: String(raw.name ?? ''),
173
+ use: raw.use == null ? null : String(raw.use),
174
+ backend_type: String(raw.backend_type ?? 'unknown'),
175
+ backend_url: String(raw.backend_url ?? ''),
176
+ is_active: Boolean(raw.is_active),
177
+ is_default: Boolean(raw.is_default),
178
+ is_public: Boolean(raw.is_public),
179
+ aws_region: raw.aws_region == null ? null : String(raw.aws_region),
180
+ aws_key_masked: raw.aws_key_masked == null ? null : String(raw.aws_key_masked),
181
+ aws_secret_masked: raw.aws_secret_masked == null ? null : String(raw.aws_secret_masked),
182
+ allowed_origins: Array.isArray(raw.allowed_origins) ? raw.allowed_origins.map(String) : [],
183
+ assume_role_arn: raw.assume_role_arn == null ? null : String(raw.assume_role_arn),
184
+ has_external_id: Boolean(raw.has_external_id),
185
+ group: relation(raw.group),
186
+ user: relation(raw.user),
187
+ };
188
+ }
189
+
190
+ export function sanitizeFileManagerUploadPolicyRow(input: FileManagerUploadPolicyRow): FileManagerUploadPolicyRow {
191
+ const raw = input as unknown as Record<string, unknown>;
192
+ const id = Number(raw.id);
193
+ const maxFileSize = Number(raw.max_file_size);
194
+ if (!Number.isSafeInteger(id) || id < 1 || !Number.isSafeInteger(maxFileSize) || maxFileSize < 0) {
195
+ throw new TypeError('Invalid FileManager upload policy');
196
+ }
197
+ return {
198
+ id,
199
+ name: String(raw.name ?? ''),
200
+ use: raw.use == null ? null : String(raw.use),
201
+ is_active: Boolean(raw.is_active),
202
+ max_file_size: maxFileSize,
203
+ allowed_extensions: Array.isArray(raw.allowed_extensions) ? raw.allowed_extensions.map(String) : [],
204
+ allowed_mime_types: Array.isArray(raw.allowed_mime_types) ? raw.allowed_mime_types.map(String) : [],
205
+ supports_direct_upload: Boolean(raw.supports_direct_upload),
206
+ };
207
+ }
208
+
209
+ export function sanitizeFileRow(input: FileRow): FileRow {
210
+ const raw = input as unknown as Record<string, unknown>;
211
+ const renditions: Record<string, FileRenditionRow> = {};
212
+ if (raw.renditions && typeof raw.renditions === 'object' && !Array.isArray(raw.renditions)) {
213
+ for (const [role, value] of Object.entries(raw.renditions as Record<string, unknown>)) {
214
+ if (!value || typeof value !== 'object') continue;
215
+ const row = value as Record<string, unknown>;
216
+ renditions[role] = {
217
+ id: Number(row.id), created: Number(row.created ?? 0), modified: Number(row.modified ?? 0),
218
+ filename: String(row.filename ?? ''), file_size: row.file_size == null ? null : Number(row.file_size),
219
+ content_type: String(row.content_type ?? 'application/octet-stream'), category: row.category == null ? null : String(row.category),
220
+ role, upload_status: String(row.upload_status ?? 'pending'),
221
+ width: row.width == null ? null : Number(row.width), height: row.height == null ? null : Number(row.height),
222
+ url: row.url == null ? null : String(row.url),
223
+ };
224
+ }
225
+ }
226
+ return {
227
+ id: Number(raw.id), created: Number(raw.created ?? 0), modified: Number(raw.modified ?? 0),
228
+ filename: String(raw.filename ?? ''), file_size: raw.file_size == null ? null : Number(raw.file_size),
229
+ content_type: String(raw.content_type ?? 'application/octet-stream'), category: raw.category == null ? null : String(raw.category),
230
+ upload_status: String(raw.upload_status ?? 'pending'), is_active: Boolean(raw.is_active), is_public: Boolean(raw.is_public),
231
+ group: relation(raw.group), user: relation(raw.user), file_manager: relation(raw.file_manager),
232
+ metadata: raw.metadata && typeof raw.metadata === 'object' && !Array.isArray(raw.metadata) ? raw.metadata as Record<string, unknown> : {},
233
+ url: raw.url == null ? null : String(raw.url), thumbnail: raw.thumbnail == null ? null : String(raw.thumbnail), renditions,
234
+ };
235
+ }
236
+
237
+ export const SUPPORTED_FILE_MANAGER_BACKENDS = [
238
+ { value: 'file', label: 'File system' },
239
+ { value: 's3', label: 'AWS S3' },
240
+ ] as const;
241
+
242
+ export const FileManagerModel = defineModel<FileManagerRow>({
243
+ name: 'file-manager', endpoint: '/api/fileman/manager',
244
+ permissions: { view: STORAGE_VIEW_PERMS, manage: STORAGE_MANAGE_PERMS },
245
+ normalizeListParams: (params) => allowListParams(params, MANAGER_FILTERS, 'list'),
246
+ sanitizeRow: sanitizeFileManagerRow,
247
+ });
248
+
249
+ export const FileManagerUploadPolicyModel = defineModel<FileManagerUploadPolicyRow>({
250
+ name: 'file-manager-upload-policy', endpoint: '/api/fileman/manager',
251
+ permissions: { view: STORAGE_MANAGE_PERMS },
252
+ normalizeListParams: (params) => {
253
+ const safe = allowListParams(params, MANAGER_FILTERS, 'upload_policy');
254
+ safe.start = 0;
255
+ safe.size = Math.min(50, Math.max(1, Number(safe.size) || 50));
256
+ safe.sort = 'name';
257
+ safe.is_active = true;
258
+ return safe;
259
+ },
260
+ sanitizeRow: sanitizeFileManagerUploadPolicyRow,
261
+ });
262
+
263
+ export const FileModel = defineModel<FileRow>({
264
+ name: 'file', endpoint: '/api/fileman/file',
265
+ permissions: { view: STORAGE_VIEW_PERMS, manage: STORAGE_MANAGE_PERMS, delete: STORAGE_MANAGE_PERMS },
266
+ actions: { regenerate_renditions: { permissions: STORAGE_MANAGE_PERMS, response: 'payload' } },
267
+ normalizeListParams: (params) => allowListParams(params, FILE_FILTERS, 'list'),
268
+ sanitizeRow: sanitizeFileRow,
269
+ });
270
+
271
+ export const ShortLinkShareModel = defineModel<ShortLinkShareRow>({
272
+ name: 'file-share', endpoint: '/api/shortlink/link',
273
+ normalizeListParams: (params) => ({
274
+ graph: 'default', source: 'fileman-share', file: params.file,
275
+ start: params.start ?? 0, size: params.size ?? 25, sort: params.sort ?? '-created',
276
+ }),
277
+ sanitizeRow: (input) => {
278
+ const row = input as unknown as Record<string, unknown>;
279
+ const metadata = row.metadata && typeof row.metadata === 'object' && !Array.isArray(row.metadata) ? row.metadata as Record<string, unknown> : {};
280
+ return {
281
+ id: Number(row.id), code: String(row.code ?? '').slice(0, 10), url: String(row.url ?? ''),
282
+ source: String(row.source ?? '').slice(0, 50), hit_count: Math.max(0, Number(row.hit_count ?? 0) || 0),
283
+ expires_at: typeof row.expires_at === 'number' || typeof row.expires_at === 'string' ? row.expires_at : null,
284
+ is_active: Boolean(row.is_active), track_clicks: Boolean(row.track_clicks),
285
+ note: typeof metadata.note === 'string' ? metadata.note.slice(0, 512) : null,
286
+ created: Number(row.created ?? 0), modified: Number(row.modified ?? 0), user: relation(row.user), group: relation(row.group),
287
+ };
288
+ },
289
+ });
290
+
291
+ export function scrubFileManagerChanges(changes: Record<string, unknown>): Record<string, unknown> {
292
+ const out = { ...changes };
293
+ for (const key of ['aws_key_masked', 'aws_secret_masked', 'mojo_secrets', 'secrets', 'upload_token']) delete out[key];
294
+ if (typeof out.aws_key !== 'string' || out.aws_key.trim() === '') delete out.aws_key;
295
+ if (typeof out.aws_secret !== 'string' || out.aws_secret.trim() === '') delete out.aws_secret;
296
+ return out;
297
+ }
298
+
299
+ export async function saveFileManagerAtomic(args: {
300
+ queryClient: QueryClient;
301
+ id: number | null;
302
+ changes: Record<string, unknown>;
303
+ expectedOwner?: { group: number | null; user: number | null };
304
+ }): Promise<FileManagerRow> {
305
+ const changes = scrubFileManagerChanges(args.changes);
306
+ let saved: FileManagerRow | null = null;
307
+ let authoritative: FileManagerRow | null = null;
308
+ let mutationError: unknown;
309
+ let refreshError: unknown;
310
+ try {
311
+ saved = sanitizeFileManagerRow(await withFreshAuth(() => mojoSave<FileManagerRow>(FileManagerModel.endpoint, args.id, changes)));
312
+ } catch (error) {
313
+ mutationError = error;
314
+ } finally {
315
+ const knownId = saved?.id ?? args.id;
316
+ const retainRefreshError = (error: unknown) => { refreshError ??= error; };
317
+ try {
318
+ await args.queryClient.invalidateQueries({ queryKey: FileManagerModel.keys.root, refetchType: 'none' });
319
+ } catch (error) { retainRefreshError(error); }
320
+ if (knownId != null) {
321
+ // A failed mutation can still have changed external state. Never let the
322
+ // pre-mutation detail snapshot regain authority while reconciliation fails.
323
+ args.queryClient.removeQueries({ queryKey: FileManagerModel.keys.one(knownId), exact: true });
324
+ try {
325
+ authoritative = sanitizeFileManagerRow(await mojoGet<FileManagerRow>(FileManagerModel.endpoint, knownId));
326
+ args.queryClient.setQueryData(FileManagerModel.keys.one(knownId), authoritative);
327
+ } catch (error) { retainRefreshError(error); }
328
+ }
329
+ try {
330
+ await args.queryClient.refetchQueries({
331
+ type: 'active',
332
+ predicate: (query) => query.queryKey[0] === FileManagerModel.endpoint && query.queryKey[1] !== 'one',
333
+ }, { throwOnError: true });
334
+ } catch (error) { retainRefreshError(error); }
335
+ }
336
+ if (mutationError !== undefined) {
337
+ if (refreshError !== undefined) attachStorageRefreshFailure(mutationError, refreshError);
338
+ throw mutationError;
339
+ }
340
+ if (refreshError !== undefined || !authoritative) {
341
+ const error = new MojoError('The backend may have changed, but authoritative refresh failed. Refresh before another action.', 0, 'storage_refresh_failed');
342
+ attachStorageRefreshFailure(error, refreshError ?? new Error('Authoritative backend row was not returned'));
343
+ throw error;
344
+ }
345
+ if (args.expectedOwner && (storageRelationId(authoritative.group) !== args.expectedOwner.group || storageRelationId(authoritative.user) !== args.expectedOwner.user)) {
346
+ throw new MojoError('The backend did not attach the requested owner. Check your directory permissions and try again.', 403, 'fk_attach_denied');
347
+ }
348
+ return authoritative;
349
+ }
350
+
351
+ const STORAGE_REFRESH_FAILURE = 'storageRefreshError';
352
+ const storageRefreshFailures = new WeakMap<object, unknown>();
353
+
354
+ function attachStorageRefreshFailure(error: unknown, refreshError: unknown): void {
355
+ if ((typeof error !== 'object' && typeof error !== 'function') || error === null) return;
356
+ storageRefreshFailures.set(error as object, refreshError);
357
+ try {
358
+ Object.defineProperty(error, STORAGE_REFRESH_FAILURE, { value: refreshError, configurable: true });
359
+ } catch { /* Preserve the primary mutation failure even when it is frozen. */ }
360
+ }
361
+
362
+ export function storageRefreshFailure(error: unknown): unknown {
363
+ if ((typeof error !== 'object' && typeof error !== 'function') || error === null) return undefined;
364
+ return storageRefreshFailures.get(error as object) ?? (error as Record<string, unknown>)[STORAGE_REFRESH_FAILURE];
365
+ }
366
+
367
+ export async function runFileManagerAction(id: number, action: 'test_connection' | 'check_cors' | 'fix_cors' | 'clone', value: unknown = true): Promise<Record<string, unknown>> {
368
+ const body = await mojoCall(`${FileManagerModel.endpoint}/${id}`, { method: 'POST', body: { [action]: value } });
369
+ const data = body.data;
370
+ if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error(`${action} returned no result`);
371
+ const row = data as Record<string, unknown>;
372
+ if (row.status === false) throw new Error(typeof row.error === 'string' ? row.error : `${action} failed`);
373
+ return {
374
+ status: row.status !== false,
375
+ ...(typeof row.id === 'number' ? { id: row.id } : {}),
376
+ ...(row.result && typeof row.result === 'object' ? { result: row.result } : {}),
377
+ };
378
+ }
379
+
380
+ export async function saveFileAndReconcileGroup(queryClient: QueryClient, id: number, changes: Pick<FileRow, 'filename' | 'is_public'> & { group?: number | null } | Record<string, unknown>, expectedGroup?: number): Promise<FileRow> {
381
+ await mojoSave<FileRow>(FileModel.endpoint, id, changes as Record<string, unknown>);
382
+ await FileModel.invalidate(queryClient);
383
+ queryClient.removeQueries({ queryKey: FileModel.keys.one(id), exact: true });
384
+ const row = sanitizeFileRow(await mojoGet<FileRow>(FileModel.endpoint, id));
385
+ queryClient.setQueryData(FileModel.keys.one(id), row);
386
+ if (expectedGroup !== undefined && storageRelationId(row.group) !== expectedGroup) {
387
+ throw new MojoError('The backend did not move the file to the requested group.', 403, 'fk_attach_denied');
388
+ }
389
+ return row;
390
+ }
391
+
392
+ export interface ShareOptions { expire_days?: number; track_clicks?: boolean; note?: string }
393
+ export interface ShareCreateResult { url: string; code: string; expires_at: string | null; track_clicks: boolean }
394
+ export async function createFileShare(id: number, options: true | ShareOptions): Promise<ShareCreateResult> {
395
+ const body = await mojoCall(`${FileModel.endpoint}/${id}`, { method: 'POST', body: { share: options } });
396
+ const raw = body.data;
397
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Share creation returned no result');
398
+ const row = raw as Record<string, unknown>;
399
+ if (row.status === false) throw new Error(typeof row.error === 'string' ? row.error : 'Share creation failed');
400
+ const url = typeof row.url === 'string' ? row.url : '';
401
+ if (!isSafeCapabilityUrl(url)) throw new Error('The share service returned an unsafe URL');
402
+ const expiresAt = row.expires_at == null ? null : String(row.expires_at);
403
+ if (expiresAt != null && !Number.isFinite(Date.parse(expiresAt))) throw new Error('The share service returned an invalid expiry');
404
+ return { url, code: String(row.shortlink_code ?? ''), expires_at: expiresAt, track_clicks: Boolean(row.track_clicks) };
405
+ }
406
+
407
+ export async function revokeFileShare(id: number): Promise<ShortLinkShareRow> {
408
+ return mojoSave<ShortLinkShareRow>(ShortLinkShareModel.endpoint, id, { is_active: false });
409
+ }
410
+
411
+ export async function setFileShareActive(id: number, isActive: boolean): Promise<ShortLinkShareRow> {
412
+ return mojoSave<ShortLinkShareRow>(ShortLinkShareModel.endpoint, id, { is_active: isActive });
413
+ }
414
+
415
+ export async function deleteFileShare(id: number): Promise<void> {
416
+ return mojoDelete(ShortLinkShareModel.endpoint, id);
417
+ }
418
+
419
+ export function isSafeCapabilityUrl(value: unknown): value is string {
420
+ if (typeof value !== 'string' || value.trim() !== value || value === '' || value.startsWith('//')) return false;
421
+ if (value.startsWith('/')) return !value.startsWith('//') && !/[\r\n]/.test(value);
422
+ try {
423
+ const url = new URL(value);
424
+ return (url.protocol === 'http:' || url.protocol === 'https:') && !url.username && !url.password;
425
+ } catch {
426
+ return false;
427
+ }
428
+ }
429
+
430
+ export function openCapabilityUrl(url: string, download = false): boolean {
431
+ if (!isSafeCapabilityUrl(url)) return false;
432
+ const anchor = document.createElement('a');
433
+ anchor.href = url;
434
+ anchor.rel = 'noreferrer';
435
+ if (download) anchor.download = '';
436
+ document.body.appendChild(anchor);
437
+ anchor.click();
438
+ anchor.remove();
439
+ return true;
440
+ }
441
+
442
+ export const exportFileManagers = createSafeExporter<FileManagerRow>({
443
+ endpoint: FileManagerModel.endpoint, filename: 'storage-backends', sanitizeRow: sanitizeFileManagerRow,
444
+ fields: [
445
+ { key: 'id' }, { key: 'name' }, { key: 'backend_type' }, { key: 'is_active' },
446
+ { key: 'is_default' }, { key: 'is_public' }, { key: 'created' },
447
+ { key: 'scope', value: (row) => storageRelationId(row.group) != null ? `group:${storageRelationId(row.group)}` : storageRelationId(row.user) != null ? `user:${storageRelationId(row.user)}` : 'system' },
448
+ ],
449
+ });
450
+
451
+ export const exportFiles = createSafeExporter<FileRow>({
452
+ endpoint: FileModel.endpoint, filename: 'storage-files', sanitizeRow: sanitizeFileRow,
453
+ fields: [
454
+ { key: 'id' }, { key: 'filename' }, { key: 'content_type' }, { key: 'category' },
455
+ { key: 'file_size' }, { key: 'upload_status' }, { key: 'is_active' },
456
+ { key: 'is_public' }, { key: 'created' },
457
+ { key: 'group_id', value: (row) => storageRelationId(row.group) },
458
+ { key: 'file_manager_id', value: (row) => storageRelationId(row.file_manager) },
459
+ ],
460
+ });
461
+
462
+ function object(value: unknown, name: string): Record<string, unknown> {
463
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${name} returned an invalid payload`);
464
+ return value as Record<string, unknown>;
465
+ }
466
+ function string(value: unknown, name: string): string {
467
+ if (typeof value !== 'string' || value === '') throw new Error(`${name} is missing`);
468
+ return value;
469
+ }
470
+ function boolean(value: unknown, name: string): boolean {
471
+ if (typeof value !== 'boolean') throw new Error(`${name} is missing`);
472
+ return value;
473
+ }
474
+ function finite(value: unknown, name: string): number {
475
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) throw new Error(`${name} is invalid`);
476
+ return value;
477
+ }
478
+
479
+ function safeAggregate(value: unknown): Record<string, number | null> | undefined {
480
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
481
+ const aggregate: Record<string, number | null> = {};
482
+ for (const [key, count] of Object.entries(value)) {
483
+ if (count === null || (typeof count === 'number' && Number.isFinite(count) && count >= 0)) aggregate[key] = count;
484
+ }
485
+ return aggregate;
486
+ }
487
+
488
+ export function parseBucketList(body: Envelope): S3BucketRow[] {
489
+ if (!Array.isArray(body.data)) throw new Error('Bucket inventory returned an invalid list');
490
+ return body.data.map((value) => {
491
+ const row = object(value, 'Bucket');
492
+ return { id: string(row.id, 'Bucket id'), name: string(row.name, 'Bucket name'), created: finite(row.created, 'Bucket created') };
493
+ });
494
+ }
495
+ export function parseBucketCreate(value: unknown): BucketCreateResult {
496
+ const row = object(value, 'Bucket create');
497
+ const created = row.created_new;
498
+ if (created !== null && typeof created !== 'boolean') throw new Error('Bucket created_new is invalid');
499
+ return { id: string(row.id, 'Bucket id'), name: string(row.name, 'Bucket name'), created_new: created };
500
+ }
501
+ export function parseBucketAccess(value: unknown): BucketAccessResult {
502
+ const row = object(value, 'Bucket access');
503
+ if (row.complete !== true || row.mutation_state !== 'complete') throw new Error('Bucket access did not report complete');
504
+ return { name: string(row.name, 'Bucket name'), is_public: boolean(row.is_public ?? row.configured_public, 'Bucket posture'), configured_public: typeof row.configured_public === 'boolean' ? row.configured_public : undefined, complete: true, mutation_state: 'complete' };
505
+ }
506
+ export function parseBucketEmpty(value: unknown): BucketEmptyResult {
507
+ const row = object(value, 'Bucket empty');
508
+ if (row.complete !== true || row.mutation_state !== 'complete') throw new Error('Bucket empty did not report complete');
509
+ return {
510
+ name: string(row.name, 'Bucket name'), complete: true, mutation_state: 'complete',
511
+ deleted_objects: finite(row.deleted_objects, 'deleted_objects'), deleted_versions: finite(row.deleted_versions, 'deleted_versions'),
512
+ deleted_markers: finite(row.deleted_markers, 'deleted_markers'), aborted_uploads: finite(row.aborted_uploads, 'aborted_uploads'),
513
+ };
514
+ }
515
+
516
+ export function parseS3Failure(error: unknown): S3FailureEvidence | null {
517
+ if (!(error instanceof MojoError) || error.errorCode !== 's3_operation_incomplete') return null;
518
+ const raw = object(error.data, 'S3 failure');
519
+ if (raw.complete !== false || !['none', 'partial', 'unknown'].includes(String(raw.mutation_state))) return null;
520
+ const state = raw.mutation_state as S3FailureEvidence['mutation_state'];
521
+ return {
522
+ name: typeof raw.name === 'string' ? raw.name : undefined,
523
+ action: typeof raw.action === 'string' ? raw.action : undefined,
524
+ complete: false, mutation_state: state,
525
+ counts: safeAggregate(raw.counts),
526
+ failed: safeAggregate(raw.failed),
527
+ remaining: raw.remaining === null ? null : safeAggregate(raw.remaining),
528
+ failure: raw.failure && typeof raw.failure === 'object' && !Array.isArray(raw.failure) ? (() => {
529
+ const failure = raw.failure as Record<string, unknown>;
530
+ return {
531
+ ...(typeof failure.operation === 'string' ? { operation: failure.operation } : {}),
532
+ ...(typeof failure.provider_code === 'string' ? { provider_code: failure.provider_code } : {}),
533
+ ...(typeof failure.retryable === 'boolean' ? { retryable: failure.retryable } : {}),
534
+ };
535
+ })() : undefined,
536
+ requested_public: typeof raw.requested_public === 'boolean' ? raw.requested_public : undefined,
537
+ created_new: raw.created_new === null || typeof raw.created_new === 'boolean' ? raw.created_new : undefined,
538
+ is_public: raw.is_public === null || typeof raw.is_public === 'boolean' ? raw.is_public : undefined,
539
+ configured_public: raw.configured_public === null || typeof raw.configured_public === 'boolean' ? raw.configured_public : undefined,
540
+ safety_lock: ['applied', 'failed', 'not_needed'].includes(String(raw.safety_lock)) ? raw.safety_lock as S3FailureEvidence['safety_lock'] : undefined,
541
+ };
542
+ }
543
+
544
+ export const BUCKET_QUERY_KEY = ['admin-storage', 'buckets'] as const;
545
+ export async function fetchBucketInventory(): Promise<S3BucketRow[]> {
546
+ return parseBucketList(await mojoCall('/api/aws/s3/bucket'));
547
+ }
548
+ export function useBuckets() {
549
+ const { can } = useCan(BUCKET_MANAGE_PERMS);
550
+ return useQuery({ queryKey: BUCKET_QUERY_KEY, queryFn: fetchBucketInventory, enabled: can, staleTime: 30_000 });
551
+ }
552
+
553
+ export interface BucketMutationOutcome<T> { data?: T; error?: unknown; evidence?: S3FailureEvidence | null; refreshError?: unknown }
554
+ export async function postBucketAndRefresh<T>(queryClient: QueryClient, name: string, body: Record<string, unknown>, parse: (value: unknown) => T, freshAuth = false): Promise<BucketMutationOutcome<T>> {
555
+ const outcome: BucketMutationOutcome<T> = {};
556
+ try {
557
+ const request = () => mojoCall(`/api/aws/s3/bucket/${encodeURIComponent(name)}`, { method: 'POST', body });
558
+ const response = freshAuth ? await withFreshAuth(request) : await request();
559
+ outcome.data = parse(response.data);
560
+ } catch (error) {
561
+ outcome.error = error;
562
+ outcome.evidence = parseS3Failure(error);
563
+ } finally {
564
+ await queryClient.invalidateQueries({ queryKey: BUCKET_QUERY_KEY });
565
+ try {
566
+ await queryClient.fetchQuery({ queryKey: BUCKET_QUERY_KEY, queryFn: fetchBucketInventory, staleTime: 0 });
567
+ } catch (refreshError) {
568
+ outcome.refreshError = refreshError;
569
+ }
570
+ }
571
+ return outcome;
572
+ }
573
+ export function createBucket(queryClient: QueryClient, name: string) {
574
+ return postBucketAndRefresh(queryClient, name, {}, parseBucketCreate);
575
+ }
576
+ export function setBucketPublic(queryClient: QueryClient, name: string, value: boolean) {
577
+ return postBucketAndRefresh(queryClient, name, { set_public: value }, parseBucketAccess);
578
+ }
579
+ export function emptyBucket(queryClient: QueryClient, name: string, confirmedName: string): Promise<BucketMutationOutcome<BucketEmptyResult>> {
580
+ if (name !== confirmedName) return Promise.resolve({ error: new MojoError('Bucket name does not match.', 400, 'invalid_request') });
581
+ const body = { empty: { confirm_name: name } };
582
+ return postBucketAndRefresh(queryClient, name, body, parseBucketEmpty, true);
583
+ }
584
+
585
+ export function deleteFile(id: number): Promise<void> { return mojoDelete(FileModel.endpoint, id); }