@k2b/cloud 0.42.0 → 0.44.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 (352) hide show
  1. package/package.json +13 -5
  2. package/scripts/app-favicon.ts +7 -6
  3. package/scripts/build.ts +3 -0
  4. package/scripts/installable-assets.ts +61 -0
  5. package/scripts/preload.ts +3 -0
  6. package/src/_internal/app-access-path.ts +12 -0
  7. package/src/_internal/app-access-request.ts +13 -0
  8. package/src/_internal/app-access-resources.ts +141 -0
  9. package/src/_internal/app-event-catalog.ts +40 -0
  10. package/src/_internal/app-event-schemas.ts +127 -0
  11. package/src/_internal/application-package.ts +40 -0
  12. package/src/_internal/built-in-app-ids.json +27 -0
  13. package/src/_internal/capabilities.ts +185 -12
  14. package/src/_internal/capability-origin.ts +4 -0
  15. package/src/_internal/capability-streams.ts +10 -5
  16. package/src/_internal/define-app.ts +159 -45
  17. package/src/_internal/installable-assets.ts +90 -0
  18. package/src/_internal/installable-head.ts +22 -0
  19. package/src/_internal/object-store-area.ts +9 -0
  20. package/src/_internal/object-store-config.ts +101 -0
  21. package/src/_internal/object-store-key.ts +14 -0
  22. package/src/_internal/page-responses.ts +34 -3
  23. package/src/_internal/process-identity.ts +7 -2
  24. package/src/_internal/registry-validation.ts +15 -1
  25. package/src/_internal/registry.ts +44 -1
  26. package/src/_internal/runtime-context.ts +9 -1
  27. package/src/_internal/runtime-watcher.ts +8 -0
  28. package/src/_internal/status-preserving-ssr.ts +12 -1
  29. package/src/access/AppAccessEditor.browser-fixture.ts +429 -0
  30. package/src/access/AppAccessEditor.browser-frame.tsx +68 -0
  31. package/src/access/AppAccessEditor.browser-harness.tsx +65 -0
  32. package/src/access/AppAccessEditor.tsx +380 -0
  33. package/src/access/PermissionEditor.browser-harness.tsx +22 -2
  34. package/src/access/PermissionEditor.tsx +386 -77
  35. package/src/access/PrincipalAvatar.tsx +63 -0
  36. package/src/access/PrincipalPicker.tsx +29 -8
  37. package/src/access/ResourceApiKeys.tsx +3 -4
  38. package/src/access/app-access-adapter.ts +51 -0
  39. package/src/access/app-access-messages.ts +172 -0
  40. package/src/access/messages.ts +14 -0
  41. package/src/access/ui.ts +4 -1
  42. package/src/account/Avatar.tsx +21 -3
  43. package/src/account/ProfilePictureDialog.tsx +699 -0
  44. package/src/account/profile-picture-messages.ts +269 -0
  45. package/src/account/ui.ts +8 -1
  46. package/src/ai/admin.ts +3 -0
  47. package/src/ai/app-frame.ts +12 -0
  48. package/src/ai/application-tasks.ts +101 -0
  49. package/src/ai/assistant-models.ts +126 -64
  50. package/src/ai/capabilities.ts +19 -4
  51. package/src/ai/capability-execution.ts +5 -2
  52. package/src/ai/chart-block.ts +63 -30
  53. package/src/ai/chat/chart-block.tsx +12 -3
  54. package/src/ai/chat/composer-adapter.ts +8 -7
  55. package/src/ai/chat/message-actions.tsx +1 -1
  56. package/src/ai/chat/message-utils.ts +2 -2
  57. package/src/ai/chat/presentation.tsx +22 -1
  58. package/src/ai/chat-quotas.ts +5 -4
  59. package/src/ai/chat-tasks.ts +2 -2
  60. package/src/ai/client/controller.ts +11 -10
  61. package/src/ai/client/projection.ts +3 -3
  62. package/src/ai/code-capability-routes.ts +1 -0
  63. package/src/ai/compaction.ts +3 -3
  64. package/src/ai/credentials.ts +13 -114
  65. package/src/ai/default-tool-contracts.ts +4 -3
  66. package/src/ai/dictation-runtime.ts +4 -4
  67. package/src/ai/enrich.ts +2 -2
  68. package/src/ai/executor.ts +38 -27
  69. package/src/ai/http.ts +17 -29
  70. package/src/ai/index.ts +11 -5
  71. package/src/ai/inference-calls.ts +6 -4
  72. package/src/ai/maintenance.ts +1 -1
  73. package/src/ai/memory-learning-runs.ts +5 -5
  74. package/src/ai/memory-learning.ts +3 -3
  75. package/src/ai/message-queue.ts +1 -2
  76. package/src/ai/migrate.ts +75 -42
  77. package/src/ai/model-access-migrate.ts +3 -17
  78. package/src/ai/model-access.ts +44 -85
  79. package/src/ai/model-card.tsx +134 -0
  80. package/src/ai/model-catalog.ts +359 -0
  81. package/src/ai/model-chooser-messages.ts +88 -0
  82. package/src/ai/model-chooser.tsx +577 -0
  83. package/src/ai/model-delete.ts +234 -0
  84. package/src/ai/model-fixture.ts +39 -0
  85. package/src/ai/model-icon.tsx +36 -0
  86. package/src/ai/model-overview.ts +53 -0
  87. package/src/ai/model-usage.ts +45 -0
  88. package/src/ai/openai-api-messages.ts +0 -4
  89. package/src/ai/openai-api-slots.ts +10 -2
  90. package/src/ai/openai-api.ts +83 -16
  91. package/src/ai/personal-agent.ts +1 -1
  92. package/src/ai/prefs.ts +12 -10
  93. package/src/ai/projects-routes.ts +2 -1
  94. package/src/ai/projects.ts +13 -12
  95. package/src/ai/protocol.ts +2 -2
  96. package/src/ai/provider-connections.ts +161 -0
  97. package/src/ai/provider-errors.ts +138 -0
  98. package/src/ai/provider-fetch.ts +4 -0
  99. package/src/ai/provider-retry.ts +1 -1
  100. package/src/ai/provider.ts +2 -1
  101. package/src/ai/quota-limits.ts +36 -0
  102. package/src/ai/quota-provider.ts +11 -2
  103. package/src/ai/quota-report.ts +7 -4
  104. package/src/ai/quotas-migrate.ts +1 -0
  105. package/src/ai/quotas.ts +19 -8
  106. package/src/ai/recent-resources.ts +6 -0
  107. package/src/ai/request-headers.ts +14 -87
  108. package/src/ai/resource-refs.ts +6 -2
  109. package/src/ai/routes.ts +17 -18
  110. package/src/ai/runtime.ts +50 -22
  111. package/src/ai/settings.ts +93 -580
  112. package/src/ai/store.ts +45 -23
  113. package/src/ai/stream.ts +1 -1
  114. package/src/ai/structured-runs.ts +2 -2
  115. package/src/ai/structured.ts +29 -46
  116. package/src/ai/task-contracts.ts +25 -5
  117. package/src/ai/task-execution.ts +15 -6
  118. package/src/ai/task-model.ts +9 -0
  119. package/src/ai/transcription.ts +37 -21
  120. package/src/ai/turn-failure.ts +24 -2
  121. package/src/ai/types.ts +50 -62
  122. package/src/ai/ui.tsx +13 -0
  123. package/src/ai/usage.ts +8 -8
  124. package/src/ai/validate.ts +8 -7
  125. package/src/ai/view-block.ts +2 -2
  126. package/src/api/accounts-entities.ts +40 -16
  127. package/src/api/admin-ai-quotas.ts +34 -56
  128. package/src/api/admin-ai-usage.ts +1 -1
  129. package/src/api/admin-ai.ts +173 -0
  130. package/src/api/admin-core-settings.ts +68 -244
  131. package/src/api/admin-directory-shares.ts +55 -0
  132. package/src/api/admin-identity.ts +81 -2
  133. package/src/api/app-access.ts +85 -0
  134. package/src/api/app-approval.ts +3 -1
  135. package/src/api/app-event-delivery.ts +51 -0
  136. package/src/api/app-events.ts +123 -0
  137. package/src/api/app-resource-access.ts +48 -0
  138. package/src/api/auth/account-mismatch.ts +24 -0
  139. package/src/api/auth.ts +26 -3
  140. package/src/api/capabilities.ts +127 -15
  141. package/src/api/capability-streams.ts +2 -5
  142. package/src/api/identity-child-invocation.ts +145 -0
  143. package/src/api/index.ts +11 -2
  144. package/src/api/mcp.ts +180 -5
  145. package/src/api/me.ts +38 -17
  146. package/src/api/pwa-phone.ts +87 -48
  147. package/src/api/pwa.ts +34 -8
  148. package/src/api/resource-links.ts +83 -6
  149. package/src/api/resource-resolve.ts +50 -0
  150. package/src/api/search/schemas.ts +3 -1
  151. package/src/api/search.ts +9 -7
  152. package/src/api/widgets.ts +1 -0
  153. package/src/app-pwa/authority.ts +75 -0
  154. package/src/app-pwa/contracts.ts +177 -0
  155. package/src/app-pwa/core-query-error.ts +20 -0
  156. package/src/app-pwa/index.ts +23 -0
  157. package/src/app-pwa/protocol.ts +27 -0
  158. package/src/app-pwa/schema.ts +59 -0
  159. package/src/app-pwa/service.ts +861 -0
  160. package/src/app-pwa/transaction.ts +338 -0
  161. package/src/app-pwa/transport-input.ts +57 -0
  162. package/src/app-pwa/transport.ts +235 -0
  163. package/src/approvals/messages.ts +2 -2
  164. package/src/browser/app-access-recovery.ts +44 -0
  165. package/src/browser/app-session.ts +14 -3
  166. package/src/browser/cloud-id.ts +86 -0
  167. package/src/browser/live-websocket.ts +5 -0
  168. package/src/browser/pwa-drafts.ts +57 -0
  169. package/src/browser/resource-clipboard.ts +4 -0
  170. package/src/browser/resource-links.ts +52 -0
  171. package/src/browser/resource-picker.tsx +3 -2
  172. package/src/capabilities/calendar-sources.ts +302 -0
  173. package/src/capabilities/executions.ts +10 -3
  174. package/src/capabilities/migrate.ts +4 -0
  175. package/src/capabilities/nested.ts +43 -0
  176. package/src/capabilities/server-invoke.ts +202 -0
  177. package/src/capabilities/server.ts +26 -187
  178. package/src/capabilities/testing.ts +5 -2
  179. package/src/cli/access.ts +6 -5
  180. package/src/cli/account.ts +57 -0
  181. package/src/cli/admin/agents.ts +66 -0
  182. package/src/cli/admin/ai-models.ts +199 -0
  183. package/src/cli/admin/ai-quotas.ts +6 -102
  184. package/src/cli/admin/ai-usage.ts +4 -4
  185. package/src/cli/admin/app-credentials.ts +5 -3
  186. package/src/cli/admin/index.ts +7 -3
  187. package/src/cli/app-access.ts +60 -0
  188. package/src/cli/apps.ts +89 -26
  189. package/src/cli/avatar.ts +64 -0
  190. package/src/cli/capabilities.ts +22 -1
  191. package/src/cli/events.ts +320 -0
  192. package/src/cli/index.ts +1 -1
  193. package/src/config/env.ts +40 -0
  194. package/src/contracts/app-access.ts +52 -5
  195. package/src/contracts/app-approval.ts +8 -1
  196. package/src/contracts/app-details.ts +89 -0
  197. package/src/contracts/app-events.ts +51 -0
  198. package/src/contracts/app.ts +24 -5
  199. package/src/contracts/calendar-source.ts +274 -0
  200. package/src/contracts/calendar-view.ts +35 -0
  201. package/src/contracts/capabilities.ts +164 -3
  202. package/src/contracts/geo-admin.ts +2 -0
  203. package/src/contracts/geo-route.ts +0 -11
  204. package/src/contracts/geo.ts +14 -2
  205. package/src/contracts/index.ts +6 -0
  206. package/src/contracts/installable.ts +40 -0
  207. package/src/contracts/notification-types.ts +36 -9
  208. package/src/contracts/profile.ts +32 -0
  209. package/src/contracts/pwa-builds.ts +14 -0
  210. package/src/contracts/pwa-location.ts +38 -0
  211. package/src/contracts/pwa.ts +24 -3
  212. package/src/contracts/registry.ts +8 -0
  213. package/src/contracts/resource-clipboard.ts +35 -0
  214. package/src/contracts/resource-pages.ts +140 -0
  215. package/src/contracts/resource-resolution.ts +13 -0
  216. package/src/contracts/shared.ts +4 -0
  217. package/src/contracts/user-notifications.ts +2 -0
  218. package/src/events/app-event-delivery.ts +100 -0
  219. package/src/events/app-events.testing.ts +65 -0
  220. package/src/events/app-events.ts +134 -0
  221. package/src/events/core-user-created.ts +24 -0
  222. package/src/events/index.ts +9 -0
  223. package/src/events/testing.ts +2 -0
  224. package/src/index.ts +2 -1
  225. package/src/server/api/respond.ts +8 -3
  226. package/src/server/index.ts +1 -0
  227. package/src/server/middleware/app-access.ts +7 -6
  228. package/src/server/middleware/auth.ts +168 -66
  229. package/src/server/middleware/index.ts +1 -0
  230. package/src/server/middleware/invocation.ts +4 -2
  231. package/src/server/middleware/workload.ts +3 -3
  232. package/src/server/oauth-scope.ts +10 -0
  233. package/src/server/public-storage.ts +106 -0
  234. package/src/server/services/access.ts +8 -0
  235. package/src/services/accounts/app.ts +35 -3
  236. package/src/services/accounts/avatar.ts +342 -35
  237. package/src/services/accounts/base-group.ts +4 -5
  238. package/src/services/accounts/directory-shares.ts +157 -0
  239. package/src/services/accounts/entities.ts +50 -6
  240. package/src/services/accounts/groups.ts +3 -1
  241. package/src/services/accounts/identities.ts +64 -12
  242. package/src/services/accounts/local-groups.ts +1 -1
  243. package/src/services/accounts/users.ts +23 -15
  244. package/src/services/app-access-management.ts +519 -0
  245. package/src/services/app-access.ts +25 -7
  246. package/src/services/app-approval.ts +15 -4
  247. package/src/services/app-details.ts +224 -0
  248. package/src/services/app-discovery.ts +2 -1
  249. package/src/services/app-event-shapes.ts +139 -0
  250. package/src/services/app-events/accept.ts +116 -0
  251. package/src/services/app-events/admin.ts +250 -0
  252. package/src/services/app-events/declarations.ts +187 -0
  253. package/src/services/app-events/deliver.ts +218 -0
  254. package/src/services/app-events.ts +92 -0
  255. package/src/services/config-conflict.ts +11 -0
  256. package/src/services/core-user-events.ts +1 -0
  257. package/src/services/freeipa-config.ts +9 -0
  258. package/src/services/geo/admin.ts +4 -1
  259. package/src/services/geo/ledger.ts +47 -14
  260. package/src/services/geo/route-chain.ts +40 -18
  261. package/src/services/geo/route-estimate.ts +19 -0
  262. package/src/services/geo/route-plan.ts +0 -2
  263. package/src/services/geo/route-service.ts +57 -39
  264. package/src/services/geo/service.ts +51 -25
  265. package/src/services/identity/core-origin.ts +12 -0
  266. package/src/services/identity/index.ts +2 -1
  267. package/src/services/identity/invocation-actor.ts +50 -2
  268. package/src/services/identity/invocation-authority.ts +38 -1
  269. package/src/services/identity/invocation-token.ts +255 -50
  270. package/src/services/identity/issue-invocation.ts +9 -1
  271. package/src/services/identity/models-only-auth.ts +13 -4
  272. package/src/services/identity/nested-invocation.ts +128 -0
  273. package/src/services/image-metadata.ts +65 -1
  274. package/src/services/index.ts +24 -1
  275. package/src/services/ipa/sync.ts +6 -2
  276. package/src/services/ipa/users.ts +4 -1
  277. package/src/services/mandates/policy.ts +19 -12
  278. package/src/services/notifications/browser.ts +4 -0
  279. package/src/services/notifications/catalog.ts +5 -2
  280. package/src/services/notifications/platform.ts +7 -2
  281. package/src/services/notifications/user.ts +18 -7
  282. package/src/services/oauth-tokens.ts +28 -2
  283. package/src/services/object-store.ts +217 -0
  284. package/src/services/providers/local/users.ts +5 -0
  285. package/src/services/pwa-devices.ts +147 -37
  286. package/src/services/service-account-credentials.ts +5 -2
  287. package/src/services/session/index.ts +14 -0
  288. package/src/services/settings/core-settings.ts +91 -47
  289. package/src/services/settings/index.ts +8 -0
  290. package/src/services/settings/versions.ts +26 -0
  291. package/src/shared/ai-models.ts +274 -0
  292. package/src/shared/ai-quotas.ts +2 -2
  293. package/src/shared/ai-usage.ts +2 -2
  294. package/src/shared/index.ts +2 -0
  295. package/src/shared-text/documents.ts +25 -8
  296. package/src/shared-text/log.ts +1 -1
  297. package/src/shared-text/text-diff.ts +75 -0
  298. package/src/ssr/AdminArea.tsx +131 -0
  299. package/src/ssr/AdminLayout.tsx +11 -3
  300. package/src/ssr/AdminSidebar.tsx +7 -7
  301. package/src/ssr/AppAccessDenied.tsx +114 -0
  302. package/src/ssr/AppBadge.tsx +14 -0
  303. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  304. package/src/ssr/AppPwaRuntime.island.tsx +82 -0
  305. package/src/ssr/CloudShell.island.tsx +25 -4
  306. package/src/ssr/Layout.tsx +15 -7
  307. package/src/ssr/PwaLayout.tsx +73 -8
  308. package/src/ssr/PwaPartCount.island.tsx +23 -0
  309. package/src/ssr/PwaRuntime.island.tsx +85 -6
  310. package/src/ssr/SettingsSaveOutcome.tsx +58 -0
  311. package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
  312. package/src/ssr/ShellRuntime.island.tsx +5 -0
  313. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  314. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +1 -1
  315. package/src/ssr/admin-active-link.ts +16 -0
  316. package/src/ssr/admin-navigation.ts +26 -20
  317. package/src/ssr/app-badges.ts +31 -7
  318. package/src/ssr/app-pwa-document.tsx +16 -0
  319. package/src/ssr/app-pwa-messages.ts +17 -0
  320. package/src/ssr/index.ts +8 -0
  321. package/src/ssr/layout-context.ts +1 -1
  322. package/src/ssr/page-trail-runtime.ts +3 -1
  323. package/src/ssr/platform-messages.ts +9 -1
  324. package/src/ssr/pwa-messages.ts +10 -2
  325. package/src/ssr/rail-navigation.ts +5 -1
  326. package/src/ssr/settings-draft.ts +153 -0
  327. package/src/ssr/shell-data.tsx +2 -0
  328. package/src/styles/global.css +1 -0
  329. package/src/styles/profile-picture.css +270 -0
  330. package/src/styles/utilities-navigation.css +6 -0
  331. package/src/workflows/ai/policy.ts +49 -0
  332. package/src/workflows/ai/runtime.ts +131 -21
  333. package/src/workflows/ai/store.ts +283 -35
  334. package/src/workflows/ai/types.ts +8 -2
  335. package/src/workflows/ai-actions.ts +148 -120
  336. package/src/workflows/authoring.ts +31 -0
  337. package/src/workflows/contracts.ts +1 -0
  338. package/src/workflows/definition.ts +2 -0
  339. package/src/workflows/editor.ts +15 -0
  340. package/src/workflows/index.ts +2 -1
  341. package/src/workflows/language/compiler.ts +34 -3
  342. package/src/workflows/runtime/executor.ts +132 -33
  343. package/src/workflows/runtime/index.ts +2 -0
  344. package/src/workflows/runtime/ports.ts +5 -1
  345. package/src/workflows/store/actions.ts +1 -1
  346. package/src/workflows/store/definitions.ts +5 -0
  347. package/src/workflows/store/runs.ts +5 -3
  348. package/src/workflows/store/worker.ts +7 -2
  349. package/src/ai/model-pricing.ts +0 -43
  350. package/src/ai/model-request-settings.ts +0 -98
  351. package/src/ai/settings-messages.ts +0 -129
  352. package/src/shared/ai-model-request-settings.ts +0 -21
