@k2b/cloud 0.6.0-rc.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 (540) hide show
  1. package/README.md +98 -0
  2. package/package.json +128 -0
  3. package/public/logo.svg +1 -0
  4. package/scripts/app-favicon.test.ts +49 -0
  5. package/scripts/app-favicon.ts +51 -0
  6. package/scripts/build.ts +205 -0
  7. package/scripts/preload.ts +164 -0
  8. package/scripts/sync-recovery-smoke.ts +200 -0
  9. package/src/_internal/app-favicon.ts +2 -0
  10. package/src/_internal/app-presentation.ts +61 -0
  11. package/src/_internal/app-workspace-state.ts +24 -0
  12. package/src/_internal/bounded-json.ts +47 -0
  13. package/src/_internal/build-metadata.ts +9 -0
  14. package/src/_internal/capabilities.ts +1118 -0
  15. package/src/_internal/define-app.ts +801 -0
  16. package/src/_internal/heartbeat.ts +167 -0
  17. package/src/_internal/help-catalog.ts +241 -0
  18. package/src/_internal/help.ts +95 -0
  19. package/src/_internal/nats-connection.ts +34 -0
  20. package/src/_internal/page-responses.ts +40 -0
  21. package/src/_internal/process-sync.ts +117 -0
  22. package/src/_internal/readiness.ts +3 -0
  23. package/src/_internal/registry-validation.ts +136 -0
  24. package/src/_internal/registry-watch.ts +52 -0
  25. package/src/_internal/registry.ts +338 -0
  26. package/src/_internal/runtime-compatibility.ts +64 -0
  27. package/src/_internal/runtime-context.ts +84 -0
  28. package/src/_internal/runtime-watcher.ts +78 -0
  29. package/src/_internal/static-assets.ts +86 -0
  30. package/src/_internal/status-preserving-ssr.ts +49 -0
  31. package/src/access/PermissionEditor.tsx +486 -0
  32. package/src/access/ResourceApiKeys.tsx +271 -0
  33. package/src/access/messages.ts +121 -0
  34. package/src/access/ui.ts +16 -0
  35. package/src/account/Avatar.tsx +28 -0
  36. package/src/account/EntitySearch.tsx +363 -0
  37. package/src/account/avatar-upload-dialog.tsx +197 -0
  38. package/src/account/avatar-upload.ts +149 -0
  39. package/src/account/ui.ts +3 -0
  40. package/src/ai/admin.ts +47 -0
  41. package/src/ai/approval-routes.ts +130 -0
  42. package/src/ai/approvals.ts +139 -0
  43. package/src/ai/assistant-models.ts +61 -0
  44. package/src/ai/attachments.ts +40 -0
  45. package/src/ai/browser.ts +92 -0
  46. package/src/ai/capabilities.ts +714 -0
  47. package/src/ai/capability-execution.ts +151 -0
  48. package/src/ai/chat/blocks.render.test.tsx +1108 -0
  49. package/src/ai/chat/blocks.tsx +730 -0
  50. package/src/ai/chat/builtin-tools.tsx +428 -0
  51. package/src/ai/chat/capability-table.tsx +87 -0
  52. package/src/ai/chat/composer-adapter.ts +169 -0
  53. package/src/ai/chat/file-tools.tsx +81 -0
  54. package/src/ai/chat/message-actions.tsx +370 -0
  55. package/src/ai/chat/message-utils.ts +479 -0
  56. package/src/ai/chat/messages.ts +43 -0
  57. package/src/ai/chat/presentation.tsx +395 -0
  58. package/src/ai/chat/primitives.tsx +3 -0
  59. package/src/ai/chat/tool-disclosure.tsx +41 -0
  60. package/src/ai/chat/turn-navigator-utils.ts +28 -0
  61. package/src/ai/chat/turn-navigator.tsx +164 -0
  62. package/src/ai/chat/user-message.tsx +149 -0
  63. package/src/ai/chat/visual-tools.tsx +435 -0
  64. package/src/ai/chat/web-tools.tsx +202 -0
  65. package/src/ai/chat-task-contracts.ts +113 -0
  66. package/src/ai/chat-tasks.ts +952 -0
  67. package/src/ai/client/controller.ts +1211 -0
  68. package/src/ai/client/file-source.ts +95 -0
  69. package/src/ai/client/live-connection.ts +140 -0
  70. package/src/ai/client/projection.ts +178 -0
  71. package/src/ai/client/transport.ts +111 -0
  72. package/src/ai/compaction.ts +182 -0
  73. package/src/ai/credentials.ts +124 -0
  74. package/src/ai/default-tool-contracts.ts +108 -0
  75. package/src/ai/default-tools.ts +127 -0
  76. package/src/ai/enrich.ts +280 -0
  77. package/src/ai/executor.ts +1624 -0
  78. package/src/ai/fetch-file-tool.ts +313 -0
  79. package/src/ai/file-context.ts +92 -0
  80. package/src/ai/file-media-type.ts +28 -0
  81. package/src/ai/file-mount.ts +16 -0
  82. package/src/ai/file-tools.ts +566 -0
  83. package/src/ai/files-store.ts +390 -0
  84. package/src/ai/firecrawl-tools.ts +305 -0
  85. package/src/ai/grids-skill.ts +126 -0
  86. package/src/ai/http.ts +265 -0
  87. package/src/ai/index.ts +337 -0
  88. package/src/ai/kit-skill.ts +22 -0
  89. package/src/ai/limits.ts +5 -0
  90. package/src/ai/live-events.ts +289 -0
  91. package/src/ai/live-messages.ts +45 -0
  92. package/src/ai/live-outbox.ts +102 -0
  93. package/src/ai/live-routes.ts +412 -0
  94. package/src/ai/live.ts +2 -0
  95. package/src/ai/maintenance.ts +302 -0
  96. package/src/ai/markdown-pdf-tool.ts +95 -0
  97. package/src/ai/memories.ts +605 -0
  98. package/src/ai/memory-learning-runs.ts +222 -0
  99. package/src/ai/memory-learning.ts +619 -0
  100. package/src/ai/memory-tool.ts +120 -0
  101. package/src/ai/memory-workflow-evidence.ts +158 -0
  102. package/src/ai/migrate.ts +2082 -0
  103. package/src/ai/model-access-migrate.ts +30 -0
  104. package/src/ai/model-access.ts +191 -0
  105. package/src/ai/personal-agent.ts +6 -0
  106. package/src/ai/prefs.ts +72 -0
  107. package/src/ai/project-tool.ts +94 -0
  108. package/src/ai/projects-routes.ts +277 -0
  109. package/src/ai/projects.ts +902 -0
  110. package/src/ai/protocol.ts +279 -0
  111. package/src/ai/provider.ts +73 -0
  112. package/src/ai/public-projection.ts +25 -0
  113. package/src/ai/resource-markers.ts +31 -0
  114. package/src/ai/resource-refs.ts +70 -0
  115. package/src/ai/routes.ts +1131 -0
  116. package/src/ai/runtime-tools.ts +39 -0
  117. package/src/ai/runtime.ts +544 -0
  118. package/src/ai/settings.ts +529 -0
  119. package/src/ai/short-id.ts +45 -0
  120. package/src/ai/skill-catalog.ts +73 -0
  121. package/src/ai/skill-format.ts +145 -0
  122. package/src/ai/skill-seeds.ts +430 -0
  123. package/src/ai/skill-tool.ts +81 -0
  124. package/src/ai/skills-routes.ts +143 -0
  125. package/src/ai/skills.ts +934 -0
  126. package/src/ai/solid.ts +27 -0
  127. package/src/ai/store.ts +3113 -0
  128. package/src/ai/stream.ts +308 -0
  129. package/src/ai/structured-runs.ts +66 -0
  130. package/src/ai/structured.ts +186 -0
  131. package/src/ai/system-prompt.ts +170 -0
  132. package/src/ai/task-prompt.ts +22 -0
  133. package/src/ai/timeline.ts +148 -0
  134. package/src/ai/tool-audit.ts +177 -0
  135. package/src/ai/tool-result-budget.ts +16 -0
  136. package/src/ai/tools.ts +155 -0
  137. package/src/ai/types.ts +967 -0
  138. package/src/ai/ui.tsx +39 -0
  139. package/src/ai/usage-migrate.ts +81 -0
  140. package/src/ai/usage.ts +315 -0
  141. package/src/ai/validate.ts +54 -0
  142. package/src/ai/vision-tool.ts +79 -0
  143. package/src/api/accounts-entities.ts +174 -0
  144. package/src/api/admin-ai-projects.ts +74 -0
  145. package/src/api/admin-ai-skills.ts +76 -0
  146. package/src/api/admin-ai-usage.ts +42 -0
  147. package/src/api/admin-core-settings.ts +508 -0
  148. package/src/api/admin-identity.ts +148 -0
  149. package/src/api/admin-lifecycle.ts +206 -0
  150. package/src/api/admin-linux-identities.ts +66 -0
  151. package/src/api/announcements.ts +131 -0
  152. package/src/api/app-approval.ts +129 -0
  153. package/src/api/apps.ts +51 -0
  154. package/src/api/auth/schemas.ts +54 -0
  155. package/src/api/auth.ts +370 -0
  156. package/src/api/capabilities.ts +658 -0
  157. package/src/api/help-messages.ts +19 -0
  158. package/src/api/help.ts +78 -0
  159. package/src/api/index.ts +108 -0
  160. package/src/api/mcp.ts +744 -0
  161. package/src/api/me-rail.ts +45 -0
  162. package/src/api/me.ts +753 -0
  163. package/src/api/search/schemas.ts +79 -0
  164. package/src/api/search.bench.ts +185 -0
  165. package/src/api/search.ts +407 -0
  166. package/src/api/sync-ops.ts +79 -0
  167. package/src/api/widgets.ts +143 -0
  168. package/src/browser/CloudResourceSearch.behavior.test.tsx +74 -0
  169. package/src/browser/CloudResourceSearch.tsx +791 -0
  170. package/src/browser/app-approval-vault.ts +305 -0
  171. package/src/browser/app-approval.ts +234 -0
  172. package/src/browser/live-websocket.ts +268 -0
  173. package/src/browser/locale-preference.ts +25 -0
  174. package/src/browser/notification-target.ts +27 -0
  175. package/src/browser/notification-timeout.ts +14 -0
  176. package/src/browser/notifications.ts +181 -0
  177. package/src/browser/resource-clipboard.ts +70 -0
  178. package/src/browser/resource-picker.tsx +39 -0
  179. package/src/browser/resource-search-messages.ts +55 -0
  180. package/src/browser/resource-search.ts +27 -0
  181. package/src/capabilities/client.ts +149 -0
  182. package/src/capabilities/response.ts +39 -0
  183. package/src/capabilities/server.ts +215 -0
  184. package/src/capabilities/signals.ts +4 -0
  185. package/src/capabilities/testing.ts +12 -0
  186. package/src/capabilities/types.ts +32 -0
  187. package/src/cli/access.ts +499 -0
  188. package/src/cli/account.ts +382 -0
  189. package/src/cli/admin/account-administration.ts +45 -0
  190. package/src/cli/admin/account-categories.ts +49 -0
  191. package/src/cli/admin/ai-usage.ts +118 -0
  192. package/src/cli/admin/app-sign-in.ts +49 -0
  193. package/src/cli/admin/data.ts +343 -0
  194. package/src/cli/admin/documentation.ts +34 -0
  195. package/src/cli/admin/gateway.ts +155 -0
  196. package/src/cli/admin/index.ts +61 -0
  197. package/src/cli/admin/instance.ts +250 -0
  198. package/src/cli/admin/jobs.ts +223 -0
  199. package/src/cli/admin/legal.ts +122 -0
  200. package/src/cli/admin/linux.ts +53 -0
  201. package/src/cli/admin/logs.ts +324 -0
  202. package/src/cli/admin/metrics.ts +142 -0
  203. package/src/cli/admin/nats.ts +167 -0
  204. package/src/cli/admin/notifications.ts +516 -0
  205. package/src/cli/admin/shared.ts +113 -0
  206. package/src/cli/admin/sync.ts +188 -0
  207. package/src/cli/admin/telemetry.ts +314 -0
  208. package/src/cli/admin/webhooks.ts +222 -0
  209. package/src/cli/admin/workflows.ts +367 -0
  210. package/src/cli/apps.ts +40 -0
  211. package/src/cli/capabilities.ts +239 -0
  212. package/src/cli/commands.ts +604 -0
  213. package/src/cli/index.ts +61 -0
  214. package/src/cli/locale.ts +31 -0
  215. package/src/cli/output.ts +58 -0
  216. package/src/clients/core.ts +20 -0
  217. package/src/config/env.ts +54 -0
  218. package/src/config/index.ts +6 -0
  219. package/src/contracts/account-categories.ts +62 -0
  220. package/src/contracts/announcements.ts +121 -0
  221. package/src/contracts/app-approval.ts +197 -0
  222. package/src/contracts/app.ts +162 -0
  223. package/src/contracts/capabilities.ts +484 -0
  224. package/src/contracts/index.ts +14 -0
  225. package/src/contracts/notification-live.ts +66 -0
  226. package/src/contracts/notification-types.ts +235 -0
  227. package/src/contracts/posix.ts +69 -0
  228. package/src/contracts/profile.ts +85 -0
  229. package/src/contracts/rail-preferences.ts +53 -0
  230. package/src/contracts/registry.ts +110 -0
  231. package/src/contracts/resource-clipboard.ts +33 -0
  232. package/src/contracts/settings-types.ts +98 -0
  233. package/src/contracts/shared.ts +408 -0
  234. package/src/contracts/user-notifications.ts +101 -0
  235. package/src/contracts/widgets.ts +183 -0
  236. package/src/desktop/index.ts +704 -0
  237. package/src/desktop/solid.tsx +938 -0
  238. package/src/index.ts +58 -0
  239. package/src/server/actor.ts +41 -0
  240. package/src/server/api/index.ts +2 -0
  241. package/src/server/api/respond.ts +96 -0
  242. package/src/server/api-client.ts +54 -0
  243. package/src/server/app-context.ts +39 -0
  244. package/src/server/help.ts +187 -0
  245. package/src/server/index.ts +89 -0
  246. package/src/server/locale.ts +55 -0
  247. package/src/server/middleware/auth.ts +386 -0
  248. package/src/server/middleware/index.ts +28 -0
  249. package/src/server/middleware/invocation.ts +55 -0
  250. package/src/server/middleware/middleware.ts +65 -0
  251. package/src/server/middleware/openapi.ts +126 -0
  252. package/src/server/middleware/rate-limit.ts +125 -0
  253. package/src/server/middleware/request-logger.ts +41 -0
  254. package/src/server/middleware/route-template.ts +46 -0
  255. package/src/server/middleware/runtime.ts +22 -0
  256. package/src/server/middleware/settings.ts +55 -0
  257. package/src/server/middleware/validator.ts +49 -0
  258. package/src/server/middleware/workload.ts +22 -0
  259. package/src/server/ratelimit.ts +151 -0
  260. package/src/server/request-cookies.ts +18 -0
  261. package/src/server/services/access.ts +644 -0
  262. package/src/server/services/freeipa/client.ts +137 -0
  263. package/src/server/services/freeipa/index.ts +9 -0
  264. package/src/server/services/freeipa/session.ts +123 -0
  265. package/src/server/services/freeipa/test-certificates.ts +44 -0
  266. package/src/server/services/freeipa/tls.ts +44 -0
  267. package/src/server/services/freeipa/transport.ts +116 -0
  268. package/src/server/services/freeipa/util.ts +59 -0
  269. package/src/server/services/geo.ts +198 -0
  270. package/src/server/services/index.ts +48 -0
  271. package/src/server/services/pagination.ts +22 -0
  272. package/src/server/services/services.ts +13 -0
  273. package/src/server/time.ts +33 -0
  274. package/src/services/account-category-policy.ts +51 -0
  275. package/src/services/account-lifecycle/audit.ts +41 -0
  276. package/src/services/account-lifecycle/index.ts +940 -0
  277. package/src/services/account-lifecycle/ipa-backfill.ts +152 -0
  278. package/src/services/account-lifecycle/notification-sender.ts +13 -0
  279. package/src/services/account-lifecycle/scheduler.ts +506 -0
  280. package/src/services/account-model.ts +22 -0
  281. package/src/services/accounts/app.ts +1671 -0
  282. package/src/services/accounts/authz.ts +44 -0
  283. package/src/services/accounts/avatar.ts +120 -0
  284. package/src/services/accounts/base-group.ts +11 -0
  285. package/src/services/accounts/base-user.ts +46 -0
  286. package/src/services/accounts/duplicate-emails.ts +82 -0
  287. package/src/services/accounts/entities.ts +622 -0
  288. package/src/services/accounts/group-sql.ts +106 -0
  289. package/src/services/accounts/groups.ts +272 -0
  290. package/src/services/accounts/index.ts +14 -0
  291. package/src/services/accounts/ipa-data.ts +66 -0
  292. package/src/services/accounts/lifecycle.ts +2 -0
  293. package/src/services/accounts/local-groups.ts +499 -0
  294. package/src/services/accounts/model.ts +129 -0
  295. package/src/services/accounts/notification-sender.ts +31 -0
  296. package/src/services/accounts/posix.ts +386 -0
  297. package/src/services/accounts/request-policy.ts +11 -0
  298. package/src/services/accounts/switching.ts +126 -0
  299. package/src/services/accounts/users.ts +745 -0
  300. package/src/services/announcements/index.ts +224 -0
  301. package/src/services/app-approval.ts +498 -0
  302. package/src/services/audit/index.ts +438 -0
  303. package/src/services/auth-flows/index.ts +15 -0
  304. package/src/services/auth-flows/ipa.ts +197 -0
  305. package/src/services/auth-flows/magic-link.ts +185 -0
  306. package/src/services/auth-flows/notification-sender.ts +10 -0
  307. package/src/services/auth-flows/password-reset.ts +268 -0
  308. package/src/services/auth-flows/proxy-return.ts +49 -0
  309. package/src/services/document-extraction.ts +148 -0
  310. package/src/services/freeipa-config.ts +179 -0
  311. package/src/services/gateway.ts +186 -0
  312. package/src/services/identity/constants.ts +18 -0
  313. package/src/services/identity/index.ts +52 -0
  314. package/src/services/identity/invocation-actor.ts +120 -0
  315. package/src/services/identity/invocation-authority.ts +53 -0
  316. package/src/services/identity/invocation-operations.ts +15 -0
  317. package/src/services/identity/invocation-token.ts +276 -0
  318. package/src/services/identity/key-config.ts +41 -0
  319. package/src/services/identity/key-ring.ts +530 -0
  320. package/src/services/identity/metrics.ts +29 -0
  321. package/src/services/identity/routes.ts +18 -0
  322. package/src/services/identity/runtime-config.ts +88 -0
  323. package/src/services/identity/session-token.ts +152 -0
  324. package/src/services/identity/signer-refresh.ts +44 -0
  325. package/src/services/identity/workload-auth.ts +64 -0
  326. package/src/services/index.ts +187 -0
  327. package/src/services/ipa/auth.ts +216 -0
  328. package/src/services/ipa/connection.ts +57 -0
  329. package/src/services/ipa/effective-groups.ts +70 -0
  330. package/src/services/ipa/groups.ts +722 -0
  331. package/src/services/ipa/guard.ts +17 -0
  332. package/src/services/ipa/index.ts +17 -0
  333. package/src/services/ipa/posix.ts +19 -0
  334. package/src/services/ipa/profile.ts +132 -0
  335. package/src/services/ipa/runtime.ts +28 -0
  336. package/src/services/ipa/search.ts +154 -0
  337. package/src/services/ipa/service-account.ts +15 -0
  338. package/src/services/ipa/sync-planning.ts +100 -0
  339. package/src/services/ipa/sync.ts +862 -0
  340. package/src/services/ipa/users.ts +806 -0
  341. package/src/services/legal-consent.ts +76 -0
  342. package/src/services/logging/index.ts +477 -0
  343. package/src/services/logging/redaction.ts +20 -0
  344. package/src/services/logging/trace.ts +1231 -0
  345. package/src/services/mandates/index.ts +855 -0
  346. package/src/services/mandates/policy.ts +85 -0
  347. package/src/services/network-security.ts +31 -0
  348. package/src/services/notifications/batches.ts +722 -0
  349. package/src/services/notifications/browser.ts +252 -0
  350. package/src/services/notifications/catalog.ts +113 -0
  351. package/src/services/notifications/channels.ts +104 -0
  352. package/src/services/notifications/dispatcher.ts +139 -0
  353. package/src/services/notifications/email.ts +118 -0
  354. package/src/services/notifications/index.ts +633 -0
  355. package/src/services/notifications/live.ts +50 -0
  356. package/src/services/notifications/observability.ts +407 -0
  357. package/src/services/notifications/platform.ts +421 -0
  358. package/src/services/notifications/runtime.ts +120 -0
  359. package/src/services/notifications/user.ts +248 -0
  360. package/src/services/notifications/web-push-transport.ts +135 -0
  361. package/src/services/oauth-tokens.ts +209 -0
  362. package/src/services/outbox.ts +187 -0
  363. package/src/services/pdf/gotenberg.ts +290 -0
  364. package/src/services/pdf/index.ts +43 -0
  365. package/src/services/pdf/markdown.ts +209 -0
  366. package/src/services/pdf/template-preview.ts +122 -0
  367. package/src/services/postgres.ts +76 -0
  368. package/src/services/providers/index.ts +27 -0
  369. package/src/services/providers/local/auth.ts +51 -0
  370. package/src/services/providers/local/index.ts +4 -0
  371. package/src/services/providers/local/users.ts +263 -0
  372. package/src/services/rail-preferences.ts +27 -0
  373. package/src/services/runtime-lifecycle.ts +191 -0
  374. package/src/services/secrets.ts +10 -0
  375. package/src/services/service-account-credentials.ts +891 -0
  376. package/src/services/service-accounts.ts +184 -0
  377. package/src/services/session/index.ts +236 -0
  378. package/src/services/session/test-fixture.ts +18 -0
  379. package/src/services/session/user.ts +144 -0
  380. package/src/services/settings/api.ts +61 -0
  381. package/src/services/settings/app.ts +86 -0
  382. package/src/services/settings/core-settings.ts +796 -0
  383. package/src/services/settings/crypto.ts +69 -0
  384. package/src/services/settings/defaults.ts +374 -0
  385. package/src/services/settings/index.ts +215 -0
  386. package/src/services/settings/namespace.ts +9 -0
  387. package/src/services/settings/snapshot.ts +49 -0
  388. package/src/services/settings/store.ts +241 -0
  389. package/src/services/settings/templates.ts +4 -0
  390. package/src/services/sync-ops.ts +403 -0
  391. package/src/services/topic-cursor.ts +22 -0
  392. package/src/services/weather/forecast.ts +323 -0
  393. package/src/services/weather/geo.ts +119 -0
  394. package/src/services/weather/index.ts +67 -0
  395. package/src/services/weather/location.ts +24 -0
  396. package/src/services/weather/locations.ts +133 -0
  397. package/src/services/weather/migrate.ts +45 -0
  398. package/src/services/weather/settings.ts +66 -0
  399. package/src/services/weather/short-id.ts +57 -0
  400. package/src/services/weather/types.ts +61 -0
  401. package/src/services/weather/ui.ts +50 -0
  402. package/src/services/webauthn.ts +385 -0
  403. package/src/shared/account-action-notice.ts +70 -0
  404. package/src/shared/account-display.ts +15 -0
  405. package/src/shared/account-session.ts +15 -0
  406. package/src/shared/ai-platform-prompt.ts +134 -0
  407. package/src/shared/ai-usage.ts +49 -0
  408. package/src/shared/app-presentation.ts +48 -0
  409. package/src/shared/app-url.ts +12 -0
  410. package/src/shared/branding.ts +14 -0
  411. package/src/shared/capability-messages.ts +70 -0
  412. package/src/shared/email-html.ts +54 -0
  413. package/src/shared/format.ts +91 -0
  414. package/src/shared/help.ts +68 -0
  415. package/src/shared/icons.ts +400 -0
  416. package/src/shared/index.ts +54 -0
  417. package/src/shared/locale.ts +48 -0
  418. package/src/shared/login-method.ts +44 -0
  419. package/src/shared/markdown/client.ts +130 -0
  420. package/src/shared/markdown/extensions/code.ts +105 -0
  421. package/src/shared/markdown/extensions/guided-help.ts +100 -0
  422. package/src/shared/markdown/extensions/images.ts +79 -0
  423. package/src/shared/markdown/extensions/info-blocks.ts +97 -0
  424. package/src/shared/markdown/extensions/katex.ts +120 -0
  425. package/src/shared/markdown/extensions/links.ts +43 -0
  426. package/src/shared/markdown/extensions/mark.ts +48 -0
  427. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  428. package/src/shared/markdown/extensions/tables.ts +109 -0
  429. package/src/shared/markdown/extensions/task-list.ts +53 -0
  430. package/src/shared/markdown/formula.ts +1187 -0
  431. package/src/shared/markdown/index.ts +193 -0
  432. package/src/shared/markdown/shared.ts +36 -0
  433. package/src/shared/mock-cover.ts +130 -0
  434. package/src/shared/network-address.ts +106 -0
  435. package/src/shared/redirect.ts +56 -0
  436. package/src/shared/template-rendering.ts +140 -0
  437. package/src/shared/theme.ts +68 -0
  438. package/src/shared/time.ts +13 -0
  439. package/src/ssr/AdminLayout.tsx +53 -0
  440. package/src/ssr/AdminSidebar.tsx +50 -0
  441. package/src/ssr/AppLaunchpad.island.tsx +236 -0
  442. package/src/ssr/AppWorkspaceController.island.tsx +21 -0
  443. package/src/ssr/BrowserNotifications.island.tsx +135 -0
  444. package/src/ssr/GlobalAnnouncements.island.tsx +142 -0
  445. package/src/ssr/GlobalSearchDialog.behavior.test.tsx +96 -0
  446. package/src/ssr/GlobalSearchDialog.tsx +35 -0
  447. package/src/ssr/GlobalSearchHelpDialog.tsx +104 -0
  448. package/src/ssr/GlobalSearchTrigger.island.tsx +55 -0
  449. package/src/ssr/HotkeysHelpRail.island.tsx +55 -0
  450. package/src/ssr/Layout.render.test.tsx +148 -0
  451. package/src/ssr/Layout.tsx +292 -0
  452. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  453. package/src/ssr/LayoutFooter.tsx +22 -0
  454. package/src/ssr/LayoutHeader.tsx +81 -0
  455. package/src/ssr/LayoutHelp.tsx +869 -0
  456. package/src/ssr/LayoutPreferences.island.tsx +36 -0
  457. package/src/ssr/LayoutRail.tsx +58 -0
  458. package/src/ssr/MinimalLayout.tsx +46 -0
  459. package/src/ssr/PageError.tsx +29 -0
  460. package/src/ssr/ProfilePreferences.island.tsx +92 -0
  461. package/src/ssr/RailApps.island.tsx +61 -0
  462. package/src/ssr/RailEditor.tsx +293 -0
  463. package/src/ssr/RegisteredHelpDocuments.island.tsx +3 -0
  464. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  465. package/src/ssr/admin-active-link.ts +47 -0
  466. package/src/ssr/admin-navigation.ts +78 -0
  467. package/src/ssr/app-appearance.ts +23 -0
  468. package/src/ssr/app-navigation.ts +41 -0
  469. package/src/ssr/help-messages.ts +104 -0
  470. package/src/ssr/index.ts +14 -0
  471. package/src/ssr/islands/SearchBar.island.tsx +80 -0
  472. package/src/ssr/islands/index.ts +16 -0
  473. package/src/ssr/layout-context.ts +17 -0
  474. package/src/ssr/layout-help-markdown.ts +9 -0
  475. package/src/ssr/layout-help-navigation.ts +24 -0
  476. package/src/ssr/layout-help-url.ts +5 -0
  477. package/src/ssr/layout-runtime.ts +22 -0
  478. package/src/ssr/page-error-messages.ts +25 -0
  479. package/src/ssr/platform-messages.ts +135 -0
  480. package/src/ssr/preference-controller.ts +44 -0
  481. package/src/ssr/profile-preferences-messages.ts +29 -0
  482. package/src/ssr/rail-context.ts +21 -0
  483. package/src/ssr/rail-messages.ts +66 -0
  484. package/src/ssr/rail-navigation.ts +60 -0
  485. package/src/ssr/runtime.ts +31 -0
  486. package/src/ssr/url-filter.ts +127 -0
  487. package/src/styles/base-popover.css +36 -0
  488. package/src/styles/effects.css +851 -0
  489. package/src/styles/global.css +268 -0
  490. package/src/styles/input.css +58 -0
  491. package/src/styles/tokens.css +230 -0
  492. package/src/styles/utilities-buttons.css +405 -0
  493. package/src/styles/utilities-data.css +33 -0
  494. package/src/styles/utilities-feedback.css +232 -0
  495. package/src/styles/utilities-layout.css +603 -0
  496. package/src/styles/utilities-markdown-editor.css +525 -0
  497. package/src/styles/utilities-markdown-table.css +234 -0
  498. package/src/styles/utilities-navigation.css +1054 -0
  499. package/src/types/ambient.d.ts +24 -0
  500. package/src/workflows/ai/index.ts +17 -0
  501. package/src/workflows/ai/runtime.ts +286 -0
  502. package/src/workflows/ai/store.ts +314 -0
  503. package/src/workflows/ai/types.ts +111 -0
  504. package/src/workflows/ai-actions.ts +269 -0
  505. package/src/workflows/authoring.ts +88 -0
  506. package/src/workflows/builtins.ts +193 -0
  507. package/src/workflows/contracts.ts +280 -0
  508. package/src/workflows/definition.ts +334 -0
  509. package/src/workflows/editor.ts +113 -0
  510. package/src/workflows/index.ts +71 -0
  511. package/src/workflows/language/binder.ts +45 -0
  512. package/src/workflows/language/canonical.ts +29 -0
  513. package/src/workflows/language/compiler.ts +396 -0
  514. package/src/workflows/language/expressions.ts +34 -0
  515. package/src/workflows/language/index.ts +20 -0
  516. package/src/workflows/language/references.ts +55 -0
  517. package/src/workflows/language/schema.ts +134 -0
  518. package/src/workflows/language/strict-yaml.ts +131 -0
  519. package/src/workflows/manifest.ts +24 -0
  520. package/src/workflows/module.ts +12 -0
  521. package/src/workflows/runtime/coordinator.ts +115 -0
  522. package/src/workflows/runtime/executor.ts +1003 -0
  523. package/src/workflows/runtime/index.ts +60 -0
  524. package/src/workflows/runtime/instant.ts +8 -0
  525. package/src/workflows/runtime/ports.ts +213 -0
  526. package/src/workflows/runtime/schedule.ts +203 -0
  527. package/src/workflows/runtime/trigger.ts +37 -0
  528. package/src/workflows/runtime/wake.ts +13 -0
  529. package/src/workflows/store/actions.ts +438 -0
  530. package/src/workflows/store/budget.ts +145 -0
  531. package/src/workflows/store/definitions.ts +345 -0
  532. package/src/workflows/store/events.ts +383 -0
  533. package/src/workflows/store/index.ts +107 -0
  534. package/src/workflows/store/observability.ts +594 -0
  535. package/src/workflows/store/runs.ts +975 -0
  536. package/src/workflows/store/transaction.ts +23 -0
  537. package/src/workflows/store/worker.ts +439 -0
  538. package/src/workflows/testing/conformance.ts +72 -0
  539. package/src/workflows/testing/index.ts +11 -0
  540. package/src/workflows/testing/process-fixtures.ts +275 -0
