@k2b/cloud 0.6.0 → 0.8.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 (349) hide show
  1. package/README.md +2 -2
  2. package/package.json +22 -11
  3. package/scripts/README.md +10 -0
  4. package/scripts/browser-performance.ts +14 -0
  5. package/scripts/build-pdf-renderer.ts +39 -0
  6. package/scripts/build.ts +20 -35
  7. package/scripts/preload.ts +16 -12
  8. package/scripts/sync-recovery-smoke.ts +22 -6
  9. package/src/_internal/app-presentation.ts +14 -2
  10. package/src/_internal/build-metadata.ts +8 -1
  11. package/src/_internal/capabilities.ts +131 -7
  12. package/src/_internal/capability-streams.ts +81 -0
  13. package/src/_internal/define-app.ts +80 -37
  14. package/src/_internal/heartbeat.ts +4 -0
  15. package/src/_internal/help-catalog.ts +27 -172
  16. package/src/_internal/help.ts +13 -76
  17. package/src/_internal/page-responses.ts +2 -0
  18. package/src/_internal/postgres-application-name.ts +30 -0
  19. package/src/_internal/process-identity.ts +15 -0
  20. package/src/_internal/process-sync.ts +1 -1
  21. package/src/_internal/readiness.ts +4 -1
  22. package/src/_internal/registry-validation.ts +29 -25
  23. package/src/_internal/registry.ts +2 -53
  24. package/src/_internal/runtime-context.ts +5 -16
  25. package/src/_internal/server-timing.ts +13 -0
  26. package/src/_internal/status-preserving-ssr.ts +18 -6
  27. package/src/_internal/web-vitals-asset.ts +10 -0
  28. package/src/access/PermissionEditor.tsx +18 -151
  29. package/src/access/PrincipalPicker.tsx +110 -0
  30. package/src/access/ResourceApiKeys.tsx +1 -1
  31. package/src/access/ui.ts +1 -0
  32. package/src/ai/admin.ts +14 -13
  33. package/src/ai/assistant-models.ts +12 -1
  34. package/src/ai/audio-format.ts +34 -0
  35. package/src/ai/audio-tool.ts +90 -0
  36. package/src/ai/browser-code-contracts.ts +201 -0
  37. package/src/ai/browser.ts +4 -0
  38. package/src/ai/capabilities.ts +55 -36
  39. package/src/ai/capability-execution.ts +47 -12
  40. package/src/ai/chat/blocks.tsx +229 -90
  41. package/src/ai/chat/builtin-tools.tsx +2 -4
  42. package/src/ai/chat/capability-result.ts +15 -0
  43. package/src/ai/chat/capability-table.tsx +2 -10
  44. package/src/ai/chat/composer-adapter.ts +83 -3
  45. package/src/ai/chat/message-actions.tsx +8 -7
  46. package/src/ai/chat/message-utils.ts +25 -1
  47. package/src/ai/chat/messages.ts +2 -0
  48. package/src/ai/chat/presentation.tsx +31 -47
  49. package/src/ai/chat/primitives.tsx +43 -1
  50. package/src/ai/chat/tool-disclosure.tsx +21 -2
  51. package/src/ai/chat/tool-groups.ts +58 -0
  52. package/src/ai/chat-quotas.ts +25 -0
  53. package/src/ai/chat-task-contracts.ts +14 -10
  54. package/src/ai/chat-tasks.ts +297 -43
  55. package/src/ai/client/controller.ts +165 -39
  56. package/src/ai/client/file-source.ts +12 -6
  57. package/src/ai/client/live-connection.ts +1 -0
  58. package/src/ai/client/projection.ts +29 -11
  59. package/src/ai/client/transport.ts +6 -0
  60. package/src/ai/code-capability-routes.ts +105 -0
  61. package/src/ai/code-capability-transport.ts +39 -0
  62. package/src/ai/code-execution.ts +53 -0
  63. package/src/ai/code-mode-skill.ts +118 -0
  64. package/src/ai/code-runtime-tools.ts +179 -0
  65. package/src/ai/code-source-contracts.ts +270 -0
  66. package/src/ai/code-source-tools.ts +188 -0
  67. package/src/ai/compaction.ts +27 -6
  68. package/src/ai/data-analysis-skill.ts +10 -0
  69. package/src/ai/default-tools.ts +99 -3
  70. package/src/ai/dictation-runtime.ts +171 -0
  71. package/src/ai/dictations.ts +188 -0
  72. package/src/ai/draft-content.ts +49 -0
  73. package/src/ai/enrich.ts +6 -0
  74. package/src/ai/executor.ts +189 -33
  75. package/src/ai/fetch-file-tool.ts +2 -2
  76. package/src/ai/file-content-version.ts +27 -0
  77. package/src/ai/file-context.ts +2 -1
  78. package/src/ai/file-media-type.ts +12 -0
  79. package/src/ai/file-reference-contracts.ts +15 -0
  80. package/src/ai/file-tools.ts +8 -1
  81. package/src/ai/files-store.ts +212 -71
  82. package/src/ai/fixtures/vision-encrypted.pdf +0 -0
  83. package/src/ai/grids-skill.ts +42 -10
  84. package/src/ai/http.ts +26 -8
  85. package/src/ai/index.ts +26 -3
  86. package/src/ai/inference-calls.ts +197 -0
  87. package/src/ai/live-events.ts +5 -1
  88. package/src/ai/live.ts +1 -1
  89. package/src/ai/markdown-pdf-tool.ts +2 -2
  90. package/src/ai/memories.ts +30 -27
  91. package/src/ai/memory-learning-runs.ts +1 -1
  92. package/src/ai/memory-learning.ts +64 -32
  93. package/src/ai/memory-tool.ts +1 -5
  94. package/src/ai/memory-workflow-evidence.ts +9 -4
  95. package/src/ai/message-queue.ts +178 -0
  96. package/src/ai/migrate.ts +189 -11
  97. package/src/ai/model-pricing.ts +43 -0
  98. package/src/ai/pdf-render-worker.ts +75 -0
  99. package/src/ai/pdf-render.fixture.ts +28 -0
  100. package/src/ai/pdf-render.ts +77 -0
  101. package/src/ai/projects-routes.ts +18 -43
  102. package/src/ai/projects.ts +33 -14
  103. package/src/ai/protocol.ts +3 -0
  104. package/src/ai/provider.ts +1 -8
  105. package/src/ai/quota-provider.ts +192 -0
  106. package/src/ai/quota-report.ts +152 -0
  107. package/src/ai/quotas-migrate.ts +45 -0
  108. package/src/ai/quotas.ts +168 -0
  109. package/src/ai/routes.ts +241 -11
  110. package/src/ai/run-timeout.ts +12 -0
  111. package/src/ai/runtime-tools.ts +7 -5
  112. package/src/ai/runtime.ts +74 -17
  113. package/src/ai/settings.ts +36 -8
  114. package/src/ai/short-id.ts +4 -5
  115. package/src/ai/skill-catalog.ts +0 -10
  116. package/src/ai/skill-search.ts +58 -0
  117. package/src/ai/skill-seeds.ts +114 -20
  118. package/src/ai/skill-tool.ts +21 -5
  119. package/src/ai/skills-routes.ts +49 -4
  120. package/src/ai/skills.ts +358 -163
  121. package/src/ai/store.ts +416 -201
  122. package/src/ai/stream.ts +5 -0
  123. package/src/ai/structured-runs.ts +4 -3
  124. package/src/ai/structured.ts +12 -1
  125. package/src/ai/system-prompt.ts +14 -2
  126. package/src/ai/task-contracts.ts +84 -0
  127. package/src/ai/task-execution.ts +92 -0
  128. package/src/ai/timeline.ts +2 -5
  129. package/src/ai/todo-contracts.ts +32 -0
  130. package/src/ai/todo-tool.ts +19 -0
  131. package/src/ai/tool-audit.ts +30 -40
  132. package/src/ai/tools.ts +6 -0
  133. package/src/ai/transcription.ts +202 -0
  134. package/src/ai/turn-timing.ts +114 -0
  135. package/src/ai/types.ts +58 -10
  136. package/src/ai/ui.tsx +3 -0
  137. package/src/ai/usage.ts +118 -118
  138. package/src/ai/vision-tool.ts +60 -10
  139. package/src/api/admin-ai-projects.ts +2 -2
  140. package/src/api/admin-ai-quotas.ts +80 -0
  141. package/src/api/admin-ai-skills.ts +48 -5
  142. package/src/api/admin-ai-usage.ts +2 -2
  143. package/src/api/admin-core-settings.ts +25 -0
  144. package/src/api/admin-identity.ts +91 -51
  145. package/src/api/admin-linux-identities.ts +10 -4
  146. package/src/api/admin-rail.ts +58 -0
  147. package/src/api/announcements.ts +14 -0
  148. package/src/api/app-approval.ts +2 -2
  149. package/src/api/auth.ts +1 -1
  150. package/src/api/capabilities.ts +324 -43
  151. package/src/api/capability-streams.ts +262 -0
  152. package/src/api/help.ts +8 -44
  153. package/src/api/index.ts +8 -2
  154. package/src/api/mcp.ts +45 -34
  155. package/src/api/me-web-vitals.ts +35 -0
  156. package/src/api/me.ts +2 -0
  157. package/src/api/search/schemas.ts +26 -15
  158. package/src/api/search.bench.ts +1 -1
  159. package/src/api/search.ts +54 -17
  160. package/src/browser/CloudResourceSearch.tsx +640 -702
  161. package/src/browser/app-approval-vault.ts +19 -7
  162. package/src/browser/command-bridge.ts +109 -0
  163. package/src/browser/command-shortcuts.ts +76 -0
  164. package/src/browser/commands.ts +67 -0
  165. package/src/browser/navigation-search.ts +48 -0
  166. package/src/browser/notifications.ts +1 -1
  167. package/src/browser/resource-picker.tsx +5 -18
  168. package/src/browser/resource-search-dialog.ts +9 -0
  169. package/src/browser/resource-search-input.ts +46 -0
  170. package/src/browser/resource-search-messages.ts +76 -40
  171. package/src/browser/resource-search.ts +7 -0
  172. package/src/browser/search-bridge.ts +102 -0
  173. package/src/browser/search-commands.ts +59 -0
  174. package/src/browser/search.ts +13 -0
  175. package/src/browser/spotlight-position.ts +208 -0
  176. package/src/browser/testing.ts +6 -0
  177. package/src/browser/web-vitals.ts +25 -0
  178. package/src/capabilities/claims.ts +179 -0
  179. package/src/capabilities/client.ts +34 -0
  180. package/src/capabilities/command-link.ts +16 -0
  181. package/src/capabilities/executions.ts +298 -0
  182. package/src/capabilities/migrate.ts +81 -0
  183. package/src/capabilities/server.ts +116 -35
  184. package/src/capabilities/stream-body.ts +16 -0
  185. package/src/capabilities/streams.ts +28 -0
  186. package/src/cli/admin/ai-quotas.ts +161 -0
  187. package/src/cli/admin/ai-skills.ts +64 -0
  188. package/src/cli/admin/ai-usage.ts +53 -30
  189. package/src/cli/admin/app-credentials.ts +77 -0
  190. package/src/cli/admin/data.ts +17 -0
  191. package/src/cli/admin/index.ts +10 -1
  192. package/src/cli/admin/instance.ts +2 -2
  193. package/src/cli/admin/linux.ts +4 -2
  194. package/src/cli/admin/sync.ts +1 -1
  195. package/src/cli/capabilities.ts +42 -0
  196. package/src/cli/capability-streams.ts +25 -0
  197. package/src/cli/index.ts +3 -2
  198. package/src/config/define-env.ts +97 -0
  199. package/src/config/env.ts +147 -51
  200. package/src/config/index.ts +3 -1
  201. package/src/contracts/app.ts +23 -3
  202. package/src/contracts/capabilities.ts +75 -5
  203. package/src/contracts/capability-streams.ts +46 -0
  204. package/src/contracts/commands.ts +49 -0
  205. package/src/contracts/index.ts +5 -2
  206. package/src/contracts/rail-admin.ts +38 -0
  207. package/src/contracts/rail-preferences.ts +16 -0
  208. package/src/contracts/registry.ts +5 -27
  209. package/src/contracts/settings-types.ts +1 -1
  210. package/src/contracts/shared.ts +18 -5
  211. package/src/contracts/web-vitals.ts +27 -0
  212. package/src/desktop/index.ts +1 -0
  213. package/src/desktop/solid.tsx +1 -1
  214. package/src/index.ts +8 -6
  215. package/src/server/index.ts +3 -0
  216. package/src/server/middleware/auth.ts +3 -2
  217. package/src/server/middleware/route-template.ts +9 -3
  218. package/src/server/middleware/runtime.ts +2 -1
  219. package/src/server/middleware/settings.ts +17 -11
  220. package/src/server/ratelimit.ts +1 -1
  221. package/src/server/services/access-revision.ts +10 -0
  222. package/src/server/services/access.ts +3 -1
  223. package/src/server/services/index.ts +1 -0
  224. package/src/services/account-category-policy.ts +9 -0
  225. package/src/services/account-lifecycle/audit.ts +1 -1
  226. package/src/services/accounts/app.ts +12 -2
  227. package/src/services/accounts/email-write.ts +23 -0
  228. package/src/services/accounts/groups.ts +15 -4
  229. package/src/services/accounts/identities.ts +249 -0
  230. package/src/services/accounts/identity-reconciliation.ts +87 -0
  231. package/src/services/accounts/ipa-data.ts +2 -2
  232. package/src/services/accounts/local-groups.ts +2 -2
  233. package/src/services/accounts/notification-sender.ts +12 -2
  234. package/src/services/accounts/posix.ts +45 -29
  235. package/src/services/accounts/users.ts +2 -2
  236. package/src/services/announcements/index.ts +69 -8
  237. package/src/services/app-approval.ts +11 -10
  238. package/src/services/audit/index.ts +1 -1
  239. package/src/services/cache-fill.ts +35 -0
  240. package/src/services/help/index.ts +109 -0
  241. package/src/services/help/maintenance.ts +25 -0
  242. package/src/services/help/store.ts +180 -0
  243. package/src/services/help/types.ts +46 -0
  244. package/src/services/identity/invocation-actor.ts +2 -2
  245. package/src/services/identity/key-config.ts +8 -5
  246. package/src/services/identity/key-ring.ts +1 -1
  247. package/src/services/identity/runtime-config.ts +6 -5
  248. package/src/services/index.ts +15 -0
  249. package/src/services/ipa/posix.ts +1 -1
  250. package/src/services/ipa/sync.ts +1 -1
  251. package/src/services/ipa/users.ts +268 -221
  252. package/src/services/legal-consent.ts +1 -1
  253. package/src/services/logging/index.ts +15 -13
  254. package/src/services/logging/trace.ts +2 -1
  255. package/src/services/logging/web-vitals.ts +81 -0
  256. package/src/services/mandates/index.ts +21 -4
  257. package/src/services/mandates/policy.ts +165 -2
  258. package/src/services/notifications/batches.ts +125 -57
  259. package/src/services/notifications/browser.ts +12 -0
  260. package/src/services/notifications/index.ts +0 -2
  261. package/src/services/notifications/platform.ts +0 -4
  262. package/src/services/oauth-tokens.ts +3 -3
  263. package/src/services/pdf/gotenberg.ts +131 -72
  264. package/src/services/pdf/index.ts +11 -8
  265. package/src/services/providers/local/users.ts +25 -16
  266. package/src/services/public-http.ts +101 -0
  267. package/src/services/rail-shortcuts.ts +95 -0
  268. package/src/services/rail-snapshot.ts +53 -0
  269. package/src/services/request-cache-redis.ts +29 -0
  270. package/src/services/session/index.ts +15 -2
  271. package/src/services/session/user.ts +62 -37
  272. package/src/services/settings/app.ts +5 -1
  273. package/src/services/settings/core-settings.ts +26 -35
  274. package/src/services/settings/defaults.ts +5 -0
  275. package/src/services/settings/index.ts +3 -3
  276. package/src/services/settings/store.ts +58 -84
  277. package/src/services/webauthn.ts +1 -1
  278. package/src/shared/ai-costs.ts +42 -0
  279. package/src/shared/ai-platform-prompt.ts +1 -1
  280. package/src/shared/ai-quotas.ts +136 -0
  281. package/src/shared/ai-usage.ts +6 -16
  282. package/src/shared/app-presentation.ts +21 -15
  283. package/src/shared/capability-messages.ts +14 -0
  284. package/src/shared/help.ts +6 -42
  285. package/src/shared/index.ts +3 -0
  286. package/src/shared/markdown/extensions/info-blocks.ts +1 -1
  287. package/src/ssr/AdminLayout.tsx +7 -5
  288. package/src/ssr/AdminSidebar.tsx +30 -16
  289. package/src/ssr/AppLaunchpad.island.tsx +33 -136
  290. package/src/ssr/AppLaunchpadPanel.tsx +181 -0
  291. package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
  292. package/src/ssr/GlobalSearchDialog.tsx +167 -20
  293. package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
  294. package/src/ssr/Layout.tsx +17 -6
  295. package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
  296. package/src/ssr/LayoutFooter.tsx +1 -1
  297. package/src/ssr/LayoutHeader.tsx +46 -14
  298. package/src/ssr/LayoutHelp.tsx +22 -23
  299. package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
  300. package/src/ssr/LayoutRail.tsx +17 -9
  301. package/src/ssr/MinimalLayout.tsx +1 -1
  302. package/src/ssr/MobileNavigation.tsx +71 -0
  303. package/src/ssr/MobileProfileActions.tsx +49 -0
  304. package/src/ssr/ProfilePreferences.island.tsx +3 -36
  305. package/src/ssr/RailApps.island.tsx +2 -2
  306. package/src/ssr/RailEditor.tsx +17 -2
  307. package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
  308. package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
  309. package/src/ssr/admin-navigation.ts +11 -1
  310. package/src/ssr/help.ts +29 -0
  311. package/src/ssr/islands/SearchBar.tsx +67 -0
  312. package/src/ssr/islands/index.ts +3 -1
  313. package/src/ssr/layout-context.ts +2 -2
  314. package/src/ssr/layout-help-search.ts +15 -0
  315. package/src/ssr/mobile-menu-history.ts +70 -0
  316. package/src/ssr/platform-messages.ts +16 -8
  317. package/src/ssr/profile-actions.ts +42 -0
  318. package/src/ssr/rail-context.ts +8 -4
  319. package/src/ssr/rail-messages.ts +2 -0
  320. package/src/ssr/rail-navigation.ts +3 -3
  321. package/src/ssr/workspace-navigation.ts +75 -0
  322. package/src/styles/effects.css +6 -6
  323. package/src/styles/global.css +54 -0
  324. package/src/styles/input.css +43 -41
  325. package/src/styles/resource-search.css +505 -0
  326. package/src/styles/utilities-feedback.css +13 -0
  327. package/src/styles/utilities-layout.css +1 -5
  328. package/src/styles/utilities-navigation.css +3 -39
  329. package/src/types/ambient.d.ts +5 -0
  330. package/src/workflows/ai/index.ts +2 -2
  331. package/src/workflows/ai/runtime.ts +19 -90
  332. package/src/workflows/ai/store.ts +2 -0
  333. package/src/workflows/ai/types.ts +6 -82
  334. package/src/workflows/ai-actions.ts +16 -13
  335. package/src/workflows/definition.ts +10 -5
  336. package/src/workflows/store/actions.ts +44 -20
  337. package/src/workflows/store/index.ts +4 -2
  338. package/src/workflows/store/runs.ts +36 -0
  339. package/src/workflows/store/worker-pool.ts +55 -0
  340. package/src/workflows/store/worker-runtime.ts +90 -0
  341. package/src/ai/kit-skill.ts +0 -22
  342. package/src/ai/usage-migrate.ts +0 -81
  343. package/src/browser/notification-target.ts +0 -27
  344. package/src/contracts/notification-live.ts +0 -66
  345. package/src/server/services/freeipa/test-certificates.ts +0 -44
  346. package/src/services/notifications/live.ts +0 -50
  347. package/src/services/session/test-fixture.ts +0 -18
  348. package/src/ssr/BrowserNotifications.island.tsx +0 -135
  349. package/src/ssr/islands/SearchBar.island.tsx +0 -80
