@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,940 @@
1
+ import { sql } from "bun";
2
+ import type { User } from "../../contracts/shared";
3
+ import { err, fail, freeipa, ok, type Result } from "../../server/services";
4
+ import { dates } from "../../shared";
5
+ import { getConfiguredExpiryDays, parseIpaAccountTransitionPolicy } from "../account-model";
6
+ import { applyIpaAccountTransitionPolicy } from "../accounts/switching";
7
+ import { audit } from "../audit";
8
+ import { getFreeIpaConfig } from "../freeipa-config";
9
+ import { buildEffectiveIpaGroupsByUid } from "../ipa/effective-groups";
10
+ import { getIpaUrl } from "../ipa/guard";
11
+ import { createIpaRuntimeCheckpoint, type IpaSyncRuntime } from "../ipa/runtime";
12
+ import { getServiceIpaSession } from "../ipa/service-account";
13
+ import { logger } from "../logging";
14
+ import { parsePgJsonRecord } from "../postgres";
15
+ import { providers } from "../providers";
16
+ import { session } from "../session";
17
+ import { get as getSetting } from "../settings";
18
+ import { writeDeletedAccountAudit } from "./audit";
19
+ import type { AccountLifecycleNotificationSender } from "./notification-sender";
20
+
21
+ const log = logger("auth:lifecycle");
22
+
23
+ type DbRow = Record<string, unknown>;
24
+
25
+ const DAY_MS = 24 * 60 * 60 * 1000;
26
+
27
+ type ReminderKind = "account_expiry";
28
+
29
+ type ReminderCandidate = {
30
+ userId: string;
31
+ uid: string;
32
+ mail: string;
33
+ givenName: string;
34
+ displayName: string;
35
+ expiresAt: Date;
36
+ kind: ReminderKind;
37
+ accountKind: "ipa" | "local-user" | "local-guest";
38
+ };
39
+
40
+ type LifecycleSummary = {
41
+ scanned: number;
42
+ changed: number;
43
+ skipped: number;
44
+ failed: number;
45
+ };
46
+
47
+ const settingInt = async (key: string, fallback: number): Promise<number> => {
48
+ const raw = await getSetting<number | string | null>(key);
49
+ const value = typeof raw === "number" ? raw : Number(raw);
50
+ return Number.isFinite(value) ? value : fallback;
51
+ };
52
+
53
+ const getIpaExpiresDays = async (): Promise<number> => getConfiguredExpiryDays("ipa", "user");
54
+
55
+ const getLocalUserExpiresDays = async (): Promise<number> => getConfiguredExpiryDays("local", "user");
56
+ const getGuestExpiresDays = async (): Promise<number> => {
57
+ return getConfiguredExpiryDays("local", "guest");
58
+ };
59
+ const getDeletedAccountsRetentionDays = async (): Promise<number> => settingInt("user.account.deleted_accounts_retention_days", 365);
60
+ const getReminderHistoryRetentionDays = async (): Promise<number> => settingInt("user.account.reminder_history_retention_days", 365);
61
+
62
+ const readIpaList = (config: {
63
+ response: Awaited<ReturnType<typeof freeipa.client.call>>;
64
+ entity: string;
65
+ }): Result<Record<string, unknown>[]> => {
66
+ if (config.response.error) {
67
+ return fail(err.internal(`Could not verify FreeIPA ${config.entity} before extension.`));
68
+ }
69
+ const records = config.response.result?.result;
70
+ if (!Array.isArray(records)) {
71
+ return fail(err.internal(`Could not verify FreeIPA ${config.entity} before extension.`));
72
+ }
73
+ if (config.response.result?.truncated !== false) {
74
+ return fail(err.internal(`Could not verify FreeIPA ${config.entity} before extension.`));
75
+ }
76
+ return ok(records as Record<string, unknown>[]);
77
+ };
78
+
79
+ const verifyCurrentIpaSyncScope = async (uid: string): Promise<Result<void>> => {
80
+ const config = await getFreeIpaConfig();
81
+ const serviceSession = await getServiceIpaSession();
82
+ if (!serviceSession.ok) {
83
+ return fail({
84
+ code: serviceSession.status === 400 ? "BAD_INPUT" : "INTERNAL",
85
+ message: serviceSession.error,
86
+ status: serviceSession.status,
87
+ });
88
+ }
89
+
90
+ const groupsRes = await freeipa.client.call({
91
+ url: config.url,
92
+ ipaSession: serviceSession.data,
93
+ method: "group_find",
94
+ args: [],
95
+ options: {
96
+ sizelimit: 0,
97
+ no_members: false,
98
+ all: true,
99
+ },
100
+ });
101
+ const groups = readIpaList({ response: groupsRes, entity: "groups" });
102
+ if (!groups.ok) return fail(groups.error);
103
+
104
+ const effectiveGroupsByUid = buildEffectiveIpaGroupsByUid(
105
+ groups.data.map((raw) => ({
106
+ cn: freeipa.util.str(raw.cn),
107
+ users: (raw.member_user as string[]) ?? [],
108
+ groups: (raw.member_group as string[]) ?? [],
109
+ })),
110
+ );
111
+ const effectiveGroups = effectiveGroupsByUid.get(uid) ?? [];
112
+ if (!config.groupsBaseSync.some((group) => effectiveGroups.includes(group))) {
113
+ return fail(err.forbidden("Your FreeIPA account is no longer in sync scope and cannot be extended."));
114
+ }
115
+
116
+ return ok();
117
+ };
118
+
119
+ const readFreshLocalIpaAccountExpiry = async (uid: string): Promise<Result<Date | null>> => {
120
+ const rows = await sql<DbRow[]>`
121
+ SELECT account_expires
122
+ FROM auth.users
123
+ WHERE uid = ${uid} AND provider = 'ipa'
124
+ `;
125
+ const row = rows[0];
126
+ if (!row) return fail(err.notFound("Your FreeIPA account was not found locally."));
127
+ return ok((row.account_expires as Date | null | undefined) ?? null);
128
+ };
129
+
130
+ const verifyIpaExtensionFreshness = async (uid: string): Promise<Result<{ accountExpires: Date }>> => {
131
+ const currentScope = await verifyCurrentIpaSyncScope(uid);
132
+ if (!currentScope.ok) return fail(currentScope.error);
133
+
134
+ const freshness = await providers.ipa.sync.user(uid).catch((error) => ({
135
+ status: "fetch_failed" as const,
136
+ error: error instanceof Error ? error.message : String(error),
137
+ }));
138
+
139
+ switch (freshness.status) {
140
+ case "synced": {
141
+ const accountExpires = await readFreshLocalIpaAccountExpiry(uid);
142
+ if (!accountExpires.ok) return accountExpires;
143
+ if (accountExpires.data === null) {
144
+ return fail(err.badInput("Accounts without an expiration date cannot be extended."));
145
+ }
146
+ return ok({ accountExpires: accountExpires.data });
147
+ }
148
+ case "expired":
149
+ return fail(err.badInput("Your FreeIPA account is expired and cannot be extended."));
150
+ case "out_of_scope":
151
+ return fail(err.forbidden("Your FreeIPA account is no longer in sync scope and cannot be extended."));
152
+ case "not_found_local":
153
+ return fail(err.notFound("Your FreeIPA account was not found locally."));
154
+ case "fetch_failed":
155
+ return fail(err.internal("Could not verify your FreeIPA account before extension."));
156
+ case "skipped_disabled":
157
+ return fail(err.badInput("FreeIPA is disabled."));
158
+ }
159
+ };
160
+
161
+ const parseReminderDays = async (): Promise<number[]> => {
162
+ const raw = await getSetting<number[]>("user.account.reminder_days");
163
+ const parsed = Array.isArray(raw) ? raw.filter((entry) => Number.isInteger(entry) && entry > 0) : [];
164
+ return [...new Set(parsed)].sort((a, b) => b - a);
165
+ };
166
+
167
+ const upsertReminderAttempt = async (config: {
168
+ userId: string;
169
+ uid: string;
170
+ mail: string;
171
+ displayName: string;
172
+ kind: ReminderKind;
173
+ thresholdDays: number;
174
+ targetExpiryAt: Date;
175
+ }): Promise<{ id: string; status: "pending" | "sent" | "error" }> => {
176
+ const rows = await sql<DbRow[]>`
177
+ INSERT INTO auth.account_lifecycle_reminders (
178
+ user_id, uid, mail, display_name, kind, threshold_days, target_expiry_at, status, attempt_count, created_at
179
+ )
180
+ VALUES (
181
+ ${config.userId}::uuid, ${config.uid}, ${config.mail}, ${config.displayName}, ${config.kind}, ${config.thresholdDays}, ${config.targetExpiryAt}, 'pending', 0, now()
182
+ )
183
+ ON CONFLICT (user_id, kind, threshold_days, target_expiry_at) WHERE user_id IS NOT NULL DO UPDATE
184
+ SET uid = EXCLUDED.uid,
185
+ mail = EXCLUDED.mail,
186
+ display_name = EXCLUDED.display_name,
187
+ status = CASE WHEN auth.account_lifecycle_reminders.status = 'sent' THEN 'sent' ELSE 'pending' END
188
+ RETURNING id, status
189
+ `;
190
+
191
+ return {
192
+ id: rows[0]!.id as string,
193
+ status: rows[0]!.status as "pending" | "sent" | "error",
194
+ };
195
+ };
196
+
197
+ const markReminderSuccess = async (id: string): Promise<void> => {
198
+ await sql`
199
+ UPDATE auth.account_lifecycle_reminders
200
+ SET status = 'sent',
201
+ attempt_count = attempt_count + 1,
202
+ last_attempt_at = now(),
203
+ sent_at = now(),
204
+ last_error = NULL
205
+ WHERE id = ${id}::uuid
206
+ `;
207
+ };
208
+
209
+ const markReminderError = async (id: string, error: string): Promise<void> => {
210
+ await sql`
211
+ UPDATE auth.account_lifecycle_reminders
212
+ SET status = 'error',
213
+ attempt_count = attempt_count + 1,
214
+ last_attempt_at = now(),
215
+ last_error = ${error}
216
+ WHERE id = ${id}::uuid
217
+ `;
218
+ };
219
+
220
+ const deleteFromFreeIpa = async (
221
+ ipaSession: string,
222
+ uid: string,
223
+ signal?: AbortSignal,
224
+ ): Promise<{ ok: true } | { ok: false; error: string }> => {
225
+ const response = await freeipa.client.call({
226
+ url: await getIpaUrl(),
227
+ ipaSession,
228
+ method: "user_del",
229
+ args: [uid],
230
+ options: {},
231
+ signal,
232
+ });
233
+ if (!response.error) return { ok: true };
234
+
235
+ const message = (response.error.message ?? "").toLowerCase();
236
+ const isNotFound = message.includes("not found") || message.includes("does not exist");
237
+ if (isNotFound) return { ok: true };
238
+
239
+ return { ok: false, error: response.error.message };
240
+ };
241
+
242
+ const listReminderCandidates = async (thresholdDays: number): Promise<ReminderCandidate[]> => {
243
+ const rows = await sql<DbRow[]>`
244
+ SELECT id,
245
+ uid,
246
+ mail,
247
+ given_name,
248
+ display_name,
249
+ account_expires AS expires_at,
250
+ 'account_expiry'::text AS kind,
251
+ 'ipa'::text AS account_kind
252
+ FROM auth.users
253
+ WHERE provider = 'ipa'
254
+ AND mail IS NOT NULL
255
+ AND account_expires IS NOT NULL
256
+ AND now() >= account_expires - (${thresholdDays} * interval '1 day')
257
+ AND now() < account_expires
258
+
259
+ UNION ALL
260
+
261
+ SELECT id,
262
+ uid,
263
+ mail,
264
+ given_name,
265
+ display_name,
266
+ account_expires AS expires_at,
267
+ 'account_expiry'::text AS kind,
268
+ 'local-guest'::text AS account_kind
269
+ FROM auth.users
270
+ WHERE provider = 'local'
271
+ AND profile = 'guest'
272
+ AND mail IS NOT NULL
273
+ AND account_expires IS NOT NULL
274
+ AND now() >= account_expires - (${thresholdDays} * interval '1 day')
275
+ AND now() < account_expires
276
+
277
+ UNION ALL
278
+
279
+ SELECT id,
280
+ uid,
281
+ mail,
282
+ given_name,
283
+ display_name,
284
+ account_expires AS expires_at,
285
+ 'account_expiry'::text AS kind,
286
+ 'local-user'::text AS account_kind
287
+ FROM auth.users
288
+ WHERE provider = 'local'
289
+ AND profile = 'user'
290
+ AND mail IS NOT NULL
291
+ AND account_expires IS NOT NULL
292
+ AND now() >= account_expires - (${thresholdDays} * interval '1 day')
293
+ AND now() < account_expires
294
+ `;
295
+
296
+ return rows.map(
297
+ (row): ReminderCandidate => ({
298
+ userId: row.id as string,
299
+ uid: row.uid as string,
300
+ mail: row.mail as string,
301
+ givenName: ((row.given_name as string) || "").trim(),
302
+ displayName: ((row.display_name as string) || "").trim(),
303
+ expiresAt: row.expires_at as Date,
304
+ kind: row.kind as ReminderKind,
305
+ accountKind: row.account_kind as ReminderCandidate["accountKind"],
306
+ }),
307
+ );
308
+ };
309
+
310
+ export const accountLifecycle = {
311
+ demoteExpiredIpaUsers: async (runtime: IpaSyncRuntime = {}): Promise<LifecycleSummary> => {
312
+ const checkpoint = createIpaRuntimeCheckpoint(runtime);
313
+ const freeIpaConfig = await getFreeIpaConfig();
314
+ if (!freeIpaConfig.enabled) {
315
+ log.info("Expired IPA demotion skipped", { reason: "freeipa_disabled" });
316
+ return { scanned: 0, changed: 0, skipped: 0, failed: 0 };
317
+ }
318
+ if (!freeIpaConfig.configured) {
319
+ throw new Error(`FreeIPA is enabled but not fully configured. Missing: ${freeIpaConfig.missingSettings.join(", ")}.`);
320
+ }
321
+
322
+ const rows = await sql<DbRow[]>`
323
+ SELECT id, uid, mail, display_name, profile, account_expires
324
+ FROM auth.users
325
+ WHERE provider = 'ipa'
326
+ AND account_expires IS NOT NULL
327
+ AND account_expires <= now()
328
+ ORDER BY account_expires ASC
329
+ `;
330
+
331
+ const transitionPolicy = parseIpaAccountTransitionPolicy(await getSetting<string | null>("freeipa.account_transition_policy"));
332
+ // Access enforcement must not depend on FreeIPA availability. Revoke local
333
+ // sessions before opening the retryable remote-cleanup phase; request-time
334
+ // expiry checks also reject bearer credentials that cannot be revoked here.
335
+ for (let index = 0; index < rows.length; index += 1) {
336
+ await checkpoint(index === 0);
337
+ const row = rows[index]!;
338
+ await session.revokeAllForUser(row.id as string);
339
+ }
340
+ const ipaSession = await freeipa.session.getServiceSession({
341
+ url: freeIpaConfig.url,
342
+ serviceUser: freeIpaConfig.serviceUser,
343
+ servicePassword: freeIpaConfig.servicePassword,
344
+ signal: runtime.signal,
345
+ });
346
+ await checkpoint(true);
347
+ const summary: LifecycleSummary = {
348
+ scanned: rows.length,
349
+ changed: 0,
350
+ skipped: 0,
351
+ failed: 0,
352
+ };
353
+
354
+ for (let index = 0; index < rows.length; index += 1) {
355
+ await checkpoint();
356
+ const row = rows[index]!;
357
+ const userId = row.id as string;
358
+ const uid = row.uid as string;
359
+ const previousProfile = (row.profile as User["profile"] | null) ?? "guest";
360
+ const ipaDelete = await deleteFromFreeIpa(ipaSession, uid, runtime.signal);
361
+ await checkpoint();
362
+ if (!ipaDelete.ok) {
363
+ summary.failed += 1;
364
+ log.error("Failed to delete expired IPA account", { uid, userId, error: ipaDelete.error });
365
+ continue;
366
+ }
367
+
368
+ try {
369
+ if (transitionPolicy === "delete") {
370
+ await sql.begin(async (tx) => {
371
+ await writeDeletedAccountAudit({
372
+ db: tx,
373
+ userId,
374
+ uid,
375
+ mail: (row.mail as string) ?? null,
376
+ displayName: (row.display_name as string) ?? null,
377
+ previousProvider: "ipa",
378
+ previousProfile,
379
+ reason: "ipa_expired_deleted",
380
+ meta: {
381
+ reason: "ipa_account_expired",
382
+ },
383
+ });
384
+ await tx`DELETE FROM auth.users WHERE id = ${userId}::uuid`;
385
+ });
386
+ } else {
387
+ await sql.begin(async (tx) => {
388
+ const target = await applyIpaAccountTransitionPolicy({
389
+ userId,
390
+ currentProfile: previousProfile,
391
+ policy: transitionPolicy,
392
+ db: tx,
393
+ });
394
+ await writeDeletedAccountAudit({
395
+ db: tx,
396
+ userId,
397
+ uid,
398
+ mail: (row.mail as string) ?? null,
399
+ displayName: (row.display_name as string) ?? null,
400
+ previousProvider: "ipa",
401
+ previousProfile,
402
+ reason: "ipa_expired_demoted",
403
+ meta: {
404
+ accountExpiresAt: target.accountExpires?.toISOString() ?? null,
405
+ targetProfile: target.targetProfile,
406
+ policy: transitionPolicy,
407
+ },
408
+ });
409
+ });
410
+ }
411
+ summary.changed += 1;
412
+ } catch (error) {
413
+ summary.failed += 1;
414
+ log.error("Failed to demote expired IPA account", {
415
+ uid,
416
+ userId,
417
+ error: error instanceof Error ? error.message : String(error),
418
+ });
419
+ }
420
+ }
421
+
422
+ return summary;
423
+ },
424
+
425
+ cleanupExpiredGuests: async (): Promise<LifecycleSummary> => {
426
+ const rows = await sql<DbRow[]>`
427
+ SELECT id, uid, mail, display_name
428
+ FROM auth.users
429
+ WHERE provider = 'local'
430
+ AND profile = 'guest'
431
+ AND account_expires IS NOT NULL
432
+ AND account_expires <= now()
433
+ ORDER BY account_expires ASC
434
+ `;
435
+
436
+ const summary: LifecycleSummary = {
437
+ scanned: rows.length,
438
+ changed: 0,
439
+ skipped: 0,
440
+ failed: 0,
441
+ };
442
+
443
+ for (const row of rows) {
444
+ const userId = row.id as string;
445
+ const uid = row.uid as string;
446
+ try {
447
+ await sql.begin(async (tx) => {
448
+ await writeDeletedAccountAudit({
449
+ db: tx,
450
+ userId,
451
+ uid,
452
+ mail: (row.mail as string) ?? null,
453
+ displayName: (row.display_name as string) ?? null,
454
+ previousProvider: "local",
455
+ previousProfile: "guest",
456
+ reason: "guest_expired_deleted",
457
+ });
458
+ await tx`DELETE FROM auth.users WHERE id = ${userId}::uuid`;
459
+ });
460
+ await session.revokeAllForUser(userId);
461
+ summary.changed += 1;
462
+ } catch (error) {
463
+ summary.failed += 1;
464
+ log.error("Failed to delete expired guest account", {
465
+ uid,
466
+ userId,
467
+ error: error instanceof Error ? error.message : String(error),
468
+ });
469
+ }
470
+ }
471
+
472
+ return summary;
473
+ },
474
+
475
+ cleanupExpiredLocalUsers: async (): Promise<LifecycleSummary> => {
476
+ const rows = await sql<DbRow[]>`
477
+ SELECT id, uid, mail, display_name
478
+ FROM auth.users
479
+ WHERE provider = 'local'
480
+ AND profile = 'user'
481
+ AND account_expires IS NOT NULL
482
+ AND account_expires <= now()
483
+ ORDER BY account_expires ASC
484
+ `;
485
+
486
+ const summary: LifecycleSummary = {
487
+ scanned: rows.length,
488
+ changed: 0,
489
+ skipped: 0,
490
+ failed: 0,
491
+ };
492
+
493
+ for (const row of rows) {
494
+ const userId = row.id as string;
495
+ const uid = row.uid as string;
496
+ try {
497
+ await sql.begin(async (tx) => {
498
+ await writeDeletedAccountAudit({
499
+ db: tx,
500
+ userId,
501
+ uid,
502
+ mail: (row.mail as string) ?? null,
503
+ displayName: (row.display_name as string) ?? null,
504
+ previousProvider: "local",
505
+ previousProfile: "user",
506
+ reason: "local_user_expired_deleted",
507
+ });
508
+ await tx`DELETE FROM auth.users WHERE id = ${userId}::uuid`;
509
+ });
510
+ await session.revokeAllForUser(userId);
511
+ summary.changed += 1;
512
+ } catch (error) {
513
+ summary.failed += 1;
514
+ log.error("Failed to delete expired local user account", {
515
+ uid,
516
+ userId,
517
+ error: error instanceof Error ? error.message : String(error),
518
+ });
519
+ }
520
+ }
521
+
522
+ return summary;
523
+ },
524
+
525
+ sendExpiryReminders: async (notificationSender: AccountLifecycleNotificationSender): Promise<LifecycleSummary> => {
526
+ const days = await parseReminderDays();
527
+ if (days.length === 0) {
528
+ return { scanned: 0, changed: 0, skipped: 0, failed: 0 };
529
+ }
530
+
531
+ let scanned = 0;
532
+ let changed = 0;
533
+ let skipped = 0;
534
+ let failed = 0;
535
+
536
+ for (const thresholdDays of days) {
537
+ const candidates = await listReminderCandidates(thresholdDays);
538
+ scanned += candidates.length;
539
+
540
+ for (const candidate of candidates) {
541
+ const attempt = await upsertReminderAttempt({
542
+ userId: candidate.userId,
543
+ uid: candidate.uid,
544
+ mail: candidate.mail,
545
+ displayName: candidate.displayName,
546
+ kind: candidate.kind,
547
+ thresholdDays,
548
+ targetExpiryAt: candidate.expiresAt,
549
+ });
550
+
551
+ if (attempt.status === "sent") {
552
+ skipped += 1;
553
+ continue;
554
+ }
555
+
556
+ try {
557
+ const notification = await notificationSender.sendExpiryReminder({
558
+ reminderId: attempt.id,
559
+ userId: candidate.userId,
560
+ firstName: candidate.givenName || candidate.displayName || candidate.uid,
561
+ displayName: candidate.displayName || candidate.uid,
562
+ expiresAt: candidate.expiresAt.toISOString(),
563
+ accountKind: candidate.accountKind,
564
+ });
565
+ if (notification.status === "error" || notification.status === "suppressed") {
566
+ await markReminderError(attempt.id, "Notification delivery was not accepted");
567
+ failed += 1;
568
+ continue;
569
+ }
570
+
571
+ // Queued means the durable notification runtime owns subsequent retries.
572
+ await markReminderSuccess(attempt.id);
573
+ changed += 1;
574
+ } catch (error) {
575
+ const message = error instanceof Error ? error.message : String(error);
576
+ await markReminderError(attempt.id, message);
577
+ failed += 1;
578
+ log.error("Failed to send expiry reminder", {
579
+ userId: candidate.userId,
580
+ uid: candidate.uid,
581
+ kind: candidate.kind,
582
+ thresholdDays,
583
+ error: message,
584
+ });
585
+ }
586
+ }
587
+ }
588
+
589
+ return { scanned, changed, skipped, failed };
590
+ },
591
+
592
+ cleanupLifecycleAudit: async (): Promise<LifecycleSummary> => {
593
+ const [deletedAccountsRetentionDays, reminderHistoryRetentionDays] = await Promise.all([
594
+ getDeletedAccountsRetentionDays(),
595
+ getReminderHistoryRetentionDays(),
596
+ ]);
597
+
598
+ const deletedRows =
599
+ deletedAccountsRetentionDays > 0
600
+ ? await sql<DbRow[]>`
601
+ DELETE FROM auth.deleted_accounts
602
+ WHERE deleted_at < now() - (${deletedAccountsRetentionDays} * interval '1 day')
603
+ RETURNING id
604
+ `
605
+ : [];
606
+ const reminderRows =
607
+ reminderHistoryRetentionDays > 0
608
+ ? await sql<DbRow[]>`
609
+ DELETE FROM auth.account_lifecycle_reminders
610
+ WHERE created_at < now() - (${reminderHistoryRetentionDays} * interval '1 day')
611
+ RETURNING id
612
+ `
613
+ : [];
614
+
615
+ return {
616
+ scanned: deletedRows.length + reminderRows.length,
617
+ changed: deletedRows.length + reminderRows.length,
618
+ skipped: 0,
619
+ failed: 0,
620
+ };
621
+ },
622
+
623
+ runLocalUserBackfill: async (): Promise<LifecycleSummary> => {
624
+ const configuredDays = await getLocalUserExpiresDays();
625
+ if (configuredDays <= 0) {
626
+ return { scanned: 0, changed: 0, skipped: 0, failed: 0 };
627
+ }
628
+
629
+ const days = Math.max(configuredDays, 7);
630
+ const target = new Date(Date.now() + days * DAY_MS);
631
+
632
+ const rows = await sql<DbRow[]>`
633
+ UPDATE auth.users
634
+ SET account_expires = ${target}
635
+ WHERE provider = 'local'
636
+ AND profile = 'user'
637
+ AND (account_expires IS NULL OR account_expires < ${target})
638
+ RETURNING id
639
+ `;
640
+
641
+ return {
642
+ scanned: rows.length,
643
+ changed: rows.length,
644
+ skipped: 0,
645
+ failed: 0,
646
+ };
647
+ },
648
+
649
+ runGuestBackfill: async (): Promise<LifecycleSummary> => {
650
+ const configuredDays = await getGuestExpiresDays();
651
+ if (configuredDays <= 0) {
652
+ return { scanned: 0, changed: 0, skipped: 0, failed: 0 };
653
+ }
654
+
655
+ const days = Math.max(configuredDays, 7);
656
+ const target = new Date(Date.now() + days * DAY_MS);
657
+
658
+ const rows = await sql<DbRow[]>`
659
+ UPDATE auth.users
660
+ SET account_expires = ${target}
661
+ WHERE provider = 'local'
662
+ AND profile = 'guest'
663
+ AND (account_expires IS NULL OR account_expires < ${target})
664
+ RETURNING id
665
+ `;
666
+
667
+ return {
668
+ scanned: rows.length,
669
+ changed: rows.length,
670
+ skipped: 0,
671
+ failed: 0,
672
+ };
673
+ },
674
+
675
+ extendCurrentUserAccount: async (config: { user: User }): Promise<Result<{ message: string; newExpiry?: string }>> => {
676
+ const auditParams = (result: Result<{ message: string; newExpiry?: string }>) => ({
677
+ action: "accounts.user.extend_account",
678
+ actor: {
679
+ userId: config.user.id,
680
+ uid: config.user.uid,
681
+ provider: config.user.provider,
682
+ roles: config.user.roles,
683
+ },
684
+ target: { type: "user", id: config.user.id, label: config.user.uid, provider: config.user.provider },
685
+ result,
686
+ });
687
+ const recordResult = (result: Result<{ message: string; newExpiry?: string }>) => audit.recordResult(auditParams(result));
688
+ const recordCompletedMutation = (result: Result<{ message: string; newExpiry?: string }>) =>
689
+ result.ok ? audit.recordResultAfterSideEffect(auditParams(result)) : audit.recordResult(auditParams(result));
690
+
691
+ if (config.user.accountExpires === null) {
692
+ return recordResult(fail(err.badInput("Accounts without an expiration date cannot be extended.")));
693
+ }
694
+
695
+ if (config.user.provider === "ipa") {
696
+ const freeIpaConfig = await getFreeIpaConfig();
697
+ if (!freeIpaConfig.enabled) {
698
+ return recordResult(ok({ message: "FreeIPA is disabled." }));
699
+ }
700
+ const configuredDays = await getIpaExpiresDays();
701
+ if (configuredDays <= 0) {
702
+ return recordResult(ok({ message: "Automatic account expiry is disabled for IPA accounts." }));
703
+ }
704
+
705
+ const freshness = await verifyIpaExtensionFreshness(config.user.uid);
706
+ if (!freshness.ok) return recordResult(fail(freshness.error));
707
+
708
+ const serviceSession = await getServiceIpaSession();
709
+ if (!serviceSession.ok) {
710
+ return recordResult(
711
+ fail({
712
+ code: serviceSession.status === 400 ? "BAD_INPUT" : "INTERNAL",
713
+ message: serviceSession.error,
714
+ status: serviceSession.status,
715
+ }),
716
+ );
717
+ }
718
+
719
+ const expiresAt = new Date(Date.now() + configuredDays * DAY_MS);
720
+ expiresAt.setUTCHours(23, 59, 59, 0);
721
+ const ipaExpiry = freeipa.util.toGeneralizedTime(expiresAt);
722
+
723
+ const response = await freeipa.client.call({
724
+ url: freeIpaConfig.url,
725
+ ipaSession: serviceSession.data,
726
+ method: "user_mod",
727
+ args: [config.user.uid],
728
+ options: { krbprincipalexpiration: ipaExpiry },
729
+ });
730
+ if (response.error) {
731
+ return recordResult(fail(err.badInput(response.error.message || "Failed to extend IPA account.")));
732
+ }
733
+
734
+ await sql`
735
+ UPDATE auth.users
736
+ SET account_expires = ${expiresAt}
737
+ WHERE id = ${config.user.id}::uuid
738
+ `;
739
+ await sql`
740
+ INSERT INTO auth.user_ipa_data (user_id, synced_at)
741
+ VALUES (${config.user.id}::uuid, now())
742
+ ON CONFLICT (user_id) DO UPDATE SET synced_at = EXCLUDED.synced_at
743
+ `;
744
+
745
+ return recordCompletedMutation(
746
+ ok({
747
+ message: `Account extended until ${dates.formatDate(expiresAt)}.`,
748
+ newExpiry: expiresAt.toISOString(),
749
+ }),
750
+ );
751
+ }
752
+
753
+ if (config.user.provider === "local" && config.user.profile === "guest") {
754
+ const guestDays = await getGuestExpiresDays();
755
+ if (guestDays <= 0) {
756
+ await sql`
757
+ UPDATE auth.users
758
+ SET account_expires = NULL
759
+ WHERE id = ${config.user.id}::uuid
760
+ `;
761
+ return recordCompletedMutation(ok({ message: "Guest account expiry is disabled." }));
762
+ }
763
+
764
+ const expiresAt = new Date(Date.now() + guestDays * DAY_MS);
765
+ await sql`
766
+ UPDATE auth.users
767
+ SET account_expires = ${expiresAt}
768
+ WHERE id = ${config.user.id}::uuid
769
+ `;
770
+
771
+ return recordCompletedMutation(
772
+ ok({
773
+ message: `Guest account extended until ${dates.formatDate(expiresAt)}.`,
774
+ newExpiry: expiresAt.toISOString(),
775
+ }),
776
+ );
777
+ }
778
+
779
+ if (config.user.provider === "local" && config.user.profile === "user") {
780
+ const localUserDays = await getLocalUserExpiresDays();
781
+ if (localUserDays <= 0) {
782
+ await sql`
783
+ UPDATE auth.users
784
+ SET account_expires = NULL
785
+ WHERE id = ${config.user.id}::uuid
786
+ `;
787
+ return recordCompletedMutation(ok({ message: "Local user account expiry is disabled." }));
788
+ }
789
+
790
+ const expiresAt = new Date(Date.now() + localUserDays * DAY_MS);
791
+ await sql`
792
+ UPDATE auth.users
793
+ SET account_expires = ${expiresAt}
794
+ WHERE id = ${config.user.id}::uuid
795
+ `;
796
+
797
+ return recordCompletedMutation(
798
+ ok({
799
+ message: `Account extended until ${dates.formatDate(expiresAt)}.`,
800
+ newExpiry: expiresAt.toISOString(),
801
+ }),
802
+ );
803
+ }
804
+
805
+ return recordResult(ok({ message: "Your account does not support extension." }));
806
+ },
807
+
808
+ listDeletedAccounts: async (config: { page: number; perPage: number; reason?: string; search?: string }) => {
809
+ const offset = (config.page - 1) * config.perPage;
810
+ const reason = config.reason?.trim() || null;
811
+ const search = config.search?.trim().toLowerCase() || null;
812
+ const pattern = search ? `%${freeipa.util.escapeLike(search)}%` : null;
813
+
814
+ const countRows = await sql<DbRow[]>`
815
+ SELECT COUNT(*)::int AS total
816
+ FROM auth.deleted_accounts
817
+ WHERE (${reason}::text IS NULL OR reason = ${reason})
818
+ AND (
819
+ ${pattern}::text IS NULL
820
+ OR LOWER(uid) LIKE ${pattern} ESCAPE '\\'
821
+ OR LOWER(COALESCE(display_name, '')) LIKE ${pattern} ESCAPE '\\'
822
+ OR LOWER(COALESCE(mail, '')) LIKE ${pattern} ESCAPE '\\'
823
+ )
824
+ `;
825
+
826
+ const rows = await sql<DbRow[]>`
827
+ SELECT id, deleted_user_id, uid, mail, display_name, previous_provider, previous_profile, reason, deleted_at, meta
828
+ FROM auth.deleted_accounts
829
+ WHERE (${reason}::text IS NULL OR reason = ${reason})
830
+ AND (
831
+ ${pattern}::text IS NULL
832
+ OR LOWER(uid) LIKE ${pattern} ESCAPE '\\'
833
+ OR LOWER(COALESCE(display_name, '')) LIKE ${pattern} ESCAPE '\\'
834
+ OR LOWER(COALESCE(mail, '')) LIKE ${pattern} ESCAPE '\\'
835
+ )
836
+ ORDER BY deleted_at DESC
837
+ LIMIT ${config.perPage}
838
+ OFFSET ${offset}
839
+ `;
840
+
841
+ return {
842
+ items: rows.map((row) => ({
843
+ id: row.id as string,
844
+ deletedUserId: row.deleted_user_id as string,
845
+ uid: row.uid as string,
846
+ mail: (row.mail as string) ?? null,
847
+ displayName: (row.display_name as string) ?? null,
848
+ previousProvider: (row.previous_provider as string) ?? null,
849
+ previousProfile: (row.previous_profile as string) ?? null,
850
+ reason: row.reason as string,
851
+ deletedAt: (row.deleted_at as Date).toISOString(),
852
+ meta: parsePgJsonRecord(row.meta) ?? {},
853
+ })),
854
+ total: Number(countRows[0]?.total ?? 0),
855
+ page: config.page,
856
+ perPage: config.perPage,
857
+ };
858
+ },
859
+
860
+ listReminderAudit: async (config: { page: number; perPage: number; status?: string; kind?: ReminderKind; search?: string }) => {
861
+ const offset = (config.page - 1) * config.perPage;
862
+ const status = config.status?.trim() || null;
863
+ const kind = config.kind ?? null;
864
+ const search = config.search?.trim().toLowerCase() || null;
865
+ const pattern = search ? `%${freeipa.util.escapeLike(search)}%` : null;
866
+
867
+ const countRows = await sql<DbRow[]>`
868
+ SELECT COUNT(*)::int AS total
869
+ FROM auth.account_lifecycle_reminders r
870
+ LEFT JOIN auth.users u ON u.id = r.user_id
871
+ WHERE (${status}::text IS NULL OR r.status = ${status})
872
+ AND (${kind}::text IS NULL OR r.kind = ${kind})
873
+ AND (
874
+ ${pattern}::text IS NULL
875
+ OR LOWER(COALESCE(r.uid, u.uid, '')) LIKE ${pattern} ESCAPE '\\'
876
+ OR LOWER(COALESCE(r.mail, u.mail, '')) LIKE ${pattern} ESCAPE '\\'
877
+ OR LOWER(COALESCE(r.display_name, u.display_name, '')) LIKE ${pattern} ESCAPE '\\'
878
+ )
879
+ `;
880
+
881
+ const rows = await sql<DbRow[]>`
882
+ SELECT r.id,
883
+ r.user_id,
884
+ r.uid AS reminder_uid,
885
+ r.mail AS reminder_mail,
886
+ r.display_name AS reminder_display_name,
887
+ r.kind,
888
+ r.threshold_days,
889
+ r.target_expiry_at,
890
+ r.status,
891
+ r.attempt_count,
892
+ r.last_attempt_at,
893
+ r.sent_at,
894
+ r.last_error,
895
+ r.created_at,
896
+ u.uid AS live_uid,
897
+ u.mail AS live_mail,
898
+ u.display_name AS live_display_name,
899
+ u.avatar_hash AS live_avatar_hash
900
+ FROM auth.account_lifecycle_reminders r
901
+ LEFT JOIN auth.users u ON u.id = r.user_id
902
+ WHERE (${status}::text IS NULL OR r.status = ${status})
903
+ AND (${kind}::text IS NULL OR r.kind = ${kind})
904
+ AND (
905
+ ${pattern}::text IS NULL
906
+ OR LOWER(COALESCE(r.uid, u.uid, '')) LIKE ${pattern} ESCAPE '\\'
907
+ OR LOWER(COALESCE(r.mail, u.mail, '')) LIKE ${pattern} ESCAPE '\\'
908
+ OR LOWER(COALESCE(r.display_name, u.display_name, '')) LIKE ${pattern} ESCAPE '\\'
909
+ )
910
+ ORDER BY r.created_at DESC
911
+ LIMIT ${config.perPage}
912
+ OFFSET ${offset}
913
+ `;
914
+
915
+ return {
916
+ items: rows.map((row) => ({
917
+ id: row.id as string,
918
+ userId: (row.user_id as string) ?? null,
919
+ uid: (row.reminder_uid as string) ?? (row.live_uid as string) ?? null,
920
+ mail: (row.reminder_mail as string) ?? (row.live_mail as string) ?? null,
921
+ displayName: (row.reminder_display_name as string) ?? (row.live_display_name as string) ?? null,
922
+ avatarHash: (row.live_avatar_hash as string | null | undefined) ?? null,
923
+ kind: row.kind as string,
924
+ thresholdDays: Number(row.threshold_days),
925
+ targetExpiryAt: (row.target_expiry_at as Date).toISOString(),
926
+ status: row.status as string,
927
+ attemptCount: Number(row.attempt_count),
928
+ lastAttemptAt: row.last_attempt_at ? (row.last_attempt_at as Date).toISOString() : null,
929
+ sentAt: row.sent_at ? (row.sent_at as Date).toISOString() : null,
930
+ lastError: (row.last_error as string) ?? null,
931
+ createdAt: (row.created_at as Date).toISOString(),
932
+ })),
933
+ total: Number(countRows[0]?.total ?? 0),
934
+ page: config.page,
935
+ perPage: config.perPage,
936
+ };
937
+ },
938
+ };
939
+
940
+ export type AccountLifecycleService = typeof accountLifecycle;