@valentinkolb/cloud 0.1.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 (196) hide show
  1. package/package.json +69 -0
  2. package/public/logo.svg +1 -0
  3. package/scripts/build.ts +113 -0
  4. package/scripts/preload.ts +73 -0
  5. package/src/_internal/define-app.ts +399 -0
  6. package/src/_internal/heartbeat.ts +33 -0
  7. package/src/_internal/registry.ts +100 -0
  8. package/src/_internal/runtime-context.ts +38 -0
  9. package/src/api/accounts-entities.ts +134 -0
  10. package/src/api/admin-lifecycle.ts +210 -0
  11. package/src/api/auth/schemas.ts +28 -0
  12. package/src/api/auth.ts +230 -0
  13. package/src/api/index.ts +66 -0
  14. package/src/api/me.ts +206 -0
  15. package/src/api/search/schemas.ts +43 -0
  16. package/src/api/search.ts +130 -0
  17. package/src/clients/core.ts +19 -0
  18. package/src/config/env.ts +23 -0
  19. package/src/config/index.ts +6 -0
  20. package/src/config/ssr.ts +58 -0
  21. package/src/contracts/app.ts +140 -0
  22. package/src/contracts/index.ts +5 -0
  23. package/src/contracts/profile.ts +67 -0
  24. package/src/contracts/registry.ts +50 -0
  25. package/src/contracts/settings-types.ts +84 -0
  26. package/src/contracts/shared.ts +258 -0
  27. package/src/contracts/widgets.ts +121 -0
  28. package/src/index.ts +6 -0
  29. package/src/server/api/index.ts +1 -0
  30. package/src/server/api/respond.ts +55 -0
  31. package/src/server/api-client.ts +54 -0
  32. package/src/server/app-context.ts +39 -0
  33. package/src/server/index.ts +62 -0
  34. package/src/server/middleware/auth.ts +168 -0
  35. package/src/server/middleware/index.ts +7 -0
  36. package/src/server/middleware/middleware.ts +47 -0
  37. package/src/server/middleware/openapi.ts +126 -0
  38. package/src/server/middleware/rate-limit.ts +126 -0
  39. package/src/server/middleware/request-logger.ts +41 -0
  40. package/src/server/middleware/validator.ts +35 -0
  41. package/src/server/services/access.ts +294 -0
  42. package/src/server/services/freeipa/client.ts +100 -0
  43. package/src/server/services/freeipa/index.ts +9 -0
  44. package/src/server/services/freeipa/session.ts +78 -0
  45. package/src/server/services/freeipa/tls.ts +48 -0
  46. package/src/server/services/freeipa/util.ts +60 -0
  47. package/src/server/services/geo.ts +154 -0
  48. package/src/server/services/index.ts +28 -0
  49. package/src/server/services/services.ts +13 -0
  50. package/src/services/account-lifecycle/audit.ts +41 -0
  51. package/src/services/account-lifecycle/index.ts +907 -0
  52. package/src/services/account-lifecycle/scheduler.ts +347 -0
  53. package/src/services/account-model.ts +21 -0
  54. package/src/services/accounts/app.ts +966 -0
  55. package/src/services/accounts/authz.ts +22 -0
  56. package/src/services/accounts/base-group.ts +11 -0
  57. package/src/services/accounts/base-user.ts +45 -0
  58. package/src/services/accounts/entities.ts +529 -0
  59. package/src/services/accounts/group-sql.ts +106 -0
  60. package/src/services/accounts/groups.ts +246 -0
  61. package/src/services/accounts/index.ts +14 -0
  62. package/src/services/accounts/ipa-data.ts +64 -0
  63. package/src/services/accounts/lifecycle.ts +2 -0
  64. package/src/services/accounts/local-groups.ts +491 -0
  65. package/src/services/accounts/model.ts +135 -0
  66. package/src/services/accounts/switching.ts +117 -0
  67. package/src/services/accounts/users.ts +714 -0
  68. package/src/services/auth-flows/index.ts +6 -0
  69. package/src/services/auth-flows/ipa.ts +128 -0
  70. package/src/services/auth-flows/magic-link.ts +119 -0
  71. package/src/services/freeipa-config.ts +89 -0
  72. package/src/services/index.ts +46 -0
  73. package/src/services/ipa/auth.ts +122 -0
  74. package/src/services/ipa/groups.ts +684 -0
  75. package/src/services/ipa/guard.ts +17 -0
  76. package/src/services/ipa/index.ts +17 -0
  77. package/src/services/ipa/profile.ts +90 -0
  78. package/src/services/ipa/search.ts +154 -0
  79. package/src/services/ipa/sync.ts +740 -0
  80. package/src/services/ipa/users.ts +794 -0
  81. package/src/services/logging/index.ts +294 -0
  82. package/src/services/notifications/email.ts +123 -0
  83. package/src/services/notifications/index.ts +413 -0
  84. package/src/services/postgres.ts +51 -0
  85. package/src/services/providers/index.ts +27 -0
  86. package/src/services/providers/local/auth.ts +13 -0
  87. package/src/services/providers/local/index.ts +4 -0
  88. package/src/services/providers/local/users.ts +255 -0
  89. package/src/services/session/index.ts +137 -0
  90. package/src/services/settings/api.ts +61 -0
  91. package/src/services/settings/app.ts +101 -0
  92. package/src/services/settings/crypto.ts +69 -0
  93. package/src/services/settings/defaults.ts +824 -0
  94. package/src/services/settings/index.ts +203 -0
  95. package/src/services/settings/namespace.ts +9 -0
  96. package/src/services/settings/snapshot.ts +49 -0
  97. package/src/services/settings/store.ts +179 -0
  98. package/src/services/settings/templates.ts +10 -0
  99. package/src/services/weather/forecast.ts +287 -0
  100. package/src/services/weather/geo.ts +110 -0
  101. package/src/services/weather/index.ts +99 -0
  102. package/src/services/weather/location.ts +24 -0
  103. package/src/services/weather/locations.ts +125 -0
  104. package/src/services/weather/migrate.ts +22 -0
  105. package/src/services/weather/types.ts +61 -0
  106. package/src/services/weather/ui.ts +50 -0
  107. package/src/shared/account-display.ts +17 -0
  108. package/src/shared/account-session.ts +15 -0
  109. package/src/shared/icons.ts +109 -0
  110. package/src/shared/index.ts +10 -0
  111. package/src/shared/markdown/client.ts +130 -0
  112. package/src/shared/markdown/extensions/code.ts +58 -0
  113. package/src/shared/markdown/extensions/images.ts +43 -0
  114. package/src/shared/markdown/extensions/info-blocks.ts +93 -0
  115. package/src/shared/markdown/extensions/katex.ts +120 -0
  116. package/src/shared/markdown/extensions/links.ts +34 -0
  117. package/src/shared/markdown/extensions/tables.ts +88 -0
  118. package/src/shared/markdown/extensions/task-list.ts +53 -0
  119. package/src/shared/markdown/index.ts +97 -0
  120. package/src/shared/markdown/shared.ts +36 -0
  121. package/src/ssr/AdminLayout.tsx +42 -0
  122. package/src/ssr/AdminSidebar.tsx +95 -0
  123. package/src/ssr/Footer.island.tsx +62 -0
  124. package/src/ssr/GlobalSearchDialog.tsx +389 -0
  125. package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
  126. package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
  127. package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
  128. package/src/ssr/Layout.tsx +326 -0
  129. package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
  130. package/src/ssr/NavMenu.island.tsx +108 -0
  131. package/src/ssr/ThemeToggleRail.island.tsx +27 -0
  132. package/src/ssr/index.ts +5 -0
  133. package/src/ssr/islands/SearchBar.island.tsx +77 -0
  134. package/src/ssr/islands/index.ts +1 -0
  135. package/src/ssr/runtime.ts +22 -0
  136. package/src/styles/base-popover.css +28 -0
  137. package/src/styles/effects.css +65 -0
  138. package/src/styles/global.css +133 -0
  139. package/src/styles/input.css +54 -0
  140. package/src/styles/tokens.css +35 -0
  141. package/src/styles/utilities-buttons.css +125 -0
  142. package/src/styles/utilities-feedback.css +65 -0
  143. package/src/styles/utilities-layout.css +122 -0
  144. package/src/styles/utilities-navigation.css +196 -0
  145. package/src/types/ambient.d.ts +8 -0
  146. package/src/ui/admin-settings.tsx +148 -0
  147. package/src/ui/dialog-core.ts +146 -0
  148. package/src/ui/filter/FilterChip.tsx +196 -0
  149. package/src/ui/filter/index.ts +2 -0
  150. package/src/ui/index.ts +19 -0
  151. package/src/ui/input/Checkbox.tsx +55 -0
  152. package/src/ui/input/ColorInput.tsx +122 -0
  153. package/src/ui/input/DateTimeInput.tsx +86 -0
  154. package/src/ui/input/ImageInput.tsx +170 -0
  155. package/src/ui/input/NumberInput.tsx +113 -0
  156. package/src/ui/input/PinInput.tsx +169 -0
  157. package/src/ui/input/SegmentedControl.tsx +99 -0
  158. package/src/ui/input/Select.tsx +288 -0
  159. package/src/ui/input/SelectChip.tsx +61 -0
  160. package/src/ui/input/Slider.tsx +118 -0
  161. package/src/ui/input/Switch.tsx +62 -0
  162. package/src/ui/input/TagsInput.tsx +115 -0
  163. package/src/ui/input/TextInput.tsx +160 -0
  164. package/src/ui/input/index.ts +13 -0
  165. package/src/ui/input/types.ts +42 -0
  166. package/src/ui/input/util.tsx +105 -0
  167. package/src/ui/ipa/Avatar.tsx +28 -0
  168. package/src/ui/ipa/GroupView.tsx +36 -0
  169. package/src/ui/ipa/LoginBtn.tsx +16 -0
  170. package/src/ui/ipa/UserView.tsx +58 -0
  171. package/src/ui/ipa/index.ts +4 -0
  172. package/src/ui/misc/ContextMenu.tsx +211 -0
  173. package/src/ui/misc/CopyButton.tsx +28 -0
  174. package/src/ui/misc/Dropdown.tsx +194 -0
  175. package/src/ui/misc/EntitySearch.tsx +213 -0
  176. package/src/ui/misc/Lightbox.tsx +194 -0
  177. package/src/ui/misc/LinkCard.tsx +34 -0
  178. package/src/ui/misc/LogEntriesTable.tsx +61 -0
  179. package/src/ui/misc/MarkdownView.tsx +65 -0
  180. package/src/ui/misc/Pagination.tsx +51 -0
  181. package/src/ui/misc/PermissionEditor.tsx +379 -0
  182. package/src/ui/misc/ProgressBar.tsx +47 -0
  183. package/src/ui/misc/RemoveBtn.tsx +27 -0
  184. package/src/ui/misc/StatCell.tsx +90 -0
  185. package/src/ui/misc/index.ts +18 -0
  186. package/src/ui/navigation.ts +32 -0
  187. package/src/ui/prompts.tsx +854 -0
  188. package/src/ui/sidebar.tsx +468 -0
  189. package/src/ui/widgets/Widget.tsx +62 -0
  190. package/src/ui/widgets/WidgetCard.tsx +19 -0
  191. package/src/ui/widgets/WidgetHero.tsx +39 -0
  192. package/src/ui/widgets/WidgetList.tsx +84 -0
  193. package/src/ui/widgets/WidgetPills.tsx +68 -0
  194. package/src/ui/widgets/WidgetStat.tsx +67 -0
  195. package/src/ui/widgets/WidgetStatus.tsx +62 -0
  196. package/src/ui/widgets/index.ts +9 -0