@@ -0,0 +1,49 @@
1
+ import { z } from "zod";
2
+
3
+ /** Commands carry small public references/options, never document bodies or secrets. */
4
+ export const COMMAND_MAX_URL_LENGTH = 8192;
5
+ export const CommandPathSchema = z
6
+ .string()
7
+ .max(2000)
8
+ .refine((value) => {
9
+ if (!value.startsWith("/") || /[\\\u0000-\u0020]/u.test(value)) return false;
10
+ try {
11
+ return new URL(value, "https://cloud.invalid").origin === "https://cloud.invalid";
12
+ } catch {
13
+ return false;
14
+ }
15
+ }, "Expected a Cloud path");
16
+
17
+ export const CommandOptionsSchema = z.object({ returnTo: CommandPathSchema.optional() }).strict();
18
+ export type CommandOptions = z.infer<typeof CommandOptionsSchema>;
19
+ export const CommandRequestSchema = CommandOptionsSchema.extend({ input: z.unknown() }).strict();
20
+ export const CommandLinkSchema = z.object({ href: z.url().max(COMMAND_MAX_URL_LENGTH) }).strict();
21
+
22
+ export const commandPath = (path: string, command: string, input: unknown, options: CommandOptions = {}): string => {
23
+ const url = new URL(CommandPathSchema.parse(path), "https://cloud.invalid");
24
+ url.searchParams.set("command", command);
25
+ url.searchParams.set("commandInput", JSON.stringify(input));
26
+ const parsed = CommandOptionsSchema.parse(options);
27
+ if (parsed.returnTo) url.searchParams.set("commandReturn", parsed.returnTo);
28
+ else url.searchParams.delete("commandReturn");
29
+ const result = `${url.pathname}${url.search}${url.hash}`;
30
+ if (result.length > COMMAND_MAX_URL_LENGTH) throw new Error("Command link is too large");
31
+ return result;
32
+ };
33
+
34
+ export const readCommand = (url: URL) => {
35
+ const id = url.searchParams.get("command");
36
+ if (!id) return null;
37
+ const raw = url.searchParams.get("commandInput") ?? "{}";
38
+ if (url.href.length > COMMAND_MAX_URL_LENGTH) throw new Error("Command link is too large");
39
+ return {
40
+ id,
41
+ input: JSON.parse(raw) as unknown,
42
+ options: CommandOptionsSchema.parse({ returnTo: url.searchParams.get("commandReturn") ?? undefined }),
43
+ };
44
+ };
45
+
46
+ export const clearCommand = (url: URL): string => {
47
+ for (const key of ["command", "commandInput", "commandReturn"]) url.searchParams.delete(key);
48
+ return `${url.pathname}${url.search}${url.hash}`;
49
+ };
@@ -1,14 +1,17 @@
1
1
  export * from "./account-categories";
