@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,6 @@
1
+ import * as ipa from "./ipa";
2
+ import * as magicLink from "./magic-link";
3
+
4
+ export { ipa, magicLink };
5
+
6
+ export const authFlows = { ipa, magicLink } as const;
@@ -0,0 +1,128 @@
1
+ import { sql } from "bun";
2
+ import { accounts } from "../accounts";
3
+ import { providers } from "../providers";
4
+ import type { User } from "../../contracts/shared";
5
+
6
+ type IpaLoginFailure =
7
+ | { ok: false; status: 401; reason: "password_expired"; message: string }
8
+ | { ok: false; status: 401; reason: "invalid_credentials"; message: string }
9
+ | { ok: false; status: 400; reason: "user_not_synced"; message: string }
10
+ | { ok: false; status: 400; reason: "user_not_found"; message: string }
11
+ | { ok: false; status: 403; reason: "account_expired"; message: string }
12
+ | { ok: false; status: 403; reason: "account_out_of_scope"; message: string }
13
+ | { ok: false; status: 503; reason: "sync_unavailable"; message: string };
14
+
15
+ type IpaLoginSuccess = {
16
+ ok: true;
17
+ ipaSession: string;
18
+ userId: string;
19
+ user: User;
20
+ };
21
+
22
+ export type IpaLoginFlowResult = IpaLoginSuccess | IpaLoginFailure;
23
+
24
+ const loadSyncedIpaUser = async (uid: string): Promise<{ ok: true; userId: string; user: User } | IpaLoginFailure> => {
25
+ const userRows = await sql`
26
+ SELECT id FROM auth.users
27
+ WHERE provider = 'ipa'
28
+ AND uid = ${uid}
29
+ `;
30
+ if (userRows.length === 0) {
31
+ return {
32
+ ok: false,
33
+ status: 400,
34
+ reason: "user_not_synced",
35
+ message: "Your account is not yet available. Please try again in a few minutes.",
36
+ };
37
+ }
38
+
39
+ const userId = userRows[0]!.id as string;
40
+ const user = await accounts.users.get({ id: userId });
41
+ if (!user) {
42
+ return {
43
+ ok: false,
44
+ status: 400,
45
+ reason: "user_not_found",
46
+ message: "User not found. Please try again.",
47
+ };
48
+ }
49
+
50
+ return { ok: true, userId, user };
51
+ };
52
+
53
+ export const login = async (params: { username: string; password: string }): Promise<IpaLoginFlowResult> => {
54
+ const loginResult = await providers.ipa.auth.login(params.username, params.password);
55
+ if (loginResult.status === "password_expired") {
56
+ return { ok: false, status: 401, reason: "password_expired", message: "Password expired" };
57
+ }
58
+ if (loginResult.status !== "success") {
59
+ return { ok: false, status: 401, reason: "invalid_credentials", message: "Invalid username or password" };
60
+ }
61
+
62
+ // Must reach a "synced" outcome before granting a session. Stale mirror rows
63
+ // (expired remotely, dropped from sync scope, or fetch failures) must never
64
+ // grant a fresh local session on the back of successful FreeIPA credentials.
65
+ const syncOutcome = await providers.ipa.sync.user(params.username);
66
+ switch (syncOutcome.status) {
67
+ case "synced":
68
+ break;
69
+ case "skipped_disabled":
70
+ break;
71
+ case "expired":
72
+ return {
73
+ ok: false,
74
+ status: 403,
75
+ reason: "account_expired",
76
+ message: "Your FreeIPA account is expired. Contact an administrator.",
77
+ };
78
+ case "out_of_scope":
79
+ return {
80
+ ok: false,
81
+ status: 403,
82
+ reason: "account_out_of_scope",
83
+ message: "Your FreeIPA account is no longer part of the sync scope. Contact an administrator.",
84
+ };
85
+ case "not_found_local":
86
+ return {
87
+ ok: false,
88
+ status: 400,
89
+ reason: "user_not_synced",
90
+ message: "Your account is not yet available. Please try again in a few minutes.",
91
+ };
92
+ case "fetch_failed":
93
+ return {
94
+ ok: false,
95
+ status: 503,
96
+ reason: "sync_unavailable",
97
+ message: "Could not verify your account with FreeIPA. Please try again.",
98
+ };
99
+ }
100
+
101
+ const userResult = await loadSyncedIpaUser(params.username);
102
+ if (!userResult.ok) return userResult;
103
+
104
+ return {
105
+ ok: true,
106
+ ipaSession: loginResult.session,
107
+ userId: userResult.userId,
108
+ user: userResult.user,
109
+ };
110
+ };
111
+
112
+ export const changeExpiredPassword = async (params: {
113
+ username: string;
114
+ currentPassword: string;
115
+ newPassword: string;
116
+ }): Promise<IpaLoginFlowResult | { ok: false; status: number; reason: "change_failed"; message: string }> => {
117
+ const changeResult = await providers.ipa.auth.changeExpiredPassword(params);
118
+ if (!changeResult.ok) {
119
+ return {
120
+ ok: false,
121
+ status: changeResult.status,
122
+ reason: "change_failed",
123
+ message: changeResult.error,
124
+ };
125
+ }
126
+
127
+ return login({ username: params.username, password: params.newPassword });
128
+ };
@@ -0,0 +1,119 @@
1
+ import { sql } from "bun";
2
+ import { accounts } from "../accounts";
3
+ import { notifications } from "../notifications";
4
+ import { providers } from "../providers";
5
+ import * as settings from "../settings";
6
+ import { renderTemplate } from "../settings/templates";
7
+ import type { User } from "../../contracts/shared";
8
+
9
+ export const request = async (params: { email: string }): Promise<
10
+ | { ok: true }
11
+ | { ok: false; status: 400; message: string }
12
+ > => {
13
+ const userRows = await sql`SELECT uid, provider FROM auth.users WHERE mail = ${params.email}`;
14
+ const hasLocalUser = userRows.some((row: { provider: string | null }) => row.provider === "local");
15
+ const hasIpaUser = userRows.some((row: { provider: string | null }) => row.provider === "ipa");
16
+ const allowSelfRegistration = await settings.get<boolean>("user.allow_self_registration");
17
+
18
+ if (!hasLocalUser && !allowSelfRegistration) {
19
+ return {
20
+ ok: false,
21
+ status: 400,
22
+ message: "Only existing local accounts can sign in with email. Contact an administrator if you need access.",
23
+ };
24
+ }
25
+
26
+ if (!hasLocalUser && hasIpaUser) {
27
+ // Return ok without sending email to prevent account enumeration.
28
+ // IPA-only users must authenticate via Kerberos, not magic-link.
29
+ return { ok: true };
30
+ }
31
+
32
+ const token = await providers.local.auth.createMagicLinkToken({ email: params.email, ttlSeconds: 300 });
33
+ const rawAppUrl = await settings.get<string>("app.url");
34
+ const appUrl = rawAppUrl.startsWith("http") ? rawAppUrl : `https://${rawAppUrl}`;
35
+ const magicLink = `${appUrl}/auth/login?token=${token}`;
36
+
37
+ const appName = await settings.get<string>("app.name");
38
+ const template = await settings.get<string>("mail.magic_link_login");
39
+
40
+ await notifications.send({
41
+ type: "email",
42
+ recipient: params.email,
43
+ subject: `${appName} Login Code`,
44
+ rawHtml: renderTemplate(template, {
45
+ TOKEN: token,
46
+ MAGIC_LINK: magicLink,
47
+ APP_NAME: appName,
48
+ }),
49
+ });
50
+
51
+ return { ok: true };
52
+ };
53
+
54
+ export const verify = async (params: { token: string }): Promise<
55
+ | { ok: true; userId: string; user: User; email: string; createdGuest: boolean }
56
+ | { ok: false; status: 401; message: string }
57
+ | { ok: false; status: number; message: string }
58
+ > => {
59
+ const payload = await providers.local.auth.consumeMagicLinkToken(params.token);
60
+ if (!payload) {
61
+ return { ok: false, status: 401, message: "Invalid or expired token" };
62
+ }
63
+
64
+ const { email } = payload;
65
+ // Reject expired accounts at login time, not just during cleanup. Without
66
+ // this, an expired local user / guest could still authenticate in the
67
+ // window between expiry and the next lifecycle run.
68
+ const userRows = await sql`
69
+ SELECT id, account_expires
70
+ FROM auth.users
71
+ WHERE mail = ${email} AND provider = 'local'
72
+ AND (account_expires IS NULL OR account_expires > now())
73
+ ORDER BY profile = 'user' DESC
74
+ LIMIT 1
75
+ `;
76
+
77
+ let userId: string;
78
+ let createdGuest = false;
79
+ if (userRows.length > 0) {
80
+ userId = userRows[0]!.id as string;
81
+ } else {
82
+ // Distinguish "no account" from "account expired" for a better error.
83
+ const expiredRows = await sql`
84
+ SELECT id
85
+ FROM auth.users
86
+ WHERE mail = ${email} AND provider = 'local'
87
+ AND account_expires IS NOT NULL AND account_expires <= now()
88
+ LIMIT 1
89
+ `;
90
+ if (expiredRows.length > 0) {
91
+ return {
92
+ ok: false,
93
+ status: 403,
94
+ message: "Your account has expired. Contact an administrator.",
95
+ };
96
+ }
97
+ const allowSelfRegistration = await settings.get<boolean>("user.allow_self_registration");
98
+ if (!allowSelfRegistration) {
99
+ return {
100
+ ok: false,
101
+ status: 401,
102
+ message: "Only existing local accounts can sign in with email. Contact an administrator if you need access.",
103
+ };
104
+ }
105
+ const guest = await providers.local.users.createGuest({ email });
106
+ if (!guest.ok) {
107
+ return { ok: false, status: guest.status, message: guest.error };
108
+ }
109
+ userId = guest.data.id;
110
+ createdGuest = true;
111
+ }
112
+
113
+ const user = await accounts.users.get({ id: userId });
114
+ if (!user) {
115
+ return { ok: false, status: 401, message: "User not found" };
116
+ }
117
+
118
+ return { ok: true, userId, user, email, createdGuest };
119
+ };
@@ -0,0 +1,89 @@
1
+ import { coreSettings } from "./settings/api";
2
+ import { setFreeIpaTlsResolver } from "../server/services/freeipa/tls";
3
+
4
+ export type FreeIpaConfig = {
5
+ enabled: boolean;
6
+ configured: boolean;
7
+ url: string;
8
+ serviceUser: string;
9
+ servicePassword: string;
10
+ groupsAdmin: string[];
11
+ groupsBaseSync: string[];
12
+ groupsBaseIpaRealm: string[];
13
+ groupsExcluded: string[];
14
+ caCert: string;
15
+ allowInsecure: boolean;
16
+ };
17
+
18
+ const normalizeString = (value: unknown): string => (typeof value === "string" ? value.trim() : "");
19
+ const normalizeStringList = (value: unknown, fallback: string[]): string[] => {
20
+ if (!Array.isArray(value)) return fallback;
21
+ const normalized = value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
22
+ return normalized.length > 0 ? normalized : fallback;
23
+ };
24
+
25
+ /**
26
+ * Read the full FreeIPA config snapshot from settings (Redis cache-aside +
27
+ * Postgres fallback). Always returns within Redis-TTL fresh data — no hidden
28
+ * dependency on request-lifecycle middleware.
29
+ */
30
+ export const getFreeIpaConfig = async (): Promise<FreeIpaConfig> => {
31
+ const [
32
+ rawUrl,
33
+ rawServiceUser,
34
+ rawServicePassword,
35
+ rawEnabled,
36
+ rawAdmin,
37
+ rawBaseSync,
38
+ rawBaseIpaRealm,
39
+ rawExcluded,
40
+ rawCaCert,
41
+ rawAllowInsecure,
42
+ ] = await Promise.all([
43
+ coreSettings.get<string>("freeipa.url"),
44
+ coreSettings.get<string>("freeipa.service_user"),
45
+ coreSettings.get<string>("freeipa.service_password"),
46
+ coreSettings.get<boolean>("freeipa.enable"),
47
+ coreSettings.get<string[]>("freeipa.groups.admin"),
48
+ coreSettings.get<string[]>("freeipa.groups.base_sync"),
49
+ coreSettings.get<string[]>("freeipa.groups.base_ipa_realm"),
50
+ coreSettings.get<string[]>("freeipa.groups.excluded"),
51
+ coreSettings.get<string>("freeipa.ca_cert"),
52
+ coreSettings.get<boolean>("freeipa.allow_insecure"),
53
+ ]);
54
+
55
+ const url = normalizeString(rawUrl);
56
+ const serviceUser = normalizeString(rawServiceUser);
57
+ const servicePassword = normalizeString(rawServicePassword);
58
+ const enabled = Boolean(rawEnabled);
59
+ const configured = url.length > 0 && serviceUser.length > 0 && servicePassword.length > 0;
60
+
61
+ return {
62
+ enabled,
63
+ configured,
64
+ url,
65
+ serviceUser,
66
+ servicePassword,
67
+ groupsAdmin: normalizeStringList(rawAdmin, ["admins"]),
68
+ groupsBaseSync: normalizeStringList(rawBaseSync, ["users"]),
69
+ groupsBaseIpaRealm: normalizeStringList(rawBaseIpaRealm, ["cloud"]),
70
+ groupsExcluded: normalizeStringList(rawExcluded, ["editors", "trust admins", "admins"]),
71
+ caCert: normalizeString(rawCaCert),
72
+ allowInsecure: Boolean(rawAllowInsecure),
73
+ };
74
+ };
75
+
76
+ // ── TLS resolver wiring ──────────────────────────────────────────────────────
77
+ // Register an async resolver at module load so the freeipa transport
78
+ // (`server/services/freeipa/client.ts` + `session.ts`) can read TLS opts
79
+ // without taking a hard dependency on settings (would create a layering cycle).
80
+ //
81
+ // Resolution order: ca_cert (proper, signed by your private CA) wins over
82
+ // allow_insecure (lab/dev kill switch). When neither is set we return
83
+ // undefined so Bun uses its default system trust store.
84
+ setFreeIpaTlsResolver(async () => {
85
+ const config = await getFreeIpaConfig();
86
+ if (config.caCert) return { ca: config.caCert };
87
+ if (config.allowInsecure) return { rejectUnauthorized: false };
88
+ return undefined;
89
+ });
@@ -0,0 +1,46 @@
1
+ export { ipa } from "./ipa";
2
+ export { accounts } from "./accounts";
3
+ export { accountsAppService } from "./accounts";
4
+ export { providers } from "./providers";
5
+ export { authFlows } from "./auth-flows";
6
+ export { toPgTextArray, toPgUuidArray, escapeLikePattern } from "./postgres";
7
+
8
+ export { logger, logging } from "./logging";
9
+ export type { LogEntry } from "./logging";
10
+
11
+ export { notifications } from "./notifications";
12
+ export type {
13
+ NotificationType,
14
+ NotificationStatus,
15
+ SendNotificationParams,
16
+ SendToUserParams,
17
+ NotificationMessage,
18
+ } from "./notifications";
19
+
20
+ export { session } from "./session";
21
+
22
+ export { accountLifecycle } from "./account-lifecycle";
23
+ export type { AccountLifecycleService } from "./account-lifecycle";
24
+ export { lifecycleJobs } from "./account-lifecycle/scheduler";
25
+
26
+ export { settings } from "./settings/namespace";
27
+ export { loadCache, get, set, remove, getAll } from "./settings";
28
+ export type { SettingEntry } from "./settings";
29
+ export { SETTINGS, SETTINGS_MAP, SETTING_GROUPS, GROUP_LABELS, registerSettings, registerGroupLabel } from "./settings/defaults";
30
+ export { validateSettingValue, normalizeSettingValue, getSettingLabel } from "./settings/defaults";
31
+ export type { SettingDef, SettingKind, SettingOption } from "./settings/defaults";
32
+ export { renderTemplate } from "./settings/templates";
33
+ export { settingsService } from "./settings/app";
34
+ export type { SettingsService } from "./settings/app";
35
+
36
+ // Typed async API + cache-aside primitives.
37
+ export { coreSettings, createSettingsAPI } from "./settings/api";
38
+ export type { SettingsAPI } from "./settings/api";
39
+ export { readKey as settingsReadKey, writeKey as settingsWriteKey, deleteKey as settingsDeleteKey, bulkRead as settingsBulkRead, allKnownKeys as settingsAllKnownKeys } from "./settings/store";
40
+ export { loadSnapshot as loadSettingsSnapshot } from "./settings/snapshot";
41
+
42
+ export { weatherService } from "./weather";
43
+ export type { WeatherService, WeatherData, DailyForecast, CurrentWeather, HourlyForecast, WeatherIcon } from "./weather";
44
+ export { migrate as migrateWeather } from "./weather/migrate";
45
+ export { getFreeIpaConfig } from "./freeipa-config";
46
+ export type { FreeIpaConfig } from "./freeipa-config";
@@ -0,0 +1,122 @@
1
+ import { freeipa } from "../../server/services";
2
+ import { getFreeIpaTls } from "../../server/services/freeipa/tls";
3
+ import type { MutationResult } from "../../contracts/shared";
4
+ import { getFreeIpaConfig } from "../freeipa-config";
5
+
6
+ const getEnabledConfig = async (): Promise<MutationResult<{ url: string; serviceUser: string; servicePassword: string }>> => {
7
+ const config = await getFreeIpaConfig();
8
+ if (!config.enabled) {
9
+ return { ok: false, error: "FreeIPA is disabled.", status: 400 };
10
+ }
11
+ if (!config.configured) {
12
+ return { ok: false, error: "FreeIPA is enabled but not fully configured.", status: 500 };
13
+ }
14
+ return {
15
+ ok: true,
16
+ data: {
17
+ url: config.url,
18
+ serviceUser: config.serviceUser,
19
+ servicePassword: config.servicePassword,
20
+ },
21
+ };
22
+ };
23
+
24
+ // ==========================
25
+ // Login
26
+ // ==========================
27
+
28
+ export type LoginResult = { status: "success"; session: string } | { status: "password_expired" } | { status: "failed" };
29
+ export const login = async (username: string, password: string): Promise<LoginResult> => {
30
+ const config = await getEnabledConfig();
31
+ if (!config.ok) return { status: "failed" };
32
+ return freeipa.session.login({ url: config.data.url, username, password });
33
+ };
34
+
35
+ export const getServiceSession = async (): Promise<string> => {
36
+ const config = await getEnabledConfig();
37
+ if (!config.ok) {
38
+ throw new Error(config.error);
39
+ }
40
+ return freeipa.session.getServiceSession({
41
+ url: config.data.url,
42
+ serviceUser: config.data.serviceUser,
43
+ servicePassword: config.data.servicePassword,
44
+ });
45
+ };
46
+
47
+ // ==========================
48
+ // Change Password (for expired passwords, no session required)
49
+ // ==========================
50
+
51
+ /**
52
+ * Change an expired or temporary password using FreeIPA's change_password endpoint.
53
+ * This endpoint works without an active session.
54
+ */
55
+ export const changeExpiredPassword = async (params: {
56
+ username: string;
57
+ currentPassword: string;
58
+ newPassword: string;
59
+ }): Promise<MutationResult<void>> => {
60
+ const { username, currentPassword, newPassword } = params;
61
+ const config = await getEnabledConfig();
62
+ if (!config.ok) return config;
63
+
64
+ const tls = await getFreeIpaTls();
65
+ const res = await fetch(`${freeipa.client.baseUrl(config.data.url)}/ipa/session/change_password`, {
66
+ method: "POST",
67
+ headers: {
68
+ "Content-Type": "application/x-www-form-urlencoded",
69
+ Referer: `${freeipa.client.baseUrl(config.data.url)}/ipa`,
70
+ Accept: "text/plain",
71
+ },
72
+ body: new URLSearchParams({
73
+ user: username,
74
+ old_password: currentPassword,
75
+ new_password: newPassword,
76
+ }),
77
+ ...(tls ? { tls } : {}),
78
+ });
79
+
80
+ // FreeIPA returns X-IPA-Pwchange-Result header
81
+ const pwchangeResult = res.headers.get("X-IPA-Pwchange-Result");
82
+ if (pwchangeResult !== "ok") {
83
+ const body = await res.text();
84
+ const policyMatch = body.match(/policy-error[^:]*:\s*(.+)/i);
85
+ const message = policyMatch?.[1] ?? "Failed to change password. Check your current password and try again.";
86
+ return { ok: false, error: message, status: 400 };
87
+ }
88
+
89
+ return { ok: true, data: undefined };
90
+ };
91
+
92
+ // ==========================
93
+ // Change Password (with session, for authenticated users)
94
+ // ==========================
95
+
96
+ /**
97
+ * Change password for an authenticated user using their session.
98
+ */
99
+ export const changePassword = async (params: { ipaSession: string; uid: string; newPassword: string }): Promise<MutationResult<void>> => {
100
+ const { ipaSession, uid, newPassword } = params;
101
+ const config = await getEnabledConfig();
102
+ if (!config.ok) return config;
103
+
104
+ const response = await freeipa.client.call({
105
+ url: config.data.url,
106
+ ipaSession,
107
+ method: "user_mod",
108
+ args: [uid],
109
+ options: {
110
+ userpassword: newPassword,
111
+ },
112
+ });
113
+ if (response.error) {
114
+ return {
115
+ ok: false,
116
+ error: response.error.message ?? "Failed to change password.",
117
+ status: freeipa.util.mapIpaErrorCode(response.error.code),
118
+ };
119
+ }
120
+
121
+ return { ok: true, data: undefined };
122
+ };