@@ -0,0 +1,907 @@
1
+ import { sql } from "bun";
2
+ import type { User } from "../../contracts/shared";
3
+ import { logger } from "../logging";
4
+ import { notifications } from "../notifications";
5
+ import { applyIpaAccountTransitionPolicy } from "../accounts/switching";
6
+ import { get as getSetting } from "../settings";
7
+ import { renderTemplate } from "../settings/templates";
8
+ import { session } from "../session";
9
+ import { getConfiguredExpiryDays, parseIpaAccountTransitionPolicy } from "../account-model";
10
+ import { getFreeIpaConfig } from "../freeipa-config";
11
+ import { parsePgJsonRecord } from "../postgres";
12
+ import { dates } from "../../shared";
13
+ import { freeipa } from "../../server/services";
14
+ import { writeDeletedAccountAudit } from "./audit";
15
+ import { getIpaUrl } from "../ipa/guard";
16
+
17
+ const log = logger("auth:lifecycle");
18
+
19
+ type DbRow = Record<string, unknown>;
20
+
21
+ const DAY_MS = 24 * 60 * 60 * 1000;
22
+
23
+ type ReminderKind = "account_expiry";
24
+
25
+ type ReminderCandidate = {
26
+ userId: string;
27
+ uid: string;
28
+ mail: string;
29
+ givenName: string;
30
+ displayName: string;
31
+ expiresAt: Date;
32
+ kind: ReminderKind;
33
+ accountKind: "ipa" | "local-user" | "local-guest";
34
+ };
35
+
36
+ type LifecycleSummary = {
37
+ scanned: number;
38
+ changed: number;
39
+ skipped: number;
40
+ failed: number;
41
+ };
42
+
43
+ const settingInt = async (key: string, fallback: number): Promise<number> => {
44
+ const raw = await getSetting<number | string | null>(key);
45
+ const value = typeof raw === "number" ? raw : Number(raw);
46
+ return Number.isFinite(value) ? value : fallback;
47
+ };
48
+
49
+ const getIpaExpiresDays = async (): Promise<number> => getConfiguredExpiryDays("ipa", "user");
50
+
51
+ const getLocalUserExpiresDays = async (): Promise<number> => getConfiguredExpiryDays("local", "user");
52
+ const getGuestExpiresDays = async (): Promise<number> => {
53
+ return getConfiguredExpiryDays("local", "guest");
54
+ };
55
+ const getDeletedAccountsRetentionDays = async (): Promise<number> => settingInt("user.account.deleted_accounts_retention_days", 365);
56
+ const getReminderHistoryRetentionDays = async (): Promise<number> => settingInt("user.account.reminder_history_retention_days", 365);
57
+
58
+ const parseReminderDays = async (): Promise<number[]> => {
59
+ const raw = await getSetting<number[]>("user.account.reminder_days");
60
+ const parsed = Array.isArray(raw) ? raw.filter((entry) => Number.isInteger(entry) && entry > 0) : [];
61
+ return [...new Set(parsed)].sort((a, b) => b - a);
62
+ };
63
+
64
+ const upsertReminderAttempt = async (config: {
65
+ userId: string;
66
+ uid: string;
67
+ mail: string;
68
+ displayName: string;
69
+ kind: ReminderKind;
70
+ thresholdDays: number;
71
+ targetExpiryAt: Date;
72
+ }): Promise<{ id: string; status: "pending" | "sent" | "error" }> => {
73
+ const rows = await sql<DbRow[]>`
74
+ INSERT INTO auth.account_lifecycle_reminders (
75
+ user_id, uid, mail, display_name, kind, threshold_days, target_expiry_at, status, attempt_count, created_at
76
+ )
77
+ VALUES (
78
+ ${config.userId}::uuid, ${config.uid}, ${config.mail}, ${config.displayName}, ${config.kind}, ${config.thresholdDays}, ${config.targetExpiryAt}, 'pending', 0, now()
79
+ )
80
+ ON CONFLICT (user_id, kind, threshold_days, target_expiry_at) WHERE user_id IS NOT NULL DO UPDATE
81
+ SET uid = EXCLUDED.uid,
82
+ mail = EXCLUDED.mail,
83
+ display_name = EXCLUDED.display_name,
84
+ status = CASE WHEN auth.account_lifecycle_reminders.status = 'sent' THEN 'sent' ELSE 'pending' END
85
+ RETURNING id, status
86
+ `;
87
+
88
+ return {
89
+ id: rows[0]!.id as string,
90
+ status: rows[0]!.status as "pending" | "sent" | "error",
91
+ };
92
+ };
93
+
94
+ const markReminderSuccess = async (id: string): Promise<void> => {
95
+ await sql`
96
+ UPDATE auth.account_lifecycle_reminders
97
+ SET status = 'sent',
98
+ attempt_count = attempt_count + 1,
99
+ last_attempt_at = now(),
100
+ sent_at = now(),
101
+ last_error = NULL
102
+ WHERE id = ${id}::uuid
103
+ `;
104
+ };
105
+
106
+ const markReminderError = async (id: string, error: string): Promise<void> => {
107
+ await sql`
108
+ UPDATE auth.account_lifecycle_reminders
109
+ SET status = 'error',
110
+ attempt_count = attempt_count + 1,
111
+ last_attempt_at = now(),
112
+ last_error = ${error}
113
+ WHERE id = ${id}::uuid
114
+ `;
115
+ };
116
+
117
+ const deleteFromFreeIpa = async (ipaSession: string, uid: string): Promise<{ ok: true } | { ok: false; error: string }> => {
118
+ const response = await freeipa.client.call({ url: await getIpaUrl(), ipaSession, method: "user_del", args: [uid], options: {} });
119
+ if (!response.error) return { ok: true };
120
+
121
+ const message = (response.error.message ?? "").toLowerCase();
122
+ const isNotFound = message.includes("not found") || message.includes("does not exist");
123
+ if (isNotFound) return { ok: true };
124
+
125
+ return { ok: false, error: response.error.message };
126
+ };
127
+
128
+ const resolveExtendUrl = async (): Promise<string> => {
129
+ const appUrl = await getSetting<string>("app.url");
130
+ const base = appUrl && appUrl.length > 0 ? appUrl : "";
131
+ if (base.startsWith("http://") || base.startsWith("https://")) return `${base.replace(/\/+$/, "")}/auth/extend`;
132
+ if (base.length > 0) return `https://${base.replace(/\/+$/, "")}/auth/extend`;
133
+ return "/auth/extend";
134
+ };
135
+
136
+ const listReminderCandidates = async (thresholdDays: number): Promise<ReminderCandidate[]> => {
137
+ const rows = await sql<DbRow[]>`
138
+ SELECT id,
139
+ uid,
140
+ mail,
141
+ given_name,
142
+ display_name,
143
+ account_expires AS expires_at,
144
+ 'account_expiry'::text AS kind,
145
+ 'ipa'::text AS account_kind
146
+ FROM auth.users
147
+ WHERE provider = 'ipa'
148
+ AND mail IS NOT NULL
149
+ AND account_expires IS NOT NULL
150
+ AND now() >= account_expires - (${thresholdDays} * interval '1 day')
151
+ AND now() < account_expires
152
+
153
+ UNION ALL
154
+
155
+ SELECT id,
156
+ uid,
157
+ mail,
158
+ given_name,
159
+ display_name,
160
+ account_expires AS expires_at,
161
+ 'account_expiry'::text AS kind,
162
+ 'local-guest'::text AS account_kind
163
+ FROM auth.users
164
+ WHERE provider = 'local'
165
+ AND profile = 'guest'
166
+ AND mail IS NOT NULL
167
+ AND account_expires IS NOT NULL
168
+ AND now() >= account_expires - (${thresholdDays} * interval '1 day')
169
+ AND now() < account_expires
170
+
171
+ UNION ALL
172
+
173
+ SELECT id,
174
+ uid,
175
+ mail,
176
+ given_name,
177
+ display_name,
178
+ account_expires AS expires_at,
179
+ 'account_expiry'::text AS kind,
180
+ 'local-user'::text AS account_kind
181
+ FROM auth.users
182
+ WHERE provider = 'local'
183
+ AND profile = 'user'
184
+ AND mail IS NOT NULL
185
+ AND account_expires IS NOT NULL
186
+ AND now() >= account_expires - (${thresholdDays} * interval '1 day')
187
+ AND now() < account_expires
188
+ `;
189
+
190
+ return rows.map(
191
+ (row): ReminderCandidate => ({
192
+ userId: row.id as string,
193
+ uid: row.uid as string,
194
+ mail: row.mail as string,
195
+ givenName: ((row.given_name as string) || "").trim(),
196
+ displayName: ((row.display_name as string) || "").trim(),
197
+ expiresAt: row.expires_at as Date,
198
+ kind: row.kind as ReminderKind,
199
+ accountKind: row.account_kind as ReminderCandidate["accountKind"],
200
+ }),
201
+ );
202
+ };
203
+
204
+ export const accountLifecycle = {
205
+ demoteExpiredIpaUsers: async (): Promise<LifecycleSummary> => {
206
+ const freeIpaConfig = (await getFreeIpaConfig());
207
+ if (!freeIpaConfig.enabled) {
208
+ log.info("Expired IPA demotion skipped", { reason: "freeipa_disabled" });
209
+ return { scanned: 0, changed: 0, skipped: 0, failed: 0 };
210
+ }
211
+ if (!freeIpaConfig.configured) {
212
+ throw new Error("FreeIPA is enabled but not fully configured.");
213
+ }
214
+
215
+ const rows = await sql<DbRow[]>`
216
+ SELECT id, uid, mail, display_name, profile, account_expires
217
+ FROM auth.users
218
+ WHERE provider = 'ipa'
219
+ AND account_expires IS NOT NULL
220
+ AND account_expires <= now()
221
+ ORDER BY account_expires ASC
222
+ `;
223
+
224
+ const transitionPolicy = parseIpaAccountTransitionPolicy(
225
+ await getSetting<string | null>("freeipa.account_transition_policy"),
226
+ );
227
+ const ipaSession = await freeipa.session.getServiceSession({
228
+ url: freeIpaConfig.url,
229
+ serviceUser: freeIpaConfig.serviceUser,
230
+ servicePassword: freeIpaConfig.servicePassword,
231
+ });
232
+ const summary: LifecycleSummary = {
233
+ scanned: rows.length,
234
+ changed: 0,
235
+ skipped: 0,
236
+ failed: 0,
237
+ };
238
+
239
+ for (const row of rows) {
240
+ const userId = row.id as string;
241
+ const uid = row.uid as string;
242
+ const previousProfile = (row.profile as User["profile"] | null) ?? "guest";
243
+ const ipaDelete = await deleteFromFreeIpa(ipaSession, uid);
244
+ if (!ipaDelete.ok) {
245
+ summary.failed += 1;
246
+ log.error("Failed to delete expired IPA account", { uid, userId, error: ipaDelete.error });
247
+ continue;
248
+ }
249
+
250
+ try {
251
+ if (transitionPolicy === "delete") {
252
+ await sql.begin(async (tx) => {
253
+ await writeDeletedAccountAudit({
254
+ db: tx,
255
+ userId,
256
+ uid,
257
+ mail: (row.mail as string) ?? null,
258
+ displayName: (row.display_name as string) ?? null,
259
+ previousProvider: "ipa",
260
+ previousProfile,
261
+ reason: "ipa_expired_deleted",
262
+ meta: {
263
+ reason: "ipa_account_expired",
264
+ },
265
+ });
266
+ await tx`DELETE FROM auth.users WHERE id = ${userId}::uuid`;
267
+ });
268
+ } else {
269
+ await sql.begin(async (tx) => {
270
+ const target = await applyIpaAccountTransitionPolicy({
271
+ userId,
272
+ currentProfile: previousProfile,
273
+ policy: transitionPolicy,
274
+ db: tx,
275
+ });
276
+ await writeDeletedAccountAudit({
277
+ db: tx,
278
+ userId,
279
+ uid,
280
+ mail: (row.mail as string) ?? null,
281
+ displayName: (row.display_name as string) ?? null,
282
+ previousProvider: "ipa",
283
+ previousProfile,
284
+ reason: "ipa_expired_demoted",
285
+ meta: {
286
+ accountExpiresAt: target.accountExpires?.toISOString() ?? null,
287
+ targetProfile: target.targetProfile,
288
+ policy: transitionPolicy,
289
+ },
290
+ });
291
+ });
292
+ }
293
+ await session.revokeAllForUser(userId);
294
+ summary.changed += 1;
295
+ } catch (error) {
296
+ summary.failed += 1;
297
+ log.error("Failed to demote expired IPA account", {
298
+ uid,
299
+ userId,
300
+ error: error instanceof Error ? error.message : String(error),
301
+ });
302
+ }
303
+ }
304
+
305
+ return summary;
306
+ },
307
+
308
+ cleanupExpiredGuests: async (): Promise<LifecycleSummary> => {
309
+ const rows = await sql<DbRow[]>`
310
+ SELECT id, uid, mail, display_name
311
+ FROM auth.users
312
+ WHERE provider = 'local'
313
+ AND profile = 'guest'
314
+ AND account_expires IS NOT NULL
315
+ AND account_expires <= now()
316
+ ORDER BY account_expires ASC
317
+ `;
318
+
319
+ const summary: LifecycleSummary = {
320
+ scanned: rows.length,
321
+ changed: 0,
322
+ skipped: 0,
323
+ failed: 0,
324
+ };
325
+
326
+ for (const row of rows) {
327
+ const userId = row.id as string;
328
+ const uid = row.uid as string;
329
+ try {
330
+ await sql.begin(async (tx) => {
331
+ await writeDeletedAccountAudit({
332
+ db: tx,
333
+ userId,
334
+ uid,
335
+ mail: (row.mail as string) ?? null,
336
+ displayName: (row.display_name as string) ?? null,
337
+ previousProvider: "local",
338
+ previousProfile: "guest",
339
+ reason: "guest_expired_deleted",
340
+ });
341
+ await tx`DELETE FROM auth.users WHERE id = ${userId}::uuid`;
342
+ });
343
+ await session.revokeAllForUser(userId);
344
+ summary.changed += 1;
345
+ } catch (error) {
346
+ summary.failed += 1;
347
+ log.error("Failed to delete expired guest account", {
348
+ uid,
349
+ userId,
350
+ error: error instanceof Error ? error.message : String(error),
351
+ });
352
+ }
353
+ }
354
+
355
+ return summary;
356
+ },
357
+
358
+ cleanupExpiredLocalUsers: async (): Promise<LifecycleSummary> => {
359
+ const rows = await sql<DbRow[]>`
360
+ SELECT id, uid, mail, display_name
361
+ FROM auth.users
362
+ WHERE provider = 'local'
363
+ AND profile = 'user'
364
+ AND account_expires IS NOT NULL
365
+ AND account_expires <= now()
366
+ ORDER BY account_expires ASC
367
+ `;
368
+
369
+ const summary: LifecycleSummary = {
370
+ scanned: rows.length,
371
+ changed: 0,
372
+ skipped: 0,
373
+ failed: 0,
374
+ };
375
+
376
+ for (const row of rows) {
377
+ const userId = row.id as string;
378
+ const uid = row.uid as string;
379
+ try {
380
+ await sql.begin(async (tx) => {
381
+ await writeDeletedAccountAudit({
382
+ db: tx,
383
+ userId,
384
+ uid,
385
+ mail: (row.mail as string) ?? null,
386
+ displayName: (row.display_name as string) ?? null,
387
+ previousProvider: "local",
388
+ previousProfile: "user",
389
+ reason: "local_user_expired_deleted",
390
+ });
391
+ await tx`DELETE FROM auth.users WHERE id = ${userId}::uuid`;
392
+ });
393
+ await session.revokeAllForUser(userId);
394
+ summary.changed += 1;
395
+ } catch (error) {
396
+ summary.failed += 1;
397
+ log.error("Failed to delete expired local user account", {
398
+ uid,
399
+ userId,
400
+ error: error instanceof Error ? error.message : String(error),
401
+ });
402
+ }
403
+ }
404
+
405
+ return summary;
406
+ },
407
+
408
+ sendExpiryReminders: async (): Promise<LifecycleSummary> => {
409
+ const days = await parseReminderDays();
410
+ if (days.length === 0) {
411
+ return { scanned: 0, changed: 0, skipped: 0, failed: 0 };
412
+ }
413
+
414
+ const template = await getSetting<string>("mail.account_expiry_reminder");
415
+ const appName = (await getSetting<string>("app.name")) || "Cloud";
416
+ const contactEmail = (await getSetting<string>("app.contact_email")) || "";
417
+ const extendUrl = await resolveExtendUrl();
418
+
419
+ let scanned = 0;
420
+ let changed = 0;
421
+ let skipped = 0;
422
+ let failed = 0;
423
+
424
+ for (const thresholdDays of days) {
425
+ const candidates = await listReminderCandidates(thresholdDays);
426
+ scanned += candidates.length;
427
+
428
+ for (const candidate of candidates) {
429
+ const attempt = await upsertReminderAttempt({
430
+ userId: candidate.userId,
431
+ uid: candidate.uid,
432
+ mail: candidate.mail,
433
+ displayName: candidate.displayName,
434
+ kind: candidate.kind,
435
+ thresholdDays,
436
+ targetExpiryAt: candidate.expiresAt,
437
+ });
438
+
439
+ if (attempt.status === "sent") {
440
+ skipped += 1;
441
+ continue;
442
+ }
443
+
444
+ const expiryText = dates.formatDate(candidate.expiresAt);
445
+
446
+ const subject = `${appName} account expires soon`;
447
+ const html = renderTemplate(template, {
448
+ FIRST_NAME: candidate.givenName || candidate.displayName || candidate.uid,
449
+ DISPLAY_NAME: candidate.displayName || candidate.uid,
450
+ EXPIRY: expiryText,
451
+ EXTEND_URL: extendUrl,
452
+ APP_NAME: appName,
453
+ CONTACT_EMAIL: contactEmail,
454
+ ACCOUNT_KIND: candidate.accountKind,
455
+ });
456
+
457
+ try {
458
+ const notification = await notifications.send({
459
+ type: "email",
460
+ recipient: candidate.mail,
461
+ subject,
462
+ rawHtml: html,
463
+ autoSend: true,
464
+ });
465
+ if (notification.status === "error") {
466
+ await markReminderError(attempt.id, notification.error ?? "Notification delivery failed");
467
+ failed += 1;
468
+ continue;
469
+ }
470
+
471
+ await markReminderSuccess(attempt.id);
472
+ changed += 1;
473
+ } catch (error) {
474
+ const message = error instanceof Error ? error.message : String(error);
475
+ await markReminderError(attempt.id, message);
476
+ failed += 1;
477
+ log.error("Failed to send expiry reminder", {
478
+ userId: candidate.userId,
479
+ uid: candidate.uid,
480
+ kind: candidate.kind,
481
+ thresholdDays,
482
+ error: message,
483
+ });
484
+ }
485
+ }
486
+ }
487
+
488
+ return { scanned, changed, skipped, failed };
489
+ },
490
+
491
+ cleanupLifecycleAudit: async (): Promise<LifecycleSummary> => {
492
+ const [deletedAccountsRetentionDays, reminderHistoryRetentionDays] = await Promise.all([
493
+ getDeletedAccountsRetentionDays(),
494
+ getReminderHistoryRetentionDays(),
495
+ ]);
496
+
497
+ const deletedRows =
498
+ deletedAccountsRetentionDays > 0
499
+ ? await sql<DbRow[]>`
500
+ DELETE FROM auth.deleted_accounts
501
+ WHERE deleted_at < now() - (${deletedAccountsRetentionDays} * interval '1 day')
502
+ RETURNING id
503
+ `
504
+ : [];
505
+ const reminderRows =
506
+ reminderHistoryRetentionDays > 0
507
+ ? await sql<DbRow[]>`
508
+ DELETE FROM auth.account_lifecycle_reminders
509
+ WHERE created_at < now() - (${reminderHistoryRetentionDays} * interval '1 day')
510
+ RETURNING id
511
+ `
512
+ : [];
513
+
514
+ return {
515
+ scanned: deletedRows.length + reminderRows.length,
516
+ changed: deletedRows.length + reminderRows.length,
517
+ skipped: 0,
518
+ failed: 0,
519
+ };
520
+ },
521
+
522
+ runIpaBackfill: async (): Promise<LifecycleSummary> => {
523
+ const freeIpaConfig = (await getFreeIpaConfig());
524
+ if (!freeIpaConfig.enabled) {
525
+ log.info("IPA backfill skipped", { reason: "freeipa_disabled" });
526
+ return { scanned: 0, changed: 0, skipped: 0, failed: 0 };
527
+ }
528
+ if (!freeIpaConfig.configured) {
529
+ throw new Error("FreeIPA is enabled but not fully configured.");
530
+ }
531
+
532
+ const configuredDays = await getIpaExpiresDays();
533
+ if (configuredDays <= 0) {
534
+ return { scanned: 0, changed: 0, skipped: 0, failed: 0 };
535
+ }
536
+
537
+ const days = Math.max(configuredDays, 7);
538
+ const minimumExpiry = new Date(Date.now() + days * DAY_MS);
539
+ minimumExpiry.setUTCHours(23, 59, 59, 0);
540
+ const ipaExpiry = freeipa.util.toGeneralizedTime(minimumExpiry);
541
+ const ipaSession = await freeipa.session.getServiceSession({
542
+ url: freeIpaConfig.url,
543
+ serviceUser: freeIpaConfig.serviceUser,
544
+ servicePassword: freeIpaConfig.servicePassword,
545
+ });
546
+
547
+ const rows = await sql<DbRow[]>`
548
+ SELECT id, uid, account_expires
549
+ FROM auth.users
550
+ WHERE provider = 'ipa'
551
+ AND (account_expires IS NULL OR account_expires < ${minimumExpiry})
552
+ ORDER BY uid
553
+ `;
554
+
555
+ const summary: LifecycleSummary = {
556
+ scanned: rows.length,
557
+ changed: 0,
558
+ skipped: 0,
559
+ failed: 0,
560
+ };
561
+
562
+ for (const row of rows) {
563
+ const userId = row.id as string;
564
+ const uid = row.uid as string;
565
+ const remote = await freeipa.client.call({
566
+ url: freeIpaConfig.url,
567
+ ipaSession,
568
+ method: "user_show",
569
+ args: [uid],
570
+ options: { all: true },
571
+ });
572
+ if (remote.error) {
573
+ summary.failed += 1;
574
+ log.error("IPA backfill read failed", { uid, userId, error: remote.error.message });
575
+ continue;
576
+ }
577
+
578
+ const remoteResult = remote.result?.result as Record<string, unknown> | undefined;
579
+ const remoteExpiry = freeipa.util.parseGeneralizedTime(remoteResult?.krbprincipalexpiration);
580
+ if (remoteExpiry && remoteExpiry >= minimumExpiry) {
581
+ await sql`
582
+ UPDATE auth.users
583
+ SET account_expires = ${remoteExpiry}
584
+ WHERE id = ${userId}::uuid
585
+ `;
586
+ await sql`
587
+ INSERT INTO auth.user_ipa_data (user_id, synced_at)
588
+ VALUES (${userId}::uuid, now())
589
+ ON CONFLICT (user_id) DO UPDATE SET synced_at = EXCLUDED.synced_at
590
+ `;
591
+ summary.skipped += 1;
592
+ continue;
593
+ }
594
+
595
+ const response = await freeipa.client.call({
596
+ url: freeIpaConfig.url,
597
+ ipaSession,
598
+ method: "user_mod",
599
+ args: [uid],
600
+ options: { krbprincipalexpiration: ipaExpiry },
601
+ });
602
+ if (response.error) {
603
+ summary.failed += 1;
604
+ log.error("IPA backfill failed", { uid, userId, error: response.error.message });
605
+ continue;
606
+ }
607
+
608
+ await sql`
609
+ UPDATE auth.users
610
+ SET account_expires = ${minimumExpiry}
611
+ WHERE id = ${userId}::uuid
612
+ `;
613
+ await sql`
614
+ INSERT INTO auth.user_ipa_data (user_id, synced_at)
615
+ VALUES (${userId}::uuid, now())
616
+ ON CONFLICT (user_id) DO UPDATE SET synced_at = EXCLUDED.synced_at
617
+ `;
618
+ summary.changed += 1;
619
+ }
620
+
621
+ return summary;
622
+ },
623
+
624
+ runLocalUserBackfill: async (): Promise<LifecycleSummary> => {
625
+ const configuredDays = await getLocalUserExpiresDays();
626
+ if (configuredDays <= 0) {
627
+ return { scanned: 0, changed: 0, skipped: 0, failed: 0 };
628
+ }
629
+
630
+ const days = Math.max(configuredDays, 7);
631
+ const target = new Date(Date.now() + days * DAY_MS);
632
+
633
+ const rows = await sql<DbRow[]>`
634
+ UPDATE auth.users
635
+ SET account_expires = ${target}
636
+ WHERE provider = 'local'
637
+ AND profile = 'user'
638
+ AND (account_expires IS NULL OR account_expires < ${target})
639
+ RETURNING id
640
+ `;
641
+
642
+ return {
643
+ scanned: rows.length,
644
+ changed: rows.length,
645
+ skipped: 0,
646
+ failed: 0,
647
+ };
648
+ },
649
+
650
+ runGuestBackfill: async (): Promise<LifecycleSummary> => {
651
+ const configuredDays = await getGuestExpiresDays();
652
+ if (configuredDays <= 0) {
653
+ return { scanned: 0, changed: 0, skipped: 0, failed: 0 };
654
+ }
655
+
656
+ const days = Math.max(configuredDays, 7);
657
+ const target = new Date(Date.now() + days * DAY_MS);
658
+
659
+ const rows = await sql<DbRow[]>`
660
+ UPDATE auth.users
661
+ SET account_expires = ${target}
662
+ WHERE provider = 'local'
663
+ AND profile = 'guest'
664
+ AND (account_expires IS NULL OR account_expires < ${target})
665
+ RETURNING id
666
+ `;
667
+
668
+ return {
669
+ scanned: rows.length,
670
+ changed: rows.length,
671
+ skipped: 0,
672
+ failed: 0,
673
+ };
674
+ },
675
+
676
+ extendCurrentUserAccount: async (config: {
677
+ user: User;
678
+ ipaSession?: string | null;
679
+ }): Promise<{ message: string; newExpiry?: string }> => {
680
+ if (config.user.provider === "ipa") {
681
+ const freeIpaConfig = (await getFreeIpaConfig());
682
+ if (!freeIpaConfig.enabled) {
683
+ return { message: "FreeIPA is disabled." };
684
+ }
685
+ const configuredDays = await getIpaExpiresDays();
686
+ if (configuredDays <= 0) {
687
+ return { message: "Automatic account expiry is disabled for IPA accounts." };
688
+ }
689
+
690
+ if (!config.ipaSession) {
691
+ throw new Error("IPA session required to extend an IPA-backed account.");
692
+ }
693
+
694
+ const expiresAt = new Date(Date.now() + configuredDays * DAY_MS);
695
+ expiresAt.setUTCHours(23, 59, 59, 0);
696
+ const ipaExpiry = freeipa.util.toGeneralizedTime(expiresAt);
697
+
698
+ const response = await freeipa.client.call({
699
+ url: freeIpaConfig.url,
700
+ ipaSession: config.ipaSession,
701
+ method: "user_mod",
702
+ args: [config.user.uid],
703
+ options: { krbprincipalexpiration: ipaExpiry },
704
+ });
705
+ if (response.error) {
706
+ throw new Error(response.error.message || "Failed to extend IPA account.");
707
+ }
708
+
709
+ await sql`
710
+ UPDATE auth.users
711
+ SET account_expires = ${expiresAt}
712
+ WHERE id = ${config.user.id}::uuid
713
+ `;
714
+ await sql`
715
+ INSERT INTO auth.user_ipa_data (user_id, synced_at)
716
+ VALUES (${config.user.id}::uuid, now())
717
+ ON CONFLICT (user_id) DO UPDATE SET synced_at = EXCLUDED.synced_at
718
+ `;
719
+
720
+ return {
721
+ message: `Account extended until ${dates.formatDate(expiresAt)}.`,
722
+ newExpiry: expiresAt.toISOString(),
723
+ };
724
+ }
725
+
726
+ if (config.user.provider === "local" && config.user.profile === "guest") {
727
+ const guestDays = await getGuestExpiresDays();
728
+ if (guestDays <= 0) {
729
+ await sql`
730
+ UPDATE auth.users
731
+ SET account_expires = NULL
732
+ WHERE id = ${config.user.id}::uuid
733
+ `;
734
+ return { message: "Guest account expiry is disabled." };
735
+ }
736
+
737
+ const expiresAt = new Date(Date.now() + guestDays * DAY_MS);
738
+ await sql`
739
+ UPDATE auth.users
740
+ SET account_expires = ${expiresAt}
741
+ WHERE id = ${config.user.id}::uuid
742
+ `;
743
+
744
+ return {
745
+ message: `Guest account extended until ${dates.formatDate(expiresAt)}.`,
746
+ newExpiry: expiresAt.toISOString(),
747
+ };
748
+ }
749
+
750
+ if (config.user.provider === "local" && config.user.profile === "user") {
751
+ const localUserDays = await getLocalUserExpiresDays();
752
+ if (localUserDays <= 0) {
753
+ await sql`
754
+ UPDATE auth.users
755
+ SET account_expires = NULL
756
+ WHERE id = ${config.user.id}::uuid
757
+ `;
758
+ return { message: "Local user account expiry is disabled." };
759
+ }
760
+
761
+ const expiresAt = new Date(Date.now() + localUserDays * DAY_MS);
762
+ await sql`
763
+ UPDATE auth.users
764
+ SET account_expires = ${expiresAt}
765
+ WHERE id = ${config.user.id}::uuid
766
+ `;
767
+
768
+ return {
769
+ message: `Account extended until ${dates.formatDate(expiresAt)}.`,
770
+ newExpiry: expiresAt.toISOString(),
771
+ };
772
+ }
773
+
774
+ return { message: "Your account does not support extension." };
775
+ },
776
+
777
+ listDeletedAccounts: async (config: { page: number; perPage: number; reason?: string; search?: string }) => {
778
+ const offset = (config.page - 1) * config.perPage;
779
+ const reason = config.reason?.trim() || null;
780
+ const search = config.search?.trim().toLowerCase() || null;
781
+ const pattern = search ? `%${freeipa.util.escapeLike(search)}%` : null;
782
+
783
+ const countRows = await sql<DbRow[]>`
784
+ SELECT COUNT(*)::int AS total
785
+ FROM auth.deleted_accounts
786
+ WHERE (${reason}::text IS NULL OR reason = ${reason})
787
+ AND (
788
+ ${pattern}::text IS NULL
789
+ OR LOWER(uid) LIKE ${pattern} ESCAPE '\\'
790
+ OR LOWER(COALESCE(display_name, '')) LIKE ${pattern} ESCAPE '\\'
791
+ OR LOWER(COALESCE(mail, '')) LIKE ${pattern} ESCAPE '\\'
792
+ )
793
+ `;
794
+
795
+ const rows = await sql<DbRow[]>`
796
+ SELECT id, deleted_user_id, uid, mail, display_name, previous_provider, previous_profile, reason, deleted_at, meta
797
+ FROM auth.deleted_accounts
798
+ WHERE (${reason}::text IS NULL OR reason = ${reason})
799
+ AND (
800
+ ${pattern}::text IS NULL
801
+ OR LOWER(uid) LIKE ${pattern} ESCAPE '\\'
802
+ OR LOWER(COALESCE(display_name, '')) LIKE ${pattern} ESCAPE '\\'
803
+ OR LOWER(COALESCE(mail, '')) LIKE ${pattern} ESCAPE '\\'
804
+ )
805
+ ORDER BY deleted_at DESC
806
+ LIMIT ${config.perPage}
807
+ OFFSET ${offset}
808
+ `;
809
+
810
+ return {
811
+ items: rows.map((row) => ({
812
+ id: row.id as string,
813
+ deletedUserId: row.deleted_user_id as string,
814
+ uid: row.uid as string,
815
+ mail: (row.mail as string) ?? null,
816
+ displayName: (row.display_name as string) ?? null,
817
+ previousProvider: (row.previous_provider as string) ?? null,
818
+ previousProfile: (row.previous_profile as string) ?? null,
819
+ reason: row.reason as string,
820
+ deletedAt: (row.deleted_at as Date).toISOString(),
821
+ meta: parsePgJsonRecord(row.meta) ?? {},
822
+ })),
823
+ total: Number(countRows[0]?.total ?? 0),
824
+ page: config.page,
825
+ perPage: config.perPage,
826
+ };
827
+ },
828
+
829
+ listReminderAudit: async (config: { page: number; perPage: number; status?: string; kind?: ReminderKind; search?: string }) => {
830
+ const offset = (config.page - 1) * config.perPage;
831
+ const status = config.status?.trim() || null;
832
+ const kind = config.kind ?? null;
833
+ const search = config.search?.trim().toLowerCase() || null;
834
+ const pattern = search ? `%${freeipa.util.escapeLike(search)}%` : null;
835
+
836
+ const countRows = await sql<DbRow[]>`
837
+ SELECT COUNT(*)::int AS total
838
+ FROM auth.account_lifecycle_reminders r
839
+ LEFT JOIN auth.users u ON u.id = r.user_id
840
+ WHERE (${status}::text IS NULL OR r.status = ${status})
841
+ AND (${kind}::text IS NULL OR r.kind = ${kind})
842
+ AND (
843
+ ${pattern}::text IS NULL
844
+ OR LOWER(COALESCE(r.uid, u.uid, '')) LIKE ${pattern} ESCAPE '\\'
845
+ OR LOWER(COALESCE(r.mail, u.mail, '')) LIKE ${pattern} ESCAPE '\\'
846
+ OR LOWER(COALESCE(r.display_name, u.display_name, '')) LIKE ${pattern} ESCAPE '\\'
847
+ )
848
+ `;
849
+
850
+ const rows = await sql<DbRow[]>`
851
+ SELECT r.id,
852
+ r.user_id,
853
+ r.uid AS reminder_uid,
854
+ r.mail AS reminder_mail,
855
+ r.display_name AS reminder_display_name,
856
+ r.kind,
857
+ r.threshold_days,
858
+ r.target_expiry_at,
859
+ r.status,
860
+ r.attempt_count,
861
+ r.last_attempt_at,
862
+ r.sent_at,
863
+ r.last_error,
864
+ r.created_at,
865
+ u.uid AS live_uid,
866
+ u.mail AS live_mail,
867
+ u.display_name AS live_display_name
868
+ FROM auth.account_lifecycle_reminders r
869
+ LEFT JOIN auth.users u ON u.id = r.user_id
870
+ WHERE (${status}::text IS NULL OR r.status = ${status})
871
+ AND (${kind}::text IS NULL OR r.kind = ${kind})
872
+ AND (
873
+ ${pattern}::text IS NULL
874
+ OR LOWER(COALESCE(r.uid, u.uid, '')) LIKE ${pattern} ESCAPE '\\'
875
+ OR LOWER(COALESCE(r.mail, u.mail, '')) LIKE ${pattern} ESCAPE '\\'
876
+ OR LOWER(COALESCE(r.display_name, u.display_name, '')) LIKE ${pattern} ESCAPE '\\'
877
+ )
878
+ ORDER BY r.created_at DESC
879
+ LIMIT ${config.perPage}
880
+ OFFSET ${offset}
881
+ `;
882
+
883
+ return {
884
+ items: rows.map((row) => ({
885
+ id: row.id as string,
886
+ userId: (row.user_id as string) ?? null,
887
+ uid: ((row.reminder_uid as string) ?? (row.live_uid as string) ?? null),
888
+ mail: ((row.reminder_mail as string) ?? (row.live_mail as string) ?? null),
889
+ displayName: ((row.reminder_display_name as string) ?? (row.live_display_name as string) ?? null),
890
+ kind: row.kind as string,
891
+ thresholdDays: Number(row.threshold_days),
892
+ targetExpiryAt: (row.target_expiry_at as Date).toISOString(),
893
+ status: row.status as string,
894
+ attemptCount: Number(row.attempt_count),
895
+ lastAttemptAt: row.last_attempt_at ? (row.last_attempt_at as Date).toISOString() : null,
896
+ sentAt: row.sent_at ? (row.sent_at as Date).toISOString() : null,
897
+ lastError: (row.last_error as string) ?? null,
898
+ createdAt: (row.created_at as Date).toISOString(),
899
+ })),
900
+ total: Number(countRows[0]?.total ?? 0),
901
+ page: config.page,
902
+ perPage: config.perPage,
903
+ };
904
+ },
905
+ };
906
+
907
+ export type AccountLifecycleService = typeof accountLifecycle;