@k2b/cloud 0.6.0-rc.0 → 0.7.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 (315) hide show
  1. package/README.md +2 -2
  2. package/package.json +30 -17
  3. package/scripts/browser-performance.ts +14 -0
  4. package/scripts/build-pdf-renderer.ts +39 -0
  5. package/scripts/build.ts +15 -35
  6. package/scripts/preload.ts +9 -12
  7. package/scripts/sync-recovery-smoke.ts +2 -1
  8. package/src/_internal/app-presentation.ts +14 -2
  9. package/src/_internal/capabilities.ts +108 -7
  10. package/src/_internal/capability-streams.ts +81 -0
  11. package/src/_internal/define-app.ts +62 -36
  12. package/src/_internal/heartbeat.ts +4 -0
  13. package/src/_internal/help-catalog.ts +27 -172
  14. package/src/_internal/help.ts +13 -76
  15. package/src/_internal/page-responses.ts +2 -0
  16. package/src/_internal/postgres-application-name.ts +30 -0
  17. package/src/_internal/process-identity.ts +15 -0
  18. package/src/_internal/process-sync.ts +1 -1
  19. package/src/_internal/registry-validation.ts +29 -25
  20. package/src/_internal/registry.ts +2 -53
  21. package/src/_internal/runtime-context.ts +5 -16
  22. package/src/_internal/server-timing.ts +13 -0
  23. package/src/_internal/status-preserving-ssr.ts +18 -6
  24. package/src/_internal/web-vitals-asset.ts +10 -0
  25. package/src/access/PermissionEditor.tsx +14 -153
  26. package/src/access/PrincipalPicker.tsx +104 -0
  27. package/src/access/ui.ts +2 -0
  28. package/src/ai/admin.ts +14 -13
  29. package/src/ai/assistant-models.ts +12 -1
  30. package/src/ai/audio-format.ts +34 -0
  31. package/src/ai/audio-tool.ts +90 -0
  32. package/src/ai/browser-code-contracts.ts +79 -0
  33. package/src/ai/browser.ts +2 -0
  34. package/src/ai/capabilities.ts +55 -36
  35. package/src/ai/capability-execution.ts +47 -12
  36. package/src/ai/chat/blocks.tsx +157 -95
  37. package/src/ai/chat/capability-result.ts +15 -0
  38. package/src/ai/chat/capability-table.tsx +2 -10
  39. package/src/ai/chat/composer-adapter.ts +48 -3
  40. package/src/ai/chat/message-actions.tsx +8 -7
  41. package/src/ai/chat/message-utils.ts +12 -1
  42. package/src/ai/chat/messages.ts +2 -0
  43. package/src/ai/chat/presentation.tsx +19 -48
  44. package/src/ai/chat/primitives.tsx +43 -1
  45. package/src/ai/chat/tool-disclosure.tsx +17 -2
  46. package/src/ai/chat/tool-groups.ts +52 -0
  47. package/src/ai/chat-quotas.ts +25 -0
  48. package/src/ai/chat-task-contracts.ts +9 -1
  49. package/src/ai/chat-tasks.ts +276 -43
  50. package/src/ai/client/controller.ts +110 -33
  51. package/src/ai/client/file-source.ts +12 -6
  52. package/src/ai/client/live-connection.ts +1 -0
  53. package/src/ai/client/projection.ts +22 -11
  54. package/src/ai/client/transport.ts +4 -0
  55. package/src/ai/code-capability-routes.ts +110 -0
  56. package/src/ai/code-capability-transport.ts +38 -0
  57. package/src/ai/code-mode-skill.ts +114 -0
  58. package/src/ai/code-runtime-tools.ts +166 -0
  59. package/src/ai/code-source-contracts.ts +202 -0
  60. package/src/ai/code-source-tools.ts +145 -0
  61. package/src/ai/compaction.ts +27 -6
  62. package/src/ai/data-analysis-skill.ts +10 -0
  63. package/src/ai/default-tools.ts +25 -2
  64. package/src/ai/dictation-runtime.ts +171 -0
  65. package/src/ai/dictations.ts +188 -0
  66. package/src/ai/draft-content.ts +34 -0
  67. package/src/ai/enrich.ts +6 -0
  68. package/src/ai/executor.ts +191 -31
  69. package/src/ai/file-content-version.ts +22 -0
  70. package/src/ai/file-context.ts +2 -1
  71. package/src/ai/file-media-type.ts +12 -0
  72. package/src/ai/file-reference-contracts.ts +15 -0
  73. package/src/ai/file-tools.ts +2 -1
  74. package/src/ai/files-store.ts +174 -71
  75. package/src/ai/fixtures/vision-encrypted.pdf +0 -0
  76. package/src/ai/grids-skill.ts +40 -10
  77. package/src/ai/http.ts +15 -8
  78. package/src/ai/index.ts +26 -2
  79. package/src/ai/inference-calls.ts +198 -0
  80. package/src/ai/live-events.ts +5 -1
  81. package/src/ai/memory-learning.ts +60 -28
  82. package/src/ai/message-queue.ts +167 -0
  83. package/src/ai/migrate.ts +187 -11
  84. package/src/ai/model-pricing.ts +43 -0
  85. package/src/ai/pdf-render-worker.ts +74 -0
  86. package/src/ai/pdf-render.fixture.ts +28 -0
  87. package/src/ai/pdf-render.ts +77 -0
  88. package/src/ai/projects-routes.ts +3 -3
  89. package/src/ai/projects.ts +20 -6
  90. package/src/ai/protocol.ts +3 -0
  91. package/src/ai/provider.ts +1 -8
  92. package/src/ai/quota-provider.ts +191 -0
  93. package/src/ai/quota-report.ts +152 -0
  94. package/src/ai/quotas-migrate.ts +45 -0
  95. package/src/ai/quotas.ts +168 -0
  96. package/src/ai/routes.ts +215 -11
  97. package/src/ai/run-timeout.ts +10 -0
  98. package/src/ai/runtime-tools.ts +3 -0
  99. package/src/ai/runtime.ts +55 -16
  100. package/src/ai/settings.ts +36 -8
  101. package/src/ai/short-id.ts +2 -2
  102. package/src/ai/skill-catalog.ts +0 -10
  103. package/src/ai/skill-search.ts +47 -0
  104. package/src/ai/skill-seeds.ts +105 -20
  105. package/src/ai/skill-tool.ts +16 -5
  106. package/src/ai/skills-routes.ts +32 -4
  107. package/src/ai/skills.ts +324 -155
  108. package/src/ai/store.ts +373 -191
  109. package/src/ai/stream.ts +5 -0
  110. package/src/ai/structured-runs.ts +4 -3
  111. package/src/ai/structured.ts +8 -1
  112. package/src/ai/system-prompt.ts +12 -2
  113. package/src/ai/timeline.ts +2 -5
  114. package/src/ai/todo-contracts.ts +20 -0
  115. package/src/ai/todo-tool.ts +16 -0
  116. package/src/ai/tool-audit.ts +30 -40
  117. package/src/ai/tools.ts +6 -0
  118. package/src/ai/transcription.ts +202 -0
  119. package/src/ai/turn-timing.ts +114 -0
  120. package/src/ai/types.ts +58 -10
  121. package/src/ai/ui.tsx +2 -0
  122. package/src/ai/usage.ts +118 -118
  123. package/src/ai/vision-tool.ts +39 -10
  124. package/src/api/admin-ai-projects.ts +2 -2
  125. package/src/api/admin-ai-quotas.ts +74 -0
  126. package/src/api/admin-ai-skills.ts +48 -5
  127. package/src/api/admin-ai-usage.ts +2 -2
  128. package/src/api/admin-core-settings.ts +25 -0
  129. package/src/api/admin-identity.ts +91 -51
  130. package/src/api/admin-rail.ts +58 -0
  131. package/src/api/announcements.ts +14 -0
  132. package/src/api/capabilities.ts +292 -43
  133. package/src/api/capability-streams.ts +262 -0
  134. package/src/api/help.ts +8 -44
  135. package/src/api/index.ts +6 -0
  136. package/src/api/mcp.ts +44 -26
  137. package/src/api/me-web-vitals.ts +35 -0
  138. package/src/api/me.ts +2 -0
  139. package/src/api/search/schemas.ts +26 -15
  140. package/src/api/search.bench.ts +1 -1
  141. package/src/api/search.ts +54 -17
  142. package/src/browser/CloudResourceSearch.tsx +640 -702
  143. package/src/browser/app-approval-vault.ts +19 -7
  144. package/src/browser/command-bridge.ts +109 -0
  145. package/src/browser/command-shortcuts.ts +76 -0
  146. package/src/browser/commands.ts +66 -0
  147. package/src/browser/navigation-search.ts +48 -0
  148. package/src/browser/notifications.ts +1 -1
  149. package/src/browser/resource-picker.tsx +5 -18
  150. package/src/browser/resource-search-dialog.ts +9 -0
  151. package/src/browser/resource-search-input.ts +46 -0
  152. package/src/browser/resource-search-messages.ts +76 -40
  153. package/src/browser/resource-search.ts +7 -0
  154. package/src/browser/search-bridge.ts +102 -0
  155. package/src/browser/search-commands.ts +59 -0
  156. package/src/browser/search.ts +13 -0
  157. package/src/browser/spotlight-position.ts +208 -0
  158. package/src/browser/testing.ts +5 -0
  159. package/src/browser/web-vitals.ts +25 -0
  160. package/src/capabilities/claims.ts +179 -0
  161. package/src/capabilities/client.ts +34 -0
  162. package/src/capabilities/command-link.ts +16 -0
  163. package/src/capabilities/executions.ts +298 -0
  164. package/src/capabilities/migrate.ts +81 -0
  165. package/src/capabilities/server.ts +114 -34
  166. package/src/capabilities/stream-body.ts +16 -0
  167. package/src/capabilities/streams.ts +28 -0
  168. package/src/cli/admin/ai-quotas.ts +161 -0
  169. package/src/cli/admin/ai-skills.ts +64 -0
  170. package/src/cli/admin/ai-usage.ts +53 -30
  171. package/src/cli/admin/app-credentials.ts +79 -0
  172. package/src/cli/admin/data.ts +17 -0
  173. package/src/cli/admin/index.ts +9 -0
  174. package/src/cli/capabilities.ts +29 -0
  175. package/src/cli/capability-streams.ts +25 -0
  176. package/src/cli/index.ts +3 -2
  177. package/src/config/env.ts +11 -8
  178. package/src/contracts/app.ts +23 -3
  179. package/src/contracts/capabilities.ts +68 -5
  180. package/src/contracts/capability-streams.ts +46 -0
  181. package/src/contracts/commands.ts +49 -0
  182. package/src/contracts/index.ts +6 -1
  183. package/src/contracts/rail-admin.ts +38 -0
  184. package/src/contracts/rail-preferences.ts +16 -0
  185. package/src/contracts/registry.ts +3 -27
  186. package/src/contracts/settings-types.ts +1 -1
  187. package/src/contracts/shared.ts +9 -5
  188. package/src/contracts/web-vitals.ts +27 -0
  189. package/src/desktop/solid.tsx +3 -3
  190. package/src/index.ts +0 -4
  191. package/src/server/index.ts +3 -0
  192. package/src/server/middleware/auth.ts +3 -2
  193. package/src/server/middleware/route-template.ts +9 -3
  194. package/src/server/middleware/runtime.ts +2 -1
  195. package/src/server/middleware/settings.ts +17 -11
  196. package/src/server/services/access-revision.ts +10 -0
  197. package/src/server/services/access.ts +1 -1
  198. package/src/server/services/index.ts +1 -0
  199. package/src/services/account-category-policy.ts +9 -0
  200. package/src/services/account-lifecycle/audit.ts +1 -1
  201. package/src/services/accounts/app.ts +12 -2
  202. package/src/services/accounts/email-write.ts +23 -0
  203. package/src/services/accounts/groups.ts +15 -4
  204. package/src/services/accounts/identities.ts +249 -0
  205. package/src/services/accounts/identity-reconciliation.ts +87 -0
  206. package/src/services/accounts/ipa-data.ts +2 -2
  207. package/src/services/accounts/local-groups.ts +2 -2
  208. package/src/services/accounts/posix.ts +45 -29
  209. package/src/services/accounts/users.ts +2 -2
  210. package/src/services/announcements/index.ts +69 -8
  211. package/src/services/audit/index.ts +1 -1
  212. package/src/services/cache-fill.ts +35 -0
  213. package/src/services/help/index.ts +109 -0
  214. package/src/services/help/maintenance.ts +25 -0
  215. package/src/services/help/store.ts +180 -0
  216. package/src/services/help/types.ts +46 -0
  217. package/src/services/identity/invocation-actor.ts +2 -2
  218. package/src/services/identity/key-config.ts +2 -1
  219. package/src/services/identity/key-ring.ts +1 -1
  220. package/src/services/index.ts +15 -0
  221. package/src/services/ipa/users.ts +268 -221
  222. package/src/services/legal-consent.ts +1 -1
  223. package/src/services/logging/index.ts +15 -13
  224. package/src/services/logging/trace.ts +2 -1
  225. package/src/services/logging/web-vitals.ts +81 -0
  226. package/src/services/mandates/index.ts +18 -4
  227. package/src/services/mandates/policy.ts +65 -2
  228. package/src/services/notifications/batches.ts +124 -57
  229. package/src/services/notifications/browser.ts +12 -0
  230. package/src/services/notifications/index.ts +0 -2
  231. package/src/services/notifications/platform.ts +0 -4
  232. package/src/services/oauth-tokens.ts +3 -3
  233. package/src/services/pdf/gotenberg.ts +107 -84
  234. package/src/services/pdf/index.ts +3 -0
  235. package/src/services/providers/local/users.ts +25 -16
  236. package/src/services/public-http.ts +101 -0
  237. package/src/services/rail-shortcuts.ts +95 -0
  238. package/src/services/rail-snapshot.ts +53 -0
  239. package/src/services/request-cache-redis.ts +28 -0
  240. package/src/services/session/index.ts +15 -2
  241. package/src/services/session/user.ts +62 -37
  242. package/src/services/settings/core-settings.ts +21 -28
  243. package/src/services/settings/defaults.ts +5 -0
  244. package/src/services/settings/store.ts +58 -84
  245. package/src/shared/ai-costs.ts +42 -0
  246. package/src/shared/ai-platform-prompt.ts +1 -1
  247. package/src/shared/ai-quotas.ts +136 -0
  248. package/src/shared/ai-usage.ts +6 -16
  249. package/src/shared/app-presentation.ts +21 -15
  250. package/src/shared/capability-messages.ts +14 -0
  251. package/src/shared/help.ts +6 -42
  252. package/src/shared/index.ts +6 -0
  253. package/src/ssr/AdminLayout.tsx +7 -5
  254. package/src/ssr/AdminSidebar.tsx +29 -15
  255. package/src/ssr/AppLaunchpad.island.tsx +24 -137
  256. package/src/ssr/AppLaunchpadPanel.tsx +181 -0
  257. package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
  258. package/src/ssr/GlobalSearchDialog.tsx +167 -20
  259. package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
  260. package/src/ssr/Layout.tsx +17 -6
  261. package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
  262. package/src/ssr/LayoutFooter.tsx +1 -1
  263. package/src/ssr/LayoutHeader.tsx +46 -14
  264. package/src/ssr/LayoutHelp.tsx +23 -22
  265. package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
  266. package/src/ssr/LayoutRail.tsx +17 -9
  267. package/src/ssr/MobileNavigation.tsx +71 -0
  268. package/src/ssr/MobileProfileActions.tsx +49 -0
  269. package/src/ssr/ProfilePreferences.island.tsx +3 -36
  270. package/src/ssr/RailApps.island.tsx +2 -2
  271. package/src/ssr/RailEditor.tsx +17 -2
  272. package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
  273. package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
  274. package/src/ssr/admin-navigation.ts +7 -1
  275. package/src/ssr/help.ts +29 -0
  276. package/src/ssr/islands/SearchBar.tsx +67 -0
  277. package/src/ssr/islands/index.ts +3 -1
  278. package/src/ssr/layout-context.ts +2 -2
  279. package/src/ssr/layout-help-search.ts +15 -0
  280. package/src/ssr/mobile-menu-history.ts +70 -0
  281. package/src/ssr/platform-messages.ts +16 -8
  282. package/src/ssr/profile-actions.ts +42 -0
  283. package/src/ssr/rail-context.ts +8 -4
  284. package/src/ssr/rail-messages.ts +2 -0
  285. package/src/ssr/rail-navigation.ts +3 -3
  286. package/src/ssr/workspace-navigation.ts +75 -0
  287. package/src/styles/effects.css +6 -6
  288. package/src/styles/global.css +54 -0
  289. package/src/styles/input.css +43 -41
  290. package/src/styles/resource-search.css +505 -0
  291. package/src/styles/utilities-feedback.css +13 -0
  292. package/src/styles/utilities-layout.css +1 -5
  293. package/src/styles/utilities-navigation.css +3 -39
  294. package/src/types/ambient.d.ts +5 -0
  295. package/src/workflows/ai/runtime.ts +5 -1
  296. package/src/workflows/ai/store.ts +2 -0
  297. package/src/workflows/ai-actions.ts +16 -13
  298. package/src/workflows/definition.ts +10 -5
  299. package/src/workflows/store/actions.ts +44 -20
  300. package/src/workflows/store/index.ts +2 -0
  301. package/src/workflows/store/runs.ts +36 -0
  302. package/src/workflows/store/worker-pool.ts +55 -0
  303. package/src/workflows/store/worker-runtime.ts +90 -0
  304. package/scripts/app-favicon.test.ts +0 -49
  305. package/src/ai/chat/blocks.render.test.tsx +0 -1108
  306. package/src/ai/kit-skill.ts +0 -22
  307. package/src/ai/usage-migrate.ts +0 -81
  308. package/src/browser/CloudResourceSearch.behavior.test.tsx +0 -74
  309. package/src/browser/notification-target.ts +0 -27
  310. package/src/contracts/notification-live.ts +0 -66
  311. package/src/services/notifications/live.ts +0 -50
  312. package/src/ssr/BrowserNotifications.island.tsx +0 -135
  313. package/src/ssr/GlobalSearchDialog.behavior.test.tsx +0 -96
  314. package/src/ssr/Layout.render.test.tsx +0 -148
  315. package/src/ssr/islands/SearchBar.island.tsx +0 -80
