@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,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
@@ -3,7 +3,12 @@ import type { TypedNotificationSendResult } from "../notifications/platform";
3
3
  export type AccountNotificationDeliveryResult = Pick<TypedNotificationSendResult, "id" | "status">;
4
4
 
5
5
  export type AccountsNotificationSender = {
6
- sendLoginLink: (input: { email: string; token: string; magicLink: string; locale?: string }) => Promise<AccountNotificationDeliveryResult>;
6
+ sendLoginLink: (input: {
7
+ email: string;
8
+ token: string;
9
+ magicLink: string;
10
+ locale?: string;
11
+ }) => Promise<AccountNotificationDeliveryResult>;
7
12
  sendFreeIpaWelcome: (input: {
8
13
  userId: string;
9
14
  uid: string;
@@ -11,7 +16,12 @@ export type AccountsNotificationSender = {
11
16
  accountExpires: string | null;
12
17
  locale?: string;
13
18
  }) => Promise<AccountNotificationDeliveryResult>;
14
- sendLocalWelcome: (input: { userId: string; email: string; accountExpires: string | null; locale?: string }) => Promise<AccountNotificationDeliveryResult>;
19
+ sendLocalWelcome: (input: {
20
+ userId: string;
21
+ email: string;
22
+ accountExpires: string | null;
23
+ locale?: string;
24
+ }) => Promise<AccountNotificationDeliveryResult>;
15
25
  sendRequestDenied: (input: {
16
26
  requestId: string;
17
27
  userId: string;
@@ -2,18 +2,19 @@ import { sql } from "bun";
2
2
  import {
3
3
  DEFAULT_LINUX_IDENTITY_CONFIGURATION,
4
4
  isPosixName,
5
- LinuxIdentityConfigurationSchema,
6
5
  type LinuxIdentityConfiguration,
6
+ LinuxIdentityConfigurationSchema,
7
7
  type PosixIdentity,
8
8
  PosixOverridesSchema,
9
9
  } from "../../contracts/posix";
10
+ import type { MutationResult } from "../../contracts/shared";
10
11
  import { freeipa } from "../../server/services";
12
+ import { type AuditActor, audit } from "../audit";
11
13
  import { getFreeIpaConfig } from "../freeipa-config";
12
14
  import { readCompleteIpaList } from "../ipa/sync-planning";
13
- import { decryptValue } from "../settings/crypto";
14
15
  import * as settings from "../settings";
15
- import { audit, type AuditActor } from "../audit";
16
- import type { MutationResult } from "../../contracts/shared";
16
+ import { decryptValue } from "../settings/crypto";
17
+ import { create as createLocalGroup } from "./local-groups";
17
18
 
18
19
  const CONFIG_KEY = "linux.identity_config";
19
20
  export const POSIX_PAGE_SIZE = 50;
@@ -274,6 +275,34 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
274
275
  }
275
276
  };
276
277
 
278
+ const assignGroup = async (tx: typeof sql, actor: Actor, id: string, ranges: IpaIdRange[]) => {
279
+ const config = await readConfig(tx);
280
+ if (!config.enabled) throw new PosixError("setup_disabled");
281
+ const [group] = await tx<
282
+ { id: string; name: string; provider: string; gid_number: number | null }[]
283
+ >`SELECT id, name, provider, gid_number FROM auth.groups WHERE id = ${id}::uuid`;
284
+ if (!group) throw new PosixError("group_not_found", 404);
285
+ if (group.provider !== "local") throw new PosixError("identity_not_locally_managed");
286
+ if (group.gid_number !== null) return { gidNumber: group.gid_number };
287
+ if (!isPosixName(group.name)) throw new PosixError("invalid_name");
288
+ const [conflict] = await tx`SELECT 1 FROM auth.groups WHERE id <> ${id}::uuid AND lower(name) = lower(${group.name}) LIMIT 1`;
289
+ if (conflict) throw new PosixError("group_conflict");
290
+ await checkRange(tx, config, ranges);
291
+ const gidNumber = await allocate(tx, "gid", id, config);
292
+ await tx`UPDATE auth.groups SET gid_number = ${gidNumber} WHERE id = ${id}::uuid`;
293
+ await audit.record(
294
+ {
295
+ action: "accounts.linux.provision_group",
296
+ outcome: "allowed",
297
+ actor: { userId: actor.id, roles: actor.roles },
298
+ target: { type: "group", id },
299
+ metadata: { gidNumber },
300
+ },
301
+ tx,
302
+ );
303
+ return { gidNumber };
304
+ };
305
+
277
306
  const service = {
278
307
  async configuration(actor: Actor) {
279
308
  requireAdmin(actor);
@@ -347,36 +376,23 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
347
376
  return { ...candidate(row), identity: { ...candidate(row).identity!, ...parsed.data } };
348
377
  });
349
378
  },
379
+ async createGroup(actor: Actor, input: { name: string; description?: string }) {
380
+ requireAdmin(actor);
381
+ const ranges = await readRanges();
382
+ return db.begin(async (tx) => {
383
+ await lock(tx);
384
+ const created = await createLocalGroup(input, tx);
385
+ if (!created.ok) throw new PosixError("group_conflict");
386
+ const { gidNumber } = await assignGroup(tx, actor, created.data.id, ranges);
387
+ return { ...created.data, gidnumber: gidNumber };
388
+ });
389
+ },
350
390
  async provisionGroup(actor: Actor, id: string) {
351
391
  requireAdmin(actor);
352
392
  const ranges = await readRanges();
353
393
  return db.begin(async (tx) => {
354
394
  await lock(tx);
355
- const config = await readConfig(tx);
356
- if (!config.enabled) throw new PosixError("setup_disabled");
357
- const [group] = await tx<
358
- { id: string; name: string; provider: string; gid_number: number | null }[]
359
- >`SELECT id, name, provider, gid_number FROM auth.groups WHERE id = ${id}::uuid`;
360
- if (!group) throw new PosixError("group_not_found", 404);
361
- if (group.provider !== "local") throw new PosixError("identity_not_locally_managed");
362
- if (group.gid_number !== null) return { gidNumber: group.gid_number };
363
- if (!isPosixName(group.name)) throw new PosixError("invalid_name");
364
- const [conflict] = await tx`SELECT 1 FROM auth.groups WHERE id <> ${id}::uuid AND lower(name) = lower(${group.name}) LIMIT 1`;
365
- if (conflict) throw new PosixError("group_conflict");
366
- await checkRange(tx, config, ranges);
367
- const gidNumber = await allocate(tx, "gid", id, config);
368
- await tx`UPDATE auth.groups SET gid_number = ${gidNumber} WHERE id = ${id}::uuid`;
369
- await audit.record(
370
- {
371
- action: "accounts.linux.provision_group",
372
- outcome: "allowed",
373
- actor: { userId: actor.id, roles: actor.roles },
374
- target: { type: "group", id },
375
- metadata: { gidNumber },
376
- },
377
- tx,
378
- );
379
- return { gidNumber };
395
+ return assignGroup(tx, actor, id, ranges);
380
396
  });
381
397
  },
382
398
  };