2
2
  export * from "./announcements";
3
3
  export * from "./app";
4
+ export * from "./app-approval";
4
5
  export * from "./capabilities";
5
- export * from "./notification-live";
6
+ export * from "./commands";
6
7
  export * from "./notification-types";
7
8
  export * from "./posix";
8
9
  export * from "./profile";
10
+ // Core app bar administration and shared shortcut values.
11
+ export { RailAccessSchema, type RailAdminEntry, RailAdminInputSchema, RailAdminSchema, type RailAdminState } from "./rail-admin";
12
+ export type { RailShortcut } from "./rail-preferences";
9
13
  export * from "./registry";
10
14
  export * from "./resource-clipboard";
11
15
  export * from "./shared";
12
16
  export * from "./user-notifications";
13
17
  export * from "./widgets";
14
- export * from "./app-approval";
@@ -0,0 +1,38 @@
1
+ import { z } from "zod";
2
+ import { RAIL_PREFERENCES_MAX_BYTES, RailShortcutSchema } from "./rail-preferences";
3
+ import { AccessEntrySchema } from "./shared";
4
+
5
+ export const RailAccessSchema = AccessEntrySchema.extend({
6
+ principal: z.discriminatedUnion("type", [
7
+ z.object({ type: z.literal("authenticated") }).strict(),
8
+ z.object({ type: z.literal("user"), userId: z.uuid() }).strict(),
9
+ z.object({ type: z.literal("group"), groupId: z.uuid() }).strict(),
10
+ ]),
11
+ permission: z.literal("read"),
12
+ });
13
+ export const RailAdminEntrySchema = z.object({ shortcut: RailShortcutSchema, access: z.array(RailAccessSchema) }).strict();
14
+ const revision = z.number().int().nonnegative().max(2_147_483_647);
15
+ export const RailAdminSchema = z.object({ revision, entries: z.array(RailAdminEntrySchema) }).strict();
16
+ /** Strip presentation metadata from editor drafts before sending or persisting grants. */
17
+ export const RailAdminInputSchema = z
18
+ .object({
19
+ revision,
20
+ entries: z.array(
21
+ z
22
+ .object({
23
+ shortcut: RailShortcutSchema,
24
+ access: z.array(RailAccessSchema.pick({ principal: true, permission: true })),
25
+ })
26
+ .strict(),
27
+ ),
28
+ })
29
+ .strict()
30
+ .superRefine((value, ctx) => {
31
+ if (new TextEncoder().encode(JSON.stringify(value)).byteLength > RAIL_PREFERENCES_MAX_BYTES)
32
+ ctx.addIssue({ code: "custom", message: "Global app bar configuration exceeds 16 KiB" });
33
+ if (new Set(value.entries.map((entry) => entry.shortcut.id)).size !== value.entries.length)
34
+ ctx.addIssue({ code: "custom", message: "Shortcut IDs must be unique" });
35
+ });
36
+ export type RailAdminInput = z.infer<typeof RailAdminInputSchema>;
37
+ export type RailAdminState = z.infer<typeof RailAdminSchema>;
38
+ export type RailAdminEntry = z.infer<typeof RailAdminEntrySchema>;
@@ -51,3 +51,19 @@ export const RailPreferencesSchema = z
51
51
  });