@@ -1,5 +1,6 @@
1
1
  import type { Context } from "hono";
2
2
  import { createMiddleware } from "hono/factory";
3
+ import { measureServerPhase } from "../../_internal/server-timing";
3
4
  import type { MessageResponse, Role, RoleOrSpecial, User, UserProfile, UserProvider } from "../../contracts/shared";
4
5
  import { isAccountExpired } from "../../services/account-model";
5
6
  import { isReservedWorkloadApiCredential } from "../../services/identity/workload-auth";
@@ -215,10 +216,10 @@ const loadAuthenticatedActor = (
215
216
  c: Context<AuthContext>,
216
217
  options: Pick<RoleOptions, "oauthAudience"> = {},
217
218
  ): Promise<AuthenticatedActorResult> => {
218
- if (options.oauthAudience) return loadAuthenticatedActorUncached(c, options);
219
+ if (options.oauthAudience) return measureServerPhase(c, "auth", () => loadAuthenticatedActorUncached(c, options));
219
220
  const existing = actorResolutionByRequest.get(c);
220
221
  if (existing) return existing;
221
- const pending = loadAuthenticatedActorUncached(c, options);
222
+ const pending = measureServerPhase(c, "auth", () => loadAuthenticatedActorUncached(c, options));
222
223
  actorResolutionByRequest.set(c, pending);
223
224
  return pending;
224
225
  };
