@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,862 @@
1
+ import { sql } from "bun";
2
+ import { freeipa } from "../../server/services";
3
+ import { writeDeletedAccountAudit } from "../account-lifecycle/audit";
4
+ import { calculateIpaProfileFromGroupNames, parseIpaAccountTransitionPolicy, parseIpaMatchMode } from "../account-model";
5
+ import { applyIpaAccountTransitionPolicy } from "../accounts/switching";
6
+ import { getFreeIpaConfig } from "../freeipa-config";
7
+ import { logger } from "../logging";
8
+ import { session } from "../session";
9
+ import { mirrorIpaPosix } from "./posix";
10
+ import * as settings from "../settings";
11
+ import { buildEffectiveIpaGroupsByUid } from "./effective-groups";
12
+ import { calculateIpaProfileFromEffectiveProjection, getEffectiveUserGroups } from "./profile";
13
+ import { createIpaRuntimeCheckpoint, type IpaSyncRuntime } from "./runtime";
14
+ import { assessIpaDestructionGuard, formatIpaDestructionGuardFailure, readCompleteIpaList, selectStaleLocalIpaRows } from "./sync-planning";
15
+
16
+ type DbRow = Record<string, unknown>;
17
+ type LocalIpaRow = DbRow & { uid: string; mail: string | null };
18
+
19
+ const log = logger("auth:ipa:sync");
20
+
21
+ const upsertUserIpaData = async (
22
+ db: typeof sql,
23
+ params: {
24
+ userId: string;
25
+ uidNumber: number | null;
26
+ primaryGidNumber: number | null;
27
+ homeDirectory: string | null;
28
+ loginShell: string | null;
29
+ phone: string | null;
30
+ ipaPasswordExpires: Date | null;
31
+ lastLoginIpa: Date | null;
32
+ employeeType: string | null;
33
+ addrStreet: string | null;
34
+ addrPostalCode: string | null;
35
+ addrCity: string | null;
36
+ addrState: string | null;
37
+ mobile: string | null;
38
+ sshPublicKeys: string[];
39
+ sshFingerprints: string[];
40
+ },
41
+ ) => {
42
+ await mirrorIpaPosix(db, params);
43
+ await db`
44
+ INSERT INTO auth.user_ipa_data (
45
+ user_id, uid_number, phone, employee_type, mobile, addr_street, addr_postal_code,
46
+ addr_city, addr_state, ipa_password_expires, last_login_ipa, synced_at, ssh_public_keys, ssh_fingerprints
47
+ )
48
+ VALUES (
49
+ ${params.userId},
50
+ ${params.uidNumber},
51
+ ${params.phone},
52
+ ${params.employeeType},
53
+ ${params.mobile},
54
+ ${params.addrStreet},
55
+ ${params.addrPostalCode},
56
+ ${params.addrCity},
57
+ ${params.addrState},
58
+ ${params.ipaPasswordExpires},
59
+ ${params.lastLoginIpa},
60
+ now(),
61
+ ${freeipa.util.toPgTextArray(params.sshPublicKeys)}::text[],
62
+ ${freeipa.util.toPgTextArray(params.sshFingerprints)}::text[]
63
+ )
64
+ ON CONFLICT (user_id) DO UPDATE SET
65
+ uid_number = EXCLUDED.uid_number,
66
+ phone = EXCLUDED.phone,
67
+ employee_type = EXCLUDED.employee_type,
68
+ mobile = EXCLUDED.mobile,
69
+ addr_street = EXCLUDED.addr_street,
70
+ addr_postal_code = EXCLUDED.addr_postal_code,
71
+ addr_city = EXCLUDED.addr_city,
72
+ addr_state = EXCLUDED.addr_state,
73
+ ipa_password_expires = EXCLUDED.ipa_password_expires,
74
+ last_login_ipa = EXCLUDED.last_login_ipa,
75
+ synced_at = EXCLUDED.synced_at,
76
+ ssh_public_keys = EXCLUDED.ssh_public_keys,
77
+ ssh_fingerprints = EXCLUDED.ssh_fingerprints
78
+ `;
79
+ };
80
+
81
+ // ==========================
82
+ // Sync Types
83
+ // ==========================
84
+
85
+ type SyncUser = {
86
+ uid: string;
87
+ uidNumber: number | null;
88
+ primaryGidNumber: number | null;
89
+ homeDirectory: string | null;
90
+ loginShell: string | null;
91
+ givenname: string;
92
+ sn: string;
93
+ displayName: string;
94
+ mail: string | null;
95
+ phone: string | null;
96
+ ipaAccountExpires: Date | null;
97
+ ipaPasswordExpires: Date | null;
98
+ lastLoginIpa: Date | null;
99
+ memberofGroup: string[];
100
+ employeeType: string | null;
101
+ addrStreet: string | null;
102
+ addrPostalCode: string | null;
103
+ addrCity: string | null;
104
+ addrState: string | null;
105
+ mobile: string | null;
106
+ sshPublicKeys: string[];
107
+ sshFingerprints: string[];
108
+ };
109
+
110
+ type SyncGroup = {
111
+ cn: string;
112
+ description: string | null;
113
+ gidnumber: number | null;
114
+ users: string[];
115
+ groups: string[];
116
+ parentGroups: string[];
117
+ managerUsers: string[];
118
+ managerGroups: string[];
119
+ };
120
+
121
+ // ==========================
122
+ // Transform helpers
123
+ // ==========================
124
+
125
+ /**
126
+ * Normalizes one raw IPA user record into the sync user model.
127
+ */
128
+ const transformSyncUser = (raw: Record<string, unknown>): SyncUser => {
129
+ const directGroups = (raw.memberof_group as string[]) ?? [];
130
+ const indirectGroups = (raw.memberofindirect_group as string[]) ?? [];
131
+ return {
132
+ uid: freeipa.util.str(raw.uid),
133
+ uidNumber: freeipa.util.num(raw.uidnumber),
134
+ primaryGidNumber: freeipa.util.num(raw.gidnumber),
135
+ homeDirectory: freeipa.util.str(raw.homedirectory) || null,
136
+ loginShell: freeipa.util.str(raw.loginshell) || null,
137
+ givenname: freeipa.util.str(raw.givenname),
138
+ sn: freeipa.util.str(raw.sn),
139
+ displayName:
140
+ freeipa.util.str(raw.displayname) ||
141
+ [freeipa.util.str(raw.givenname), freeipa.util.str(raw.sn)].filter(Boolean).join(" ") ||
142
+ freeipa.util.str(raw.uid),
143
+ mail: freeipa.util.str(raw.mail) || null,
144
+ phone: freeipa.util.str(raw.telephonenumber) || null,
145
+ ipaAccountExpires: freeipa.util.parseGeneralizedTime(raw.krbprincipalexpiration),
146
+ ipaPasswordExpires: freeipa.util.parseGeneralizedTime(raw.krbpasswordexpiration),
147
+ lastLoginIpa: freeipa.util.parseGeneralizedTime(raw.krblastsuccessfulauth),
148
+ memberofGroup: [...directGroups, ...indirectGroups],
149
+ employeeType: freeipa.util.str(raw.employeetype) || null,
150
+ addrStreet: freeipa.util.str(raw.street) || null,
151
+ addrPostalCode: freeipa.util.str(raw.postalcode) || null,
152
+ addrCity: freeipa.util.str(raw.l) || null,
153
+ addrState: freeipa.util.str(raw.st) || null,
154
+ mobile: freeipa.util.str(raw.mobile) || null,
155
+ sshPublicKeys: Array.isArray(raw.ipasshpubkey) ? raw.ipasshpubkey : [],
156
+ sshFingerprints: Array.isArray(raw.sshpubkeyfp) ? raw.sshpubkeyfp : [],
157
+ };
158
+ };
159
+
160
+ /**
161
+ * Normalizes one raw IPA group record into the sync group model.
162
+ * `excludedGroupsSet` is hoisted to the caller so we don't re-read settings
163
+ * once per group.
164
+ */
165
+ const transformSyncGroup = (raw: Record<string, unknown>, excludedGroupsSet: Set<string> = new Set()): SyncGroup => ({
166
+ cn: freeipa.util.str(raw.cn),
167
+ description: freeipa.util.str(raw.description) || null,
168
+ gidnumber: freeipa.util.num(raw.gidnumber),
169
+ users: (raw.member_user as string[]) ?? [],
170
+ groups: ((raw.member_group as string[]) ?? []).filter((g) => !excludedGroupsSet.has(g)),
171
+ parentGroups: ((raw.memberof_group as string[]) ?? []).filter((g) => !excludedGroupsSet.has(g)),
172
+ managerUsers: (raw.membermanager_user as string[]) ?? [],
173
+ managerGroups: ((raw.membermanager_group as string[]) ?? []).filter((g) => !excludedGroupsSet.has(g)),
174
+ });
175
+
176
+ /**
177
+ * Evaluates whether an IPA account is already expired based on account metadata.
178
+ */
179
+ const isExpired = (u: SyncUser): boolean => {
180
+ if (!u.ipaAccountExpires) return false;
181
+ return u.ipaAccountExpires < new Date();
182
+ };
183
+
184
+ /**
185
+ * Reads one IPA list response and throws for transport/protocol failures.
186
+ * Sync must fail hard on invalid payloads to avoid destructive partial updates.
187
+ */
188
+ // ==========================
189
+ // Sync
190
+ // ==========================
191
+
192
+ /**
193
+ * Runs a full IPA-to-local sync pass for users, groups, and memberships.
194
+ */
195
+ export const syncFromIpa = async (runtime: IpaSyncRuntime = {}): Promise<void> => {
196
+ const startedAt = Date.now();
197
+ let leaseHeartbeats = 0;
198
+ const runCheckpoint = createIpaRuntimeCheckpoint(runtime, {
199
+ onHeartbeat: () => {
200
+ leaseHeartbeats += 1;
201
+ },
202
+ });
203
+ const config = await getFreeIpaConfig();
204
+ if (!config.enabled) {
205
+ log.info("Sync skipped", { reason: "freeipa_disabled" });
206
+ return;
207
+ }
208
+ if (!config.configured) {
209
+ throw new Error(`FreeIPA is enabled but not fully configured. Missing: ${config.missingSettings.join(", ")}.`);
210
+ }
211
+ const excludedGroupsSet = freeipa.util.toExcludedGroupsSet(config.groupsExcluded);
212
+ const ipaSession = await freeipa.session.getServiceSession({
213
+ url: config.url,
214
+ serviceUser: config.serviceUser,
215
+ servicePassword: config.servicePassword,
216
+ signal: runtime.signal,
217
+ });
218
+ await runCheckpoint(true);
219
+
220
+ const snapshotFetchStartedAt = Date.now();
221
+ const [usersRes, groupsRes] = await Promise.all([
222
+ freeipa.client.call({
223
+ url: config.url,
224
+ ipaSession,
225
+ method: "user_find",
226
+ args: [],
227
+ options: { sizelimit: 0, all: true },
228
+ signal: runtime.signal,
229
+ }),
230
+ freeipa.client.call({
231
+ url: config.url,
232
+ ipaSession,
233
+ method: "group_find",
234
+ args: [],
235
+ options: {
236
+ sizelimit: 0,
237
+ no_members: false,
238
+ all: true,
239
+ },
240
+ signal: runtime.signal,
241
+ }),
242
+ ]);
243
+ const snapshotFetchDurationMs = Date.now() - snapshotFetchStartedAt;
244
+ await runCheckpoint(true);
245
+
246
+ const allRawUsers = readCompleteIpaList({ response: usersRes, entity: "users" });
247
+ const allUsers = allRawUsers.map(transformSyncUser);
248
+ const allRawGroups = readCompleteIpaList({ response: groupsRes, entity: "groups" });
249
+ const effectiveGroupsByUid = buildEffectiveIpaGroupsByUid(allRawGroups.map((raw) => transformSyncGroup(raw)));
250
+ const users = allUsers.filter((user) => {
251
+ const effectiveGroups = effectiveGroupsByUid.get(user.uid) ?? [];
252
+ return config.groupsBaseSync.some((group) => effectiveGroups.includes(group));
253
+ });
254
+ const groups = allRawGroups.map((raw) => transformSyncGroup(raw, excludedGroupsSet)).filter((g) => !excludedGroupsSet.has(g.cn));
255
+
256
+ const activeUsers = users.filter((u) => !isExpired(u));
257
+ const expiredUsers = users.length - activeUsers.length;
258
+ // Only ACTIVE remote users are considered in scope. Expired ones fall through to the
259
+ // stale/transition branch so their local mirror is either demoted or deleted per policy,
260
+ // and their sessions are revoked. Treating expired users as in-scope would leave a stale
261
+ // unexpired local row plus live sessions.
262
+ const groupCns = new Set(groups.map((g) => g.cn));
263
+ const matchMode = parseIpaMatchMode(await settings.get<string | null>("freeipa.user_match_mode"));
264
+ const transitionPolicy = parseIpaAccountTransitionPolicy(await settings.get<string | null>("freeipa.account_transition_policy"));
265
+
266
+ let matchedExistingUsersByMail = 0;
267
+ let migratedLocalUsers = 0;
268
+ let skippedLocalMailConflicts = 0;
269
+ let skippedLocalUidConflicts = 0;
270
+ let upsertedUsersByUid = 0;
271
+ let insertedUsersByUid = 0;
272
+ let updatedUsersByUid = 0;
273
+ let deletedGroups = 0;
274
+
275
+ const localIpaRows = await sql<DbRow[]>`
276
+ SELECT id, uid, mail, display_name, profile
277
+ FROM auth.users
278
+ WHERE provider = 'ipa'
279
+ ORDER BY uid
280
+ `;
281
+ const localIpaGroupRows = await sql<DbRow[]>`
282
+ SELECT name
283
+ FROM auth.groups
284
+ WHERE provider = 'ipa'
285
+ ORDER BY name
286
+ `;
287
+ const localIpaUsers = localIpaRows.length;
288
+ const localGroups = localIpaGroupRows.length;
289
+ const currentRowByUid = new Map(localIpaRows.map((row) => [row.uid as string, row]));
290
+ const currentRowsByMail = new Map<string, DbRow[]>();
291
+ for (const row of localIpaRows) {
292
+ const mail = row.mail as string | null;
293
+ if (!mail) continue;
294
+ const matches = currentRowsByMail.get(mail);
295
+ if (matches) matches.push(row);
296
+ else currentRowsByMail.set(mail, [row]);
297
+ }
298
+ const currentRowForRemoteUser = (user: SyncUser): DbRow | undefined => {
299
+ const byUid = currentRowByUid.get(user.uid);
300
+ if (byUid) return byUid;
301
+ if (!user.mail) return undefined;
302
+ const byMail = currentRowsByMail.get(user.mail) ?? [];
303
+ return byMail.length === 1 ? byMail[0] : undefined;
304
+ };
305
+ let profileDriftCount = 0;
306
+ const profileDriftSamples: string[] = [];
307
+ let profilesPromoted = 0;
308
+ let profilesDemoted = 0;
309
+ for (const user of activeUsers) {
310
+ const effectiveGroups = effectiveGroupsByUid.get(user.uid) ?? [];
311
+ const graphProfile = calculateIpaProfileFromGroupNames(effectiveGroups, config.groupsBaseIpaRealm);
312
+ const userSideProfile = calculateIpaProfileFromGroupNames(user.memberofGroup, config.groupsBaseIpaRealm);
313
+ if (graphProfile !== userSideProfile) {
314
+ profileDriftCount += 1;
315
+ if (profileDriftSamples.length < 10) profileDriftSamples.push(user.uid);
316
+ }
317
+
318
+ const previousProfile = currentRowForRemoteUser(user)?.profile as "user" | "guest" | undefined;
319
+ if (previousProfile === "guest" && graphProfile === "user") profilesPromoted += 1;
320
+ if (previousProfile === "user" && graphProfile === "guest") profilesDemoted += 1;
321
+ }
322
+
323
+ const localIpaIdentityRows: LocalIpaRow[] = localIpaRows.map((row) => ({
324
+ ...row,
325
+ uid: row.uid as string,
326
+ mail: (row.mail as string | null) ?? null,
327
+ }));
328
+ const staleLocalUsers = selectStaleLocalIpaRows({
329
+ localRows: localIpaIdentityRows,
330
+ activeRemoteUsers: activeUsers.map((user) => ({ uid: user.uid, mail: user.mail })),
331
+ });
332
+ const affectedUserIds = new Set(staleLocalUsers.map((row) => row.id as string));
333
+ for (const user of activeUsers) {
334
+ const previousRow = currentRowForRemoteUser(user);
335
+ const previousProfile = previousRow?.profile as "user" | "guest" | undefined;
336
+ const nextProfile = calculateIpaProfileFromGroupNames(effectiveGroupsByUid.get(user.uid) ?? [], config.groupsBaseIpaRealm);
337
+ if (previousRow && previousProfile === "user" && nextProfile === "guest") {
338
+ affectedUserIds.add(previousRow.id as string);
339
+ }
340
+ }
341
+ const deletedGroupNames = localIpaGroupRows.map((row) => row.name as string).filter((name) => !groupCns.has(name));
342
+ const guard = assessIpaDestructionGuard({
343
+ affectedUserIds,
344
+ localUsers: localIpaUsers,
345
+ deletedGroupNames,
346
+ localGroups,
347
+ limits: config.syncGuard,
348
+ });
349
+ log.info("IPA sync destructive plan assessed", guard);
350
+ if (guard.violations.length > 0) throw new Error(formatIpaDestructionGuardFailure(guard));
351
+ if (profilesDemoted > 0) {
352
+ log.warn("IPA sync will downgrade user profiles", { profilesDemoted });
353
+ }
354
+ if (profileDriftCount > 0) {
355
+ log.warn("IPA user memberOf drift detected; using group graph projection", {
356
+ profileDriftCount,
357
+ sampleUids: profileDriftSamples,
358
+ });
359
+ }
360
+
361
+ // Revoke before the local transition so a crash after the transaction
362
+ // cannot leave a session valid for a user that no longer appears in the next
363
+ // IPA sync plan. A failed transaction may require a relogin, which is the
364
+ // safe side of this cross-store boundary.
365
+ for (const staleUser of staleLocalUsers) {
366
+ await runCheckpoint();
367
+ await session.revokeAllForUser(staleUser.id as string);
368
+ }
369
+
370
+ let scopeTransitions = 0;
371
+ let effectiveGroupsRebuilt = 0;
372
+ await runCheckpoint(true);
373
+ const transactionStartedAt = Date.now();
374
+ await sql.begin(async (tx) => {
375
+ // 1. Upsert active IPA users
376
+ // Match order: mail (existing IPA user, handles UID renames) → mail (guest promotion) → uid (new or unchanged)
377
+ for (let userIndex = 0; userIndex < activeUsers.length; userIndex += 1) {
378
+ await runCheckpoint();
379
+ const u = activeUsers[userIndex]!;
380
+ const effectiveGroups = effectiveGroupsByUid.get(u.uid) ?? [];
381
+ const profile = calculateIpaProfileFromGroupNames(effectiveGroups, config.groupsBaseIpaRealm);
382
+ const provider = "ipa";
383
+
384
+ if (u.mail) {
385
+ // First: match existing IPA user by mail (handles UID renames)
386
+ const updated = await tx`
387
+ UPDATE auth.users SET
388
+ uid = ${u.uid}, provider = ${provider}, profile = ${profile}, admin = false,
389
+ given_name = ${u.givenname}, sn = ${u.sn},
390
+ display_name = ${u.displayName}, mail = ${u.mail},
391
+ account_expires = ${u.ipaAccountExpires}
392
+ WHERE mail = ${u.mail} AND provider = 'ipa'
393
+ RETURNING id`;
394
+ if (updated.length > 0) {
395
+ await upsertUserIpaData(tx, { userId: updated[0]!.id as string, ...u });
396
+ matchedExistingUsersByMail += 1;
397
+ continue;
398
+ }
399
+
400
+ // Second: optionally migrate a unique local account to IPA.
401
+ if (matchMode === "migrate") {
402
+ const localMatches = await tx<DbRow[]>`
403
+ SELECT id
404
+ FROM auth.users
405
+ WHERE mail = ${u.mail} AND provider = 'local'
406
+ ORDER BY profile = 'user' DESC, created_at ASC
407
+ LIMIT 2
408
+ `;
409
+
410
+ if (localMatches.length === 1) {
411
+ const migrated = await tx`
412
+ UPDATE auth.users SET
413
+ uid = ${u.uid}, provider = ${provider}, profile = ${profile}, admin = false,
414
+ given_name = ${u.givenname}, sn = ${u.sn},
415
+ display_name = ${u.displayName}, mail = ${u.mail},
416
+ account_expires = ${u.ipaAccountExpires}
417
+ WHERE id = ${localMatches[0]!.id as string}::uuid
418
+ RETURNING id`;
419
+ if (migrated.length > 0) {
420
+ await upsertUserIpaData(tx, { userId: migrated[0]!.id as string, ...u });
421
+ migratedLocalUsers += 1;
422
+ continue;
423
+ }
424
+ } else if (localMatches.length > 1) {
425
+ skippedLocalMailConflicts += 1;
426
+ log.warn("Skipping IPA provider migration because multiple local accounts matched by mail", {
427
+ mail: u.mail,
428
+ uid: u.uid,
429
+ });
430
+ continue;
431
+ }
432
+ }
433
+ }
434
+
435
+ const uidConflictRows = await tx<DbRow[]>`
436
+ SELECT id
437
+ FROM auth.users
438
+ WHERE uid = ${u.uid} AND provider = 'local'
439
+ LIMIT 1
440
+ `;
441
+ if (uidConflictRows.length > 0) {
442
+ skippedLocalUidConflicts += 1;
443
+ log.warn("Skipping IPA sync user upsert because a local account already uses the UID", {
444
+ uid: u.uid,
445
+ mail: u.mail,
446
+ });
447
+ continue;
448
+ }
449
+
450
+ // Third: insert new or update existing by uid
451
+ const upserted = await tx<DbRow[]>`
452
+ INSERT INTO auth.users (uid, provider, profile, admin, given_name, sn, display_name, mail, account_expires)
453
+ VALUES (${u.uid}, ${provider}, ${profile}, false, ${u.givenname}, ${u.sn}, ${u.displayName}, ${u.mail}, ${u.ipaAccountExpires})
454
+ ON CONFLICT (uid) DO UPDATE SET
455
+ provider = ${provider},
456
+ profile = ${profile},
457
+ admin = false,
458
+ given_name = EXCLUDED.given_name, sn = EXCLUDED.sn,
459
+ display_name = EXCLUDED.display_name, mail = EXCLUDED.mail,
460
+ account_expires = EXCLUDED.account_expires
461
+ RETURNING id, (xmax = 0) AS inserted`;
462
+ await upsertUserIpaData(tx, { userId: upserted[0]!.id as string, ...u });
463
+ upsertedUsersByUid += 1;
464
+ if (Boolean(upserted[0]?.inserted)) insertedUsersByUid += 1;
465
+ else updatedUsersByUid += 1;
466
+ }
467
+
468
+ for (let staleIndex = 0; staleIndex < staleLocalUsers.length; staleIndex += 1) {
469
+ await runCheckpoint();
470
+ const stale = staleLocalUsers[staleIndex]!;
471
+ const userId = stale.id as string;
472
+ const uid = stale.uid as string;
473
+ const previousProfile = (stale.profile as "user" | "guest" | null) ?? "guest";
474
+
475
+ if (transitionPolicy === "delete") {
476
+ await writeDeletedAccountAudit({
477
+ db: tx,
478
+ userId,
479
+ uid,
480
+ mail: (stale.mail as string) ?? null,
481
+ displayName: (stale.display_name as string) ?? null,
482
+ previousProvider: "ipa",
483
+ previousProfile,
484
+ reason: "sync_out_of_scope_deleted",
485
+ meta: {
486
+ reason: "missing_from_ipa_sync_scope",
487
+ },
488
+ });
489
+ await tx`DELETE FROM auth.users WHERE id = ${userId}::uuid`;
490
+ scopeTransitions += 1;
491
+ continue;
492
+ }
493
+
494
+ const target = await applyIpaAccountTransitionPolicy({
495
+ userId,
496
+ currentProfile: previousProfile,
497
+ policy: transitionPolicy,
498
+ db: tx,
499
+ });
500
+ await writeDeletedAccountAudit({
501
+ db: tx,
502
+ userId,
503
+ uid,
504
+ mail: (stale.mail as string) ?? null,
505
+ displayName: (stale.display_name as string) ?? null,
506
+ previousProvider: "ipa",
507
+ previousProfile,
508
+ reason: "sync_out_of_scope_demoted",
509
+ meta: {
510
+ accountExpiresAt: target.accountExpires?.toISOString() ?? null,
511
+ targetProfile: target.targetProfile,
512
+ reason: "missing_from_ipa_sync_scope",
513
+ },
514
+ });
515
+ scopeTransitions += 1;
516
+ }
517
+
518
+ // 2. Upsert groups + delete stale
519
+ for (let groupIndex = 0; groupIndex < groups.length; groupIndex += 1) {
520
+ await runCheckpoint();
521
+ const g = groups[groupIndex]!;
522
+ await tx`
523
+ INSERT INTO auth.groups (id, cn, name, provider, description, gid_number, synced_at)
524
+ VALUES (gen_random_uuid(), ${g.cn}, ${g.cn}, 'ipa', ${g.description}, ${g.gidnumber}, now())
525
+ ON CONFLICT (provider, name) DO UPDATE SET
526
+ cn = EXCLUDED.cn,
527
+ description = EXCLUDED.description,
528
+ gid_number = EXCLUDED.gid_number,
529
+ synced_at = now()`;
530
+ }
531
+ const groupCnArray = [...groupCns];
532
+ if (groupCnArray.length > 0) {
533
+ const deleted = await tx<DbRow[]>`
534
+ DELETE FROM auth.groups
535
+ WHERE provider = 'ipa'
536
+ AND name <> ALL(${freeipa.util.toPgTextArray(groupCnArray)}::text[])
537
+ RETURNING name
538
+ `;
539
+ deletedGroups = deleted.length;
540
+ } else {
541
+ const deleted = await tx<DbRow[]>`
542
+ DELETE FROM auth.groups
543
+ WHERE provider = 'ipa'
544
+ RETURNING name
545
+ `;
546
+ deletedGroups = deleted.length;
547
+ }
548
+
549
+ const groupRows = await tx<DbRow[]>`SELECT id, name FROM auth.groups WHERE provider = 'ipa'`;
550
+ const groupNameToId = new Map<string, string>(groupRows.map((row) => [row.name as string, row.id as string]));
551
+
552
+ // 3. Rebuild junction tables
553
+ await tx`DELETE FROM auth.user_groups_v2 WHERE group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')`;
554
+ await tx`
555
+ DELETE FROM auth.group_groups_v2
556
+ WHERE parent_group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')
557
+ OR child_group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')
558
+ `;
559
+ await tx`DELETE FROM auth.group_manager_users_v2 WHERE group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')`;
560
+ await tx`
561
+ DELETE FROM auth.group_manager_groups_v2
562
+ WHERE group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')
563
+ OR manager_group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')
564
+ `;
565
+
566
+ const userIdRows: DbRow[] = await tx`SELECT id, uid FROM auth.users WHERE provider = 'ipa'`;
567
+ const uidToId = new Map<string, string>(userIdRows.map((r) => [r.uid as string, r.id as string]));
568
+
569
+ await tx`
570
+ DELETE FROM auth.ipa_user_effective_groups
571
+ WHERE user_id IN (SELECT id FROM auth.users WHERE provider = 'ipa')
572
+ `;
573
+ const effectiveGroupRows: { user_id: string; group_name: string }[] = [];
574
+ for (const [uid, groupNames] of effectiveGroupsByUid) {
575
+ const userId = uidToId.get(uid);
576
+ if (!userId) continue;
577
+ for (const groupName of groupNames) {
578
+ effectiveGroupRows.push({ user_id: userId, group_name: groupName });
579
+ }
580
+ }
581
+ if (effectiveGroupRows.length > 0) {
582
+ await runCheckpoint();
583
+ await tx`INSERT INTO auth.ipa_user_effective_groups ${sql(effectiveGroupRows, "user_id", "group_name")} ON CONFLICT DO NOTHING`;
584
+ }
585
+ effectiveGroupsRebuilt = effectiveGroupRows.length;
586
+
587
+ // Bulk INSERT helper. Bun's `sql(rows)` only generates valid VALUES
588
+ // syntax when fed an array of OBJECTS (it derives the column list from
589
+ // object keys). Passing array-of-arrays produces broken SQL like
590
+ // `VALUES ("0", "1") VALUES($1, $2),...` — see Bun postgres bug. So we
591
+ // build typed objects and use the column-form `sql(rows, "col_a", "col_b")`
592
+ // to control column ordering explicitly.
593
+
594
+ // user_groups — built from group's member_user (authoritative source)
595
+ const userGroupRows: { user_id: string; group_id: string }[] = [];
596
+ for (const g of groups) {
597
+ const groupId = groupNameToId.get(g.cn);
598
+ if (!groupId) continue;
599
+ for (const uid of g.users) {
600
+ const userId = uidToId.get(uid);
601
+ if (userId) userGroupRows.push({ user_id: userId, group_id: groupId });
602
+ }
603
+ }
604
+ if (userGroupRows.length > 0) {
605
+ await runCheckpoint();
606
+ await tx`INSERT INTO auth.user_groups_v2 ${sql(userGroupRows, "user_id", "group_id")} ON CONFLICT DO NOTHING`;
607
+ }
608
+
609
+ // group_groups + manager junctions
610
+ const groupGroupRows: { parent_group_id: string; child_group_id: string }[] = [];
611
+ const managerUserRows: { group_id: string; user_id: string }[] = [];
612
+ const managerGroupRows: { group_id: string; manager_group_id: string }[] = [];
613
+ for (const g of groups) {
614
+ const groupId = groupNameToId.get(g.cn);
615
+ if (!groupId) continue;
616
+ for (const child of g.groups) {
617
+ const childGroupId = groupNameToId.get(child);
618
+ if (childGroupId) groupGroupRows.push({ parent_group_id: groupId, child_group_id: childGroupId });
619
+ }
620
+ for (const uid of g.managerUsers) {
621
+ const userId = uidToId.get(uid);
622
+ if (userId) managerUserRows.push({ group_id: groupId, user_id: userId });
623
+ }
624
+ for (const mgr of g.managerGroups) {
625
+ const managerGroupId = groupNameToId.get(mgr);
626
+ if (managerGroupId) managerGroupRows.push({ group_id: groupId, manager_group_id: managerGroupId });
627
+ }
628
+ }
629
+ if (groupGroupRows.length > 0) {
630
+ await runCheckpoint();
631
+ await tx`INSERT INTO auth.group_groups_v2 ${sql(groupGroupRows, "parent_group_id", "child_group_id")} ON CONFLICT DO NOTHING`;
632
+ }
633
+ if (managerUserRows.length > 0) {
634
+ await tx`INSERT INTO auth.group_manager_users_v2 ${sql(managerUserRows, "group_id", "user_id")} ON CONFLICT DO NOTHING`;
635
+ }
636
+ if (managerGroupRows.length > 0) {
637
+ await tx`INSERT INTO auth.group_manager_groups_v2 ${sql(managerGroupRows, "group_id", "manager_group_id")} ON CONFLICT DO NOTHING`;
638
+ }
639
+ });
640
+ const transactionDurationMs = Date.now() - transactionStartedAt;
641
+
642
+ log.info("Sync complete", {
643
+ durationMs: Date.now() - startedAt,
644
+ snapshotFetchDurationMs,
645
+ transactionDurationMs,
646
+ leaseHeartbeats,
647
+ remoteUsersFetched: allRawUsers.length,
648
+ remoteUsersInScope: users.length,
649
+ remoteExpiredUsers: expiredUsers,
650
+ activeUsersSynced: activeUsers.length,
651
+ matchedExistingUsersByMail,
652
+ migratedLocalUsers,
653
+ skippedLocalMailConflicts,
654
+ skippedLocalUidConflicts,
655
+ staleUsersDemoted: scopeTransitions,
656
+ scopeTransitions,
657
+ profileDriftCount,
658
+ profilesPromoted,
659
+ profilesDemoted,
660
+ effectiveGroupsRebuilt,
661
+ upsertedUsersByUid,
662
+ insertedUsersByUid,
663
+ updatedUsersByUid,
664
+ groupsSynced: groups.length,
665
+ deletedGroups,
666
+ localIpaUsersBefore: localIpaUsers,
667
+ localGroupsBefore: localGroups,
668
+ destructiveUserChanges: guard.userChanges,
669
+ destructiveUserChangePercent: guard.userChangePercent,
670
+ destructiveGroupDeletions: guard.groupDeletions,
671
+ destructiveGroupDeletionPercent: guard.groupDeletionPercent,
672
+ destructionGuardLimits: guard.limits,
673
+ });
674
+ };
675
+
676
+ /**
677
+ * Outcome of a single-user sync attempt. The login flow must reject any outcome
678
+ * other than `synced`; stale mirror state must never grant a fresh session when
679
+ * FreeIPA says the user is expired, missing, or out of sync scope.
680
+ */
681
+ export type SyncUserOutcome =
682
+ | { status: "synced"; userId: string }
683
+ | { status: "skipped_disabled" }
684
+ | { status: "fetch_failed"; error: string }
685
+ | { status: "expired"; userId: string | null }
686
+ | { status: "out_of_scope"; userId: string | null }
687
+ | { status: "not_found_local" };
688
+
689
+ /**
690
+ * Reconcile a local IPA mirror row after discovering the remote user is no
691
+ * longer valid because the IPA account is expired. Full sync also uses this
692
+ * transition policy for graph-derived out-of-scope users; single-user sync does
693
+ * not demote based on FreeIPA user-side memberOf data.
694
+ */
695
+ const reconcileOutOfScopeUser = async (params: {
696
+ userId: string;
697
+ uid: string;
698
+ mail: string | null;
699
+ displayName: string | null;
700
+ previousProfile: "user" | "guest";
701
+ reason: "ipa_expired_demoted" | "ipa_expired_deleted" | "sync_out_of_scope_demoted" | "sync_out_of_scope_deleted";
702
+ meta?: Record<string, unknown>;
703
+ }): Promise<void> => {
704
+ const transitionPolicy = parseIpaAccountTransitionPolicy(await settings.get<string | null>("freeipa.account_transition_policy"));
705
+
706
+ await session.revokeAllForUser(params.userId);
707
+ await sql.begin(async (tx) => {
708
+ if (transitionPolicy === "delete") {
709
+ await writeDeletedAccountAudit({
710
+ db: tx,
711
+ userId: params.userId,
712
+ uid: params.uid,
713
+ mail: params.mail,
714
+ displayName: params.displayName,
715
+ previousProvider: "ipa",
716
+ previousProfile: params.previousProfile,
717
+ reason: params.reason.endsWith("_demoted")
718
+ ? (params.reason.replace("_demoted", "_deleted") as typeof params.reason)
719
+ : params.reason,
720
+ meta: params.meta ?? {},
721
+ });
722
+ await tx`DELETE FROM auth.users WHERE id = ${params.userId}::uuid`;
723
+ return;
724
+ }
725
+
726
+ const target = await applyIpaAccountTransitionPolicy({
727
+ userId: params.userId,
728
+ currentProfile: params.previousProfile,
729
+ policy: transitionPolicy,
730
+ db: tx,
731
+ });
732
+ await writeDeletedAccountAudit({
733
+ db: tx,
734
+ userId: params.userId,
735
+ uid: params.uid,
736
+ mail: params.mail,
737
+ displayName: params.displayName,
738
+ previousProvider: "ipa",
739
+ previousProfile: params.previousProfile,
740
+ reason: params.reason,
741
+ meta: {
742
+ ...(params.meta ?? {}),
743
+ accountExpiresAt: target.accountExpires?.toISOString() ?? null,
744
+ targetProfile: target.targetProfile,
745
+ },
746
+ });
747
+ });
748
+ };
749
+
750
+ /**
751
+ * Sync a single user's attributes from FreeIPA.
752
+ * Called on login to ensure time-sensitive data is up-to-date.
753
+ *
754
+ * IMPORTANT: Only syncs user attributes (name, mail, expiry, aliases).
755
+ * Does NOT sync group memberships. Scope/profile come from the last full-sync
756
+ * effective group projection; user-side memberOf drift is logged but never used
757
+ * for destructive transitions here.
758
+ *
759
+ * Returns a typed outcome so callers (notably `authFlows.ipa.login`) can decide
760
+ * whether to grant a session. Expired users are reconciled immediately; group
761
+ * scope changes are handled by full sync.
762
+ */
763
+ export const syncUser = async (username: string): Promise<SyncUserOutcome> => {
764
+ const config = await getFreeIpaConfig();
765
+ if (!config.enabled) {
766
+ log.info("Single-user sync skipped", { reason: "freeipa_disabled", username });
767
+ return { status: "skipped_disabled" };
768
+ }
769
+ if (!config.configured) {
770
+ throw new Error(`FreeIPA is enabled but not fully configured. Missing: ${config.missingSettings.join(", ")}.`);
771
+ }
772
+ const ipaSession = await freeipa.session.getServiceSession({
773
+ url: config.url,
774
+ serviceUser: config.serviceUser,
775
+ servicePassword: config.servicePassword,
776
+ });
777
+
778
+ // Fetch user from FreeIPA
779
+ const userRes = await freeipa.client.call({
780
+ url: config.url,
781
+ ipaSession,
782
+ method: "user_show",
783
+ args: [username],
784
+ options: { all: true },
785
+ });
786
+ if (userRes.error || !userRes.result?.result) {
787
+ const error = userRes.error?.message ?? "user_show returned empty result";
788
+ log.warn("Could not fetch user", { username, error });
789
+ return { status: "fetch_failed", error };
790
+ }
791
+
792
+ const raw = userRes.result.result as Record<string, unknown>;
793
+ const user = transformSyncUser(raw);
794
+
795
+ const existingRow: DbRow | undefined = (
796
+ await sql<DbRow[]>`
797
+ SELECT id, mail, display_name, profile
798
+ FROM auth.users
799
+ WHERE uid = ${user.uid} AND provider = 'ipa'
800
+ `
801
+ )[0];
802
+ const existingUserId = (existingRow?.id as string | undefined) ?? null;
803
+ const previousProfile: "user" | "guest" = (existingRow?.profile as "user" | "guest" | undefined) ?? "guest";
804
+
805
+ if (isExpired(user)) {
806
+ log.warn("User expired during single-user sync", { username });
807
+ if (existingUserId) {
808
+ await reconcileOutOfScopeUser({
809
+ userId: existingUserId,
810
+ uid: user.uid,
811
+ mail: (existingRow?.mail as string | null) ?? user.mail,
812
+ displayName: (existingRow?.display_name as string | null) ?? user.displayName,
813
+ previousProfile,
814
+ reason: "ipa_expired_demoted",
815
+ meta: { accountExpiresAt: user.ipaAccountExpires?.toISOString() ?? null },
816
+ });
817
+ }
818
+ return { status: "expired", userId: existingUserId };
819
+ }
820
+
821
+ if (!existingUserId) {
822
+ log.warn("User not found in local DB during single-user sync", { username });
823
+ return { status: "not_found_local" };
824
+ }
825
+
826
+ const effectiveGroups = await getEffectiveUserGroups(existingUserId);
827
+ const inSyncGroups = config.groupsBaseSync.some((g) => effectiveGroups.includes(g));
828
+ if (!inSyncGroups) {
829
+ log.warn("User not in projected sync groups during single-user sync", {
830
+ username,
831
+ reason: "missing_from_last_full_sync_projection",
832
+ });
833
+ return { status: "out_of_scope", userId: existingUserId };
834
+ }
835
+
836
+ const profile = await calculateIpaProfileFromEffectiveProjection(existingUserId);
837
+ const userSideProfile = calculateIpaProfileFromGroupNames(user.memberofGroup, config.groupsBaseIpaRealm);
838
+ if (profile !== userSideProfile) {
839
+ log.warn("IPA user memberOf drift detected during single-user sync", {
840
+ username,
841
+ projectedProfile: profile,
842
+ userSideProfile,
843
+ });
844
+ }
845
+ const provider = "ipa";
846
+
847
+ // Update user attributes only (no group sync!)
848
+ await sql`
849
+ UPDATE auth.users SET
850
+ provider = ${provider},
851
+ profile = ${profile},
852
+ admin = false,
853
+ given_name = ${user.givenname},
854
+ sn = ${user.sn},
855
+ display_name = ${user.displayName},
856
+ mail = ${user.mail},
857
+ account_expires = ${user.ipaAccountExpires}
858
+ WHERE uid = ${user.uid}
859
+ `;
860
+ await sql.begin((tx) => upsertUserIpaData(tx, { userId: existingUserId, ...user }));
861
+ return { status: "synced", userId: existingUserId };
862
+ };