@valentinkolb/cloud 0.4.0 → 0.5.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 (193) hide show
  1. package/package.json +18 -6
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +53 -46
  4. package/src/api/accounts-entities.ts +4 -0
  5. package/src/api/admin-core-settings.ts +98 -0
  6. package/src/api/announcements.ts +131 -0
  7. package/src/api/auth/schemas.ts +24 -0
  8. package/src/api/auth.ts +113 -10
  9. package/src/api/index.ts +7 -2
  10. package/src/api/me.ts +203 -14
  11. package/src/api/search/schemas.ts +1 -0
  12. package/src/api/search.ts +62 -8
  13. package/src/config/ssr.ts +2 -9
  14. package/src/contracts/announcements.test.ts +37 -0
  15. package/src/contracts/announcements.ts +121 -0
  16. package/src/contracts/app.ts +2 -0
  17. package/src/contracts/index.ts +3 -2
  18. package/src/contracts/registry.ts +2 -0
  19. package/src/contracts/shared.ts +108 -1
  20. package/src/desktop/index.ts +704 -0
  21. package/src/desktop/solid.tsx +938 -0
  22. package/src/server/api/index.ts +1 -1
  23. package/src/server/api/respond.ts +50 -10
  24. package/src/server/index.ts +44 -38
  25. package/src/server/middleware/auth.ts +98 -9
  26. package/src/server/middleware/index.ts +2 -1
  27. package/src/server/middleware/settings.ts +26 -0
  28. package/src/server/services/access.test.ts +197 -0
  29. package/src/server/services/access.ts +254 -6
  30. package/src/server/services/index.ts +14 -11
  31. package/src/server/services/pagination.ts +22 -0
  32. package/src/server/time.ts +45 -0
  33. package/src/services/account-lifecycle/index.ts +142 -18
  34. package/src/services/accounts/app.ts +658 -170
  35. package/src/services/accounts/authz.test.ts +77 -0
  36. package/src/services/accounts/authz.ts +22 -0
  37. package/src/services/accounts/entities.ts +84 -5
  38. package/src/services/accounts/groups.ts +30 -24
  39. package/src/services/accounts/model.test.ts +30 -0
  40. package/src/services/accounts/switching.test.ts +14 -0
  41. package/src/services/accounts/switching.ts +15 -6
  42. package/src/services/accounts/users.ts +75 -52
  43. package/src/services/announcements/index.test.ts +32 -0
  44. package/src/services/announcements/index.ts +224 -0
  45. package/src/services/audit/index.test.ts +84 -0
  46. package/src/services/audit/index.ts +431 -0
  47. package/src/services/auth-flows/index.ts +9 -2
  48. package/src/services/auth-flows/ipa.ts +0 -2
  49. package/src/services/auth-flows/magic-link.ts +3 -2
  50. package/src/services/auth-flows/password-reset.ts +284 -0
  51. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  52. package/src/services/auth-flows/proxy-return.ts +49 -0
  53. package/src/services/gateway.ts +162 -0
  54. package/src/services/index.ts +44 -2
  55. package/src/services/ipa/effective-groups.test.ts +33 -0
  56. package/src/services/ipa/effective-groups.ts +70 -0
  57. package/src/services/ipa/profile.ts +45 -3
  58. package/src/services/ipa/search.ts +3 -5
  59. package/src/services/ipa/service-account.ts +15 -0
  60. package/src/services/ipa/sync-planning.test.ts +32 -0
  61. package/src/services/ipa/sync-planning.ts +22 -0
  62. package/src/services/ipa/sync.ts +110 -38
  63. package/src/services/oauth-tokens.ts +104 -0
  64. package/src/services/postgres.ts +21 -6
  65. package/src/services/providers/local/auth.test.ts +22 -0
  66. package/src/services/providers/local/auth.ts +46 -3
  67. package/src/services/secrets.ts +10 -0
  68. package/src/services/service-account-credentials.test.ts +210 -0
  69. package/src/services/service-account-credentials.ts +715 -0
  70. package/src/services/service-accounts.ts +188 -0
  71. package/src/services/session/index.ts +7 -8
  72. package/src/services/settings/app.ts +4 -20
  73. package/src/services/settings/defaults.ts +64 -22
  74. package/src/services/settings/store.ts +47 -0
  75. package/src/services/weather/forecast.ts +40 -7
  76. package/src/services/webauthn.test.ts +36 -0
  77. package/src/services/webauthn.ts +384 -0
  78. package/src/shared/icons.ts +391 -100
  79. package/src/shared/index.ts +7 -0
  80. package/src/shared/markdown/extensions/code.ts +38 -1
  81. package/src/shared/markdown/extensions/images.ts +39 -3
  82. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  83. package/src/shared/markdown/extensions/mark.ts +48 -0
  84. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  85. package/src/shared/markdown/extensions/tables.ts +79 -58
  86. package/src/shared/markdown/formula.test.ts +1089 -0
  87. package/src/shared/markdown/formula.ts +1187 -0
  88. package/src/shared/markdown/index.ts +76 -2
  89. package/src/shared/mock-cover.ts +130 -0
  90. package/src/shared/redirect.test.ts +49 -0
  91. package/src/shared/redirect.ts +52 -0
  92. package/src/shared/theme.test.ts +24 -0
  93. package/src/shared/theme.ts +68 -0
  94. package/src/shared/time.ts +13 -0
  95. package/src/ssr/AdminLayout.tsx +7 -3
  96. package/src/ssr/AdminSidebar.tsx +115 -49
  97. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  98. package/src/ssr/Footer.island.tsx +3 -8
  99. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  100. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  101. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  102. package/src/ssr/Layout.tsx +74 -66
  103. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  104. package/src/ssr/LayoutHelp.tsx +266 -0
  105. package/src/ssr/NavMenu.island.tsx +0 -39
  106. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  107. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  108. package/src/ssr/islands/index.ts +13 -0
  109. package/src/styles/base-popover.css +5 -2
  110. package/src/styles/effects.css +87 -6
  111. package/src/styles/global.css +146 -9
  112. package/src/styles/input.css +3 -1
  113. package/src/styles/utilities-buttons.css +133 -27
  114. package/src/styles/utilities-code-display.css +67 -0
  115. package/src/styles/utilities-completion.css +223 -0
  116. package/src/styles/utilities-detail.css +73 -0
  117. package/src/styles/utilities-feedback.css +16 -15
  118. package/src/styles/utilities-layout.css +42 -2
  119. package/src/styles/utilities-markdown-editor.css +472 -0
  120. package/src/styles/utilities-navigation.css +63 -8
  121. package/src/styles/utilities-script.css +84 -0
  122. package/src/styles/utilities-table-tile.css +229 -0
  123. package/src/types/ambient.d.ts +9 -0
  124. package/src/ui/completion/behaviors.test.ts +95 -0
  125. package/src/ui/completion/behaviors.ts +205 -0
  126. package/src/ui/completion/engine.ts +368 -0
  127. package/src/ui/completion/index.ts +40 -0
  128. package/src/ui/completion/overlay.ts +92 -0
  129. package/src/ui/dialog-core.ts +173 -45
  130. package/src/ui/filter/FilterChip.tsx +42 -40
  131. package/src/ui/index.ts +11 -12
  132. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  133. package/src/ui/input/CheckboxCard.tsx +91 -0
  134. package/src/ui/input/Combobox.tsx +375 -0
  135. package/src/ui/input/DatePicker.tsx +846 -0
  136. package/src/ui/input/DateTimeInput.tsx +29 -4
  137. package/src/ui/input/FileDropzone.tsx +116 -0
  138. package/src/ui/input/IconInput.tsx +116 -0
  139. package/src/ui/input/ImageInput.tsx +19 -2
  140. package/src/ui/input/MultiSelectInput.tsx +448 -0
  141. package/src/ui/input/NumberInput.tsx +417 -61
  142. package/src/ui/input/SegmentedControl.tsx +2 -2
  143. package/src/ui/input/Select.tsx +172 -10
  144. package/src/ui/input/Slider.tsx +3 -4
  145. package/src/ui/input/Switch.tsx +3 -2
  146. package/src/ui/input/TemplateEditor.tsx +212 -0
  147. package/src/ui/input/TextInput.tsx +144 -13
  148. package/src/ui/input/index.ts +53 -8
  149. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  150. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  151. package/src/ui/input/markdown/actions.ts +233 -0
  152. package/src/ui/input/markdown/active-formats.ts +94 -0
  153. package/src/ui/input/markdown/behaviors.ts +193 -0
  154. package/src/ui/input/markdown/code-zone.ts +23 -0
  155. package/src/ui/input/markdown/highlight.ts +316 -0
  156. package/src/ui/layout.ts +22 -0
  157. package/src/ui/misc/AppOverview.tsx +105 -0
  158. package/src/ui/misc/AppWorkspace.tsx +607 -0
  159. package/src/ui/misc/Calendar.tsx +1291 -0
  160. package/src/ui/misc/Chart.tsx +162 -0
  161. package/src/ui/misc/CodeDisplay.tsx +54 -0
  162. package/src/ui/misc/ContextMenu.tsx +2 -2
  163. package/src/ui/misc/DataTable.tsx +269 -0
  164. package/src/ui/misc/DockWorkspace.tsx +425 -0
  165. package/src/ui/misc/Docs.tsx +153 -0
  166. package/src/ui/misc/Dropdown.tsx +2 -2
  167. package/src/ui/misc/EntitySearch.tsx +260 -129
  168. package/src/ui/misc/LinkCard.tsx +14 -2
  169. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  170. package/src/ui/misc/Pagination.tsx +31 -12
  171. package/src/ui/misc/PanelDialog.tsx +109 -0
  172. package/src/ui/misc/Panes.tsx +873 -0
  173. package/src/ui/misc/PermissionEditor.tsx +358 -262
  174. package/src/ui/misc/Placeholder.tsx +40 -0
  175. package/src/ui/misc/ProgressBar.tsx +1 -1
  176. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  177. package/src/ui/misc/SettingsModal.tsx +150 -0
  178. package/src/ui/misc/StatCell.tsx +182 -40
  179. package/src/ui/misc/StatGrid.tsx +149 -0
  180. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  181. package/src/ui/misc/code-highlight.ts +213 -0
  182. package/src/ui/misc/index.ts +93 -12
  183. package/src/ui/prompts.tsx +362 -312
  184. package/src/ui/toast.ts +384 -0
  185. package/src/ui/widgets/Widget.tsx +12 -4
  186. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  187. package/src/ui/ipa/GroupView.tsx +0 -36
  188. package/src/ui/ipa/LoginBtn.tsx +0 -16
  189. package/src/ui/ipa/UserView.tsx +0 -58
  190. package/src/ui/ipa/index.ts +0 -4
  191. package/src/ui/navigation.ts +0 -32
  192. package/src/ui/sidebar.tsx +0 -468
  193. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -5,6 +5,7 @@ import { renderTemplate } from "../settings/templates";