@@ -16,6 +16,8 @@
16
16
  * Templates only: the request path itself never leaves the app, so ids
17
17
  * and query strings stay out of telemetry by construction.
18
18
  */
19
+
20
+ import type { Context } from "hono";
19
21
  import { createMiddleware } from "hono/factory";
20
22
  import { routePath } from "hono/route";
21
23
  import { ROUTE_TEMPLATE_HEADER } from "../../services/gateway";
@@ -32,14 +34,18 @@ const MAX_TEMPLATE_LENGTH = 200;
32
34
  */
33
35
  const stripParamConstraints = (template: string): string => template.replace(/\{[^}]*\}/g, "");
34
36
 
37
+ export const matchedRouteTemplate = (c: Context): string | null => {
38
+ const template = routePath(c);
39
+ return !template || template === UNMATCHED ? null : stripParamConstraints(template).slice(0, MAX_TEMPLATE_LENGTH);
40
+ };
41
+
35
42
  export const routeTemplate = createMiddleware(async (c, next) => {
36
43
  await next();
37
44
 
38
45
  // Best-effort: telemetry enrichment must never break a response.
39
46
  try {
40
- const template = routePath(c);
41
- if (!template || template === UNMATCHED) return;
42
- c.res.headers.set(ROUTE_TEMPLATE_HEADER, stripParamConstraints(template).slice(0, MAX_TEMPLATE_LENGTH));
47
+ const template = matchedRouteTemplate(c);
48
+ if (template) c.res.headers.set(ROUTE_TEMPLATE_HEADER, template);
43
49
  } catch {
44
50
  // Immutable response headers or an unrouted context — nothing to report.
45
51
  }
@@ -9,11 +9,12 @@
9
9
  */
10
10
  import { createMiddleware } from "hono/factory";
11
11
  import { ensureRuntimeWatcher, getCurrentRuntime } from "../../_internal/runtime-watcher";
12
+ import { measureServerPhase } from "../../_internal/server-timing";
12
13
  import { routeTemplate } from "./route-template";
13
14
 
14
15
  export const runtime = () =>
15
16
  createMiddleware(async (c, next) => {
16
- await ensureRuntimeWatcher();
17
+ await measureServerPhase(c, "runtime", ensureRuntimeWatcher);
17
18
  (c as unknown as { set: (k: string, v: unknown) => void }).set("runtime", getCurrentRuntime());
18
19
  // Piggybacks on the middleware every proxied app already installs, so
19
20
  // gateway telemetry gets per-endpoint route templates without any app
@@ -13,6 +13,7 @@
13
13
  * `.use()` path instead.
14
14
  */
15
15
  import { createMiddleware } from "hono/factory";
16
+ import { measureServerPhase } from "../../_internal/server-timing";
16
17
  import {
17
18
  type ActiveAnnouncementsResponse,
18
19
  type AnnouncementCookieState,
@@ -38,18 +39,23 @@ export const settings = (opts?: { skipPrefixes?: readonly string[] }) => {
38
39
  return createMiddleware(async (c, next) => {
39
40
  const path = c.req.path;
40
41
  if (!skip.some((p) => path.startsWith(p))) {
41
- (c as unknown as { set: (k: string, v: unknown) => void }).set("settings", await loadSnapshot());
42
- }
43
- const cookieHeader = c.req.header("Cookie") ?? null;
44
- if (shouldLoadAnnouncements(path, cookieHeader)) {
45
- const cookieState = parseAnnouncementCookieHeader(cookieHeader);
46
- try {
47
- const active = await announcements.active.forState({ state: cookieState });
48
- (c as unknown as { set: (k: string, v: unknown) => void }).set("announcements", { ...active, cookieState });
49
- } catch (error) {
50
- log.warn("Failed to preload announcements", { error: error instanceof Error ? error.message : String(error) });
51
- }
42
+ (c as unknown as { set: (k: string, v: unknown) => void }).set("settings", await measureServerPhase(c, "settings", loadSnapshot));
52
43
  }
53
44
  await next();
54
45
  });
55
46
  };
47
+
48
+ /** Called by SSR finalization, after redirects and non-HTML responses are known. */
49
+ export const preloadLayoutAnnouncements = async (c: import("hono").Context): Promise<void> => {
50
+ const path = c.req.path;
51
+ const cookieHeader = c.req.header("Cookie") ?? null;
52
+ if (shouldLoadAnnouncements(path, cookieHeader)) {
53
+ const cookieState = parseAnnouncementCookieHeader(cookieHeader);
54
+ try {
55
+ const active = await announcements.active.forState({ state: cookieState });
56
+ (c as unknown as { set: (k: string, v: unknown) => void }).set("announcements", { ...active, cookieState });
57
+ } catch (error) {
58
+ log.warn("Failed to preload announcements", { error: error instanceof Error ? error.message : String(error) });
59
+ }
60
+ }
61
+ };
@@ -0,0 +1,10 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { Principal, PermissionLevel } from "./access";
3
+
4
+ /** Compare the grants that were reviewed with those held under a resource lock. */
5
+ export function accessRevision(entries: ReadonlyArray<{ id: string; principal: Principal; permission: PermissionLevel }>): string {
6
+ const state = entries.map(entry => [entry.id, entry.permission,
7
+ Object.entries(entry.principal).sort(([left], [right]) => left.localeCompare(right))])
8
+ .sort((left, right) => String(left[0]).localeCompare(String(right[0])));
9
+ return createHash("sha256").update(JSON.stringify(state)).digest("hex");
10
+ }
@@ -568,7 +568,7 @@ export const listUsersWithAccess = async (params: {
568
568
  * Resolve display names for access entries.
569
569
  * Populates the displayName field based on principal type.
570
570
  */
571
- export const resolveDisplayNames = async (entries: AccessEntry[]): Promise<AccessEntry[]> => {
571
+ export const resolveDisplayNames = async <T extends { principal: Principal }>(entries: T[]): Promise<(T & { displayName: string; avatarHash?: string | null })[]> => {
572
572
  const userIds = entries.filter((e) => e.principal.type === "user").map((e) => (e.principal as { type: "user"; userId: string }).userId);
573
573
 
574
574
  const groupIds = entries
@@ -46,3 +46,4 @@ export { crypto, err, fail, isServiceError, ok, okMany, paginate, password, svg,
46
46
  // Compat aliases for old API names
47
47
  export const images = { generateFallback: _svg.generateAvatar, parseWebpDataUrl: _svg.parseWebpDataUrl };
48
48
  export const generatePassword = _password.random;
49
+ export { accessRevision } from "./access-revision";
@@ -20,6 +20,15 @@ const readValue = async (key: string, fallback: boolean | string, db: typeof sql
20
20
  return value;
21
21
  };
22
22
 
23
+ /** Decode a policy selected alongside a user, without a second database read. */
24
+ export const decodeAccountCategoryEnabled = async (encrypted: unknown): Promise<boolean> => {
25
+ if (encrypted === null || encrypted === undefined) return true;
26
+ if (typeof encrypted !== "string") throw new Error("Invalid account category configuration");
27
+ const value = await decryptValue(encrypted);
28
+ if (typeof value !== "boolean") throw new Error("Invalid account category configuration");
29
+ return value;
30
+ };
31
+
23
32
  export const isAccountCategoryAllowed = async (
24
33
  user: { provider: "local" | "ipa"; profile: "guest" | "user" },
25
34
  db: typeof sql = sql,
@@ -35,7 +35,7 @@ export const writeDeletedAccountAudit = async (config: {
35
35
  ${config.previousProvider},
36
36
  ${config.previousProfile},
37
37
  ${config.reason},
38
- ${JSON.stringify(config.meta ?? {})}::jsonb
38
+ ${JSON.stringify(config.meta ?? {})}::text::jsonb
39
39
  )
40
40
  `;
41
41
  };
@@ -37,6 +37,7 @@ import { listDuplicateEmails } from "./duplicate-emails";
37
37
  import * as entities from "./entities";
38
38
  import * as groups from "./groups";
39
39
  import type { AccountsNotificationSender } from "./notification-sender";
40
+ import { PosixError } from "./posix";
40
41
  import { accountRequestsEnabled } from "./request-policy";
41
42
  import * as users from "./users";
42
43
 
@@ -141,6 +142,15 @@ const fromMutationResult = <T>(result: MutationResult<T>): Result<T> => {
141
142
  return fail(toServiceError(result.status, result.error));
142
143
  };
143
144
 
145
+ const fromGroupMutation = async <T>(operation: () => Promise<MutationResult<T>>): Promise<Result<T>> => {
146
+ try {
147
+ return fromMutationResult(await operation());
148
+ } catch (error) {
149
+ if (error instanceof PosixError) return fail({ code: error.code, message: error.message, status: error.status });
150
+ throw error;
151
+ }
152
+ };
153
+
144
154
  const mapAccountRequestRow = (row: DbRow): AccountRequest => ({
145
155
  id: row.id as string,
146
156
  userId: row.user_id as string | null,
@@ -1119,7 +1129,7 @@ export const accountsAppService = {
1119
1129
  target: { type: "group", label: config.name, provider: config.provider },
1120
1130
  });
1121
1131
  if (adminError) return adminError;
1122
- const result = fromMutationResult(await groups.create(config));
1132
+ const result = await fromGroupMutation(() => groups.create(config));
1123
1133
  return recordCompletedMutation({
1124
1134
  action: "accounts.group.create",
1125
1135
  actor: auditActor(config.actor),
@@ -1164,7 +1174,7 @@ export const accountsAppService = {
1164
1174
  target,
1165
1175
  });
1166
1176
  if (adminError) return adminError;
1167
- const result = fromMutationResult(await groups.makePosix(config));
1177
+ const result = await fromGroupMutation(() => groups.makePosix(config));
1168
1178
  return recordCompletedMutation({
1169
1179
  action: "accounts.group.make_posix",
1170
1180
  actor: auditActor(config.actor),
@@ -0,0 +1,23 @@
1
+ import type { sql } from "bun";
2
+
3
+ export const normalizeAccountEmail = (email: string): string => email.trim().toLowerCase();
4
+
5
+ /** Hold through the write (including a FreeIPA mutation), on the same transaction. */
6
+ export const lockAccountEmail = async (db: typeof sql, email: string): Promise<void> => {
7
+ // Take the table's ordinary write lock before the advisory lock. Local POSIX
8
+ // provisioning takes a stronger table lock first; this keeps their order compatible.
9
+ await db`LOCK TABLE auth.users IN ROW EXCLUSIVE MODE`;
10
+ await db`SELECT pg_advisory_xact_lock(hashtextextended('cloud:account-email:' || ${normalizeAccountEmail(email)}, 0))`;
11
+ };
12
+
13
+ export const findAccountsByEmail = (db: typeof sql, email: string) =>
14
+ db<
15
+ {
16
+ id: string;
17
+ uid: string;
18
+ provider: "local" | "ipa";
19
+ mail: string;
20
+ }[]
21
+ >`SELECT id, uid, provider, mail FROM auth.users WHERE lower(btrim(mail)) = ${normalizeAccountEmail(email)}`;
22
+
23
+ export const emailAlreadyUsed = () => ({ ok: false as const, error: "An account with this email already exists.", status: 409 as const });
@@ -4,9 +4,11 @@ import { freeipa } from "../../server/services";
4
4
  import { getServiceIpaSession } from "../ipa/service-account";
5
5
  import { toPgUuidArray } from "../postgres";
6
6
  import { providers } from "../providers";
7
+ import type { AccountsActor } from "./authz";
7
8
  import { buildBaseGroup } from "./base-group";
8
9
  import { buildManagedGroupScopeCondition, buildMemberGroupScopeCondition } from "./group-sql";
9
10
  import * as localGroups from "./local-groups";
11
+ import { posix } from "./posix";
10
12
 
11
13
  type DbRow = Record<string, unknown>;
12
14
 
@@ -146,13 +148,14 @@ export const getManagedGroups = async (params: { id: string; provider?: UserProv
146
148
  };
147
149
 
148
150
  export const create = async (params: {
151
+ actor: AccountsActor;
149
152
  provider: UserProvider;
150
153
  name: string;
151
154
  description?: string;
152
155
  posix?: boolean;
153
156
  }): Promise<MutationResult<BaseGroup>> => {
154
157
  if (params.provider === "local") {
155
- if (params.posix) return { ok: false, error: "Local groups do not support POSIX mode", status: 400 };
158
+ if (params.posix) return { ok: true, data: await posix.createGroup({ id: params.actor.userId, roles: params.actor.roles }, params) };
156
159
  return localGroups.create({ name: params.name, description: params.description });
157
160
  }
158
161
  const serviceSession = await getServiceIpaSession();
@@ -188,9 +191,17 @@ export const remove = async (params: { id: string; provider?: UserProvider }): P
188
191
  });
189
192
  };
190
193
 
191
- export const makePosix = async (params: { id: string; provider?: UserProvider }): Promise<MutationResult<{ gidnumber: number | null }>> => {
192
- const provider = params.provider ?? (await getGroup(params.id))?.provider;
193
- if (provider === "local") return { ok: false, error: "Local groups do not support POSIX mode", status: 400 };
194
+ export const makePosix = async (params: {
195
+ actor: AccountsActor;
196
+ id: string;
197
+ provider?: UserProvider;
198
+ }): Promise<MutationResult<{ gidnumber: number | null }>> => {
199
+ const provider = (await getGroup(params.id))?.provider;
200
+ if (!provider) return { ok: false, error: "Group not found", status: 404 };
201
+ if (provider === "local") {
202
+ const result = await posix.provisionGroup({ id: params.actor.userId, roles: params.actor.roles }, params.id);
203
+ return { ok: true, data: { gidnumber: result.gidNumber } };
204
+ }
194
205
  const serviceSession = await getServiceIpaSession();
195
206
  if (!serviceSession.ok) return serviceSession;
196
207
  return providers.ipa.groups.makePosix({
@@ -0,0 +1,249 @@
1
+ import { type SQLQuery, sql } from "bun";
2
+ import { z } from "zod";
3
+ import { DEFAULT_LINUX_IDENTITY_CONFIGURATION, LinuxIdentityConfigurationSchema } from "../../contracts/posix";
4
+ import type { RequestActor, UserProfile, UserProvider } from "../../contracts/shared";
5
+ import { userFromActor } from "../../server/actor";
6
+ import { isAccountCategoryAllowed } from "../account-category-policy";
7
+ import { toPgTextArray } from "../postgres";
8
+ import { decryptValue } from "../settings/crypto";
9
+ import { buildMemberGroupScopeCondition } from "./group-sql";
10
+ import { type AccountIdentityReconciliation, readUpstreamIdentity } from "./identity-reconciliation";
11
+
12
+ export type AccountIdentityUser = {
13
+ id: string;
14
+ provider: UserProvider;
15
+ username: string;
16
+ profile: UserProfile;
17
+ posix: { uidNumber: number; primaryGidNumber: number } | null;
18
+ };
19
+ export type AccountIdentityGroup = { id: string; provider: UserProvider; name: string; gidNumber: number | null };
20
+ export type AccountIdentityPage<T> = { items: T[]; nextCursor: string | null };
21
+ export type AccountIdentityAvailability = { localLinuxEnabled: boolean; freeipaEnabled: boolean };
22
+ type InventoryFilter = { provider: UserProvider; after?: string; id?: string; name?: string };
23
+
24
+ export class AccountIdentityError extends Error {
25
+ constructor(
26
+ public readonly code: string,
27
+ public readonly status: 400 | 401 | 403 = 403,
28
+ ) {
29
+ super(code);
30
+ }
31
+ }
32
+
33
+ // The same bounded page size as Linux identity administration.
34
+ const PAGE_SIZE = 50;
35
+ const cursorSchema = z.uuid();
36
+ const positiveId = (value: number | null): value is number => Number.isSafeInteger(value) && value !== null && value > 0;
37
+ type UserRow = {
38
+ id: string;
39
+ provider: UserProvider;
40
+ uid: string;
41
+ profile: UserProfile;
42
+ admin: boolean;
43
+ expired: boolean;
44
+ managed_by: UserProvider | null;
45
+ uid_number: number | null;
46
+ primary_gid_number: number | null;
47
+ };
48
+ const projectUser = (row: UserRow): AccountIdentityUser => ({
49
+ id: row.id,
50
+ provider: row.provider,
51
+ username: row.uid,
52
+ profile: row.profile,
53
+ posix:
54
+ row.managed_by === row.provider && positiveId(row.uid_number) && positiveId(row.primary_gid_number)
55
+ ? { uidNumber: row.uid_number, primaryGidNumber: row.primary_gid_number }
56
+ : null,
57
+ });
58
+ const page = <T extends { id: string }>(rows: T[]): AccountIdentityPage<T> => ({
59
+ items: rows.slice(0, PAGE_SIZE),
60
+ nextCursor: rows.length > PAGE_SIZE ? rows[PAGE_SIZE - 1]!.id : null,
61
+ });
62
+ const cursor = (after?: string): string | null => {
63
+ if (after !== undefined && !cursorSchema.safeParse(after).success) throw new AccountIdentityError("invalid_cursor", 400);
64
+ return after ?? null;
65
+ };
66
+
67
+ /** Server-side identity reads. Authentication and application resource authorization remain caller-owned. */
68
+ export const createAccountIdentityService = (db: typeof sql = sql, upstreamIdentity = readUpstreamIdentity) => {
69
+ const setting = async (key: string, fallback: unknown): Promise<unknown> => {
70
+ const [row] = await db<{ value: string }[]>`SELECT value FROM settings.entries WHERE key = ${key}`;
71
+ return row ? decryptValue(row.value) : fallback;
72
+ };
73
+ const availability = async (): Promise<AccountIdentityAvailability> => {
74
+ const [raw, ipa] = await Promise.all([
75
+ setting("linux.identity_config", DEFAULT_LINUX_IDENTITY_CONFIGURATION),
76
+ setting("freeipa.enable", false),
77
+ ]);
78
+ try {
79
+ const config = LinuxIdentityConfigurationSchema.parse(typeof raw === "string" ? JSON.parse(raw) : raw);
80
+ if (typeof ipa !== "boolean") throw new Error("Invalid FreeIPA state");
81
+ return { localLinuxEnabled: config.enabled, freeipaEnabled: ipa };
82
+ } catch {
83
+ throw new AccountIdentityError("invalid_identity_configuration");
84
+ }
85
+ };
86
+ const userRows = (filter: SQLQuery) => db<UserRow[]>`
87
+ SELECT u.id, u.provider, u.uid, u.profile, u.admin,
88
+ (u.account_expires IS NOT NULL AND u.account_expires <= now()) AS expired,
89
+ p.managed_by, p.uid_number, p.primary_gid_number
90
+ FROM auth.users u LEFT JOIN auth.user_posix p ON p.user_id = u.id
91
+ WHERE ${filter}
92
+ ORDER BY u.id LIMIT ${PAGE_SIZE + 1}
93
+ `;
94
+ const requireUser = async (actor: RequestActor) => {
95
+ const supplied = userFromActor(actor);
96
+ if (!supplied) throw new AccountIdentityError("user_required", 401);
97
+ const [row] = await userRows(sql`u.id = ${supplied.id}::uuid`);
98
+ if (!row || row.expired || row.provider !== supplied.provider || row.uid !== supplied.uid || row.profile !== supplied.profile)
99
+ throw new AccountIdentityError("identity_unavailable");
100
+ if (!(await isAccountCategoryAllowed(row, db))) throw new AccountIdentityError("identity_unavailable");
101
+ const state = await availability();
102
+ if (row.provider === "ipa" && !state.freeipaEnabled) throw new AccountIdentityError("identity_unavailable");
103
+ return { row, state, supplied };
104
+ };
105
+ const requireAdmin = async (actor: RequestActor) => {
106
+ const current = await requireUser(actor);
107
+ if (!current.supplied.roles.includes("admin")) throw new AccountIdentityError("admin_required");
108
+ if (current.row.provider === "local") {
109
+ if (!current.row.admin || current.row.profile !== "user") throw new AccountIdentityError("admin_required");
110
+ } else {
111
+ const configured = await setting("freeipa.groups.admin", ["admins"]);
112
+ if (!Array.isArray(configured) || !configured.every((value): value is string => typeof value === "string"))
113
+ throw new AccountIdentityError("invalid_identity_configuration");
114
+ const names = configured.length > 0 ? configured : ["admins"];
115
+ const [membership] = await db`SELECT 1 FROM auth.ipa_user_effective_groups
116
+ WHERE user_id = ${current.row.id}::uuid AND group_name = ANY(${toPgTextArray(names)}::text[]) LIMIT 1`;
117
+ if (!membership) throw new AccountIdentityError("admin_required");
118
+ }
119
+ return current;
120
+ };
121
+ const groupRows = async (filter: SQLQuery) => {
122
+ const rows = await db<{ id: string; provider: UserProvider; name: string; gid_number: number | null }[]>`
123
+ SELECT g.id, g.provider, g.name, g.gid_number FROM auth.groups g
124
+ WHERE ${filter} ORDER BY g.id LIMIT ${PAGE_SIZE + 1}
125
+ `;
126
+ return rows.map(
127
+ (row): AccountIdentityGroup => ({
128
+ id: row.id,
129
+ provider: row.provider,
130
+ name: row.name,
131
+ gidNumber: positiveId(row.gid_number) ? row.gid_number : null,
132
+ }),
133
+ );
134
+ };
135
+ async function inventory(
136
+ actor: RequestActor,
137
+ options: InventoryFilter & { kind: "users" },
138
+ ): Promise<AccountIdentityPage<AccountIdentityUser>>;
139
+ async function inventory(
140
+ actor: RequestActor,
141
+ options: InventoryFilter & { kind: "groups" },
142
+ ): Promise<AccountIdentityPage<AccountIdentityGroup>>;
143
+ async function inventory(actor: RequestActor, options: InventoryFilter & { kind: "users" | "groups" }) {
144
+ await requireAdmin(actor);
145
+ const after = cursor(options.after);
146
+ const id = cursor(options.id);
147
+ const name = options.name ?? null;
148
+ if (options.provider !== "local" && options.provider !== "ipa") throw new AccountIdentityError("invalid_provider", 400);
149
+ if (options.kind === "users") {
150
+ return page(
151
+ (
152
+ await userRows(sql`u.provider = ${options.provider} AND (${after}::uuid IS NULL OR u.id > ${after}::uuid)
153
+ AND (${id}::uuid IS NULL OR u.id = ${id}::uuid) AND (${name}::text IS NULL OR u.uid = ${name})`)
154
+ ).map(projectUser),
155
+ );
156
+ }
157
+ if (options.kind !== "groups") throw new AccountIdentityError("invalid_inventory_kind", 400);
158
+ return page(
159
+ await groupRows(sql`g.provider = ${options.provider} AND (${after}::uuid IS NULL OR g.id > ${after}::uuid)
160
+ AND (${id}::uuid IS NULL OR g.id = ${id}::uuid) AND (${name}::text IS NULL OR g.name = ${name})`),
161
+ );
162
+ }
163
+ const readLocalIdentity = async (input: {
164
+ kind: "users" | "groups";
165
+ name: string;
166
+ identityId?: string;
167
+ }): Promise<AccountIdentityReconciliation> => {
168
+ if (input.kind === "users") {
169
+ const rows = await userRows(sql`(u.provider = 'local' AND u.uid = ${input.name}) OR u.id = ${input.identityId ?? null}::uuid`);
170
+ const expected = rows.find((row) => row.id === input.identityId);
171
+ if (expected && (expected.provider !== "local" || expected.uid !== input.name))
172
+ return { state: "unknown", reason: "identity_conflict" };
173
+ const matches = rows.filter((row) => row.provider === "local" && row.uid === input.name);
174
+ if (!matches.length) return { state: "absent" };
175
+ if (matches.length !== 1) return { state: "unknown", reason: "invalid_response" };
176
+ const current = projectUser(matches[0]!);
177
+ return {
178
+ state: "present",
179
+ identity: {
180
+ id: current.id,
181
+ name: current.username,
182
+ uidNumber: current.posix?.uidNumber ?? null,
183
+ gidNumber: current.posix?.primaryGidNumber ?? null,
184
+ },
185
+ eligible: current.profile === "user",
186
+ };
187
+ }
188
+ const rows = await groupRows(sql`(g.provider = 'local' AND g.name = ${input.name}) OR g.id = ${input.identityId ?? null}::uuid`);
189
+ const expected = rows.find((row) => row.id === input.identityId);
190
+ if (expected && (expected.provider !== "local" || expected.name !== input.name))
191
+ return { state: "unknown", reason: "identity_conflict" };
192
+ const matches = rows.filter((row) => row.provider === "local" && row.name === input.name);
193
+ if (!matches.length) return { state: "absent" };
194
+ if (matches.length !== 1) return { state: "unknown", reason: "invalid_response" };
195
+ const current = matches[0]!;
196
+ return {
197
+ state: "present",
198
+ identity: { id: current.id, name: current.name, uidNumber: null, gidNumber: current.gidNumber },
199
+ eligible: current.gidNumber !== null,
200
+ };
201
+ };
202
+ const validateLookup = (input: { kind: "users" | "groups"; name: string }) => {
203
+ if ((input.kind !== "users" && input.kind !== "groups") || typeof input.name !== "string" || !input.name || input.name.includes("\0"))
204
+ throw new AccountIdentityError("invalid_identity_lookup", 400);
205
+ };
206
+ return {
207
+ async reconcile(
208
+ actor: RequestActor,
209
+ input: { kind: "users" | "groups"; provider: UserProvider; name: string; identityId?: string; signal?: AbortSignal },
210
+ ): Promise<AccountIdentityReconciliation> {
211
+ const { state } = await requireAdmin(actor);
212
+ validateLookup(input);
213
+ if (input.provider !== "local" && input.provider !== "ipa") throw new AccountIdentityError("invalid_identity_lookup", 400);
214
+ if (input.identityId !== undefined) cursor(input.identityId);
215
+ if (input.signal?.aborted) return { state: "unknown", reason: "provider_unavailable" };
216
+ if (input.provider === "ipa") {
217
+ if (!state.freeipaEnabled) return { state: "unknown", reason: "provider_disabled" };
218
+ return upstreamIdentity(input);
219
+ }
220
+ const result = await readLocalIdentity(input);
221
+ return input.signal?.aborted ? { state: "unknown", reason: "provider_unavailable" } : result;
222
+ },
223
+ /** Trusted application jobs only; this read does not authorize an HTTP caller or a filesystem mutation. */
224
+ async localLifecycle(input: { kind: "users" | "groups"; identityId: string; name: string }) {
225
+ validateLookup(input);
226
+ cursor(input.identityId);
227
+ const { localLinuxEnabled } = await availability();
228
+ return { localLinuxEnabled, identity: await readLocalIdentity(input) };
229
+ },
230
+ async self(actor: RequestActor) {
231
+ const { row, state } = await requireUser(actor);
232
+ return { user: projectUser(row), availability: state };
233
+ },
234
+ async groups(actor: RequestActor, options: { after?: string } = {}): Promise<AccountIdentityPage<AccountIdentityGroup>> {
235
+ const { row, state } = await requireUser(actor);
236
+ const after = cursor(options.after);
237
+ return page(
238
+ await groupRows(sql`
239
+ (${after}::uuid IS NULL OR g.id > ${after}::uuid)
240
+ AND (g.provider = 'local' OR ${state.freeipaEnabled})
241
+ AND ${buildMemberGroupScopeCondition({ userId: row.id, groupProvider: sql`g.provider` })}
242
+ `),
243
+ );
244
+ },
245
+ inventory,
246
+ };
247
+ };
248
+
249
+ export const accountIdentities = createAccountIdentityService();
@@ -0,0 +1,87 @@
1
+ import { z } from "zod";
2
+ import { freeipa } from "../../server/services";
3
+ import type { IpaRpcResponse } from "../../server/services/freeipa/client";
4
+ import { FREEIPA_REQUEST_TIMEOUT_MS } from "../../server/services/freeipa/transport";
5
+ import { getFreeIpaConfig } from "../freeipa-config";
6
+
7
+ export type AccountIdentityReconciliation =
8
+ | {
9
+ state: "present";
10
+ identity: { id: string | null; name: string; uidNumber: number | null; gidNumber: number | null };
11
+ eligible: boolean;
12
+ }
13
+ | { state: "absent" }
14
+ | { state: "unknown"; reason: "provider_disabled" | "provider_unavailable" | "invalid_response" | "identity_conflict" };
15
+ export type IdentityLookup = { kind: "users" | "groups"; name: string; signal?: AbortSignal };
16
+
17
+ const number = z
18
+ .union([z.number().int(), z.string().regex(/^\d+$/).transform(Number)])
19
+ .pipe(z.number().int().positive().max(2_147_483_647));
20
+ const attributeNumber = z.array(number).length(1).optional();
21
+ const userRecord = z.object({ uid: z.array(z.string()).length(1), uidnumber: attributeNumber, gidnumber: attributeNumber });
22
+ const groupRecord = z.object({ cn: z.array(z.string()).length(1), gidnumber: attributeNumber });
23
+ const notFound = (response: IpaRpcResponse) =>
24
+ response.error?.kind === "rpc" && response.error.code === 4001 && response.error.name === "NotFound";
25
+
26
+ // Exact show responses have no pagination or Cloud sync filter. Only the named
27
+ // upstream NotFound error proves absence; HTTP 404 and malformed success do not.
28
+ export function parseIdentityShow(input: IdentityLookup, response: IpaRpcResponse): AccountIdentityReconciliation {
29
+ if (notFound(response)) return { state: "absent" };
30
+ if (response.error)
31
+ return { state: "unknown", reason: response.error.kind === "invalid_response" ? "invalid_response" : "provider_unavailable" };
32
+ const parsed = input.kind === "users" ? userRecord.safeParse(response.result?.result) : groupRecord.safeParse(response.result?.result);
33
+ if (!parsed.success) return { state: "unknown", reason: "invalid_response" };
34
+ const record = parsed.data;
35
+ const name = "uid" in record ? record.uid[0]! : record.cn[0]!;
36
+ if (name !== input.name) return { state: "unknown", reason: "invalid_response" };
37
+ const uidNumber = "uidnumber" in record ? (record.uidnumber?.[0] ?? null) : null;
38
+ const gidNumber = record.gidnumber?.[0] ?? null;
39
+ return {
40
+ state: "present",
41
+ identity: { id: null, name, uidNumber, gidNumber },
42
+ eligible: gidNumber !== null && (input.kind === "groups" || uidNumber !== null),
43
+ };
44
+ }
45
+
46
+ export const readUpstreamIdentity = async (
47
+ input: IdentityLookup,
48
+ deps = { config: getFreeIpaConfig, session: freeipa.session.getServiceSession, call: freeipa.client.call },
49
+ ): Promise<AccountIdentityReconciliation> => {
50
+ try {
51
+ const timeout = AbortSignal.timeout(FREEIPA_REQUEST_TIMEOUT_MS);
52
+ const signal = input.signal ? AbortSignal.any([input.signal, timeout]) : timeout;
53
+ signal.throwIfAborted();
54
+ const config = await deps.config();
55
+ signal.throwIfAborted();
56
+ if (!config.enabled) return { state: "unknown", reason: "provider_disabled" };
57
+ if (!config.configured) return { state: "unknown", reason: "provider_unavailable" };
58
+ const ipaSession = await deps.session({
59
+ url: config.url,
60
+ serviceUser: config.serviceUser,
61
+ servicePassword: config.servicePassword,
62
+ signal,
63
+ });
64
+ signal.throwIfAborted();
65
+ const query = async (method: string) => {
66
+ signal.throwIfAborted();
67
+ const response = await deps.call({
68
+ url: config.url,
69
+ ipaSession,
70
+ method,
71
+ args: [input.name],
72
+ options: { all: true, no_members: true },
73
+ signal,
74
+ });
75
+ signal.throwIfAborted();
76
+ return response;
77
+ };
78
+ const result = parseIdentityShow(input, await query(input.kind === "users" ? "user_show" : "group_show"));
79
+ if (input.kind !== "users" || result.state !== "absent") return result;
80
+ // user_show includes preserved users, but staged users live in a separate
81
+ // container and can retain a former account's data while awaiting activation.
82
+ const staged = parseIdentityShow(input, await query("stageuser_show"));
83
+ return staged.state === "present" ? { ...staged, eligible: false } : staged;
84
+ } catch {
85
+ return { state: "unknown", reason: "provider_unavailable" };
86
+ }
87
+ };
@@ -3,9 +3,9 @@ import type { IpaUserData } from "../../contracts/shared";
3
3
 
4
4
  type DbRow = Record<string, unknown>;
5
5
 
6
- export const userIpaDataJoin = sql`LEFT JOIN auth.user_ipa_data ui ON ui.user_id = u.id`;
6
+ export const userIpaDataJoin = () => sql`LEFT JOIN auth.user_ipa_data ui ON ui.user_id = u.id`;
7
7
 
8
- export const userIpaDataColumns = sql`
8
+ export const userIpaDataColumns = () => sql`
9
9
  CASE WHEN EXISTS(SELECT 1 FROM auth.user_posix p WHERE p.user_id = u.id AND p.managed_by = 'ipa')
10
10
  THEN (SELECT p.uid_number FROM auth.user_posix p WHERE p.user_id = u.id AND p.managed_by = 'ipa')
11
11
  ELSE ui.uid_number END AS ipa_uid_number,
@@ -77,10 +77,10 @@ export const get = async (params: { id: string }): Promise<BaseGroup | null> =>
77
77
  return row ? toBaseGroup(row) : null;
78
78
  };
79
79
 
80
- export const create = async (params: { name: string; description?: string }): Promise<MutationResult<BaseGroup>> => {
80
+ export const create = async (params: { name: string; description?: string }, db: typeof sql = sql): Promise<MutationResult<BaseGroup>> => {
81
81
  const storedCn = `local:${params.name}`;
82
82
  try {
83
- const rows = await sql<DbRow[]>`
83
+ const rows = await db<DbRow[]>`
84
84
  INSERT INTO auth.groups (id, cn, provider, name, description, synced_at)
85
85
  VALUES (gen_random_uuid(), ${storedCn}, 'local', ${params.name}, ${params.description ?? null}, now())
86
86
  RETURNING id, provider, name, description, gid_number