@@ -0,0 +1,157 @@
1
+ import { err, fail, ok, type Result } from "@k2b/stdlib";
2
+ import { sql } from "bun";
3
+ import { z } from "zod";
4
+ import type { User } from "../../contracts/shared";
5
+ import type { RequestActor } from "../../server/middleware/auth";
6
+ import { audit } from "../audit";
7
+
8
+ import * as entities from "./entities";
9
+
10
+ export const DirectorySharePrincipalSchema = z.discriminatedUnion("type", [
11
+ z.object({ type: z.literal("user"), userId: z.uuid() }).strict(),
12
+ z.object({ type: z.literal("group"), groupId: z.uuid() }).strict(),
13
+ z.object({ type: z.literal("service_account"), serviceAccountId: z.uuid() }).strict(),
14
+ ]);
15
+ type DirectorySharePrincipal = z.infer<typeof DirectorySharePrincipalSchema>;
16
+ type Administrator = Pick<User, "id" | "uid" | "provider" | "roles">;
17
+ const administrator = (actor: Administrator) => actor.roles.includes("admin");
18
+ const auditActor = (actor: Administrator) => ({ userId: actor.id, uid: actor.uid, provider: actor.provider, roles: actor.roles });
19
+ const recipientExists = async (id: string) => {
20
+ const [row] = await sql`SELECT id FROM auth.service_accounts WHERE id=${id}::uuid AND kind IN ('standalone','agent')`;
21
+ return Boolean(row);
22
+ };
23
+
24
+ export const directoryShares = {
25
+ list: async (params: { actor: Administrator; recipientId: string; page: number; perPage: number }) => {
26
+ if (!administrator(params.actor)) return fail(err.forbidden("Administrator access required"));
27
+ if (!(await recipientExists(params.recipientId))) return fail(err.notFound("Service account"));
28
+ const rows = await sql<
29
+ {
30
+ id: string;
31
+ user_id: string | null;
32
+ group_id: string | null;
33
+ service_account_id: string | null;
34
+ label: string;
35
+ service_account_kind: string | null;
36
+ total: number;
37
+ }[]
38
+ >`SELECT share.id, share.user_id, share.group_id, share.service_account_id,
39
+ COALESCE(NULLIF(u.display_name,''),u.uid,g.name,sa.name) AS label,
40
+ sa.kind AS service_account_kind, count(*) OVER()::int AS total
41
+ FROM auth.directory_shares share
42
+ LEFT JOIN auth.users u ON u.id=share.user_id
43
+ LEFT JOIN auth.groups g ON g.id=share.group_id
44
+ LEFT JOIN auth.service_accounts sa ON sa.id=share.service_account_id
45
+ WHERE share.recipient_id=${params.recipientId}::uuid
46
+ ORDER BY share.created_at, share.id
47
+ LIMIT ${params.perPage} OFFSET ${(params.page - 1) * params.perPage}`;
48
+ const total =
49
+ rows[0]?.total ??
50
+ (
51
+ await sql<
52
+ { total: number }[]
53
+ >`SELECT count(*)::int AS total FROM auth.directory_shares WHERE recipient_id=${params.recipientId}::uuid`
54
+ )[0]!.total;
55
+ return ok({
56
+ items: rows.map((row) => ({
57
+ id: row.id,
58
+ label: row.label,
59
+ serviceAccountKind: row.service_account_kind,
60
+ principal: row.user_id
61
+ ? { type: "user" as const, userId: row.user_id }
62
+ : row.group_id
63
+ ? { type: "group" as const, groupId: row.group_id }
64
+ : { type: "service_account" as const, serviceAccountId: row.service_account_id! },
65
+ })),
66
+ total,
67
+ page: params.page,
68
+ perPage: params.perPage,
69
+ });
70
+ },
71
+ grant: async (params: {
72
+ actor: Administrator;
73
+ recipientId: string;
74
+ principal: DirectorySharePrincipal;
75
+ }): Promise<Result<{ id: string }>> => {
76
+ if (!administrator(params.actor)) return fail(err.forbidden("Administrator access required"));
77
+ const principal = DirectorySharePrincipalSchema.safeParse(params.principal);
78
+ if (!principal.success) return fail(err.badInput("Choose an exact directory entry"));
79
+ if (!(await recipientExists(params.recipientId))) return fail(err.notFound("Service account"));
80
+ const target = principal.data;
81
+ try {
82
+ return await sql.begin(async (db) => {
83
+ const [row] = await db<{ id: string }[]>`INSERT INTO auth.directory_shares(recipient_id,user_id,group_id,service_account_id)
84
+ VALUES(${params.recipientId}::uuid, ${target.type === "user" ? target.userId : null}::uuid,
85
+ ${target.type === "group" ? target.groupId : null}::uuid,
86
+ ${target.type === "service_account" ? target.serviceAccountId : null}::uuid)
87
+ ON CONFLICT (recipient_id,user_id,group_id,service_account_id) DO UPDATE SET recipient_id=EXCLUDED.recipient_id RETURNING id`;
88
+ if (!row) return fail(err.internal("Could not save directory access"));
89
+ await audit.record(
90
+ {
91
+ action: "accounts.directory.grant",
92
+ outcome: "allowed",
93
+ actor: auditActor(params.actor),
94
+ target: { type: "directory_share", id: row.id },
95
+ metadata: { serviceAccountId: params.recipientId, principal: target },
96
+ },
97
+ db,
98
+ );
99
+ return ok(row);
100
+ });
101
+ } catch (error) {
102
+ if (
103
+ z.object({ code: z.literal("23503") }).safeParse(error).success ||
104
+ z.object({ errno: z.literal("23503") }).safeParse(error).success
105
+ )
106
+ return fail(err.notFound("Directory entry"));
107
+ throw error;
108
+ }
109
+ },
110
+ revoke: async (params: { actor: Administrator; recipientId: string; id: string }): Promise<Result<void>> => {
111
+ if (!administrator(params.actor)) return fail(err.forbidden("Administrator access required"));
112
+ return sql.begin(async (db) => {
113
+ await db`DELETE FROM auth.directory_shares WHERE id=${params.id}::uuid AND recipient_id=${params.recipientId}::uuid`;
114
+ await audit.record(
115
+ {
116
+ action: "accounts.directory.revoke",
117
+ outcome: "allowed",
118
+ actor: auditActor(params.actor),
119
+ target: { type: "directory_share", id: params.id },
120
+ metadata: { serviceAccountId: params.recipientId },
121
+ },
122
+ db,
123
+ );
124
+ return ok();
125
+ });
126
+ },
127
+ search: async (params: { actor: RequestActor; query: Omit<entities.EntityListParams, "visibility">; requestId?: string }) => {
128
+ const { actor, query } = params;
129
+ const result = await (async () => {
130
+ if (
131
+ actor.kind !== "service_account" ||
132
+ actor.delegatedUser ||
133
+ !["standalone", "agent"].includes(actor.serviceAccount.kind) ||
134
+ !actor.scopes.includes("read")
135
+ )
136
+ return fail(err.forbidden("Directory read access required"));
137
+ // Sharing an entry never shares its membership or management relationships.
138
+ if (
139
+ query.memberOfGroupId ||
140
+ query.managerOfGroupId ||
141
+ query.parentGroupId ||
142
+ query.managedByUserId ||
143
+ query.userMemberOfGroupIds?.length
144
+ )
145
+ return fail(err.forbidden("Directory shares do not include group relationships"));
146
+ return ok(await entities.list({ ...query, visibility: { type: "directory_shares", serviceAccountId: actor.serviceAccount.id } }));
147
+ })();
148
+ return audit.recordResult({
149
+ action: "accounts.directory.search",
150
+ result,
151
+ requestId: params.requestId,
152
+ target: actor.kind === "service_account" ? { type: "service_account", id: actor.serviceAccount.id } : null,
153
+ metadata:
154
+ actor.kind === "service_account" ? { serviceAccountId: actor.serviceAccount.id, credentialId: actor.credentialId ?? null } : {},
155
+ });
156
+ },
157
+ };
@@ -2,7 +2,7 @@ import { sql } from "bun";
2
2
  import type { EntityKind, EntityListItem, UserProfile, UserProvider } from "../../contracts/shared";