@@ -172,7 +172,7 @@ export const get = async (params: { id: string } | { uid: string }): Promise<Use
172
172
  const { groupsAdmin } = await getFreeIpaConfig();
173
173
  const rows = await sql<DbRow[]>`
174
174
  SELECT u.*,
175
- ${userIpaDataColumns},
175
+ ${userIpaDataColumns()},
176
176
  CASE
177
177
  WHEN u.provider = 'local' THEN u.admin
178
178
  ELSE EXISTS(
@@ -203,7 +203,7 @@ export const get = async (params: { id: string } | { uid: string }): Promise<Use
203
203
  ${managedGroupIdsSubquery(userIdExpr)}
204
204
  ), '{}') AS manages_group_ids
205
205
  FROM auth.users u
206
- ${userIpaDataJoin}
206
+ ${userIpaDataJoin()}
207
207
  WHERE ${whereClause}
208
208
  `;
209
209
  if (rows.length === 0) return null;
@@ -1,5 +1,7 @@
1
1
  import { err, fail, ok, type Result } from "@k2b/stdlib";
2
2
  import { sql } from "bun";
3
+ import { HTTPException } from "hono/http-exception";
4
+ import { z } from "zod";
3
5
  import type {
4
6
  AnnouncementCookieState,
5
7
  AnnouncementDisplayEntry,
@@ -7,10 +9,25 @@ import type {
7
9
  CreateAnnouncement,
8
10
  UpdateAnnouncement,
9
11
  } from "../../contracts/announcements";
12
+ import { AnnouncementDisplayEntrySchema } from "../../contracts/announcements";
13
+ import { hasRole, type User } from "../../contracts/shared";
10
14
  import { markdown } from "../../shared/markdown";
15
+ import { claimCacheFill, completeCacheFill } from "../cache-fill";
11
16
  import { logger } from "../logging";
17
+ import { requestCacheRedis } from "../request-cache-redis";
12
18
 
13
19
  const log = logger("announcements");
20
+ const ACTIVE_CACHE_KEY = "appglobalcache:shared:announcements:v1";
21
+ const invalidateActiveCache = async () => (await requestCacheRedis()).del(ACTIVE_CACHE_KEY);
22
+ const invalidateAfterMutation = async () => {
23
+ try {
24
+ await invalidateActiveCache();
25
+ } catch (error) {
26
+ // The database mutation already succeeded; do not invite duplicate creates.
27
+ // Unavailable Redis reads fall back to Postgres; old cache entries expire.
28
+ log.warn("Announcement saved but cache invalidation failed", { error: error instanceof Error ? error.message : String(error) });
29
+ }
30
+ };
14
31
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
15
32
 
16
33
  type AnnouncementRow = {
@@ -119,6 +136,7 @@ const create = async (params: { data: CreateAnnouncement; actorId: string }): Pr
119
136
  RETURNING id, version, kind, title, body, tone, published_at, expires_at,
120
137
  created_at, updated_at, created_by, updated_by
121
138
  `;
139
+ if (row) await invalidateAfterMutation();
122
140
  return row ? ok(mapRow(row)) : fail(err.internal("Failed to create announcement."));
123
141
  } catch (error) {
124
142
  log.error("Failed to create announcement", { error: error instanceof Error ? error.message : String(error) });
@@ -156,6 +174,7 @@ const update = async (params: { id: string; data: UpdateAnnouncement; actorId: s
156
174
  RETURNING id, version, kind, title, body, tone, published_at, expires_at,
157
175
  created_at, updated_at, created_by, updated_by
158
176
  `;
177
+ if (row) await invalidateAfterMutation();
159
178
  return row ? ok(mapRow(row)) : fail(err.notFound("Announcement"));
160
179
  } catch (error) {
161
180
  log.error("Failed to update announcement", { id: params.id, error: error instanceof Error ? error.message : String(error) });
@@ -166,6 +185,7 @@ const update = async (params: { id: string; data: UpdateAnnouncement; actorId: s
166
185
  const remove = async (params: { id: string }): Promise<Result<void>> => {
167
186
  if (!UUID_PATTERN.test(params.id)) return fail(err.notFound("Announcement"));
168
187
  const result = await sql`DELETE FROM announcements.entries WHERE id = ${params.id}::uuid`;
188
+ if (result.count > 0) await invalidateAfterMutation();
169
189
  return result.count > 0 ? ok() : fail(err.notFound("Announcement"));
170
190
  };
171
191
 
@@ -182,28 +202,65 @@ const listActive = async (params: { now?: Date } = {}): Promise<AnnouncementEntr
182
202
  return rows.map(mapRow);
183
203
  };
184
204
 
185
- export const selectVisibleForState = (
186
- entries: AnnouncementEntry[],
205
+ const selectEntriesForState = <T extends { kind: AnnouncementEntry["kind"]; version: number }>(
206
+ entries: T[],
187
207
  state: AnnouncementCookieState,
188
- ): { banners: AnnouncementDisplayEntry[]; announcements: AnnouncementDisplayEntry[]; latestAnnouncementVersion: number } => {
208
+ ) => {
189
209
  const dismissedBanners = new Set(state.dismissedBannerVersions);
190
210
  const activeAnnouncements = entries.filter((entry) => entry.kind === "announcement");
191
211
  const latestAnnouncementVersion = activeAnnouncements.reduce((max, entry) => Math.max(max, entry.version), state.seenAnnouncementVersion);
192
212
  return {
193
213
  banners: entries
194
214
  .filter((entry) => entry.kind === "banner" && !dismissedBanners.has(entry.version))
195
- .sort((a, b) => b.version - a.version)
196
- .map(renderAnnouncement),
215
+ .sort((a, b) => b.version - a.version),
197
216
  announcements: activeAnnouncements
198
217
  .filter((entry) => entry.version > state.seenAnnouncementVersion)
199
- .sort((a, b) => b.version - a.version)
200
- .map(renderAnnouncement),
218
+ .sort((a, b) => b.version - a.version),
201
219
  latestAnnouncementVersion,
202
220
  };
203
221
  };
204
222
 
223
+ export const selectVisibleForState = (entries: AnnouncementEntry[], state: AnnouncementCookieState) => {
224
+ const selected = selectEntriesForState(entries, state);
225
+ return { ...selected, banners: selected.banners.map(renderAnnouncement), announcements: selected.announcements.map(renderAnnouncement) };
226
+ };
227
+
228
+ const cachedActive = async (): Promise<AnnouncementDisplayEntry[]> => {
229
+ let cached: string | null = null;
230
+ try {
231
+ cached = await (await requestCacheRedis()).get(ACTIVE_CACHE_KEY);
232
+ } catch {
233
+ /* Read authoritative data below. */
234
+ }
235
+ let entries: AnnouncementDisplayEntry[] | undefined;
236
+ if (cached !== null) {
237
+ try {
238
+ entries = z.array(AnnouncementDisplayEntrySchema).parse(JSON.parse(cached));
239
+ } catch {
240
+ /* Miss. */
241
+ }
242
+ }
243
+ if (!entries) {
244
+ const lease = await claimCacheFill(ACTIVE_CACHE_KEY, cached);
245
+ // Include scheduled entries: publish/expiry boundaries are applied at read time.
246
+ const rows = await sql<AnnouncementRow[]>`
247
+ SELECT id, version, kind, title, body, tone, published_at, expires_at,
248
+ created_at, updated_at, created_by, updated_by
249
+ FROM announcements.entries
250
+ WHERE expires_at IS NULL OR expires_at > now()
251
+ ORDER BY version DESC
252
+ `;
253
+ entries = rows.map((row) => renderAnnouncement(mapRow(row)));
254
+ await completeCacheFill(ACTIVE_CACHE_KEY, lease, JSON.stringify(entries), 300);
255
+ }
256
+ const now = Date.now();
257
+ return entries.filter((entry) => Date.parse(entry.publishedAt) <= now && (!entry.expiresAt || Date.parse(entry.expiresAt) > now));
258
+ };
259
+
205
260
  const activeForState = async (params: { state: AnnouncementCookieState; now?: Date }) =>
206
- selectVisibleForState(await listActive({ now: params.now }), params.state);
261
+ params.now
262
+ ? selectVisibleForState(await listActive({ now: params.now }), params.state)
263
+ : selectEntriesForState(await cachedActive(), params.state);
207
264
 
208
265
  export const announcements = {
209
266
  admin: {
@@ -212,6 +269,10 @@ export const announcements = {
212
269
  create,
213
270
  update,
214
271
  remove,
272
+ invalidateCache: async (actor: User | undefined) => {
273
+ if (!actor || !hasRole(actor, "admin")) throw new HTTPException(403, { message: "Administrator access required" });
274
+ await invalidateActiveCache();
275
+ },
215
276
  },
216
277
  active: {
217
278
  list: listActive,