5
5
  import { session } from "../session";
6
6
  import { freeipa } from "../../server/services";
7
7
  import { providers } from "../providers";
8
+ import { getServiceIpaSession } from "../ipa/service-account";
8
9
  import { transitionIpaUserToLocal } from "./switching";
9
10
  import {
10
11
  canPersistStoredAdmin,
@@ -25,6 +26,7 @@ import {
25
26
  recursiveGroupNamesSubquery,
26
27
  } from "./group-sql";
27
28
  import { getFreeIpaConfig } from "../freeipa-config";
29
+ import { createAuthLoginUrl } from "../../shared/redirect";
28
30
  import type {
29
31
  BaseUser,
30
32
  MutationResult,
@@ -82,7 +84,7 @@ const sendMagicLinkEmail = async (email: string): Promise<void> => {
82
84
  const token = await providers.local.auth.createMagicLinkToken({ email, ttlSeconds: 300 });
83
85
  const rawAppUrl = await settings.get<string>("app.url");
84
86
  const appUrl = rawAppUrl.startsWith("http") ? rawAppUrl : `https://${rawAppUrl}`;
85
- const magicLink = `${appUrl}/auth/login?token=${token}`;
87
+ const magicLink = createAuthLoginUrl(appUrl, { token });
86
88
  const appName = await settings.get<string>("app.name");
87
89
  const template = await settings.get<string>("mail.magic_link_login");
88
90
 
@@ -113,17 +115,21 @@ const buildUser = (row: DbRow, groupsAdmin: string[]): User => {
113
115
  const memberofGroupIds = (row.member_group_ids as string[]) ?? [];
114
116
  const manages = (row.manages as string[]) ?? [];
115
117
  const managesGroupIds = (row.manages_group_ids as string[]) ?? [];
118
+ const effectiveAdmin =
119
+ row.effective_admin !== undefined
120
+ ? Boolean(row.effective_admin)
121
+ : resolveEffectiveAdminState({
122
+ provider,
123
+ storedAdmin: Boolean(row.admin),
124
+ memberofGroup,
125
+ groupsAdmin,
126
+ });
116
127
  const roles = buildRoles({
117
128
  provider,
118
129
  profile,
119
130
  memberofGroup,
120
131
  manages,
121
- admin: resolveEffectiveAdminState({
122
- provider,
123
- storedAdmin: Boolean(row.admin),
124
- memberofGroup,
125
- groupsAdmin,
126
- }),
132
+ admin: effectiveAdmin,
127
133
  });
128
134
  const common = {
129
135
  id: row.id as string,
@@ -160,9 +166,19 @@ const buildUser = (row: DbRow, groupsAdmin: string[]): User => {
160
166
  export const get = async (params: { id: string } | { uid: string }): Promise<User | null> => {
161
167
  const whereClause = "id" in params ? sql`u.id = ${params.id}` : sql`u.uid = ${params.uid}`;
162
168
  const userIdExpr = "id" in params ? sql`${params.id}` : sql`u.id`;
169
+ const { groupsAdmin } = await getFreeIpaConfig();
163
170
  const rows = await sql<DbRow[]>`
164
171
  SELECT u.*,
165
172
  ${userIpaDataColumns},
173
+ CASE
174
+ WHEN u.provider = 'local' THEN u.admin
175
+ ELSE EXISTS(
176
+ SELECT 1
177
+ FROM auth.ipa_user_effective_groups eg
178
+ WHERE eg.user_id = u.id
179
+ AND eg.group_name = ANY(${toPgTextArray(groupsAdmin)}::text[])
180
+ )
181
+ END AS effective_admin,
166
182
  COALESCE(ARRAY(
167
183
  SELECT g.name
168
184
  FROM auth.user_groups_v2 ug
@@ -188,7 +204,6 @@ export const get = async (params: { id: string } | { uid: string }): Promise<Use
188
204
  WHERE ${whereClause}
189
205
  `;
190
206
  if (rows.length === 0) return null;
191
- const { groupsAdmin } = await getFreeIpaConfig();
192
207
  return buildUser(rows[0]!, groupsAdmin);
193
208
  };
194
209
 
@@ -203,13 +218,22 @@ export const getMinimal = async (params: { id: string } | { uid: string }): Prom
203
218
  return buildUserMutationTarget(rows[0]!);
204
219
  };
205
220
 
206
- /**
207
- * Minimal user lookup by UID. Returns id + roles WITHOUT group-derived roles.
208
- * IPA admin status from group membership is NOT resolved here.
209
- * Use the full `get()` for authorization decisions that depend on group-derived admin.
210
- */
211
221
  export const getByUid = async (params: { uid: string }): Promise<{ id: string; roles: Role[] } | null> => {
212
- const rows = await sql<DbRow[]>`SELECT id, provider, profile, admin FROM auth.users WHERE uid = ${params.uid}`;
222
+ const { groupsAdmin } = await getFreeIpaConfig();
223
+ const rows = await sql<DbRow[]>`
224
+ SELECT u.id, u.provider, u.profile, u.admin,
225
+ CASE
226
+ WHEN u.provider = 'local' THEN u.admin
227
+ ELSE EXISTS(
228
+ SELECT 1
229
+ FROM auth.ipa_user_effective_groups eg
230
+ WHERE eg.user_id = u.id
231
+ AND eg.group_name = ANY(${toPgTextArray(groupsAdmin)}::text[])
232
+ )
233
+ END AS effective_admin
234
+ FROM auth.users u
235
+ WHERE u.uid = ${params.uid}
236
+ `;
213
237
  if (rows.length === 0) return null;
214
238
  const { provider, profile } = resolveProviderProfile(rows[0]!);
215
239
  const roles = buildRoles({
@@ -217,10 +241,7 @@ export const getByUid = async (params: { uid: string }): Promise<{ id: string; r
217
241
  profile,
218
242
  memberofGroup: [],
219
243
  manages: [],
220
- admin: resolveEffectiveAdminState({
221
- provider,
222
- storedAdmin: Boolean(rows[0]!.admin),
223
- }),
244
+ admin: Boolean(rows[0]!.effective_admin),
224
245
  });
225
246
  return { id: rows[0]!.id as string, roles };
226
247
  };
@@ -281,11 +302,9 @@ export const list = async (params: {
281
302
  WHEN u.provider = 'local' THEN u.admin
282
303
  ELSE EXISTS(
283
304
  SELECT 1
284
- FROM auth.user_groups_v2 ug
285
- JOIN auth.groups g_admin ON g_admin.id = ug.group_id
286
- WHERE ug.user_id = u.id
287
- AND g_admin.provider = 'ipa'
288
- AND g_admin.name = ANY(${toPgTextArray(groupsAdmin)}::text[])
305
+ FROM auth.ipa_user_effective_groups eg
306
+ WHERE eg.user_id = u.id
307
+ AND eg.group_name = ANY(${toPgTextArray(groupsAdmin)}::text[])
289
308
  )
290
309
  END AS effective_admin
291
310
  FROM auth.users u
@@ -384,7 +403,6 @@ export const getManagedGroupIds = async (params: { id: string; recursive?: boole
384
403
  };
385
404
 
386
405
  export const demoteToGuest = async (params: {
387
- ipaSession?: string | null;
388
406
  id: string;
389
407
  actor: { userId: string; uid: string };
390
408
  }): Promise<MutationResult<void>> => {
@@ -393,19 +411,17 @@ export const demoteToGuest = async (params: {
393
411
  if (user.provider !== "ipa") {
394
412
  return { ok: false, error: "Only IPA-backed accounts can be demoted to local guests", status: 400 };
395
413
  }
396
- if (!params.ipaSession) {
397
- return { ok: false, error: "IPA session required to demote IPA-backed users", status: 401 };
398
- }
414
+ const serviceSession = await getServiceIpaSession();
415
+ if (!serviceSession.ok) return serviceSession;
399
416
 
400
417
  return providers.ipa.users.demoteToGuest({
401
- ipaSession: params.ipaSession,
418
+ ipaSession: serviceSession.data,
402
419
  id: params.id,
403
420
  actor: params.actor,
404
421
  });
405
422
  };
406
423
 
407
424
  export const create = async (params: {
408
- ipaSession?: string | null;
409
425
  data: CreateUserData;
410
426
  }): Promise<MutationResult<{ user: User; temporaryPassword?: string }>> => {
411
427
  if (params.data.provider === "local" && params.data.admin && !canPersistStoredAdmin("local", params.data.profile)) {
@@ -436,12 +452,11 @@ export const create = async (params: {
436
452
  return { ok: true, data: { user } };
437
453
  }
438
454
 
439
- if (!params.ipaSession) {
440
- return { ok: false, error: "IPA session required to create IPA-backed users", status: 401 };
441
- }
455
+ const serviceSession = await getServiceIpaSession();
456
+ if (!serviceSession.ok) return serviceSession;
442
457
 
443
458
  const created = await providers.ipa.users.create({
444
- ipaSession: params.ipaSession,
459
+ ipaSession: serviceSession.data,
445
460
  profile: params.data.profile,
446
461
  accountExpires,
447
462
  data: {
@@ -466,7 +481,6 @@ export const create = async (params: {
466
481
  };
467
482
 
468
483
  export const update = async (params: {
469
- ipaSession?: string | null;
470
484
  id: string;
471
485
  data: UpdateUserData;
472
486
  }): Promise<MutationResult<void>> => {
@@ -474,9 +488,10 @@ export const update = async (params: {
474
488
  if (!user) return { ok: false, error: "User not found", status: 404 };
475
489
 
476
490
  if (user.provider === "ipa") {
477
- if (!params.ipaSession) return { ok: false, error: "IPA session required to update IPA-backed users", status: 401 };
491
+ const serviceSession = await getServiceIpaSession();
492
+ if (!serviceSession.ok) return serviceSession;
478
493
  return providers.ipa.users.update({
479
- ipaSession: params.ipaSession,
494
+ ipaSession: serviceSession.data,
480
495
  id: params.id,
481
496
  data: params.data,
482
497
  });
@@ -528,17 +543,27 @@ export const setAdmin = async (params: {
528
543
  };
529
544
 
530
545
  export const setExpiry = async (params: {
531
- ipaSession?: string | null;
546
+ actor?: { userId: string; uid: string; roles: string[] };
532
547
  id: string;
533
548
  expiryDate: string | null;
534
549
  }): Promise<MutationResult<void>> => {
535
550
  const user = await getMinimal({ id: params.id });
536
551
  if (!user) return { ok: false, error: "User not found", status: 404 };
537
552
 
553
+ const selfTarget = params.actor?.userId === params.id;
554
+ if (selfTarget && !params.actor?.roles.includes("admin")) {
555
+ return { ok: false, error: "Only admins can change their own account expiry.", status: 403 };
556
+ }
557
+
558
+ // Explicit account-expiry management is allowed to target the acting admin
559
+ // as well. Automatic self-extension remains handled separately by the
560
+ // account-lifecycle service and must not turn non-expiring accounts back into
561
+ // expiring ones implicitly.
538
562
  if (user.provider === "ipa") {
539
- if (!params.ipaSession) return { ok: false, error: "IPA session required to update IPA-backed expiry", status: 401 };
563
+ const serviceSession = await getServiceIpaSession();
564
+ if (!serviceSession.ok) return serviceSession;
540
565
  return providers.ipa.users.setExpiry({
541
- ipaSession: params.ipaSession,
566
+ ipaSession: serviceSession.data,
542
567
  id: params.id,
543
568
  expiryDate: params.expiryDate,
544
569
  });
@@ -590,14 +615,13 @@ export const createLoginToken = async (params: {
590
615
  ok: true,
591
616
  data: {
592
617
  token,
593
- magicLink: `${appUrl}/auth/login?token=${token}`,
618
+ magicLink: createAuthLoginUrl(appUrl, { token }),
594
619
  expiresInSeconds,
595
620
  },
596
621
  };
597
622
  };
598
623
 
599
624
  export const resetPassword = async (params: {
600
- ipaSession?: string | null;
601
625
  id: string;
602
626
  }): Promise<MutationResult<{ password: string }>> => {
603
627
  const user = await getMinimal({ id: params.id });
@@ -605,16 +629,16 @@ export const resetPassword = async (params: {
605
629
  if (user.provider !== "ipa") {
606
630
  return { ok: false, error: "Password resets are only available for IPA-backed accounts", status: 400 };
607
631
  }
608
- if (!params.ipaSession) return { ok: false, error: "IPA session required to reset IPA-backed passwords", status: 401 };
632
+ const serviceSession = await getServiceIpaSession();
633
+ if (!serviceSession.ok) return serviceSession;
609
634
 
610
635
  return providers.ipa.users.resetPassword({
611
- ipaSession: params.ipaSession,
636
+ ipaSession: serviceSession.data,
612
637
  id: params.id,
613
638
  });
614
639
  };
615
640
 
616
641
  export const switchProvider = async (params: {
617
- ipaSession?: string | null;
618
642
  id: string;
619
643
  provider: UserProvider;
620
644
  }): Promise<MutationResult<void>> => {
@@ -634,9 +658,8 @@ export const switchProvider = async (params: {
634
658
  return { ok: false, error: "FreeIPA is disabled.", status: 400 };
635
659
  }
636
660
 
637
- if (!params.ipaSession) {
638
- return { ok: false, error: "IPA session required to switch account providers", status: 401 };
639
- }
661
+ const serviceSession = await getServiceIpaSession();
662
+ if (!serviceSession.ok) return serviceSession;
640
663
 
641
664
  if (params.provider === "ipa") {
642
665
  if (!user.mail) {
@@ -644,7 +667,7 @@ export const switchProvider = async (params: {
644
667
  }
645
668
 
646
669
  const result = await providers.ipa.users.create({
647
- ipaSession: params.ipaSession,
670
+ ipaSession: serviceSession.data,
648
671
  profile: currentProfile,
649
672
  accountExpires: currentExpiry,
650
673
  data: {
@@ -661,7 +684,7 @@ export const switchProvider = async (params: {
661
684
 
662
685
  const response = await freeipa.client.call({
663
686
  url: freeIpaConfig.url,
664
- ipaSession: params.ipaSession,
687
+ ipaSession: serviceSession.data,
665
688
  method: "user_del",
666
689
  args: [user.uid],
667
690
  options: {},
@@ -691,7 +714,6 @@ export const switchProvider = async (params: {
691
714
  };
692
715
 
693
716
  export const remove = async (params: {
694
- ipaSession?: string | null;
695
717
  id: string;
696
718
  actor: { userId: string; uid: string };
697
719
  }): Promise<MutationResult<void>> => {
@@ -699,9 +721,10 @@ export const remove = async (params: {
699
721
  if (!user) return { ok: false, error: "User not found", status: 404 };
700
722
 
701
723
  if (user.provider === "ipa") {
702
- if (!params.ipaSession) return { ok: false, error: "IPA session required to delete IPA-backed users", status: 401 };
724
+ const serviceSession = await getServiceIpaSession();
725
+ if (!serviceSession.ok) return serviceSession;
703
726
  return providers.ipa.users.remove({
704
- ipaSession: params.ipaSession,
727
+ ipaSession: serviceSession.data,
705
728
  id: params.id,
706
729
  actor: params.actor,
707
730
  });
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { AnnouncementEntry } from "../../contracts/announcements";
3
+ import { selectVisibleForState } from "./index";
4
+
5
+ const entry = (version: number, kind: AnnouncementEntry["kind"]): AnnouncementEntry => ({
6
+ id: crypto.randomUUID(),
7
+ version,
8
+ kind,
9
+ title: `${kind} ${version}`,
10
+ body: `**Body ${version}**`,
11
+ tone: "info",
12
+ publishedAt: new Date("2026-06-09T12:00:00.000Z").toISOString(),
13
+ expiresAt: null,
14
+ createdAt: new Date("2026-06-09T12:00:00.000Z").toISOString(),
15
+ updatedAt: new Date("2026-06-09T12:00:00.000Z").toISOString(),
16
+ createdBy: null,
17
+ updatedBy: null,
18
+ });
19
+
20
+ describe("selectVisibleForState", () => {
21
+ test("returns unseen announcements and undismissed banners", () => {
22
+ const result = selectVisibleForState([entry(4, "announcement"), entry(3, "announcement"), entry(2, "banner"), entry(1, "banner")], {
23
+ seenAnnouncementVersion: 3,
24
+ dismissedBannerVersions: [1],
25
+ });
26
+
27
+ expect(result.announcements.map((item) => item.version)).toEqual([4]);
28
+ expect(result.banners.map((item) => item.version)).toEqual([2]);
29
+ expect(result.latestAnnouncementVersion).toBe(4);
30
+ expect(result.announcements[0]?.bodyHtml).toContain("<strong>Body 4</strong>");
31
+ });
32
+ });
@@ -0,0 +1,224 @@
1
+ import { err, fail, ok, type Result } from "@valentinkolb/stdlib";
2
+ import { sql } from "bun";
3
+ import type {
4
+ AnnouncementCookieState,
5
+ AnnouncementDisplayEntry,
6
+ AnnouncementEntry,
7
+ CreateAnnouncement,
8
+ UpdateAnnouncement,
9
+ } from "../../contracts/announcements";
10
+ import { markdown } from "../../shared/markdown";
11
+ import { logger } from "../logging";
12
+
13
+ const log = logger("announcements");
14
+ 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
+
16
+ type AnnouncementRow = {
17
+ id: string;
18
+ version: number;
19
+ kind: "announcement" | "banner";
20
+ title: string;
21
+ body: string;
22
+ tone: "info" | "success" | "warning" | "danger";
23
+ published_at: Date;
24
+ expires_at: Date | null;
25
+ created_at: Date;
26
+ updated_at: Date;
27
+ created_by: string | null;
28
+ updated_by: string | null;
29
+ };
30
+
31
+ type ListAdminConfig = {
32
+ filter?: {
33
+ kind?: "announcement" | "banner";
34
+ query?: string;
35
+ };
36
+ };
37
+
38
+ const mapRow = (row: AnnouncementRow): AnnouncementEntry => ({
39
+ id: row.id,
40
+ version: row.version,
41
+ kind: row.kind,
42
+ title: row.title,
43
+ body: row.body,
44
+ tone: row.tone,
45
+ publishedAt: row.published_at.toISOString(),
46
+ expiresAt: row.expires_at?.toISOString() ?? null,
47
+ createdAt: row.created_at.toISOString(),
48
+ updatedAt: row.updated_at.toISOString(),
49
+ createdBy: row.created_by,
50
+ updatedBy: row.updated_by,
51
+ });
52
+
53
+ export const renderAnnouncement = (entry: AnnouncementEntry): AnnouncementDisplayEntry => {
54
+ const { body, ...rest } = entry;
55
+ return { ...rest, bodyHtml: markdown.renderSync(body) };
56
+ };
57
+
58
+ const validateDates = (input: Pick<CreateAnnouncement | UpdateAnnouncement, "publishedAt" | "expiresAt">) => {
59
+ const publishedAt = input.publishedAt ? new Date(input.publishedAt) : null;
60
+ const expiresAt = input.expiresAt ? new Date(input.expiresAt) : null;
61
+ if (publishedAt && Number.isNaN(publishedAt.getTime())) return fail(err.badInput("Invalid publish date."));
62
+ if (expiresAt && Number.isNaN(expiresAt.getTime())) return fail(err.badInput("Invalid expiry date."));
63
+ if (publishedAt && expiresAt && expiresAt.getTime() <= publishedAt.getTime()) {
64
+ return fail(err.badInput("Expiry date must be after publish date."));
65
+ }
66
+ return ok({ publishedAt, expiresAt });
67
+ };
68
+
69
+ const listAdmin = async (config: ListAdminConfig = {}): Promise<AnnouncementEntry[]> => {
70
+ const kind = config.filter?.kind;
71
+ const query = config.filter?.query?.trim();
72
+ const search = query ? `%${query.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_")}%` : null;
73
+
74
+ const rows = await sql<AnnouncementRow[]>`
75
+ SELECT id, version, kind, title, body, tone, published_at, expires_at,
76
+ created_at, updated_at, created_by, updated_by
77
+ FROM announcements.entries
78
+ WHERE (${kind ?? null}::text IS NULL OR kind = ${kind ?? null})
79
+ AND (
80
+ ${search}::text IS NULL
81
+ OR title ILIKE ${search} ESCAPE '\\'
82
+ OR body ILIKE ${search} ESCAPE '\\'
83
+ )
84
+ ORDER BY published_at DESC, version DESC
85
+ `;
86
+ return rows.map(mapRow);
87
+ };
88
+
89
+ const get = async (params: { id: string }): Promise<AnnouncementEntry | null> => {
90
+ if (!UUID_PATTERN.test(params.id)) return null;
91
+ const [row] = await sql<AnnouncementRow[]>`
92
+ SELECT id, version, kind, title, body, tone, published_at, expires_at,
93
+ created_at, updated_at, created_by, updated_by
94
+ FROM announcements.entries
95
+ WHERE id = ${params.id}::uuid
96
+ `;
97
+ return row ? mapRow(row) : null;
98
+ };
99
+
100
+ const create = async (params: { data: CreateAnnouncement; actorId: string }): Promise<Result<AnnouncementEntry>> => {
101
+ const dateResult = validateDates(params.data);
102
+ if (!dateResult.ok) return dateResult;
103
+
104
+ try {
105
+ const [row] = await sql<AnnouncementRow[]>`
106
+ INSERT INTO announcements.entries (
107
+ kind, title, body, tone, published_at, expires_at, created_by, updated_by
108
+ )
109
+ VALUES (
110
+ ${params.data.kind},
111
+ ${params.data.title},
112
+ ${params.data.body},
113
+ ${params.data.tone},
114
+ COALESCE(${params.data.publishedAt ?? null}::timestamptz, now()),
115
+ ${params.data.expiresAt ?? null}::timestamptz,
116
+ ${params.actorId}::uuid,
117
+ ${params.actorId}::uuid
118
+ )
119
+ RETURNING id, version, kind, title, body, tone, published_at, expires_at,
120
+ created_at, updated_at, created_by, updated_by
121
+ `;
122
+ return row ? ok(mapRow(row)) : fail(err.internal("Failed to create announcement."));
123
+ } catch (error) {
124
+ log.error("Failed to create announcement", { error: error instanceof Error ? error.message : String(error) });
125
+ return fail(err.internal("Failed to create announcement."));
126
+ }
127
+ };
128
+
129
+ const update = async (params: { id: string; data: UpdateAnnouncement; actorId: string }): Promise<Result<AnnouncementEntry>> => {
130
+ if (!UUID_PATTERN.test(params.id)) return fail(err.notFound("Announcement"));
131
+
132
+ try {
133
+ const existing = await get({ id: params.id });
134
+ if (!existing) return fail(err.notFound("Announcement"));
135
+ const dateResult = validateDates({
136
+ publishedAt: params.data.publishedAt ?? existing.publishedAt,
137
+ expiresAt: "expiresAt" in params.data ? params.data.expiresAt : existing.expiresAt,
138
+ });
139
+ if (!dateResult.ok) return dateResult;
140
+
141
+ const [row] = await sql<AnnouncementRow[]>`
142
+ UPDATE announcements.entries
143
+ SET
144
+ kind = COALESCE(${params.data.kind ?? null}, kind),
145
+ title = COALESCE(${params.data.title ?? null}, title),
146
+ body = COALESCE(${params.data.body ?? null}, body),
147
+ tone = COALESCE(${params.data.tone ?? null}, tone),
148
+ published_at = COALESCE(${params.data.publishedAt ?? null}::timestamptz, published_at),
149
+ expires_at = CASE
150
+ WHEN ${"expiresAt" in params.data} THEN ${params.data.expiresAt ?? null}::timestamptz
151
+ ELSE expires_at
152
+ END,
153
+ updated_at = now(),
154
+ updated_by = ${params.actorId}::uuid
155
+ WHERE id = ${params.id}::uuid
156
+ RETURNING id, version, kind, title, body, tone, published_at, expires_at,
157
+ created_at, updated_at, created_by, updated_by
158
+ `;
159
+ return row ? ok(mapRow(row)) : fail(err.notFound("Announcement"));
160
+ } catch (error) {
161
+ log.error("Failed to update announcement", { id: params.id, error: error instanceof Error ? error.message : String(error) });
162
+ return fail(err.internal("Failed to update announcement."));
163
+ }
164
+ };
165
+
166
+ const remove = async (params: { id: string }): Promise<Result<void>> => {
167
+ if (!UUID_PATTERN.test(params.id)) return fail(err.notFound("Announcement"));
168
+ const result = await sql`DELETE FROM announcements.entries WHERE id = ${params.id}::uuid`;
169
+ return result.count > 0 ? ok() : fail(err.notFound("Announcement"));
170
+ };
171
+
172
+ const listActive = async (params: { now?: Date } = {}): Promise<AnnouncementEntry[]> => {
173
+ const now = params.now ?? new Date();
174
+ const rows = await sql<AnnouncementRow[]>`
175
+ SELECT id, version, kind, title, body, tone, published_at, expires_at,
176
+ created_at, updated_at, created_by, updated_by
177
+ FROM announcements.entries
178
+ WHERE published_at <= ${now}
179
+ AND (expires_at IS NULL OR expires_at > ${now})
180
+ ORDER BY version DESC
181
+ `;
182
+ return rows.map(mapRow);
183
+ };
184
+
185
+ export const selectVisibleForState = (
186
+ entries: AnnouncementEntry[],
187
+ state: AnnouncementCookieState,
188
+ ): { banners: AnnouncementDisplayEntry[]; announcements: AnnouncementDisplayEntry[]; latestAnnouncementVersion: number } => {
189
+ const dismissedBanners = new Set(state.dismissedBannerVersions);
190
+ const activeAnnouncements = entries.filter((entry) => entry.kind === "announcement");
191
+ const latestAnnouncementVersion = activeAnnouncements.reduce((max, entry) => Math.max(max, entry.version), state.seenAnnouncementVersion);
192
+ return {
193
+ banners: entries
194
+ .filter((entry) => entry.kind === "banner" && !dismissedBanners.has(entry.version))
195
+ .sort((a, b) => b.version - a.version)
196
+ .map(renderAnnouncement),
197
+ announcements: activeAnnouncements
198
+ .filter((entry) => entry.version > state.seenAnnouncementVersion)
199
+ .sort((a, b) => b.version - a.version)
200
+ .map(renderAnnouncement),
201
+ latestAnnouncementVersion,
202
+ };
203
+ };
204
+
205
+ const activeForState = async (params: { state: AnnouncementCookieState; now?: Date }) =>
206
+ selectVisibleForState(await listActive({ now: params.now }), params.state);
207
+
208
+ export const announcements = {
209
+ admin: {
210
+ list: listAdmin,
211
+ get,
212
+ create,
213
+ update,
214
+ remove,
215
+ },
216
+ active: {
217
+ list: listActive,
218
+ forState: activeForState,
219
+ selectForState: selectVisibleForState,
220
+ },
221
+ render: renderAnnouncement,
222
+ };
223
+
224
+ export type AnnouncementsService = typeof announcements;
@@ -0,0 +1,84 @@
1
+ import { sql } from "bun";
2
+ import { describe, expect, test } from "bun:test";
3
+ import { audit, sanitizeAuditMetadata, sanitizeAuditText } from "./index";
4
+
5
+ describe("sanitizeAuditMetadata", () => {
6
+ test("redacts sensitive nested metadata keys", () => {
7
+ expect(
8
+ sanitizeAuditMetadata({
9
+ changedFields: ["mail"],
10
+ password: "secret",
11
+ nested: {
12
+ apiToken: "token",
13
+ ipaSession: "cookie",
14
+ safe: "value",
15
+ },
16
+ }),
17
+ ).toEqual({
18
+ changedFields: ["mail"],
19
+ password: "[REDACTED]",
20
+ nested: {
21
+ apiToken: "[REDACTED]",
22
+ ipaSession: "[REDACTED]",
23
+ safe: "value",
24
+ },
25
+ });
26
+ });
27
+
28
+ test("truncates large values and arrays", () => {
29
+ const sanitized = sanitizeAuditMetadata({
30
+ text: "x".repeat(510),
31
+ values: Array.from({ length: 52 }, (_, index) => index),
32
+ }) as Record<string, unknown>;
33
+
34
+ expect(sanitized.text).toBe(`${"x".repeat(500)}...`);
35
+ expect(sanitized.values).toEqual([...Array.from({ length: 50 }, (_, index) => index), "[2 more]"]);
36
+ });
37
+
38
+ test("redacts sensitive reason and error text", () => {
39
+ expect(sanitizeAuditText("IPA session required to update IPA-backed users")).toBe("[REDACTED]");
40
+ expect(sanitizeAuditText("Current password is incorrect.")).toBe("[REDACTED]");
41
+ expect(sanitizeAuditText("Access denied")).toBe("Access denied");
42
+ });
43
+
44
+ test("lists only safe actor-owned self-service activity", async () => {
45
+ const userId = crypto.randomUUID();
46
+ const otherUserId = crypto.randomUUID();
47
+ const requestId = `self-service-activity-${crypto.randomUUID()}`;
48
+
49
+ try {
50
+ await audit.record({
51
+ action: "service_account_credential.create",
52
+ outcome: "allowed",
53
+ actor: { userId, uid: "current-user", provider: "local" },
54
+ target: { type: "service_account_credential", id: crypto.randomUUID(), label: "Test key" },
55
+ requestId,
56
+ });
57
+ await audit.record({
58
+ action: "service_account_credential.create",
59
+ outcome: "allowed",
60
+ actor: { userId: otherUserId, uid: "other-user", provider: "local" },
61
+ target: { type: "service_account_credential", id: crypto.randomUUID(), label: "Other key" },
62
+ requestId,
63
+ });
64
+ await audit.record({
65
+ action: "accounts.user.set_expiry",
66
+ outcome: "allowed",
67
+ actor: { userId: otherUserId, uid: "admin", provider: "local" },
68
+ target: { type: "user", id: userId, label: "current-user" },
69
+ requestId,
70
+ });
71
+
72
+ const page = await audit.listSelfServiceActivity({ userId, days: 30, pagination: { page: 1, perPage: 20 } });
73
+
74
+ expect(page.total).toBe(1);
75
+ expect(page.items[0]).toMatchObject({
76
+ action: "service_account_credential.create",
77
+ label: "API key created",
78
+ context: "Test key",
79
+ });
80
+ } finally {
81
+ await sql`DELETE FROM audit.events WHERE request_id = ${requestId}`;
82
+ }
83
+ });
84
+ });