3
3
  import { getFreeIpaConfig } from "../freeipa-config";
4
4
  import { escapeLikePattern, toPgTextArray, toPgUuidArray } from "../postgres";
5
- import type { ServiceAccountKind } from "../service-accounts";
5
+ import type { ServiceAccountKind, ServiceAccountStatus } from "../service-accounts";
6
6
  import { buildBaseGroup, personalOwnerJoin } from "./base-group";
7
7
  import { buildBaseUser } from "./base-user";
8
8
  import { buildManagedGroupScopeCondition, recursiveGroupIdsSubquery } from "./group-sql";
@@ -11,13 +11,18 @@ type DbRow = Record<string, unknown>;
11
11
  type SqlFragment = any;
12
12
 
13
13
  export type EntityListParams = {
14
- visibility: { type: "directory" } | { type: "self_and_effective_groups"; userId: string };
14
+ visibility:
15
+ | { type: "directory" }
16
+ | { type: "self_and_effective_groups"; userId: string }
17
+ | { type: "directory_shares"; serviceAccountId: string };
15
18
  search?: string;
16
19
  kinds?: EntityKind[];
17
20
  provider?: UserProvider;
18
21
  profile?: UserProfile;
19
22
  userIds?: string[];
20
23
  groupIds?: string[];
24
+ serviceAccountKinds?: ServiceAccountKind[];
25
+ serviceAccountStatus?: ServiceAccountStatus;
21
26
  excludeUserIds?: string[];
22
27
  excludeGroupIds?: string[];
23
28
  excludeServiceAccountIds?: string[];
@@ -432,7 +437,17 @@ export const list = async (
432
437
  const visibilityCondition =
433
438
  params.visibility.type === "directory"
434
439
  ? sql`TRUE`
435
- : sql`(
440
+ : params.visibility.type === "directory_shares"
441
+ ? sql`EXISTS (
442
+ SELECT 1 FROM auth.directory_shares share
443
+ JOIN auth.service_accounts recipient ON recipient.id = share.recipient_id
444
+ WHERE recipient.id = ${params.visibility.serviceAccountId}::uuid
445
+ AND recipient.kind IN ('standalone', 'agent') AND recipient.status = 'active'
446
+ AND ((entity_rows.kind = 'user' AND share.user_id = entity_rows.id)
447
+ OR (entity_rows.kind = 'group' AND share.group_id = entity_rows.id)
448
+ OR (entity_rows.kind = 'service_account' AND share.service_account_id = entity_rows.id))
449
+ )`
450
+ : sql`(
436
451
  (kind = 'user' AND id = ${params.visibility.userId}::uuid)
437
452
  OR (
438
453
  kind = 'group'
@@ -459,6 +474,13 @@ export const list = async (
459
474
  (params.excludeServiceAccountIds?.length ?? 0) === 0
460
475
  ? sql`TRUE`
461
476
  : sql`(kind <> 'service_account' OR id <> ALL(${toPgUuidArray(params.excludeServiceAccountIds ?? [])}::uuid[]))`;
477
+ const serviceAccountKindsCondition =
478
+ (params.serviceAccountKinds?.length ?? 0) === 0
479
+ ? sql`TRUE`
480
+ : sql`(kind <> 'service_account' OR service_account_kind = ANY(${toPgTextArray(params.serviceAccountKinds ?? [])}::text[]))`;
481
+ const serviceAccountStatusCondition = params.serviceAccountStatus
482
+ ? sql`(kind <> 'service_account' OR status = ${params.serviceAccountStatus})`
483
+ : sql`TRUE`;
462
484
  const userMemberOfGroupCondition =
463
485
  (params.userMemberOfGroupIds?.length ?? 0) === 0
464
486
  ? sql`TRUE`
@@ -478,6 +500,8 @@ export const list = async (
478
500
  AND ${excludeUserCondition}
479
501
  AND ${excludeGroupCondition}
480
502
  AND ${excludeServiceAccountCondition}
503
+ AND ${serviceAccountKindsCondition}
504
+ AND ${serviceAccountStatusCondition}
481
505
  AND ${userMemberOfGroupCondition}
482
506
  AND ${personalCondition}
483
507
  AND (
@@ -500,9 +524,10 @@ export const list = async (
500
524
  OR (
501
525
  kind = 'service_account' AND (
502
526
  LOWER(name) LIKE ${pattern} ESCAPE '\\'
503
- OR LOWER(COALESCE(app_id, '')) LIKE ${pattern} ESCAPE '\\'
527
+ OR (${params.visibility.type !== "directory_shares"} AND (LOWER(COALESCE(app_id, '')) LIKE ${pattern} ESCAPE '\\'
504
528
  OR LOWER(COALESCE(resource_type, '')) LIKE ${pattern} ESCAPE '\\'
505
529
  OR LOWER(COALESCE(resource_id, '')) LIKE ${pattern} ESCAPE '\\'
530
+ ))
506
531
  )
507
532
  )
508
533
  )
@@ -635,9 +660,28 @@ export const list = async (
635
660
  OFFSET ${offset}
636
661
  `;
637
662
 
638
- const total = rows.length > 0 ? Number((rows[0] as Record<string, unknown>).total) : 0;
663
+ // A page past the end has no rows to carry the window count; the total still counts every match.
664
+ let total = rows.length > 0 ? Number(rows[0]!.total) : 0;
665
+ if (!rows.length && page > 1) total = (await list({ ...params, page: 1, perPage: 1 })).total;
639
666
  return {
640
- items: rows.map(mapEntityRow),
667
+ items: rows.map((row) => {
668
+ const entity = mapEntityRow(row);
669
+ if (params.visibility.type !== "directory_shares") return entity;
670
+ if (entity.kind === "group") return { ...entity, group: { ...entity.group, personalOwner: null } };
671
+ if (entity.kind === "service_account")
672
+ return {
673
+ ...entity,
674
+ serviceAccount: {
675
+ ...entity.serviceAccount,
676
+ delegatedUserId: null,
677
+ createdBy: null,
678
+ appId: null,
679
+ resourceType: null,
680
+ resourceId: null,
681
+ },
682
+ };
683
+ return entity;
684
+ }),
641
685
  total,
642
686
  pagination: {
643
687
  page,
@@ -86,7 +86,9 @@ const listCanonical = async (params: {
86
86
  OFFSET ${offset}
87
87
  `;
88
88
 
89
- const total = rows.length > 0 ? Number((rows[0] as Record<string, unknown>).total) : 0;
89
+ // A page past the end has no rows to carry the window count; the total still counts every match.
90
+ let total = rows.length > 0 ? Number((rows[0] as Record<string, unknown>).total) : 0;
91
+ if (!rows.length && page > 1) total = (await listCanonical({ ...params, page: 1, perPage: 1 })).total;
90
92
  return {
91
93
  groups: rows.map(buildBaseGroup),
92
94
  total,
@@ -16,7 +16,7 @@ export type AccountIdentityUser = {
16
16
  profile: UserProfile;
17
17
  posix: { uidNumber: number; primaryGidNumber: number } | null;
18
18
  };
19
- /** `personal` marks a user's personal Linux group (their stored primary group). */
19
+ /** `personal` marks a user's durably owned personal Linux group. */
20
20
  export type AccountIdentityGroup = { id: string; provider: UserProvider; name: string; gidNumber: number | null; personal: boolean };
21
21
  export type AccountIdentityPage<T> = { items: T[]; nextCursor: string | null };
22
22
  export type AccountIdentityAvailability = { localLinuxEnabled: boolean; freeipaEnabled: boolean };
@@ -122,7 +122,7 @@ export const createAccountIdentityService = (db: typeof sql = sql, upstreamIdent
122
122
  const groupRows = async (filter: SQLQuery) => {
123
123
  const rows = await db<{ id: string; provider: UserProvider; name: string; gid_number: number | null; personal: boolean }[]>`
124
124
  SELECT g.id, g.provider, g.name, g.gid_number,
125
- EXISTS(SELECT 1 FROM auth.user_posix p WHERE p.primary_group_id = g.id) AS personal
125
+ EXISTS(SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id) AS personal
126
126
  FROM auth.groups g
127
127
  WHERE ${filter} ORDER BY g.id LIMIT ${PAGE_SIZE + 1}
128
128
  `;
@@ -207,22 +207,74 @@ export const createAccountIdentityService = (db: typeof sql = sql, upstreamIdent
207
207
  if ((input.kind !== "users" && input.kind !== "groups") || typeof input.name !== "string" || !input.name || input.name.includes("\0"))
208
208
  throw new AccountIdentityError("invalid_identity_lookup", 400);
209
209
  };
210
+ const reconcileIdentity = async (
211
+ input: { kind: "users" | "groups"; provider: UserProvider; name: string; identityId?: string; signal?: AbortSignal },
212
+ state: AccountIdentityAvailability,
213
+ ): Promise<AccountIdentityReconciliation> => {
214
+ validateLookup(input);
215
+ if (input.provider !== "local" && input.provider !== "ipa") throw new AccountIdentityError("invalid_identity_lookup", 400);
216
+ if (input.identityId !== undefined) cursor(input.identityId);
217
+ if (input.signal?.aborted) return { state: "unknown", reason: "provider_unavailable" };
218
+ if (input.provider === "ipa") {
219
+ if (!state.freeipaEnabled) return { state: "unknown", reason: "provider_disabled" };
220
+ return upstreamIdentity(input);
221
+ }
222
+ const result = await readLocalIdentity(input);
223
+ return input.signal?.aborted ? { state: "unknown", reason: "provider_unavailable" } : result;
224
+ };
210
225
  return {
226
+ /** Trusted application provisioning only; no profile, grants, credentials, or administrator impersonation. */
227
+ async provisioningUsers(input: { provider?: UserProvider; id?: string; after?: string }) {
228
+ if (input.provider !== undefined && input.provider !== "local" && input.provider !== "ipa")
229
+ throw new AccountIdentityError("invalid_provider", 400);
230
+ const id = cursor(input.id);
231
+ const after = cursor(input.after);
232
+ const state = await availability();
233
+ const [localAllowed, ipaAllowed] = await Promise.all([
234
+ isAccountCategoryAllowed({ provider: "local", profile: "user" }, db),
235
+ isAccountCategoryAllowed({ provider: "ipa", profile: "user" }, db),
236
+ ]);
237
+ const rows = await db<
238
+ {
239
+ id: string;
240
+ provider: UserProvider;
241
+ uid: string;
242
+ managed_by: UserProvider | null;
243
+ uid_number: number | null;
244
+ primary_gid_number: number | null;
245
+ }[]
246
+ >`SELECT u.id,u.provider,u.uid,p.managed_by,p.uid_number,p.primary_gid_number
247
+ FROM auth.users u LEFT JOIN auth.user_posix p ON p.user_id=u.id
248
+ WHERE u.provider IN ('local','ipa') AND (${input.provider ?? null}::text IS NULL OR u.provider=${input.provider ?? null})
249
+ AND u.profile='user'
250
+ AND (u.account_expires IS NULL OR u.account_expires>now())
251
+ AND (${id}::uuid IS NULL OR u.id=${id}::uuid)
252
+ AND (${after}::uuid IS NULL OR u.id>${after}::uuid)
253
+ ORDER BY u.id LIMIT ${PAGE_SIZE + 1}`;
254
+ const source = page(rows);
255
+ const items = source.items
256
+ .filter((row) => (row.provider === "local" ? localAllowed : ipaAllowed && state.freeipaEnabled))
257
+ .map((row) => ({
258
+ id: row.id,
259
+ provider: row.provider,
260
+ username: row.uid,
261
+ posix:
262
+ row.managed_by === row.provider && positiveId(row.uid_number) && positiveId(row.primary_gid_number)
263
+ ? { uidNumber: row.uid_number, primaryGidNumber: row.primary_gid_number }
264
+ : null,
265
+ }));
266
+ return { items, nextCursor: source.nextCursor, availability: state };
267
+ },
211
268
  async reconcile(
212
269
  actor: RequestActor,
213
270
  input: { kind: "users" | "groups"; provider: UserProvider; name: string; identityId?: string; signal?: AbortSignal },
214
271
  ): Promise<AccountIdentityReconciliation> {
215
272
  const { state } = await requireAdmin(actor);
216
- validateLookup(input);
217
- if (input.provider !== "local" && input.provider !== "ipa") throw new AccountIdentityError("invalid_identity_lookup", 400);
218
- if (input.identityId !== undefined) cursor(input.identityId);
219
- if (input.signal?.aborted) return { state: "unknown", reason: "provider_unavailable" };
220
- if (input.provider === "ipa") {
221
- if (!state.freeipaEnabled) return { state: "unknown", reason: "provider_disabled" };
222
- return upstreamIdentity(input);
223
- }
224
- const result = await readLocalIdentity(input);
225
- return input.signal?.aborted ? { state: "unknown", reason: "provider_unavailable" } : result;
273
+ return reconcileIdentity(input, state);
274
+ },
275
+ /** Trusted provisioning services use the same live identity check as administrator provisioning. */
276
+ async provisioningIdentity(input: { kind: "users"; provider: UserProvider; name: string; identityId: string; signal?: AbortSignal }) {
277
+ return reconcileIdentity(input, await availability());
226
278
  },
227
279
  /** Trusted application jobs only; this read does not authorize an HTTP caller or a filesystem mutation. */
228
280
  async localLifecycle(input: { kind: "users" | "groups"; identityId: string; name: string }) {
@@ -164,7 +164,7 @@ export const remove = async (params: { id: string }): Promise<MutationResult<voi
164
164
  const group = await getLocalGroupById(params.id);
165
165
  if (!group) return { ok: false, error: "Group not found", status: 404 };
166
166
 
167
- const [primary] = await sql`SELECT 1 FROM auth.user_posix WHERE primary_group_id = ${params.id}::uuid LIMIT 1`;
167
+ const [primary] = await sql`SELECT 1 FROM auth.personal_groups WHERE group_id = ${params.id}::uuid LIMIT 1`;
168
168
  if (primary) return { ok: false, error: "This group is a user's primary Linux group and cannot be deleted.", status: 409 };
169
169
  try {
170
170
  await sql`DELETE FROM auth.groups WHERE id = ${params.id}::uuid AND provider = 'local'`;
@@ -4,7 +4,7 @@ import { freeipa } from "../../server/services";
4
4
  import { createAuthLoginUrl } from "../../shared/redirect";
5
5
  import { isAccountCategoryAllowed } from "../account-category-policy";
6
6
  import type { AuditActor } from "../audit";
7
- import { getFreeIpaConfig } from "../freeipa-config";
7
+ import { getFreeIpaAdminGroups, getFreeIpaConfig } from "../freeipa-config";
8
8
  import { getServiceIpaSession } from "../ipa/service-account";
9
9
  import { toPgTextArray, toPgUuidArray } from "../postgres";
10
10
  import { providers } from "../providers";
@@ -12,7 +12,7 @@ import { session } from "../session";
12
12
  import * as settings from "../settings";
13
13
  import { buildRoles } from "./authz";
14
14
  import { buildBaseUser, resolveProviderProfile } from "./base-user";
15
- import { managedGroupIdsSubquery, managedGroupsNamesSubquery, recursiveGroupIdsSubquery, recursiveGroupNamesSubquery } from "./group-sql";
15
+ import { managedGroupIdsSubquery, managedGroupsNamesSubquery, recursiveGroupIdsSubquery, recursiveUserGroupsSubquery } from "./group-sql";
16
16
  import { buildIpaUserData, emptyIpaUserData, userIpaDataColumns, userIpaDataJoin } from "./ipa-data";
17
17
  import {
18
18
  canPersistStoredAdmin,
@@ -25,7 +25,7 @@ import {
25
25
  import type { AccountsNotificationSender } from "./notification-sender";
26
26
  import { transitionIpaUserToLocal } from "./switching";
27
27
 
28
- export { clearAvatar, getAvatar, parseAvatarDataUrl, setAvatar } from "./avatar";
28
+ export { clearAvatar, getAvatar, getAvatarState, parseAvatarDataUrl, setAvatar, updateAvatar } from "./avatar";
29
29
 
30
30
  type DbRow = Record<string, unknown>;
31
31
  type UserMutationTarget = BaseUser & { accountExpires: string | null; storedAdmin: boolean };
@@ -168,11 +168,12 @@ const buildUser = (row: DbRow, groupsAdmin: string[]): User => {
168
168
  };
169
169
  };
170
170
 
171
- export const get = async (params: { id: string } | { uid: string }): Promise<User | null> => {
172
- const whereClause = "id" in params ? sql`u.id = ${params.id}` : sql`u.uid = ${params.uid}`;
173
- const userIdExpr = "id" in params ? sql`${params.id}` : sql`u.id`;
174
- const { groupsAdmin } = await getFreeIpaConfig();
175
- const rows = await sql<DbRow[]>`
171
+ export const get = async (params: { id: string } | { uid: string }, options: { db?: SQL } = {}): Promise<User | null> => {
172
+ const db = options.db ?? sql;
173
+ const whereClause = "id" in params ? db`u.id = ${params.id}` : db`u.uid = ${params.uid}`;
174
+ const userIdExpr = "id" in params ? db`${params.id}` : db`u.id`;
175
+ const groupsAdmin = options.db ? await getFreeIpaAdminGroups(db) : (await getFreeIpaConfig()).groupsAdmin;
176
+ const rows = await db<DbRow[]>`
176
177
  SELECT u.*,
177
178
  ${userIpaDataColumns()},
178
179
  CASE
@@ -346,17 +347,24 @@ export const list = async (params: {
346
347
  };
347
348
  };
348
349
 
349
- export const getGroups = async (params: { id: string; recursive?: boolean }): Promise<string[]> => {
350
+ /** Technical membership reads stay complete; human-facing lists exclude personal groups explicitly. */
351
+ export const getGroups = async (params: { id: string; recursive?: boolean; includePersonal?: boolean }): Promise<string[]> => {
350
352
  if (params.recursive) {
351
- const rows = await sql<DbRow[]>`${recursiveGroupNamesSubquery(params.id)}`;
353
+ const rows = await sql<DbRow[]>`${recursiveUserGroupsSubquery({
354
+ userId: params.id,
355
+ select: sql`
356
+ SELECT DISTINCT g.name FROM user_all_groups ag
357
+ JOIN auth.groups g ON g.id = ag.group_id
358
+ WHERE (${params.includePersonal !== false} OR NOT EXISTS (SELECT 1 FROM auth.user_posix p WHERE p.primary_group_id = g.id))
359
+ ORDER BY g.name
360
+ `,
361
+ })}`;
352
362
  return rows.map((row) => row.name as string);
353
363
  }
354
-
355
364
  const rows = await sql<DbRow[]>`
356
- SELECT g.name
357
- FROM auth.user_groups_v2 ug
358
- JOIN auth.groups g ON g.id = ug.group_id
359
- WHERE ug.user_id = ${params.id}::uuid
365
+ SELECT g.name FROM auth.groups g
366
+ WHERE g.id IN (SELECT group_id FROM auth.user_groups_v2 WHERE user_id = ${params.id}::uuid)
367
+ AND (${params.includePersonal !== false} OR NOT EXISTS (SELECT 1 FROM auth.user_posix p WHERE p.primary_group_id = g.id))
360
368
  ORDER BY g.name
361
369
  `;
362
370
  return rows.map((row) => row.name as string);