52
52
  export type RailPreferences = z.infer<typeof RailPreferencesSchema>;
53
53
  export const defaultRailPreferences = (): RailPreferences => ({ revision: 0, visibility: {}, shortcuts: [] });
54
+
55
+ /** Managed entries are separate from the writable personal API contract. */
56
+ export type RailSnapshot = RailPreferences & { managedShortcuts?: RailShortcut[] };
57
+ export const RailSnapshotSchema = z
58
+ .object({
59
+ revision: z.number(),
60
+ visibility: z.record(z.string(), z.boolean()),
61
+ shortcuts: z.array(RailShortcutSchema),
62
+ managedShortcuts: z.array(RailShortcutSchema).optional(),
63
+ })
64
+ .strict()
65
+ .superRefine(({ managedShortcuts, ...personal }, ctx) => {
66
+ if (!RailPreferencesSchema.safeParse(personal).success) ctx.addIssue({ code: "custom", message: "Invalid personal app bar settings" });
67
+ if (new TextEncoder().encode(JSON.stringify(managedShortcuts ?? [])).byteLength > RAIL_PREFERENCES_MAX_BYTES)
68
+ ctx.addIssue({ code: "custom", message: "Managed shortcuts exceed the 16 KiB page budget" });
69
+ });
@@ -1,5 +1,4 @@
1
- import type { HelpManifest } from "../shared/help";
2
- import type { AppAdminNavigationGroup, AppAppearance, AppAppearanceColor, AppPresentationCatalog } from "./app";
1
+ import type { AppAdminNavigationGroup, AppAppearance, AppAppearanceColor, AppPresentationCatalog, AppSearchLink } from "./app";
3
2
  import type { CapabilityManifest, CapabilityPresentationCatalog } from "./capabilities";