@@ -0,0 +1,1671 @@
1
+ import { sql } from "bun";
2
+ import { accountCategory } from "../../contracts/account-categories";
3
+ import type {
4
+ BaseGroup,
5
+ BaseUser,
6
+ EntityKind,
7
+ EntityListItem,
8
+ GroupMember,
9
+ MutationResult,
10
+ User,
11
+ UserProfile,
12
+ UserProvider,
13
+ } from "../../contracts/shared";
14
+ import {
15
+ err,
16
+ fail,
17
+ ok,
18
+ type PageParams,
19
+ type Paginated,
20
+ paginate,
21
+ paginateItems,
22
+ type Result,
23
+ type ServiceError,
24
+ } from "../../server/services";
25
+ import { renderAccountActionNotice } from "../../shared/account-action-notice";
26
+ import { isAccountCategoryAllowed } from "../account-category-policy";
27
+ import { accountLifecycle } from "../account-lifecycle";
28
+ import { lifecycleJobs } from "../account-lifecycle/scheduler";
29
+ import { type AuditActor, type AuditTarget, audit } from "../audit";
30
+ import { getFreeIpaConfig } from "../freeipa-config";
31
+ import { type LogEntry, logger, logging } from "../logging";
32
+ import { isUniqueViolation } from "../postgres";
33
+ import { providers } from "../providers";
34
+ import { get as getSetting } from "../settings";
35
+ import { type AccountsActor, canMutateManagedGroup, hasOnlySelfUpdateFields, isAdminActor, isSelfTarget } from "./authz";
36
+ import { listDuplicateEmails } from "./duplicate-emails";
37
+ import * as entities from "./entities";
38
+ import * as groups from "./groups";
39
+ import type { AccountsNotificationSender } from "./notification-sender";
40
+ import { accountRequestsEnabled } from "./request-policy";
41
+ import * as users from "./users";
42
+
43
+ type CreateUserInput =
44
+ | {
45
+ provider: "ipa";
46
+ email: string;
47
+ givenname: string;
48
+ sn: string;
49
+ displayName?: string;
50
+ autoSendNotification?: boolean;
51
+ requestId?: string;
52
+ }
53
+ | {
54
+ provider: "local";
55
+ profile: UserProfile;
56
+ admin?: boolean;
57
+ email: string;
58
+ givenname: string;
59
+ sn: string;
60
+ displayName?: string;
61
+ autoSendNotification?: boolean;
62
+ requestId?: string;
63
+ };
64
+
65
+ type DbRow = Record<string, unknown>;
66
+ type MutationErrorStatus = Extract<MutationResult, { ok: false }>["status"];
67
+ type CreateUserResult = {
68
+ id: string;
69
+ uid: string;
70
+ accountExpires: string | null;
71
+ notificationSent: boolean;
72
+ creationNotice?: { markdown: string | null; failed: boolean };
73
+ };
74
+
75
+ export type AccountRequestStatus = "pending" | "completed" | "denied";
76
+ export type AccountRequestScope = "open" | "processed" | "all";
77
+
78
+ export type AccountRequest = {
79
+ id: string;
80
+ userId: string | null;
81
+ email: string;
82
+ firstName: string;
83
+ lastName: string;
84
+ displayName: string | null;
85
+ phone: string | null;
86
+ comment: string | null;
87
+ status: AccountRequestStatus;
88
+ createdAt: string;
89
+ };
90
+
91
+ export type AccountsDashboardSummary = {
92
+ ipaAccountsTotal: number;
93
+ localAccountsTotal: number;
94
+ localUserAccountsTotal: number;
95
+ localGuestAccountsTotal: number;
96
+ groupsTotal: number;
97
+ ipaGroupsTotal: number;
98
+ localGroupsTotal: number;
99
+ openRequests: number;
100
+ ipaExpiring30d: number;
101
+ localUserExpiring30d: number;
102
+ localGuestExpiring30d: number;
103
+ overdueLocalGuests: number;
104
+ reminderErrors: number;
105
+ deletedLast7d: number;
106
+ runHealthWindow: number;
107
+ recentSyncRuns: number;
108
+ recentSyncRunsWithFailures: number;
109
+ recentDemotionRuns: number;
110
+ recentDemotionRunsWithFailures: number;
111
+ recentReminderRuns: number;
112
+ recentReminderRunsWithFailures: number;
113
+ lastSync: {
114
+ createdAt: string;
115
+ users: number;
116
+ groups: number;
117
+ } | null;
118
+ };
119
+
120
+ const ACTIVITY_SOURCES = [
121
+ "auth:ipa:sync",
122
+ "auth:ipa:backfill",
123
+ "auth:local-user:backfill",
124
+ "auth:guest:backfill",
125
+ "auth:reminder:daily",
126
+ "auth:guest:cleanup",
127
+ "auth:lifecycle:scheduler",
128
+ ] as const;
129
+
130
+ const toServiceError = (status: MutationErrorStatus, message: string): ServiceError => {
131
+ if (status === 400) return err.badInput(message);
132
+ if (status === 401) return err.unauthenticated(message);
133
+ if (status === 403) return err.forbidden(message);
134
+ if (status === 404) return { code: "NOT_FOUND", message, status };
135
+ if (status === 409) return { code: "CONFLICT", message, status };
136
+ return err.internal(message);
137
+ };
138
+
139
+ const fromMutationResult = <T>(result: MutationResult<T>): Result<T> => {
140
+ if (result.ok) return ok(result.data);
141
+ return fail(toServiceError(result.status, result.error));
142
+ };
143
+
144
+ const mapAccountRequestRow = (row: DbRow): AccountRequest => ({
145
+ id: row.id as string,
146
+ userId: row.user_id as string | null,
147
+ email: (row.email as string) ?? "",
148
+ firstName: (row.first_name as string) ?? "",
149
+ lastName: (row.last_name as string) ?? "",
150
+ displayName: (row.display_name as string) ?? null,
151
+ phone: (row.phone as string) ?? null,
152
+ comment: row.comment as string | null,
153
+ status: row.status as AccountRequestStatus,
154
+ createdAt: (row.created_at as Date).toISOString(),
155
+ });
156
+
157
+ const mapSummary = (row: DbRow): AccountsDashboardSummary => {
158
+ const lastSyncCreatedAt = row.last_sync_created_at as Date | null;
159
+
160
+ return {
161
+ ipaAccountsTotal: Number(row.ipa_accounts_total ?? 0),
162
+ localAccountsTotal: Number(row.local_accounts_total ?? 0),
163
+ localUserAccountsTotal: Number(row.local_user_accounts_total ?? 0),
164
+ localGuestAccountsTotal: Number(row.local_guest_accounts_total ?? 0),
165
+ groupsTotal: Number(row.groups_total ?? 0),
166
+ ipaGroupsTotal: Number(row.ipa_groups_total ?? 0),
167
+ localGroupsTotal: Number(row.local_groups_total ?? 0),
168
+ openRequests: Number(row.open_requests ?? 0),
169
+ ipaExpiring30d: Number(row.ipa_expiring_30d ?? 0),
170
+ localUserExpiring30d: Number(row.local_user_expiring_30d ?? 0),
171
+ localGuestExpiring30d: Number(row.local_guest_expiring_30d ?? 0),
172
+ overdueLocalGuests: Number(row.overdue_local_guests ?? 0),
173
+ reminderErrors: Number(row.reminder_errors ?? 0),
174
+ deletedLast7d: Number(row.deleted_last_7d ?? 0),
175
+ runHealthWindow: Number(row.run_health_window ?? 10),
176
+ recentSyncRuns: Number(row.recent_sync_runs ?? 0),
177
+ recentSyncRunsWithFailures: Number(row.recent_sync_runs_with_failures ?? 0),
178
+ recentDemotionRuns: Number(row.recent_demotion_runs ?? 0),
179
+ recentDemotionRunsWithFailures: Number(row.recent_demotion_runs_with_failures ?? 0),
180
+ recentReminderRuns: Number(row.recent_reminder_runs ?? 0),
181
+ recentReminderRunsWithFailures: Number(row.recent_reminder_runs_with_failures ?? 0),
182
+ lastSync: lastSyncCreatedAt
183
+ ? {
184
+ createdAt: lastSyncCreatedAt.toISOString(),
185
+ users: Number(row.last_sync_users ?? 0),
186
+ groups: Number(row.last_sync_groups ?? 0),
187
+ }
188
+ : null,
189
+ };
190
+ };
191
+
192
+ const appLog = logger("accounts:app");
193
+
194
+ const auditActor = (actor: AccountsActor | null | undefined): AuditActor | null =>
195
+ actor
196
+ ? {
197
+ userId: actor.userId,
198
+ uid: actor.uid,
199
+ provider: actor.provider,
200
+ roles: actor.roles,
201
+ }
202
+ : null;
203
+
204
+ const userTarget = (user: { id?: string | null; uid?: string | null; provider?: string | null } | null | undefined): AuditTarget => ({
205
+ type: "user",
206
+ id: user?.id ?? null,
207
+ label: user?.uid ?? null,
208
+ provider: user?.provider ?? null,
209
+ });
210
+
211
+ const groupTarget = (group: { id?: string | null; name?: string | null; provider?: string | null } | null | undefined): AuditTarget => ({
212
+ type: "group",
213
+ id: group?.id ?? null,
214
+ label: group?.name ?? null,
215
+ provider: group?.provider ?? null,
216
+ });
217
+
218
+ const recordCompletedMutation = <T>(params: {
219
+ action: string;
220
+ actor?: AuditActor | null;
221
+ target?: AuditTarget | null;
222
+ metadata?: Record<string, unknown> | null;
223
+ result: Result<T>;
224
+ }) => (params.result.ok ? audit.recordResultAfterSideEffect(params) : audit.recordResult(params));
225
+
226
+ const requireAdminActor = async <T>(params: {
227
+ actor: AccountsActor | null | undefined;
228
+ action: string;
229
+ target?: AuditTarget;
230
+ }): Promise<Result<T> | null> => {
231
+ if (isAdminActor(params.actor)) return null;
232
+ return audit.deny<T>({
233
+ action: params.action,
234
+ actor: auditActor(params.actor),
235
+ target: params.target,
236
+ message: "Admin access required",
237
+ });
238
+ };
239
+
240
+ const requireDifferentActor = async <T>(params: {
241
+ actor: AccountsActor | null | undefined;
242
+ targetUserId: string;
243
+ action: string;
244
+ message: string;
245
+ target?: AuditTarget;
246
+ }): Promise<Result<T> | null> => {
247
+ if (!isSelfTarget({ actor: params.actor, targetUserId: params.targetUserId })) return null;
248
+ return audit.deny<T>({
249
+ action: params.action,
250
+ actor: auditActor(params.actor),
251
+ target: params.target,
252
+ message: params.message,
253
+ });
254
+ };
255
+
256
+ const authorizeGroupMutation = async <T>(params: {
257
+ actor: AccountsActor | null | undefined;
258
+ group: BaseGroup;
259
+ action: string;
260
+ }): Promise<Result<T> | null> => {
261
+ if (canMutateManagedGroup({ actor: params.actor, groupId: params.group.id, managedGroupIds: [] })) return null;
262
+ if (!params.actor) {
263
+ return audit.deny<T>({
264
+ action: params.action,
265
+ target: groupTarget(params.group),
266
+ message: "Access denied",
267
+ });
268
+ }
269
+ const managedGroupIds = await users.getManagedGroupIds({ id: params.actor.userId, recursive: true });
270
+ if (canMutateManagedGroup({ actor: params.actor, groupId: params.group.id, managedGroupIds })) return null;
271
+ return audit.deny<T>({
272
+ action: params.action,
273
+ actor: auditActor(params.actor),
274
+ target: groupTarget(params.group),
275
+ message: "Access denied",
276
+ });
277
+ };
278
+
279
+ const buildAccountRequestWhereClause = (config: {
280
+ access: { userId: string; isAdmin: boolean };
281
+ filter?: { status?: AccountRequestStatus; scope?: AccountRequestScope };
282
+ }) => {
283
+ if (!config.access.isAdmin) {
284
+ return sql`r.user_id = ${config.access.userId}::uuid AND r.status = 'pending'`;
285
+ }
286
+
287
+ if (config.filter?.status) {
288
+ return sql`r.status = ${config.filter.status}`;
289
+ }
290
+
291
+ const scope = config.filter?.scope ?? "open";
292
+ if (scope === "processed") return sql`r.status IN ('completed', 'denied')`;
293
+ if (scope === "all") return sql`TRUE`;
294
+ return sql`r.status = 'pending'`;
295
+ };
296
+
297
+ export const accountsAppService = {
298
+ user: {
299
+ listDuplicateEmails,
300
+ list: async (config: {
301
+ pagination?: PageParams;
302
+ filter?: { search?: string };
303
+ scope?: { ids?: string[]; uids?: string[]; provider?: UserProvider; profile?: UserProfile };
304
+ }): Promise<Paginated<BaseUser>> => {
305
+ const { page, perPage } = paginate(config.pagination);
306
+ const result = await users.list({
307
+ page,
308
+ perPage,
309
+ search: config.filter?.search,
310
+ ids: config.scope?.ids,
311
+ uids: config.scope?.uids,
312
+ provider: config.scope?.provider,
313
+ profile: config.scope?.profile,
314
+ });
315
+
316
+ return {
317
+ items: result.users,
318
+ page,
319
+ perPage,
320
+ total: result.total,
321
+ hasNext: result.pagination.has_next,
322
+ };
323
+ },
324
+ get: async (config: { id: string } | { uid: string }): Promise<User | null> => users.get(config),
325
+ getMinimal: async (config: { id: string } | { uid: string }) => users.getMinimal(config),
326
+ getAvatar: async (config: { id: string }) => users.getAvatar(config),
327
+ setAvatar: async (config: { actor: AccountsActor; id: string; dataUrl: string }) => {
328
+ const target = await users.getMinimal({ id: config.id });
329
+ const targetInfo = userTarget(target);
330
+ const selfService = config.actor.userId === config.id;
331
+ if (!selfService) {
332
+ const adminError = await requireAdminActor<{ avatarHash: string }>({
333
+ actor: config.actor,
334
+ action: "accounts.user.avatar.update",
335
+ target: targetInfo,
336
+ });
337
+ if (adminError) return adminError;
338
+ }
339
+ const result = fromMutationResult(await users.setAvatar({ id: config.id, dataUrl: config.dataUrl }));
340
+ return recordCompletedMutation({
341
+ action: "accounts.user.avatar.update",
342
+ actor: auditActor(config.actor),
343
+ target: targetInfo,
344
+ metadata: { selfService, avatarHash: result.ok ? result.data.avatarHash : null },
345
+ result,
346
+ });
347
+ },
348
+ clearAvatar: async (config: { actor: AccountsActor; id: string }) => {
349
+ const target = await users.getMinimal({ id: config.id });
350
+ const targetInfo = userTarget(target);
351
+ const selfService = config.actor.userId === config.id;
352
+ if (!selfService) {
353
+ const adminError = await requireAdminActor<void>({
354
+ actor: config.actor,
355
+ action: "accounts.user.avatar.clear",
356
+ target: targetInfo,
357
+ });
358
+ if (adminError) return adminError;
359
+ }
360
+ const result = fromMutationResult(await users.clearAvatar({ id: config.id }));
361
+ return recordCompletedMutation({
362
+ action: "accounts.user.avatar.clear",
363
+ actor: auditActor(config.actor),
364
+ target: targetInfo,
365
+ metadata: { selfService },
366
+ result,
367
+ });
368
+ },
369
+ group: {
370
+ list: async (config: {
371
+ userId: string;
372
+ recursive?: boolean;
373
+ pagination?: PageParams;
374
+ filter?: { query?: string };
375
+ }): Promise<Paginated<string>> => {
376
+ const userGroups = await users.getGroups({
377
+ id: config.userId,
378
+ recursive: config.recursive,
379
+ });
380
+ const query = config.filter?.query?.trim().toLowerCase();
381
+ const filtered = query ? userGroups.filter((groupName) => groupName.toLowerCase().includes(query)) : userGroups;
382
+ return paginateItems(filtered, config.pagination);
383
+ },
384
+ },
385
+ groupId: {
386
+ list: async (config: { userId: string; recursive?: boolean }): Promise<string[]> =>
387
+ users.getGroupIds({
388
+ id: config.userId,
389
+ recursive: config.recursive,
390
+ }),
391
+ },
392
+ managedGroup: {
393
+ list: async (config: {
394
+ userId: string;
395
+ recursive?: boolean;
396
+ pagination?: PageParams;
397
+ filter?: { query?: string };
398
+ }): Promise<Paginated<string>> => {
399
+ const managedGroups = await users.getManagedGroups({
400
+ id: config.userId,
401
+ recursive: config.recursive,
402
+ });
403
+ const query = config.filter?.query?.trim().toLowerCase();
404
+ const filtered = query ? managedGroups.filter((groupName) => groupName.toLowerCase().includes(query)) : managedGroups;
405
+ return paginateItems(filtered, config.pagination);
406
+ },
407
+ },
408
+ managedGroupId: {
409
+ /**
410
+ * Use this for authorization checks — names are only unique per provider,
411
+ * so comparing names across providers can grant incorrect access.
412
+ */
413
+ list: async (config: { userId: string; recursive?: boolean }): Promise<string[]> =>
414
+ users.getManagedGroupIds({
415
+ id: config.userId,
416
+ recursive: config.recursive,
417
+ }),
418
+ },
419
+ create: async (config: {
420
+ actor: AccountsActor;
421
+ data: CreateUserInput;
422
+ processedBy: string;
423
+ notificationSender: AccountsNotificationSender;
424
+ locale?: string;
425
+ }): Promise<Result<CreateUserResult>> => {
426
+ const adminError = await requireAdminActor<{ id: string; uid: string; accountExpires: string | null; notificationSent: boolean }>({
427
+ actor: config.actor,
428
+ action: "accounts.user.create",
429
+ target: { type: "user", label: config.data.email, provider: config.data.provider },
430
+ });
431
+ if (adminError) return adminError;
432
+ let requestCompletionFailed = false;
433
+ const createFromRequest = async () => {
434
+ const txResult = await sql.begin(async (tx) => {
435
+ const requestRows: DbRow[] = await tx`
436
+ SELECT r.id
437
+ FROM auth.account_requests r
438
+ JOIN auth.users u ON u.id = r.user_id
439
+ WHERE r.id = ${config.data.requestId}
440
+ AND r.status = 'pending'
441
+ AND lower(u.mail) = lower(${config.data.email})
442
+ LIMIT 1
443
+ FOR UPDATE OF r
444
+ `;
445
+ if (requestRows.length === 0) {
446
+ return {
447
+ completed: false,
448
+ result: fail(err.badInput("Account request not found, already processed, or not owned by the target email.")),
449
+ };
450
+ }
451
+
452
+ const result = fromMutationResult(
453
+ await users.create({
454
+ actor: auditActor(config.actor) ?? undefined,
455
+ data: {
456
+ ...config.data,
457
+ profile: config.data.provider === "ipa" ? "user" : config.data.profile,
458
+ admin: config.data.provider === "local" ? config.data.admin : undefined,
459
+ },
460
+ }),
461
+ );
462
+ if (!result.ok) return { completed: false, result };
463
+
464
+ const completedRows: DbRow[] = await tx`
465
+ UPDATE auth.account_requests
466
+ SET status = 'completed', processed_at = now(), processed_by = ${config.processedBy}
467
+ WHERE id = ${config.data.requestId}
468
+ AND user_id = ${result.data.user.id}::uuid
469
+ AND status = 'pending'
470
+ RETURNING id
471
+ `;
472
+
473
+ return { completed: completedRows.length > 0, result };
474
+ });
475
+
476
+ if (txResult.result.ok && !txResult.completed) {
477
+ requestCompletionFailed = true;
478
+ appLog.warn("Account request completion did not match", {
479
+ requestId: config.data.requestId,
480
+ createdUserId: txResult.result.data.user.id,
481
+ processedBy: config.processedBy,
482
+ });
483
+ await audit.recordResultAfterSideEffect({
484
+ action: "accounts.request.complete",
485
+ actor: auditActor(config.actor),
486
+ target: { type: "account_request", id: config.data.requestId },
487
+ metadata: { createdUserId: txResult.result.data.user.id, provider: config.data.provider },
488
+ result: fail(err.badInput("Account request not found, already processed, or not owned by the created user")),
489
+ });
490
+ }
491
+
492
+ return txResult.result;
493
+ };
494
+
495
+ const createResult = config.data.requestId
496
+ ? await createFromRequest()
497
+ : fromMutationResult(
498
+ await users.create({
499
+ actor: auditActor(config.actor) ?? undefined,
500
+ data: {
501
+ ...config.data,
502
+ profile: config.data.provider === "ipa" ? "user" : config.data.profile,
503
+ admin: config.data.provider === "local" ? config.data.admin : undefined,
504
+ },
505
+ }),
506
+ );
507
+ if (!createResult.ok) {
508
+ return audit.recordResult({
509
+ action: "accounts.user.create",
510
+ actor: auditActor(config.actor),
511
+ target: { type: "user", label: config.data.email, provider: config.data.provider },
512
+ metadata: { provider: config.data.provider, requestId: config.data.requestId ?? null },
513
+ result: createResult,
514
+ });
515
+ }
516
+
517
+ const created = createResult.data;
518
+
519
+ const autoSend = config.data.autoSendNotification ?? true;
520
+ let notificationSent = false;
521
+ if (autoSend && created.user.mail) {
522
+ try {
523
+ const delivery =
524
+ config.data.provider === "ipa" && created.temporaryPassword
525
+ ? await config.notificationSender.sendFreeIpaWelcome({
526
+ userId: created.user.id,
527
+ uid: created.user.uid,
528
+ temporaryPassword: created.temporaryPassword,
529
+ accountExpires: created.user.accountExpires,
530
+ locale: config.locale,
531
+ })
532
+ : config.data.provider === "local"
533
+ ? await config.notificationSender.sendLocalWelcome({
534
+ userId: created.user.id,
535
+ email: created.user.mail,
536
+ accountExpires: created.user.accountExpires,
537
+ locale: config.locale,
538
+ })
539
+ : null;
540
+ notificationSent = delivery?.status === "delivered" || delivery?.status === "queued";
541
+ if (delivery && !notificationSent) {
542
+ appLog.warn("Account welcome notification was not accepted", {
543
+ userId: created.user.id,
544
+ provider: config.data.provider,
545
+ notificationEventId: delivery.id,
546
+ notificationStatus: delivery.status,
547
+ });
548
+ }
549
+ } catch (error) {
550
+ appLog.error("Account welcome notification failed", {
551
+ userId: created.user.id,
552
+ provider: config.data.provider,
553
+ error: error instanceof Error ? error.message : String(error),
554
+ });
555
+ }
556
+ }
557
+
558
+ let creationNotice: { markdown: string | null; failed: boolean } = { markdown: null, failed: false };
559
+ try {
560
+ creationNotice.markdown = renderAccountActionNotice(await getSetting<string>("user.action_notice"), {
561
+ action: "user.create",
562
+ id: created.user.id,
563
+ name: created.user.uid,
564
+ uid: created.user.uid,
565
+ email: created.user.mail ?? "",
566
+ firstName: config.data.givenname,
567
+ lastName: config.data.sn,
568
+ provider: created.user.provider,
569
+ profile: created.user.profile,
570
+ category: accountCategory(created.user),
571
+ });
572
+ } catch {
573
+ creationNotice.failed = true;
574
+ appLog.warn("Account created, but administrator notice could not be rendered", { userId: created.user.id });
575
+ }
576
+ return audit.recordResultAfterSideEffect({
577
+ action: "accounts.user.create",
578
+ actor: auditActor(config.actor),
579
+ target: userTarget(created.user),
580
+ metadata: {
581
+ provider: config.data.provider,
582
+ requestId: config.data.requestId ?? null,
583
+ notificationSent,
584
+ requestCompletionFailed,
585
+ },
586
+ result: ok({
587
+ id: created.user.id,
588
+ uid: created.user.uid,
589
+ accountExpires: created.user.accountExpires,
590
+ notificationSent,
591
+ creationNotice,
592
+ }),
593
+ });
594
+ },
595
+ update: async (config: { actor: AccountsActor; id: string; data: Parameters<typeof users.update>[0]["data"] }) => {
596
+ const target = await users.getMinimal({ id: config.id });
597
+ const targetInfo = userTarget(target);
598
+ const selfService = config.actor.userId === config.id;
599
+ if (selfService && !hasOnlySelfUpdateFields(config.data as Record<string, unknown>)) {
600
+ return audit.recordResult({
601
+ action: "accounts.user.update",
602
+ actor: auditActor(config.actor),
603
+ target: targetInfo,
604
+ metadata: { changedFields: Object.keys(config.data), selfService },
605
+ result: fail(err.forbidden("Only admins can update account management fields.")),
606
+ });
607
+ }
608
+ if (!selfService) {
609
+ const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.user.update", target: targetInfo });
610
+ if (adminError) return adminError;
611
+ }
612
+ const result = fromMutationResult(await users.update(config));
613
+ return recordCompletedMutation({
614
+ action: "accounts.user.update",
615
+ actor: auditActor(config.actor),
616
+ target: targetInfo,
617
+ metadata: { changedFields: Object.keys(config.data), selfService },
618
+ result,
619
+ });
620
+ },
621
+ resetPassword: async (config: { actor: AccountsActor; id: string }) => {
622
+ const target = await users.getMinimal({ id: config.id });
623
+ const targetInfo = userTarget(target);
624
+ const adminError = await requireAdminActor<{ password: string }>({
625
+ actor: config.actor,
626
+ action: "accounts.user.password_reset",
627
+ target: targetInfo,
628
+ });
629
+ if (adminError) return adminError;
630
+ const selfError = await requireDifferentActor<{ password: string }>({
631
+ actor: config.actor,
632
+ targetUserId: config.id,
633
+ action: "accounts.user.password_reset",
634
+ target: targetInfo,
635
+ message: "You cannot reset your own password from the admin users API.",
636
+ });
637
+ if (selfError) return selfError;
638
+ const result = fromMutationResult(await users.resetPassword(config));
639
+ return recordCompletedMutation({
640
+ action: "accounts.user.password_reset",
641
+ actor: auditActor(config.actor),
642
+ target: targetInfo,
643
+ result: result.ok ? ok({ password: "[REDACTED]" }) : result,
644
+ }).then(() => result);
645
+ },
646
+ setExpiry: async (config: { actor: AccountsActor; id: string; expiryDate: string | null }) => {
647
+ const target = await users.getMinimal({ id: config.id });
648
+ const targetInfo = userTarget(target);
649
+ const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.user.set_expiry", target: targetInfo });
650
+ if (adminError) return adminError;
651
+ const result = fromMutationResult(await users.setExpiry(config));
652
+ return recordCompletedMutation({
653
+ action: "accounts.user.set_expiry",
654
+ actor: auditActor(config.actor),
655
+ target: targetInfo,
656
+ metadata: { expiryDate: config.expiryDate },
657
+ result,
658
+ });
659
+ },
660
+ setProfile: async (config: { actor: AccountsActor; id: string; profile: UserProfile }) => {
661
+ const target = await users.getMinimal({ id: config.id });
662
+ const targetInfo = userTarget(target);
663
+ const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.user.set_profile", target: targetInfo });
664
+ if (adminError) return adminError;
665
+ const selfError =
666
+ config.profile === "guest"
667
+ ? await requireDifferentActor<void>({
668
+ actor: config.actor,
669
+ targetUserId: config.id,
670
+ action: "accounts.user.set_profile",
671
+ target: targetInfo,
672
+ message: "You cannot demote your own account to guest.",
673
+ })
674
+ : null;
675
+ if (selfError) return selfError;
676
+ const result = fromMutationResult(await users.setProfile({ ...config, actor: auditActor(config.actor) ?? undefined }));
677
+ return recordCompletedMutation({
678
+ action: "accounts.user.set_profile",
679
+ actor: auditActor(config.actor),
680
+ target: targetInfo,
681
+ metadata: { profile: config.profile },
682
+ result,
683
+ });
684
+ },
685
+ setAdmin: async (config: { actor: AccountsActor; id: string; admin: boolean }) => {
686
+ const target = await users.getMinimal({ id: config.id });
687
+ const targetInfo = userTarget(target);
688
+ const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.user.set_admin", target: targetInfo });
689
+ if (adminError) return adminError;
690
+ const result = fromMutationResult(await users.setAdmin(config));
691
+ return recordCompletedMutation({
692
+ action: "accounts.user.set_admin",
693
+ actor: auditActor(config.actor),
694
+ target: targetInfo,
695
+ metadata: { admin: config.admin },
696
+ result,
697
+ });
698
+ },
699
+ switchProvider: async (config: { actor: AccountsActor; id: string; provider: UserProvider }) => {
700
+ const target = await users.getMinimal({ id: config.id });
701
+ const targetInfo = userTarget(target);
702
+ const adminError = await requireAdminActor<void>({
703
+ actor: config.actor,
704
+ action: "accounts.user.switch_provider",
705
+ target: targetInfo,
706
+ });
707
+ if (adminError) return adminError;
708
+ const selfError = await requireDifferentActor<void>({
709
+ actor: config.actor,
710
+ targetUserId: config.id,
711
+ action: "accounts.user.switch_provider",
712
+ target: targetInfo,
713
+ message: "You cannot switch your own account provider.",
714
+ });
715
+ if (selfError) return selfError;
716
+ const result = fromMutationResult(await users.switchProvider(config));
717
+ return recordCompletedMutation({
718
+ action: "accounts.user.switch_provider",
719
+ actor: auditActor(config.actor),
720
+ target: targetInfo,
721
+ metadata: { provider: config.provider },
722
+ result,
723
+ });
724
+ },
725
+ demoteToGuest: async (config: { actor: AccountsActor; id: string }) => {
726
+ const target = await users.getMinimal({ id: config.id });
727
+ const targetInfo = userTarget(target);
728
+ const adminError = await requireAdminActor<void>({
729
+ actor: config.actor,
730
+ action: "accounts.user.demote_to_guest",
731
+ target: targetInfo,
732
+ });
733
+ if (adminError) return adminError;
734
+ const selfError = await requireDifferentActor<void>({
735
+ actor: config.actor,
736
+ targetUserId: config.id,
737
+ action: "accounts.user.demote_to_guest",
738
+ target: targetInfo,
739
+ message: "You cannot demote your own account.",
740
+ });
741
+ if (selfError) return selfError;
742
+ const result = fromMutationResult(await users.demoteToGuest(config));
743
+ return recordCompletedMutation({
744
+ action: "accounts.user.demote_to_guest",
745
+ actor: auditActor(config.actor),
746
+ target: targetInfo,
747
+ result,
748
+ });
749
+ },
750
+ sendLoginLink: async (config: {
751
+ actor: AccountsActor;
752
+ id: string;
753
+ notificationSender: AccountsNotificationSender;
754
+ locale?: string;
755
+ }) => {
756
+ const target = await users.getMinimal({ id: config.id });
757
+ const adminError = await requireAdminActor<void>({
758
+ actor: config.actor,
759
+ action: "accounts.user.send_login_link",
760
+ target: userTarget(target),
761
+ });
762
+ if (adminError) return adminError;
763
+ const result = fromMutationResult(await users.sendLoginLink(config));
764
+ return recordCompletedMutation({
765
+ action: "accounts.user.send_login_link",
766
+ actor: auditActor(config.actor),
767
+ target: userTarget(target),
768
+ result,
769
+ });
770
+ },
771
+ createLoginToken: async (config: { actor: AccountsActor; id: string }) => {
772
+ const target = await users.getMinimal({ id: config.id });
773
+ const targetInfo = userTarget(target);
774
+ const adminError = await requireAdminActor<{ token: string; magicLink: string; expiresInSeconds: number }>({
775
+ actor: config.actor,
776
+ action: "accounts.user.create_login_token",
777
+ target: targetInfo,
778
+ });
779
+ if (adminError) return adminError;
780
+ const result = fromMutationResult(await users.createLoginToken(config));
781
+ await recordCompletedMutation({
782
+ action: "accounts.user.create_login_token",
783
+ actor: auditActor(config.actor),
784
+ target: targetInfo,
785
+ result: result.ok ? ok({ token: "[REDACTED]", magicLink: "[REDACTED]", expiresInSeconds: result.data.expiresInSeconds }) : result,
786
+ });
787
+ return result;
788
+ },
789
+ remove: async (config: { actor: AccountsActor; id: string }) => {
790
+ const target = await users.getMinimal({ id: config.id });
791
+ const targetInfo = userTarget(target);
792
+ const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.user.remove", target: targetInfo });
793
+ if (adminError) return adminError;
794
+ const selfError = await requireDifferentActor<void>({
795
+ actor: config.actor,
796
+ targetUserId: config.id,
797
+ action: "accounts.user.remove",
798
+ target: targetInfo,
799
+ message: "You cannot delete your own account.",
800
+ });
801
+ if (selfError) return selfError;
802
+ const result = fromMutationResult(await users.remove(config));
803
+ return recordCompletedMutation({
804
+ action: "accounts.user.remove",
805
+ actor: auditActor(config.actor),
806
+ target: targetInfo,
807
+ result,
808
+ });
809
+ },
810
+
811
+ /**
812
+ * Change an IPA user's own password. Verifies the current password via
813
+ * `providers.ipa.auth.login` and issues the change through the session
814
+ * returned by verification. Keeps this logic inside core — the accounts
815
+ * admin app is UI only and must not dispatch on provider or speak to
816
+ * FreeIPA directly.
817
+ */
818
+ changeOwnPassword: async (config: { user: User; currentPassword: string; newPassword: string }): Promise<Result<void>> => {
819
+ const actor = { userId: config.user.id, uid: config.user.uid, roles: config.user.roles, provider: config.user.provider };
820
+ if (!(await getFreeIpaConfig()).enabled) {
821
+ const result = fail(err.badInput("FreeIPA is disabled."));
822
+ return audit.recordResult({
823
+ action: "accounts.user.change_own_password",
824
+ actor: auditActor(actor),
825
+ target: userTarget(config.user),
826
+ result,
827
+ });
828
+ }
829
+ if (config.user.provider !== "ipa") {
830
+ const result = fail(err.badInput("Password change is only available for IPA accounts."));
831
+ return audit.recordResult({
832
+ action: "accounts.user.change_own_password",
833
+ actor: auditActor(actor),
834
+ target: userTarget(config.user),
835
+ result,
836
+ });
837
+ }
838
+
839
+ const verify = await providers.ipa.auth.login(config.user.uid, config.currentPassword);
840
+ if (verify.status !== "success") {
841
+ const result = fail(err.unauthenticated("Current password is incorrect."));
842
+ return audit.recordResult({
843
+ action: "accounts.user.change_own_password",
844
+ actor: auditActor(actor),
845
+ target: userTarget(config.user),
846
+ result,
847
+ });
848
+ }
849
+
850
+ const result = await providers.ipa.auth.changePassword({
851
+ ipaSession: verify.session,
852
+ uid: config.user.uid,
853
+ newPassword: config.newPassword,
854
+ });
855
+ const serviceResult = result.ok ? ok(undefined) : fail(toServiceError(result.status, result.error));
856
+ return recordCompletedMutation({
857
+ action: "accounts.user.change_own_password",
858
+ actor: auditActor(actor),
859
+ target: userTarget(config.user),
860
+ result: serviceResult,
861
+ });
862
+ },
863
+
864
+ /**
865
+ * Self-delete for the current user. Only guest profiles may self-delete;
866
+ * callers must enforce that before calling. Dispatches to the correct
867
+ * provider internally — callers should not branch on provider themselves.
868
+ */
869
+ removeSelf: async (config: { user: User }): Promise<Result<void>> => {
870
+ const actor = { userId: config.user.id, uid: config.user.uid, roles: config.user.roles, provider: config.user.provider };
871
+ if (config.user.profile !== "guest") {
872
+ const result = fail(err.forbidden("Only guest accounts can be self-deleted."));
873
+ return audit.recordResult({
874
+ action: "accounts.user.remove_self",
875
+ actor: auditActor(actor),
876
+ target: userTarget(config.user),
877
+ result,
878
+ });
879
+ }
880
+ if (config.user.provider === "ipa") {
881
+ const result = fromMutationResult(await users.remove({ id: config.user.id, actor }));
882
+ return recordCompletedMutation({
883
+ action: "accounts.user.remove_self",
884
+ actor: auditActor(actor),
885
+ target: userTarget(config.user),
886
+ result,
887
+ });
888
+ }
889
+ const result = fromMutationResult(await providers.local.users.remove({ id: config.user.id, actor }));
890
+ return recordCompletedMutation({
891
+ action: "accounts.user.remove_self",
892
+ actor: auditActor(actor),
893
+ target: userTarget(config.user),
894
+ result,
895
+ });
896
+ },
897
+ },
898
+
899
+ group: {
900
+ list: async (config: {
901
+ pagination?: PageParams;
902
+ filter?: { search?: string };
903
+ scope?: { userId?: string; ids?: string[]; provider?: UserProvider; mode?: "all" | "member" | "managed" };
904
+ }): Promise<Paginated<BaseGroup>> => {
905
+ const { page, perPage } = paginate(config.pagination);
906
+ const result = await groups.list({
907
+ page,
908
+ perPage,
909
+ search: config.filter?.search,
910
+ userId: config.scope?.userId,
911
+ scope: config.scope?.mode,
912
+ ids: config.scope?.ids,
913
+ provider: config.scope?.provider,
914
+ });
915
+
916
+ return {
917
+ items: result.groups,
918
+ page,
919
+ perPage,
920
+ total: result.total,
921
+ hasNext: result.pagination.hasNext,
922
+ };
923
+ },
924
+ get: async (config: { id: string }) => groups.get(config),
925
+ member: {
926
+ list: async (config: {
927
+ id: string;
928
+ recursive?: boolean;
929
+ pagination?: PageParams;
930
+ filter?: { query?: string; type?: GroupMember["type"] };
931
+ }): Promise<Paginated<GroupMember>> => {
932
+ const members = await groups.getMembers({
933
+ id: config.id,
934
+ recursive: config.recursive,
935
+ });
936
+ const query = config.filter?.query?.trim().toLowerCase();
937
+ const type = config.filter?.type;
938
+ const filtered = members.filter((member) => {
939
+ if (type && member.type !== type) return false;
940
+ if (!query) return true;
941
+ const id = member.id.toLowerCase();
942
+ const displayName = (member.displayName ?? "").toLowerCase();
943
+ return id.includes(query) || displayName.includes(query);
944
+ });
945
+ return paginateItems(filtered, config.pagination);
946
+ },
947
+ add: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; userId?: string; groupId?: string }) => {
948
+ const group = await groups.get({ id: config.id });
949
+ if (!group) {
950
+ const result = fail(err.notFound("Group not found"));
951
+ return audit.recordResult({
952
+ action: "accounts.group.member.add",
953
+ actor: auditActor(config.actor),
954
+ target: { type: "group", id: config.id },
955
+ result,
956
+ });
957
+ }
958
+ const accessError = await authorizeGroupMutation<void>({ actor: config.actor, group, action: "accounts.group.member.add" });
959
+ if (accessError) return accessError;
960
+ const result = fromMutationResult(
961
+ await groups.addMember({
962
+ id: config.id,
963
+ provider: config.provider,
964
+ user: config.userId,
965
+ group: config.groupId,
966
+ }),
967
+ );
968
+ return recordCompletedMutation({
969
+ action: "accounts.group.member.add",
970
+ actor: auditActor(config.actor),
971
+ target: groupTarget(group),
972
+ metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
973
+ result,
974
+ });
975
+ },
976
+ remove: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; userId?: string; groupId?: string }) => {
977
+ const group = await groups.get({ id: config.id });
978
+ if (!group) {
979
+ const result = fail(err.notFound("Group not found"));
980
+ return audit.recordResult({
981
+ action: "accounts.group.member.remove",
982
+ actor: auditActor(config.actor),
983
+ target: { type: "group", id: config.id },
984
+ result,
985
+ });
986
+ }
987
+ const accessError = await authorizeGroupMutation<void>({ actor: config.actor, group, action: "accounts.group.member.remove" });
988
+ if (accessError) return accessError;
989
+ const result = fromMutationResult(
990
+ await groups.removeMember({
991
+ id: config.id,
992
+ provider: config.provider,
993
+ user: config.userId,
994
+ group: config.groupId,
995
+ }),
996
+ );
997
+ return recordCompletedMutation({
998
+ action: "accounts.group.member.remove",
999
+ actor: auditActor(config.actor),
1000
+ target: groupTarget(group),
1001
+ metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
1002
+ result,
1003
+ });
1004
+ },
1005
+ },
1006
+ manager: {
1007
+ list: async (config: {
1008
+ id: string;
1009
+ pagination?: PageParams;
1010
+ filter?: { query?: string; type?: GroupMember["type"] };
1011
+ }): Promise<Paginated<GroupMember>> => {
1012
+ const managers = await groups.getManagers({ id: config.id });
1013
+ const query = config.filter?.query?.trim().toLowerCase();
1014
+ const type = config.filter?.type;
1015
+ const filtered = managers.filter((manager) => {
1016
+ if (type && manager.type !== type) return false;
1017
+ if (!query) return true;
1018
+ const id = manager.id.toLowerCase();
1019
+ const displayName = (manager.displayName ?? "").toLowerCase();
1020
+ return id.includes(query) || displayName.includes(query);
1021
+ });
1022
+ return paginateItems(filtered, config.pagination);
1023
+ },
1024
+ add: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; userId?: string; groupId?: string }) => {
1025
+ const group = await groups.get({ id: config.id });
1026
+ if (!group) {
1027
+ const result = fail(err.notFound("Group not found"));
1028
+ return audit.recordResult({
1029
+ action: "accounts.group.manager.add",
1030
+ actor: auditActor(config.actor),
1031
+ target: { type: "group", id: config.id },
1032
+ result,
1033
+ });
1034
+ }
1035
+ const accessError = await requireAdminActor<void>({
1036
+ actor: config.actor,
1037
+ action: "accounts.group.manager.add",
1038
+ target: groupTarget(group),
1039
+ });
1040
+ if (accessError) return accessError;
1041
+ const result = fromMutationResult(
1042
+ await groups.addManager({
1043
+ id: config.id,
1044
+ provider: config.provider,
1045
+ user: config.userId,
1046
+ group: config.groupId,
1047
+ }),
1048
+ );
1049
+ return recordCompletedMutation({
1050
+ action: "accounts.group.manager.add",
1051
+ actor: auditActor(config.actor),
1052
+ target: groupTarget(group),
1053
+ metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
1054
+ result,
1055
+ });
1056
+ },
1057
+ remove: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; userId?: string; groupId?: string }) => {
1058
+ const group = await groups.get({ id: config.id });
1059
+ if (!group) {
1060
+ const result = fail(err.notFound("Group not found"));
1061
+ return audit.recordResult({
1062
+ action: "accounts.group.manager.remove",
1063
+ actor: auditActor(config.actor),
1064
+ target: { type: "group", id: config.id },
1065
+ result,
1066
+ });
1067
+ }
1068
+ const accessError = await requireAdminActor<void>({
1069
+ actor: config.actor,
1070
+ action: "accounts.group.manager.remove",
1071
+ target: groupTarget(group),
1072
+ });
1073
+ if (accessError) return accessError;
1074
+ const result = fromMutationResult(
1075
+ await groups.removeManager({
1076
+ id: config.id,
1077
+ provider: config.provider,
1078
+ user: config.userId,
1079
+ group: config.groupId,
1080
+ }),
1081
+ );
1082
+ return recordCompletedMutation({
1083
+ action: "accounts.group.manager.remove",
1084
+ actor: auditActor(config.actor),
1085
+ target: groupTarget(group),
1086
+ metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
1087
+ result,
1088
+ });
1089
+ },
1090
+ },
1091
+ parent: {
1092
+ list: async (config: {
1093
+ id: string;
1094
+ recursive?: boolean;
1095
+ pagination?: PageParams;
1096
+ filter?: { query?: string };
1097
+ }): Promise<Paginated<string>> => {
1098
+ const parentGroups = await groups.getParents({
1099
+ id: config.id,
1100
+ recursive: config.recursive,
1101
+ });
1102
+ const query = config.filter?.query?.trim().toLowerCase();
1103
+ const filtered = query ? parentGroups.filter((groupId) => groupId.toLowerCase().includes(query)) : parentGroups;
1104
+ return paginateItems(filtered, config.pagination);
1105
+ },
1106
+ },
1107
+ managedGroup: {
1108
+ list: async (config: { id: string; pagination?: PageParams; filter?: { query?: string } }): Promise<Paginated<string>> => {
1109
+ const managedGroups = await groups.getManagedGroups({ id: config.id });
1110
+ const query = config.filter?.query?.trim().toLowerCase();
1111
+ const filtered = query ? managedGroups.filter((groupId) => groupId.toLowerCase().includes(query)) : managedGroups;
1112
+ return paginateItems(filtered, config.pagination);
1113
+ },
1114
+ },
1115
+ create: async (config: { actor: AccountsActor; provider: UserProvider; name: string; description?: string; posix?: boolean }) => {
1116
+ const adminError = await requireAdminActor<BaseGroup>({
1117
+ actor: config.actor,
1118
+ action: "accounts.group.create",
1119
+ target: { type: "group", label: config.name, provider: config.provider },
1120
+ });
1121
+ if (adminError) return adminError;
1122
+ const result = fromMutationResult(await groups.create(config));
1123
+ return recordCompletedMutation({
1124
+ action: "accounts.group.create",
1125
+ actor: auditActor(config.actor),
1126
+ target: result.ok ? groupTarget(result.data) : { type: "group", label: config.name, provider: config.provider },
1127
+ metadata: { posix: config.posix ?? false },
1128
+ result,
1129
+ });
1130
+ },
1131
+ update: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; description: string }) => {
1132
+ const group = await groups.get({ id: config.id });
1133
+ const target = groupTarget(group ?? { id: config.id, name: null, provider: config.provider ?? null });
1134
+ const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.group.update", target });
1135
+ if (adminError) return adminError;
1136
+ const result = fromMutationResult(await groups.update(config));
1137
+ return recordCompletedMutation({
1138
+ action: "accounts.group.update",
1139
+ actor: auditActor(config.actor),
1140
+ target,
1141
+ metadata: { changedFields: ["description"] },
1142
+ result,
1143
+ });
1144
+ },
1145
+ remove: async (config: { actor: AccountsActor; id: string; provider?: UserProvider }) => {
1146
+ const group = await groups.get({ id: config.id });
1147
+ const target = groupTarget(group ?? { id: config.id, name: null, provider: config.provider ?? null });
1148
+ const adminError = await requireAdminActor<void>({ actor: config.actor, action: "accounts.group.remove", target });
1149
+ if (adminError) return adminError;
1150
+ const result = fromMutationResult(await groups.remove(config));
1151
+ return recordCompletedMutation({
1152
+ action: "accounts.group.remove",
1153
+ actor: auditActor(config.actor),
1154
+ target,
1155
+ result,
1156
+ });
1157
+ },
1158
+ makePosix: async (config: { actor: AccountsActor; id: string; provider?: UserProvider }) => {
1159
+ const group = await groups.get({ id: config.id });
1160
+ const target = groupTarget(group ?? { id: config.id, name: null, provider: config.provider ?? null });
1161
+ const adminError = await requireAdminActor<{ gidnumber: number | null }>({
1162
+ actor: config.actor,
1163
+ action: "accounts.group.make_posix",
1164
+ target,
1165
+ });
1166
+ if (adminError) return adminError;
1167
+ const result = fromMutationResult(await groups.makePosix(config));
1168
+ return recordCompletedMutation({
1169
+ action: "accounts.group.make_posix",
1170
+ actor: auditActor(config.actor),
1171
+ target,
1172
+ result,
1173
+ });
1174
+ },
1175
+ },
1176
+ entity: {
1177
+ list: async (config: {
1178
+ actor: AccountsActor;
1179
+ pagination?: PageParams;
1180
+ search?: string;
1181
+ kinds?: EntityKind[];
1182
+ provider?: UserProvider;
1183
+ profile?: UserProfile;
1184
+ userIds?: string[];
1185
+ groupIds?: string[];
1186
+ excludeUserIds?: string[];
1187
+ excludeGroupIds?: string[];
1188
+ excludeServiceAccountIds?: string[];
1189
+ userMemberOfGroupIds?: string[];
1190
+ memberOfGroupId?: string;
1191
+ managerOfGroupId?: string;
1192
+ parentGroupId?: string;
1193
+ managedByUserId?: string;
1194
+ recursive?: boolean;
1195
+ }): Promise<Paginated<EntityListItem>> => {
1196
+ const canSearchDirectory = config.actor.roles.includes("user");
1197
+ const usesRelationFilter = Boolean(
1198
+ config.userMemberOfGroupIds?.length ||
1199
+ config.memberOfGroupId ||
1200
+ config.managerOfGroupId ||
1201
+ config.parentGroupId ||
1202
+ config.managedByUserId,
1203
+ );
1204
+ if (!canSearchDirectory && usesRelationFilter) {
1205
+ throw new Error("Guest accounts cannot use entity relation filters");
1206
+ }
1207
+ const { page, perPage } = paginate(config.pagination);
1208
+ const result = await entities.list({
1209
+ visibility: canSearchDirectory ? { type: "directory" } : { type: "self_and_effective_groups", userId: config.actor.userId },
1210
+ search: config.search,
1211
+ kinds: config.kinds,
1212
+ provider: config.provider,
1213
+ profile: config.profile,
1214
+ userIds: config.userIds,
1215
+ groupIds: config.groupIds,
1216
+ excludeUserIds: config.excludeUserIds,
1217
+ excludeGroupIds: config.excludeGroupIds,
1218
+ excludeServiceAccountIds: config.excludeServiceAccountIds,
1219
+ userMemberOfGroupIds: config.userMemberOfGroupIds,
1220
+ memberOfGroupId: config.memberOfGroupId,
1221
+ managerOfGroupId: config.managerOfGroupId,
1222
+ parentGroupId: config.parentGroupId,
1223
+ managedByUserId: config.managedByUserId,
1224
+ recursive: config.recursive,
1225
+ page,
1226
+ perPage,
1227
+ });
1228
+
1229
+ return {
1230
+ items: result.items,
1231
+ page,
1232
+ perPage,
1233
+ total: result.total,
1234
+ hasNext: result.pagination.hasNext,
1235
+ };
1236
+ },
1237
+ },
1238
+
1239
+ accountRequest: {
1240
+ isEnabled: accountRequestsEnabled,
1241
+ list: async (config: {
1242
+ access: { userId: string; isAdmin: boolean };
1243
+ pagination?: PageParams;
1244
+ filter?: { status?: AccountRequestStatus; scope?: AccountRequestScope };
1245
+ }): Promise<Paginated<AccountRequest>> => {
1246
+ const { page, perPage, offset } = paginate(config.pagination);
1247
+ const where = buildAccountRequestWhereClause(config);
1248
+ const rows = await sql`
1249
+ SELECT r.id, r.user_id, u.mail AS email, u.given_name AS first_name, u.sn AS last_name,
1250
+ u.display_name, r.phone, r.comment, r.status, r.created_at
1251
+ FROM auth.account_requests r
1252
+ JOIN auth.users u ON u.id = r.user_id
1253
+ WHERE ${where}
1254
+ ORDER BY r.created_at DESC
1255
+ LIMIT ${perPage}
1256
+ OFFSET ${offset}
1257
+ `;
1258
+
1259
+ const totalRows = await sql`
1260
+ SELECT COUNT(*)::int AS total
1261
+ FROM auth.account_requests r
1262
+ WHERE ${where}
1263
+ `;
1264
+
1265
+ const total = totalRows[0]?.total ?? 0;
1266
+ return {
1267
+ items: rows.map(mapAccountRequestRow),
1268
+ page,
1269
+ perPage,
1270
+ total,
1271
+ hasNext: page * perPage < total,
1272
+ };
1273
+ },
1274
+ get: async (config: { id: string; access: { userId: string; isAdmin: boolean } }) => {
1275
+ const rows: DbRow[] = await sql`
1276
+ SELECT r.id, r.user_id, u.mail AS email, u.given_name AS first_name, u.sn AS last_name,
1277
+ u.display_name, r.phone, r.comment, r.status, r.created_at
1278
+ FROM auth.account_requests r
1279
+ JOIN auth.users u ON u.id = r.user_id
1280
+ WHERE r.id = ${config.id}
1281
+ `;
1282
+
1283
+ if (rows.length === 0) {
1284
+ return fail(err.notFound("Request"));
1285
+ }
1286
+
1287
+ const request = rows[0]!;
1288
+ if (!config.access.isAdmin && request.user_id !== config.access.userId) {
1289
+ return fail(err.forbidden("Access denied"));
1290
+ }
1291
+
1292
+ return ok(mapAccountRequestRow(request));
1293
+ },
1294
+ getPendingForUser: async (config: { userId: string }): Promise<{ id: string; createdAt: Date } | null> => {
1295
+ const rows: DbRow[] = await sql`
1296
+ SELECT id, created_at FROM auth.account_requests
1297
+ WHERE user_id = ${config.userId} AND status = 'pending'
1298
+ LIMIT 1
1299
+ `;
1300
+ if (rows.length === 0) return null;
1301
+ return {
1302
+ id: rows[0]!.id as string,
1303
+ createdAt: rows[0]!.created_at as Date,
1304
+ };
1305
+ },
1306
+ create: async (config: {
1307
+ user: Pick<User, "id" | "uid" | "mail" | "provider" | "roles">;
1308
+ data: { phone?: string; comment?: string; acceptedAgb: true };
1309
+ }) => {
1310
+ const actor = { userId: config.user.id, uid: config.user.uid, roles: config.user.roles, provider: config.user.provider };
1311
+ if (!(await accountRequestsEnabled())) {
1312
+ return audit.recordResult({
1313
+ action: "accounts.request.create",
1314
+ actor: auditActor(actor),
1315
+ target: { type: "account_request" },
1316
+ result: fail(err.forbidden("Account requests are disabled")),
1317
+ });
1318
+ }
1319
+ if (!(await getFreeIpaConfig()).enabled || !(await isAccountCategoryAllowed({ provider: "ipa", profile: "user" }))) {
1320
+ const result = fail(err.badInput("FreeIPA access is disabled"));
1321
+ return audit.recordResult({
1322
+ action: "accounts.request.create",
1323
+ actor: auditActor(actor),
1324
+ target: { type: "account_request", label: config.user.mail },
1325
+ result,
1326
+ });
1327
+ }
1328
+ if (config.user.provider !== "local") {
1329
+ const result = fail(err.forbidden("Only local accounts can request IPA-backed access"));
1330
+ return audit.recordResult({
1331
+ action: "accounts.request.create",
1332
+ actor: auditActor(actor),
1333
+ target: { type: "account_request", label: config.user.mail },
1334
+ result,
1335
+ });
1336
+ }
1337
+ if (!config.user.mail) {
1338
+ const result = fail(err.badInput("Your account has no email address"));
1339
+ return audit.recordResult({
1340
+ action: "accounts.request.create",
1341
+ actor: auditActor(actor),
1342
+ target: { type: "account_request", id: config.user.id },
1343
+ result,
1344
+ });
1345
+ }
1346
+
1347
+ const existingRows: DbRow[] = await sql`
1348
+ SELECT id FROM auth.account_requests
1349
+ WHERE user_id = ${config.user.id} AND status = 'pending'
1350
+ `;
1351
+ if (existingRows.length > 0) {
1352
+ const result = fail({
1353
+ code: "CONFLICT",
1354
+ message: "You already have a pending account request",
1355
+ status: 409,
1356
+ });
1357
+ return audit.recordResult({
1358
+ action: "accounts.request.create",
1359
+ actor: auditActor(actor),
1360
+ target: { type: "account_request", label: config.user.mail },
1361
+ result,
1362
+ });
1363
+ }
1364
+
1365
+ try {
1366
+ const rows: DbRow[] = await sql`
1367
+ INSERT INTO auth.account_requests (id, user_id, phone, comment, accepted_agb)
1368
+ VALUES (gen_random_uuid(), ${config.user.id}, ${config.data.phone ?? null}, ${config.data.comment ?? null}, ${config.data.acceptedAgb})
1369
+ RETURNING id
1370
+ `;
1371
+
1372
+ const requestId = rows[0]!.id as string;
1373
+ const result = ok({
1374
+ id: requestId,
1375
+ message: "FreeIPA account request submitted",
1376
+ });
1377
+ return audit.recordResultAfterSideEffect({
1378
+ action: "accounts.request.create",
1379
+ actor: auditActor(actor),
1380
+ target: { type: "account_request", id: requestId, label: config.user.mail },
1381
+ metadata: { hasPhone: !!config.data.phone, hasComment: !!config.data.comment },
1382
+ result,
1383
+ });
1384
+ } catch (error) {
1385
+ // Belt-and-suspenders: the partial unique index
1386
+ // uq_account_requests_one_pending_per_user closes the race between the
1387
+ // check above and the insert under concurrent submissions.
1388
+ if (isUniqueViolation(error, "uq_account_requests_one_pending_per_user")) {
1389
+ const result = fail({
1390
+ code: "CONFLICT",
1391
+ message: "You already have a pending account request",
1392
+ status: 409,
1393
+ });
1394
+ return audit.recordResult({
1395
+ action: "accounts.request.create",
1396
+ actor: auditActor(actor),
1397
+ target: { type: "account_request", label: config.user.mail },
1398
+ result,
1399
+ });
1400
+ }
1401
+ throw error;
1402
+ }
1403
+ },
1404
+ withdraw: async (config: { id: string; actor: AccountsActor }) => {
1405
+ const deletedRows: DbRow[] = await sql`
1406
+ DELETE FROM auth.account_requests
1407
+ WHERE id = ${config.id}
1408
+ AND user_id = ${config.actor.userId}::uuid
1409
+ AND status = 'pending'
1410
+ RETURNING id
1411
+ `;
1412
+
1413
+ if (deletedRows.length > 0) {
1414
+ return audit.recordResultAfterSideEffect({
1415
+ action: "accounts.request.withdraw",
1416
+ actor: auditActor(config.actor),
1417
+ target: { type: "account_request", id: config.id },
1418
+ result: ok(),
1419
+ });
1420
+ }
1421
+
1422
+ const rows: DbRow[] = await sql`
1423
+ SELECT id, user_id, status FROM auth.account_requests WHERE id = ${config.id}
1424
+ `;
1425
+ if (rows.length === 0) {
1426
+ const result = fail(err.notFound("Request"));
1427
+ return audit.recordResult({
1428
+ action: "accounts.request.withdraw",
1429
+ actor: auditActor(config.actor),
1430
+ target: { type: "account_request", id: config.id },
1431
+ result,
1432
+ });
1433
+ }
1434
+ const request = rows[0]!;
1435
+ if (request.user_id !== config.actor.userId) {
1436
+ const result = fail(err.forbidden("Access denied"));
1437
+ return audit.recordResult({
1438
+ action: "accounts.request.withdraw",
1439
+ actor: auditActor(config.actor),
1440
+ target: { type: "account_request", id: config.id },
1441
+ result,
1442
+ });
1443
+ }
1444
+ if (request.status !== "pending") {
1445
+ const result = fail(err.forbidden("Only pending requests can be withdrawn"));
1446
+ return audit.recordResult({
1447
+ action: "accounts.request.withdraw",
1448
+ actor: auditActor(config.actor),
1449
+ target: { type: "account_request", id: config.id },
1450
+ result,
1451
+ });
1452
+ }
1453
+ const result = fail(err.conflict("Account request could not be withdrawn. Please retry."));
1454
+ return audit.recordResult({
1455
+ action: "accounts.request.withdraw",
1456
+ actor: auditActor(config.actor),
1457
+ target: { type: "account_request", id: config.id },
1458
+ result,
1459
+ });
1460
+ },
1461
+ deny: async (config: {
1462
+ id: string;
1463
+ reason?: string;
1464
+ actor: AccountsActor;
1465
+ notificationSender: AccountsNotificationSender;
1466
+ locale?: string;
1467
+ }) => {
1468
+ const adminError = await requireAdminActor<void>({
1469
+ actor: config.actor,
1470
+ action: "accounts.request.deny",
1471
+ target: { type: "account_request", id: config.id },
1472
+ });
1473
+ if (adminError) return adminError;
1474
+ const rows: DbRow[] = await sql`
1475
+ UPDATE auth.account_requests r
1476
+ SET status = 'denied', denied_reason = ${config.reason ?? null}, processed_at = now(), processed_by = ${config.actor.userId}
1477
+ FROM auth.users u
1478
+ WHERE r.id = ${config.id}
1479
+ AND r.status = 'pending'
1480
+ AND u.id = r.user_id
1481
+ RETURNING r.id, r.user_id, r.status, u.mail AS email, u.given_name AS first_name
1482
+ `;
1483
+
1484
+ if (rows.length === 0) {
1485
+ const currentRows: DbRow[] = await sql`
1486
+ SELECT id, status FROM auth.account_requests WHERE id = ${config.id}
1487
+ `;
1488
+ if (currentRows.length === 0) {
1489
+ const result = fail(err.notFound("Request"));
1490
+ return audit.recordResult({
1491
+ action: "accounts.request.deny",
1492
+ actor: auditActor(config.actor),
1493
+ target: { type: "account_request", id: config.id },
1494
+ result,
1495
+ });
1496
+ }
1497
+ const result = fail(err.badInput("Only pending requests can be denied"));
1498
+ return audit.recordResult({
1499
+ action: "accounts.request.deny",
1500
+ actor: auditActor(config.actor),
1501
+ target: { type: "account_request", id: config.id },
1502
+ result,
1503
+ });
1504
+ }
1505
+
1506
+ const request = rows[0]!;
1507
+
1508
+ let notificationStatus: string | null = null;
1509
+ if (config.reason) {
1510
+ try {
1511
+ const delivery = await config.notificationSender.sendRequestDenied({
1512
+ requestId: config.id,
1513
+ userId: request.user_id as string,
1514
+ firstName: request.first_name as string,
1515
+ reason: config.reason,
1516
+ sentBy: config.actor.userId,
1517
+ locale: config.locale,
1518
+ });
1519
+ notificationStatus = delivery.status;
1520
+ if (delivery.status === "error" || delivery.status === "suppressed") {
1521
+ appLog.warn("Account request denial notification was not accepted", {
1522
+ requestId: config.id,
1523
+ notificationEventId: delivery.id,
1524
+ notificationStatus: delivery.status,
1525
+ });
1526
+ }
1527
+ } catch (error) {
1528
+ notificationStatus = "error";
1529
+ appLog.error("Account request denial notification failed", {
1530
+ requestId: config.id,
1531
+ error: error instanceof Error ? error.message : String(error),
1532
+ });
1533
+ }
1534
+ }
1535
+
1536
+ return audit.recordResultAfterSideEffect({
1537
+ action: "accounts.request.deny",
1538
+ actor: auditActor(config.actor),
1539
+ target: { type: "account_request", id: config.id, label: request.email as string },
1540
+ metadata: { hasReason: !!config.reason, notificationStatus },
1541
+ result: ok(),
1542
+ });
1543
+ },
1544
+ },
1545
+
1546
+ dashboard: {
1547
+ get: async (): Promise<AccountsDashboardSummary> => {
1548
+ const rows = await sql<DbRow[]>`
1549
+ WITH latest_sync AS (
1550
+ SELECT created_at, metadata
1551
+ FROM logging.entries
1552
+ WHERE source = 'auth:ipa:sync'
1553
+ AND message = 'Sync complete'
1554
+ ORDER BY created_at DESC
1555
+ LIMIT 1
1556
+ ),
1557
+ recent_sync_runs AS (
1558
+ SELECT message, metadata
1559
+ FROM logging.entries
1560
+ WHERE source = 'auth:ipa:sync'
1561
+ AND message IN ('Sync complete', 'Sync step failed', 'Expired IPA demotion step failed')
1562
+ ORDER BY created_at DESC
1563
+ LIMIT 10
1564
+ ),
1565
+ recent_demotion_runs AS (
1566
+ SELECT metadata
1567
+ FROM logging.entries
1568
+ WHERE source = 'auth:ipa:sync'
1569
+ AND message = 'Expired IPA demotion complete'
1570
+ ORDER BY created_at DESC
1571
+ LIMIT 10
1572
+ ),
1573
+ recent_reminder_runs AS (
1574
+ SELECT metadata
1575
+ FROM logging.entries
1576
+ WHERE source = 'auth:reminder:daily'
1577
+ AND message = 'Reminder run complete'
1578
+ ORDER BY created_at DESC
1579
+ LIMIT 10
1580
+ )
1581
+ SELECT
1582
+ (SELECT COUNT(*)::int FROM auth.users WHERE provider = 'ipa') AS ipa_accounts_total,
1583
+ (SELECT COUNT(*)::int FROM auth.users WHERE provider = 'local') AS local_accounts_total,
1584
+ (SELECT COUNT(*)::int FROM auth.users WHERE provider = 'local' AND profile = 'user') AS local_user_accounts_total,
1585
+ (SELECT COUNT(*)::int FROM auth.users WHERE provider = 'local' AND profile = 'guest') AS local_guest_accounts_total,
1586
+ (SELECT COUNT(*)::int FROM auth.groups) AS groups_total,
1587
+ (SELECT COUNT(*)::int FROM auth.groups WHERE provider = 'ipa') AS ipa_groups_total,
1588
+ (SELECT COUNT(*)::int FROM auth.groups WHERE provider = 'local') AS local_groups_total,
1589
+ (SELECT COUNT(*)::int FROM auth.account_requests WHERE status = 'pending') AS open_requests,
1590
+ (
1591
+ SELECT COUNT(*)::int
1592
+ FROM auth.users
1593
+ WHERE provider = 'ipa'
1594
+ AND account_expires IS NOT NULL
1595
+ AND account_expires > now()
1596
+ AND account_expires <= now() + interval '30 days'
1597
+ ) AS ipa_expiring_30d,
1598
+ (
1599
+ SELECT COUNT(*)::int
1600
+ FROM auth.users
1601
+ WHERE provider = 'local'
1602
+ AND profile = 'guest'
1603
+ AND account_expires IS NOT NULL
1604
+ AND account_expires > now()
1605
+ AND account_expires <= now() + interval '30 days'
1606
+ ) AS local_guest_expiring_30d,
1607
+ (
1608
+ SELECT COUNT(*)::int
1609
+ FROM auth.users
1610
+ WHERE provider = 'local'
1611
+ AND profile = 'user'
1612
+ AND account_expires IS NOT NULL
1613
+ AND account_expires > now()
1614
+ AND account_expires <= now() + interval '30 days'
1615
+ ) AS local_user_expiring_30d,
1616
+ (
1617
+ SELECT COUNT(*)::int
1618
+ FROM auth.users
1619
+ WHERE provider = 'local'
1620
+ AND profile = 'guest'
1621
+ AND account_expires IS NOT NULL
1622
+ AND account_expires <= now()
1623
+ ) AS overdue_local_guests,
1624
+ (SELECT COUNT(*)::int FROM auth.account_lifecycle_reminders WHERE status = 'error') AS reminder_errors,
1625
+ (SELECT COUNT(*)::int FROM auth.deleted_accounts WHERE deleted_at >= now() - interval '7 days') AS deleted_last_7d,
1626
+ 10 AS run_health_window,
1627
+ (SELECT COUNT(*)::int FROM recent_sync_runs) AS recent_sync_runs,
1628
+ (
1629
+ SELECT COUNT(*)::int
1630
+ FROM recent_sync_runs
1631
+ WHERE message <> 'Sync complete'
1632
+ ) AS recent_sync_runs_with_failures,
1633
+ (SELECT COUNT(*)::int FROM recent_demotion_runs) AS recent_demotion_runs,
1634
+ (
1635
+ SELECT COUNT(*)::int
1636
+ FROM recent_demotion_runs
1637
+ WHERE COALESCE((metadata->>'failed')::int, 0) > 0
1638
+ ) AS recent_demotion_runs_with_failures,
1639
+ (SELECT COUNT(*)::int FROM recent_reminder_runs) AS recent_reminder_runs,
1640
+ (
1641
+ SELECT COUNT(*)::int
1642
+ FROM recent_reminder_runs
1643
+ WHERE COALESCE((metadata->>'failed')::int, 0) > 0
1644
+ ) AS recent_reminder_runs_with_failures,
1645
+ (SELECT created_at FROM latest_sync) AS last_sync_created_at,
1646
+ COALESCE((SELECT COALESCE((metadata->>'activeUsersSynced')::int, (metadata->>'users')::int) FROM latest_sync), 0) AS last_sync_users,
1647
+ COALESCE((SELECT COALESCE((metadata->>'groupsSynced')::int, (metadata->>'groups')::int) FROM latest_sync), 0) AS last_sync_groups
1648
+ `;
1649
+ return mapSummary(rows[0] ?? {});
1650
+ },
1651
+ activity: async (): Promise<LogEntry[]> => {
1652
+ const result = await logging.list({ page: 1, perPage: 15, offset: 0 }, { sources: [...ACTIVITY_SOURCES] });
1653
+ return result.entries;
1654
+ },
1655
+ },
1656
+
1657
+ lifecycle: {
1658
+ deletedAccounts: { list: accountLifecycle.listDeletedAccounts },
1659
+ reminders: { list: accountLifecycle.listReminderAudit },
1660
+ },
1661
+
1662
+ jobs: {
1663
+ runIpaBackfill: async (): Promise<string> => lifecycleJobs.submitIpaBackfill(),
1664
+ runLocalUserBackfill: async (): Promise<string> => lifecycleJobs.submitLocalUserBackfill(),
1665
+ runGuestBackfill: async (): Promise<string> => lifecycleJobs.submitGuestBackfill(),
1666
+ },
1667
+ } as const;
1668
+
1669
+ export type AccountsAppService = typeof accountsAppService;
1670
+ export type AccountsDashboardActivityEntry = LogEntry;
1671
+ export { ACTIVITY_SOURCES };