4
3
  import type { Role } from "./shared";
5
4
  import type { DashboardWidgetPresentation } from "./widgets";
@@ -36,31 +35,7 @@ export type CapabilityRegistryEntry = {
36
35
  presentation?: CapabilityPresentationCatalog;
37
36
  };
38
37
 
39
- export type AppRegistryHelpSummary = HelpManifest;
40
-
41
- export type HelpRegistryDocument = {
42
- id: string;
43
- title: string;
44
- icon?: string;
45
- description?: string;
46
- order: number;
47
- markdown: string;
48
- /** Precomputed by the app. Older registry entries may omit it during rolling upgrades. */
49
- searchText?: string;
50
- };
51
-
52
- /** Full Markdown lives separately so normal app discovery stays small. */
53
- export type HelpRegistryEntry = {
54
- appId: string;
55
- appName: string;
56
- appIcon: string;
57
- manifestHash: string;
58
- /** Base language that owns the complete logical article set. Absent on legacy registrations. */
59
- baseLocale?: string;
60
- /** Partial localized article variants stored in the same bounded logical registration. */
61
- documentsByLocale?: Readonly<Record<string, readonly HelpRegistryDocument[]>>;
62
- documents: readonly HelpRegistryDocument[];
63
- };
38
+ export type AppRegistryHelpSummary = { manifestHash: string; pageBase: string; baseLocale: string };
64
39
 
65
40
  export type AppRegistryLegalLink = {
66
41
  label: string;
@@ -76,6 +51,8 @@ export type AppRegistryWidget = {
76
51
  };
77
52
 
78
53
  export type AppRuntimeMetadata = {
54
+ /** Cloud version of the running app. Missing on apps built before versions were baked in. */
55
+ version?: string;
79
56
  release: string;
80
57
  syncVersion: string;
81
58
  };
@@ -102,6 +79,7 @@ export type AppRegistryEntry = {
102
79
  capabilities?: AppRegistryCapabilitySummary;
103
80
  help?: AppRegistryHelpSummary;
104
81
  legalLinks?: AppRegistryLegalLink[];
82
+ searchLinks?: readonly AppSearchLink[];
105
83
  widgets?: AppRegistryWidget[];
106
84
  /** Setting keys declared by this app. Used by admin tooling to avoid treating live app-owned settings as legacy. */
107
85
  settingKeys?: readonly string[];
@@ -43,7 +43,7 @@ type StringLikeKind = "string" | "text" | "email" | "url" | "secret" | "image" |
43
43
  export type AppSettingDef =
44
44
  | (CommonDef & { kind: StringLikeKind; default: string; placeholder?: string; templateVars?: readonly string[] })
45
45
  | (CommonDef & { kind: "boolean"; default: boolean })
46
- | (CommonDef & { kind: "number"; default: number; min?: number; max?: number; placeholder?: string })
46
+ | (CommonDef & { kind: "number"; default: number; min?: number; max?: number; integer?: boolean; placeholder?: string })
47
47
  | (CommonDef & { kind: "enum"; default: string; options: ReadonlyArray<SettingOption> })
48
48
  | (CommonDef & { kind: "string_list"; default: readonly string[]; placeholder?: string })
49
49
  | (CommonDef & { kind: "number_list"; default: readonly number[]; placeholder?: string });
@@ -288,12 +288,25 @@ export const AccountActivityListResponseSchema = z.object({
288
288
  });
289
289
  export type AccountActivityListResponse = z.infer<typeof AccountActivityListResponseSchema>;
290
290
 
291
+ export const AuthenticatedPrincipalSchema = z.discriminatedUnion("type", [
292
+ z.object({
293
+ type: z.literal("user").describe("One user account."),
294
+ userId: z.uuid().describe("Exact user ID returned by the Accounts directory."),
295
+ }),
296
+ z.object({
297
+ type: z.literal("group").describe("Members of one group."),
298
+ groupId: z.uuid().describe("Exact group ID returned by the Accounts directory."),
299
+ }),
300
+ z.object({
301
+ type: z.literal("service_account").describe("One service account."),
302
+ serviceAccountId: z.uuid().describe("Exact service account ID returned by the Accounts directory."),
303
+ }),
304
+ z.object({ type: z.literal("authenticated").describe("Every authenticated identity; no directory ID is needed.") }),
305
+ ]);
306
+
291
307
  export const PrincipalSchema = z.discriminatedUnion("type", [
292
- z.object({ type: z.literal("user"), userId: z.uuid() }),
293
- z.object({ type: z.literal("group"), groupId: z.uuid() }),
294
- z.object({ type: z.literal("service_account"), serviceAccountId: z.uuid() }),
295
- z.object({ type: z.literal("authenticated") }),
296
- z.object({ type: z.literal("public") }),
308
+ ...AuthenticatedPrincipalSchema.options,
309
+ z.object({ type: z.literal("public").describe("Public access where supported by the resource; no directory ID is needed.") }),
297
310
  ]);
298
311
  export type Principal = z.infer<typeof PrincipalSchema>;
299
312
 
@@ -0,0 +1,27 @@
1
+ import { z } from "zod";
2
+
3
+ /** One metric plus six server phases and bounded routing metadata. */
4
+ export const WEB_VITALS_MAX_BYTES = 4096;
5
+ export const WebVitalsReportSchema = z
6
+ .object({
7
+ appId: z
8
+ .string()
9
+ .min(1)
10
+ .max(64)
11
+ .regex(/^[a-z0-9-]+$/),
12
+ routeTemplate: z
13
+ .string()
14
+ .min(1)
15
+ .max(200)
16
+ .regex(/^\/[a-zA-Z0-9_/:.*-]*$/),
17
+ id: z.string().min(1).max(100),
18
+ name: z.enum(["LCP", "INP", "CLS"]),
19
+ value: z.number().nonnegative(),
20
+ navigationType: z.string().max(32),
21
+ serverTiming: z.partialRecord(
22
+ z.enum(["auth", "settings", "runtime", "ssr_data", "ssr_finalize", "ssr_render"]),
23
+ z.number().nonnegative(),
24
+ ),
25
+ })
26
+ .strict();
27
+ export type WebVitalsReport = z.infer<typeof WebVitalsReportSchema>;
@@ -311,6 +311,7 @@ const getSql = (): DesktopSql => {
311
311
  const { Database } = requireFn("bun:sqlite") as { Database: new (path: string) => any };
312
312
  const { dirname, resolve } = requireFn("node:path") as typeof import("node:path");
313
313
  const { mkdirSync } = requireFn("node:fs") as typeof import("node:fs");
314
+ // Declared in the platform registry; this browser-safe module must not import zod-backed config.
314
315
  const dbPath = process.env.CLOUD_DESKTOP_SQLITE_PATH ?? resolve(process.cwd(), ".local", "desktop.sqlite");
315
316
  mkdirSync(dirname(dbPath), { recursive: true });
316
317
  const db = new Database(dbPath);
@@ -1,6 +1,6 @@
1
1
  import { deserialize as deserializeProps, serialize as serializeProps } from "seroval";
2
2
  import type { Component, JSX, ParentProps } from "solid-js";
3
- import { children, createContext, createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, useContext } from "solid-js";
3
+ import { children, createContext, createMemo, createSignal, For, onCleanup, onMount, Show, useContext } from "solid-js";
4
4
  import { Dynamic, Portal } from "solid-js/web";
5
5
  import {
6
6
  type DesktopEnvironment,
package/src/index.ts CHANGED
@@ -10,21 +10,23 @@ export {
10
10
  unbindProcessSync,
11
11
  } from "./_internal/process-sync";
12
12
  export { APP_READINESS_PATH } from "./_internal/readiness";
13
- export type { AppRegistryDetail, AppRegistryIssue, AppRegistrySnapshot, CapabilityRegistryRecord, DashboardWidget } from "./_internal/registry";
13
+ export type {
14
+ AppRegistryDetail,
15
+ AppRegistryIssue,
16
+ AppRegistrySnapshot,
17
+ CapabilityRegistryRecord,
18
+ DashboardWidget,
19
+ } from "./_internal/registry";
14
20
  export {
15
21
  APP_REGISTRY_CONFIG,
16
22
  APP_REGISTRY_PREFIX,
17
23
  APP_REGISTRY_TTL_MS,
18
- CAPABILITY_REGISTRY_CONFIG,
19
- HELP_REGISTRY_CONFIG,
20
24
  appRegistry,
25
+ CAPABILITY_REGISTRY_CONFIG,
21
26
  capabilityRegistry,
22
27
  getApp,
23
- getHelp,
24
- helpRegistry,
25
28
  listApps,
26
29
  listAppsDetailed,
27
- listHelp,
28
30
  listLegalLinks,
29
31
  listWidgets,
30
32
  readAppRegistrySnapshot,
@@ -8,6 +8,7 @@ export { defineHelp, defineHelpCollection, type HelpCollection, type HelpDefinit
8
8
  export { DEFAULT_LOCALE, getLocale, LOCALE_COOKIE, LOCALE_HEADER, locale, preferredLocale, resolveLocale } from "./locale";
9
9
  export type {
10
10
  AuthContext,
11
+ InvocationExpectation,
11
12
  InvocationProvenance,
12
13
  RateLimitConfig,
13
14
  RateLimitRouteOverride,
@@ -26,6 +27,7 @@ export {
26
27
  rateLimit,
27
28
  rejectReservedWorkloadCredential,
28
29
  requestLogger,
30
+ requireInvocation,
29
31
  requiresAdmin,
30
32
  requiresAuth,
31
33
  requiresIpa,
@@ -56,6 +58,7 @@ export type {
56
58
  ServiceErrorCode,
57
59
  } from "./services";
58
60
  export {
61
+ accessRevision,
59
62
  buildAccessPrincipalCondition,
60
63
  buildAccessPrincipalTierConditions,
61
64
  createAccess,
@@ -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
+ };
@@ -86,7 +86,7 @@ export class RateLimitError extends Error {
86
86
  // ==========================
87
87
 
88
88
  export const ratelimit = (config: RateLimiterConfig): RateLimiter => {
89
- // windowSecs: 0 — e.g. Number(process.env.RL_WINDOW) on an unset var — made
89
+ // windowSecs: 0 — e.g. `Number()` of an unset variable — made
90
90
  // both window keys Infinity and collapse to one, elapsedRatio NaN, and
91
91
  // `EXPIRE key 0` delete the counter on every first increment, so the
92
92
  // weighted count never exceeded the limit and the limiter allowed
@@ -0,0 +1,10 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { PermissionLevel, Principal } 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
7
+ .map((entry) => [entry.id, entry.permission, 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,9 @@ 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 }>(
572
+ entries: T[],
573
+ ): Promise<(T & { displayName: string; avatarHash?: string | null })[]> => {
572
574
  const userIds = entries.filter((e) => e.principal.type === "user").map((e) => (e.principal as { type: "user"; userId: string }).userId);
573
575
 
574
576
  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({