@valentinkolb/cloud 0.4.0 → 0.5.1

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 (194) 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 +116 -13
  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 +47 -7
  49. package/src/services/auth-flows/magic-link.ts +92 -20
  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/notifications/index.ts +82 -11
  64. package/src/services/oauth-tokens.ts +104 -0
  65. package/src/services/postgres.ts +21 -6
  66. package/src/services/providers/local/auth.test.ts +22 -0
  67. package/src/services/providers/local/auth.ts +46 -3
  68. package/src/services/secrets.ts +10 -0
  69. package/src/services/service-account-credentials.test.ts +210 -0
  70. package/src/services/service-account-credentials.ts +715 -0
  71. package/src/services/service-accounts.ts +188 -0
  72. package/src/services/session/index.ts +7 -8
  73. package/src/services/settings/app.ts +4 -20
  74. package/src/services/settings/defaults.ts +79 -22
  75. package/src/services/settings/store.ts +47 -0
  76. package/src/services/weather/forecast.ts +40 -7
  77. package/src/services/webauthn.test.ts +36 -0
  78. package/src/services/webauthn.ts +384 -0
  79. package/src/shared/icons.ts +391 -100
  80. package/src/shared/index.ts +7 -0
  81. package/src/shared/markdown/extensions/code.ts +38 -1
  82. package/src/shared/markdown/extensions/images.ts +39 -3
  83. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  84. package/src/shared/markdown/extensions/mark.ts +48 -0
  85. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  86. package/src/shared/markdown/extensions/tables.ts +79 -58
  87. package/src/shared/markdown/formula.test.ts +1089 -0
  88. package/src/shared/markdown/formula.ts +1187 -0
  89. package/src/shared/markdown/index.ts +76 -2
  90. package/src/shared/mock-cover.ts +130 -0
  91. package/src/shared/redirect.test.ts +58 -0
  92. package/src/shared/redirect.ts +56 -0
  93. package/src/shared/theme.test.ts +24 -0
  94. package/src/shared/theme.ts +68 -0
  95. package/src/shared/time.ts +13 -0
  96. package/src/ssr/AdminLayout.tsx +7 -3
  97. package/src/ssr/AdminSidebar.tsx +115 -49
  98. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  99. package/src/ssr/Footer.island.tsx +3 -8
  100. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  101. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  102. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  103. package/src/ssr/Layout.tsx +74 -66
  104. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  105. package/src/ssr/LayoutHelp.tsx +266 -0
  106. package/src/ssr/NavMenu.island.tsx +0 -39
  107. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  108. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  109. package/src/ssr/islands/index.ts +13 -0
  110. package/src/styles/base-popover.css +5 -2
  111. package/src/styles/effects.css +87 -6
  112. package/src/styles/global.css +146 -9
  113. package/src/styles/input.css +3 -1
  114. package/src/styles/utilities-buttons.css +133 -27
  115. package/src/styles/utilities-code-display.css +67 -0
  116. package/src/styles/utilities-completion.css +223 -0
  117. package/src/styles/utilities-detail.css +73 -0
  118. package/src/styles/utilities-feedback.css +16 -15
  119. package/src/styles/utilities-layout.css +42 -2
  120. package/src/styles/utilities-markdown-editor.css +472 -0
  121. package/src/styles/utilities-navigation.css +63 -8
  122. package/src/styles/utilities-script.css +84 -0
  123. package/src/styles/utilities-table-tile.css +229 -0
  124. package/src/types/ambient.d.ts +9 -0
  125. package/src/ui/completion/behaviors.test.ts +95 -0
  126. package/src/ui/completion/behaviors.ts +205 -0
  127. package/src/ui/completion/engine.ts +368 -0
  128. package/src/ui/completion/index.ts +40 -0
  129. package/src/ui/completion/overlay.ts +92 -0
  130. package/src/ui/dialog-core.ts +173 -45
  131. package/src/ui/filter/FilterChip.tsx +42 -40
  132. package/src/ui/index.ts +11 -12
  133. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  134. package/src/ui/input/CheckboxCard.tsx +91 -0
  135. package/src/ui/input/Combobox.tsx +375 -0
  136. package/src/ui/input/DatePicker.tsx +846 -0
  137. package/src/ui/input/DateTimeInput.tsx +29 -4
  138. package/src/ui/input/FileDropzone.tsx +116 -0
  139. package/src/ui/input/IconInput.tsx +116 -0
  140. package/src/ui/input/ImageInput.tsx +19 -2
  141. package/src/ui/input/MultiSelectInput.tsx +448 -0
  142. package/src/ui/input/NumberInput.tsx +417 -61
  143. package/src/ui/input/SegmentedControl.tsx +2 -2
  144. package/src/ui/input/Select.tsx +172 -10
  145. package/src/ui/input/Slider.tsx +3 -4
  146. package/src/ui/input/Switch.tsx +3 -2
  147. package/src/ui/input/TemplateEditor.tsx +212 -0
  148. package/src/ui/input/TextInput.tsx +144 -13
  149. package/src/ui/input/index.ts +53 -8
  150. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  151. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  152. package/src/ui/input/markdown/actions.ts +233 -0
  153. package/src/ui/input/markdown/active-formats.ts +94 -0
  154. package/src/ui/input/markdown/behaviors.ts +193 -0
  155. package/src/ui/input/markdown/code-zone.ts +23 -0
  156. package/src/ui/input/markdown/highlight.ts +316 -0
  157. package/src/ui/layout.ts +22 -0
  158. package/src/ui/misc/AppOverview.tsx +105 -0
  159. package/src/ui/misc/AppWorkspace.tsx +607 -0
  160. package/src/ui/misc/Calendar.tsx +1291 -0
  161. package/src/ui/misc/Chart.tsx +162 -0
  162. package/src/ui/misc/CodeDisplay.tsx +54 -0
  163. package/src/ui/misc/ContextMenu.tsx +2 -2
  164. package/src/ui/misc/DataTable.tsx +269 -0
  165. package/src/ui/misc/DockWorkspace.tsx +425 -0
  166. package/src/ui/misc/Docs.tsx +153 -0
  167. package/src/ui/misc/Dropdown.tsx +2 -2
  168. package/src/ui/misc/EntitySearch.tsx +260 -129
  169. package/src/ui/misc/LinkCard.tsx +14 -2
  170. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  171. package/src/ui/misc/Pagination.tsx +31 -12
  172. package/src/ui/misc/PanelDialog.tsx +109 -0
  173. package/src/ui/misc/Panes.tsx +873 -0
  174. package/src/ui/misc/PermissionEditor.tsx +358 -262
  175. package/src/ui/misc/Placeholder.tsx +40 -0
  176. package/src/ui/misc/ProgressBar.tsx +1 -1
  177. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  178. package/src/ui/misc/SettingsModal.tsx +150 -0
  179. package/src/ui/misc/StatCell.tsx +182 -40
  180. package/src/ui/misc/StatGrid.tsx +149 -0
  181. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  182. package/src/ui/misc/code-highlight.ts +213 -0
  183. package/src/ui/misc/index.ts +93 -12
  184. package/src/ui/prompts.tsx +362 -312
  185. package/src/ui/toast.ts +384 -0
  186. package/src/ui/widgets/Widget.tsx +12 -4
  187. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  188. package/src/ui/ipa/GroupView.tsx +0 -36
  189. package/src/ui/ipa/LoginBtn.tsx +0 -16
  190. package/src/ui/ipa/UserView.tsx +0 -58
  191. package/src/ui/ipa/index.ts +0 -4
  192. package/src/ui/navigation.ts +0 -32
  193. package/src/ui/sidebar.tsx +0 -468
  194. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -1,45 +1,108 @@
1
- import { sql } from "bun";
1
+ import { redis, sql } from "bun";
2
2
  import { accounts } from "../accounts";
3
3
  import { notifications } from "../notifications";
4
4
  import { providers } from "../providers";
5
5
  import * as settings from "../settings";
6
6
  import { renderTemplate } from "../settings/templates";
7
7
  import type { User } from "../../contracts/shared";
8
+ import { createAuthLoginUrl } from "../../shared/redirect";
9
+ import { logger } from "../logging";
8
10
 
9
- export const request = async (params: { email: string }): Promise<
11
+ const log = logger("auth:magic-link");
12
+ const IPA_HINT_COOLDOWN_SECONDS = 300;
13
+
14
+ const normalizeEmail = (email: string): string => email.trim().toLowerCase();
15
+ const ipaHintCooldownKey = (email: string): string => `ipa-email-login-hint-cooldown:${email}`;
16
+
17
+ const getAppUrl = async (): Promise<string> => {
18
+ const rawAppUrl = await settings.get<string>("app.url");
19
+ return rawAppUrl.startsWith("http") ? rawAppUrl : `https://${rawAppUrl}`;
20
+ };
21
+
22
+ const hasIpaAccountForEmail = async (email: string): Promise<boolean> => {
23
+ const rows = await sql<{ exists: boolean }[]>`
24
+ SELECT EXISTS (
25
+ SELECT 1
26
+ FROM auth.users
27
+ WHERE provider = 'ipa'
28
+ AND lower(btrim(mail)) = ${email}
29
+ ) AS exists
30
+ `;
31
+ return Boolean(rows[0]?.exists);
32
+ };
33
+
34
+ const claimIpaHintCooldown = async (email: string): Promise<boolean> => {
35
+ const result = await redis.send("SET", [
36
+ ipaHintCooldownKey(email),
37
+ "1",
38
+ "EX",
39
+ String(IPA_HINT_COOLDOWN_SECONDS),
40
+ "NX",
41
+ ]);
42
+ return result === "OK";
43
+ };
44
+
45
+ const sendIpaEmailLoginHint = async (params: { email: string; redirectTo?: string }): Promise<void> => {
46
+ const appUrl = await getAppUrl();
47
+ const loginUrl = createAuthLoginUrl(appUrl, {
48
+ method: "ipa",
49
+ redirectTo: params.redirectTo,
50
+ });
51
+ const [appName, contactEmail, template] = await Promise.all([
52
+ settings.get<string>("app.name"),
53
+ settings.get<string>("app.contact_email"),
54
+ settings.get<string>("mail.ipa_email_login_hint"),
55
+ ]);
56
+
57
+ await notifications.send({
58
+ type: "email",
59
+ recipient: params.email,
60
+ subject: `${appName} FreeIPA Sign In`,
61
+ rawHtml: renderTemplate(template, {
62
+ EMAIL: params.email,
63
+ LOGIN_URL: loginUrl,
64
+ APP_NAME: appName,
65
+ CONTACT_EMAIL: contactEmail?.trim() ?? "",
66
+ }),
67
+ });
68
+ };
69
+
70
+ export const request = async (params: { email: string; redirectTo?: string }): Promise<
10
71
  | { ok: true }
11
72
  | { ok: false; status: 400; message: string }
12
73
  > => {
13
- const userRows = await sql`SELECT uid, provider FROM auth.users WHERE mail = ${params.email}`;
74
+ const email = normalizeEmail(params.email);
75
+ const hasIpaUser = await hasIpaAccountForEmail(email);
76
+ const userRows = hasIpaUser ? [] : await sql`SELECT uid, provider FROM auth.users WHERE lower(btrim(mail)) = ${email}`;
14
77
  const hasLocalUser = userRows.some((row: { provider: string | null }) => row.provider === "local");
15
- const hasIpaUser = userRows.some((row: { provider: string | null }) => row.provider === "ipa");
16
78
  const allowSelfRegistration = await settings.get<boolean>("user.allow_self_registration");
17
79
 
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
- };
80
+ if (hasIpaUser) {
81
+ if (await claimIpaHintCooldown(email)) {
82
+ void sendIpaEmailLoginHint({ email, redirectTo: params.redirectTo }).catch((error) => {
83
+ log.warn("Failed to send FreeIPA email-login hint", {
84
+ email,
85
+ error: error instanceof Error ? error.message : String(error),
86
+ });
87
+ });
88
+ }
89
+ return { ok: true };
24
90
  }
25
91
 
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.
92
+ if (!hasLocalUser && !allowSelfRegistration) {
29
93
  return { ok: true };
30
94
  }
31
95
 
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}`;
96
+ const token = await providers.local.auth.createMagicLinkToken({ email, ttlSeconds: 300 });
97
+ const appUrl = await getAppUrl();
98
+ const magicLink = createAuthLoginUrl(appUrl, { token, redirectTo: params.redirectTo });
36
99
 
37
100
  const appName = await settings.get<string>("app.name");
38
101
  const template = await settings.get<string>("mail.magic_link_login");
39
102
 
40
103
  await notifications.send({
41
104
  type: "email",
42
- recipient: params.email,
105
+ recipient: email,
43
106
  subject: `${appName} Login Code`,
44
107
  rawHtml: renderTemplate(template, {
45
108
  TOKEN: token,
@@ -62,13 +125,22 @@ export const verify = async (params: { token: string }): Promise<
62
125
  }
63
126
 
64
127
  const { email } = payload;
128
+ const normalizedEmail = normalizeEmail(email);
129
+ if (await hasIpaAccountForEmail(normalizedEmail)) {
130
+ return {
131
+ ok: false,
132
+ status: 401,
133
+ message: "This email address belongs to a FreeIPA-managed account. Sign in with FreeIPA.",
134
+ };
135
+ }
136
+
65
137
  // Reject expired accounts at login time, not just during cleanup. Without
66
138
  // this, an expired local user / guest could still authenticate in the
67
139
  // window between expiry and the next lifecycle run.
68
140
  const userRows = await sql`
69
141
  SELECT id, account_expires
70
142
  FROM auth.users
71
- WHERE mail = ${email} AND provider = 'local'
143
+ WHERE lower(btrim(mail)) = ${normalizedEmail} AND provider = 'local'
72
144
  AND (account_expires IS NULL OR account_expires > now())
73
145
  ORDER BY profile = 'user' DESC
74
146
  LIMIT 1
@@ -83,7 +155,7 @@ export const verify = async (params: { token: string }): Promise<
83
155
  const expiredRows = await sql`
84
156
  SELECT id
85
157
  FROM auth.users
86
- WHERE mail = ${email} AND provider = 'local'
158
+ WHERE lower(btrim(mail)) = ${normalizedEmail} AND provider = 'local'
87
159
  AND account_expires IS NOT NULL AND account_expires <= now()
88
160
  LIMIT 1
89
161
  `;
@@ -0,0 +1,284 @@
1
+ import { redis, sql } from "bun";
2
+ import { notifications } from "../notifications";
3
+ import { providers } from "../providers";
4
+ import { session } from "../session";
5
+ import * as settings from "../settings";
6
+ import { renderTemplate } from "../settings/templates";
7
+ import { logger } from "../logging";
8
+ import { getServiceIpaSession } from "../ipa/service-account";
9
+ import { getFreeIpaConfig } from "../freeipa-config";
10
+ import type { User } from "../../contracts/shared";
11
+ import { createAuthPasswordResetUrl } from "../../shared/redirect";
12
+ import * as ipaFlow from "./ipa";
13
+
14
+ const log = logger("auth:password-reset");
15
+
16
+ const REQUEST_TTL_SECONDS = 900;
17
+ const REQUEST_COOLDOWN_SECONDS = 60;
18
+ const GENERIC_MESSAGE =
19
+ "If this account can reset a password, a reset link has been sent.";
20
+
21
+ type ResetTarget = {
22
+ userId: string;
23
+ uid: string;
24
+ email: string;
25
+ };
26
+
27
+ type ResetAttemptSuccess = {
28
+ ok: true;
29
+ userId: string;
30
+ user: User;
31
+ };
32
+
33
+ type ResetAttemptFailure =
34
+ | {
35
+ ok: false;
36
+ status: 400;
37
+ reason: "policy_failed";
38
+ message: string;
39
+ }
40
+ | {
41
+ ok: false;
42
+ status: 400 | 401;
43
+ reason: "invalid_or_expired";
44
+ message: string;
45
+ }
46
+ | {
47
+ ok: false;
48
+ status: number;
49
+ reason: "reset_failed" | "login_failed";
50
+ message: string;
51
+ };
52
+
53
+ const normalizeEmail = (email: string): string => email.trim().toLowerCase();
54
+ const cooldownKey = (email: string) => `password-reset-cooldown:${email}`;
55
+
56
+ const isInCooldown = async (email: string): Promise<boolean> => {
57
+ if (await redis.get(cooldownKey(email))) return true;
58
+ await redis.set(cooldownKey(email), "1", "EX", REQUEST_COOLDOWN_SECONDS);
59
+ return false;
60
+ };
61
+
62
+ const buildTarget = (row: { id: string; uid: string; mail: string }): ResetTarget => ({
63
+ userId: row.id,
64
+ uid: row.uid,
65
+ email: row.mail,
66
+ });
67
+
68
+ const resolveResetTarget = async (email: string): Promise<ResetTarget | null> => {
69
+ const rows = await sql<{ id: string; uid: string; mail: string }[]>`
70
+ SELECT id, uid, btrim(mail) AS mail
71
+ FROM auth.users
72
+ WHERE provider = 'ipa'
73
+ AND profile = 'user'
74
+ AND lower(btrim(mail)) = ${email}
75
+ AND (account_expires IS NULL OR account_expires > now())
76
+ `;
77
+
78
+ if (rows.length !== 1) {
79
+ if (rows.length > 1) {
80
+ log.warn("Password reset skipped: ambiguous IPA email", {
81
+ email,
82
+ matches: rows.length,
83
+ });
84
+ }
85
+ return null;
86
+ }
87
+
88
+ return buildTarget(rows[0]!);
89
+ };
90
+
91
+ const resolveResetTargetForToken = async (params: {
92
+ userId: string;
93
+ email: string;
94
+ }): Promise<ResetTarget | null> => {
95
+ const rows = await sql<{ id: string; uid: string; mail: string }[]>`
96
+ SELECT id, uid, btrim(mail) AS mail
97
+ FROM auth.users
98
+ WHERE id = ${params.userId}
99
+ AND provider = 'ipa'
100
+ AND profile = 'user'
101
+ AND lower(btrim(mail)) = ${params.email}
102
+ AND (account_expires IS NULL OR account_expires > now())
103
+ LIMIT 1
104
+ `;
105
+
106
+ return rows.length === 1 ? buildTarget(rows[0]!) : null;
107
+ };
108
+
109
+ const sendResetEmail = async (
110
+ params: ResetTarget & { redirectTo?: string }
111
+ ): Promise<void> => {
112
+ const token = await providers.local.auth.createPasswordResetToken({
113
+ userId: params.userId,
114
+ uid: params.uid,
115
+ email: params.email,
116
+ ttlSeconds: REQUEST_TTL_SECONDS,
117
+ });
118
+ const rawAppUrl = await settings.get<string>("app.url");
119
+ const appUrl = rawAppUrl.startsWith("http")
120
+ ? rawAppUrl
121
+ : `https://${rawAppUrl}`;
122
+ const resetLink = createAuthPasswordResetUrl(appUrl, {
123
+ token,
124
+ redirectTo: params.redirectTo,
125
+ });
126
+ const [appName, contactEmail, template] = await Promise.all([
127
+ settings.get<string>("app.name"),
128
+ settings.get<string>("app.contact_email"),
129
+ settings.get<string>("mail.password_reset"),
130
+ ]);
131
+
132
+ await notifications.send({
133
+ type: "email",
134
+ recipient: params.email,
135
+ subject: `${appName} Password Reset`,
136
+ rawHtml: renderTemplate(template, {
137
+ RESET_LINK: resetLink,
138
+ APP_NAME: appName,
139
+ CONTACT_EMAIL: contactEmail?.trim() ?? "",
140
+ }),
141
+ });
142
+ };
143
+
144
+ const changeTemporaryPassword = async (params: {
145
+ userId: string;
146
+ uid: string;
147
+ email: string;
148
+ temporaryPassword: string;
149
+ newPassword: string;
150
+ }): Promise<ResetAttemptSuccess | ResetAttemptFailure> => {
151
+ const changeResult = await ipaFlow.changeExpiredPassword({
152
+ username: params.uid,
153
+ currentPassword: params.temporaryPassword,
154
+ newPassword: params.newPassword,
155
+ });
156
+
157
+ if (!changeResult.ok) {
158
+ if (changeResult.reason === "change_failed") {
159
+ return {
160
+ ok: false,
161
+ status: 400,
162
+ reason: "policy_failed",
163
+ message: `${changeResult.message} Request a new reset link and choose a stronger password.`,
164
+ };
165
+ }
166
+
167
+ return {
168
+ ok: false,
169
+ status: changeResult.status,
170
+ reason: "login_failed",
171
+ message: changeResult.message,
172
+ };
173
+ }
174
+
175
+ await session.revokeAllForUser(changeResult.userId);
176
+
177
+ return {
178
+ ok: true,
179
+ userId: changeResult.userId,
180
+ user: changeResult.user,
181
+ };
182
+ };
183
+
184
+ export const request = async (params: {
185
+ email: string;
186
+ redirectTo?: string;
187
+ }): Promise<{ ok: true; message: string }> => {
188
+ const email = normalizeEmail(params.email);
189
+ if (await isInCooldown(email)) {
190
+ log.info("Password reset request ignored during cooldown");
191
+ return { ok: true, message: GENERIC_MESSAGE };
192
+ }
193
+
194
+ const freeIpaConfig = await getFreeIpaConfig();
195
+ if (!freeIpaConfig.enabled || !freeIpaConfig.configured) {
196
+ log.info("Password reset request accepted while FreeIPA is unavailable");
197
+ return { ok: true, message: GENERIC_MESSAGE };
198
+ }
199
+
200
+ const target = await resolveResetTarget(email);
201
+ if (!target) {
202
+ log.info("Password reset request accepted without eligible target");
203
+ return { ok: true, message: GENERIC_MESSAGE };
204
+ }
205
+
206
+ await sendResetEmail({ ...target, redirectTo: params.redirectTo });
207
+ log.info("Password reset email sent", { uid: target.uid });
208
+ return { ok: true, message: GENERIC_MESSAGE };
209
+ };
210
+
211
+ export const complete = async (params: {
212
+ token?: string;
213
+ newPassword: string;
214
+ }): Promise<ResetAttemptSuccess | ResetAttemptFailure> => {
215
+ if (!params.token) {
216
+ return {
217
+ ok: false,
218
+ status: 400,
219
+ reason: "invalid_or_expired",
220
+ message: "Missing password reset token.",
221
+ };
222
+ }
223
+
224
+ const payload = await providers.local.auth.consumePasswordResetToken(
225
+ params.token
226
+ );
227
+ if (!payload) {
228
+ return {
229
+ ok: false,
230
+ status: 401,
231
+ reason: "invalid_or_expired",
232
+ message:
233
+ "This password reset link has expired. Request a new reset link.",
234
+ };
235
+ }
236
+
237
+ const target = await resolveResetTargetForToken({
238
+ userId: payload.userId,
239
+ email: payload.email,
240
+ });
241
+ if (!target) {
242
+ return {
243
+ ok: false,
244
+ status: 401,
245
+ reason: "invalid_or_expired",
246
+ message:
247
+ "This password reset link has expired. Request a new reset link.",
248
+ };
249
+ }
250
+
251
+ const serviceSession = await getServiceIpaSession();
252
+ if (!serviceSession.ok) {
253
+ return {
254
+ ok: false,
255
+ status: serviceSession.status,
256
+ reason: "reset_failed",
257
+ message: serviceSession.error,
258
+ };
259
+ }
260
+
261
+ const resetResult = await providers.ipa.users.resetPassword({
262
+ ipaSession: serviceSession.data,
263
+ id: target.userId,
264
+ });
265
+ if (!resetResult.ok) {
266
+ return {
267
+ ok: false,
268
+ status: resetResult.status,
269
+ reason: "reset_failed",
270
+ message: resetResult.error,
271
+ };
272
+ }
273
+
274
+ return changeTemporaryPassword({
275
+ ...target,
276
+ temporaryPassword: resetResult.data.password,
277
+ newPassword: params.newPassword,
278
+ });
279
+ };
280
+
281
+ export const passwordReset = {
282
+ request,
283
+ complete,
284
+ } as const;
@@ -0,0 +1,24 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import * as proxyReturn from "./proxy-return";
3
+
4
+ describe("proxy auth return tokens", () => {
5
+ test("creates and consumes one-time return tokens", async () => {
6
+ const token = await proxyReturn.create({
7
+ clientId: "proxy-client",
8
+ url: "https://protected.example/path?query=1",
9
+ ttlSeconds: 30,
10
+ });
11
+
12
+ expect(token).toBeTruthy();
13
+ const consumed = await proxyReturn.consume({ token: token! });
14
+ expect(consumed).toEqual({
15
+ clientId: "proxy-client",
16
+ url: "https://protected.example/path?query=1",
17
+ });
18
+ expect(await proxyReturn.consume({ token: token! })).toBeNull();
19
+ });
20
+
21
+ test("rejects non-http return URLs", async () => {
22
+ expect(await proxyReturn.create({ clientId: "proxy-client", url: "javascript:alert(1)" })).toBeNull();
23
+ });
24
+ });
@@ -0,0 +1,49 @@
1
+ import { redis } from "bun";
2
+
3
+ const KEY_PREFIX = "auth:proxy-return:";
4
+ const DEFAULT_TTL_SECONDS = 300;
5
+
6
+ type ProxyReturnPayload = {
7
+ clientId: string;
8
+ url: string;
9
+ };
10
+
11
+ const key = (token: string) => `${KEY_PREFIX}${token}`;
12
+
13
+ const normalizeReturnUrl = (value: string): string | null => {
14
+ try {
15
+ const url = new URL(value);
16
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
17
+ return url.toString();
18
+ } catch {
19
+ return null;
20
+ }
21
+ };
22
+
23
+ export const create = async (params: { clientId: string; url: string; ttlSeconds?: number }): Promise<string | null> => {
24
+ const url = normalizeReturnUrl(params.url);
25
+ if (!url) return null;
26
+
27
+ const token = crypto.randomUUID();
28
+ const payload: ProxyReturnPayload = {
29
+ clientId: params.clientId,
30
+ url,
31
+ };
32
+ await redis.set(key(token), JSON.stringify(payload), "EX", params.ttlSeconds ?? DEFAULT_TTL_SECONDS);
33
+ return token;
34
+ };
35
+
36
+ export const consume = async (params: { token: string }): Promise<ProxyReturnPayload | null> => {
37
+ const raw = await redis.getdel(key(params.token));
38
+ if (!raw) return null;
39
+
40
+ try {
41
+ const payload = JSON.parse(raw) as Partial<ProxyReturnPayload>;
42
+ if (typeof payload.clientId !== "string" || typeof payload.url !== "string") return null;
43
+ const url = normalizeReturnUrl(payload.url);
44
+ if (!url) return null;
45
+ return { clientId: payload.clientId, url };
46
+ } catch {
47
+ return null;
48
+ }
49
+ };
@@ -0,0 +1,162 @@
1
+ import { ephemeral, topic } from "@valentinkolb/sync";
2
+ import { logger } from "./logging";
3
+
4
+ const SNAPSHOT_TTL_MS = 30_000;
5
+ const TOPIC_PREFIX = "cloud:gateway:telemetry";
6
+ const TOPIC_ID = "events";
7
+ const TOPIC_RETENTION_MS = 24 * 60 * 60 * 1000;
8
+ const TOPIC_TENANT = "default";
9
+ const DROP_LOG_INTERVAL_MS = 30_000;
10
+
11
+ const log = logger("gateway:telemetry");
12
+
13
+ export type GatewayRouteWarning = {
14
+ appId: string;
15
+ prefix: string;
16
+ reason: string;
17
+ detail?: string;
18
+ };
19
+
20
+ export type GatewayRouteSnapshotInput = {
21
+ instanceId: string;
22
+ baseUrl: string;
23
+ startedAt: number;
24
+ routeHash: string;
25
+ routeWarnings: GatewayRouteWarning[];
26
+ table: {
27
+ version: number;
28
+ builtAt: number;
29
+ routeCount: number;
30
+ routes: Array<{ prefix: string; appId: string }>;
31
+ };
32
+ stats: {
33
+ totalRequests: number;
34
+ noRouteCount: number;
35
+ byApp: Map<string, { count: number; totalMs: number; errors: number }>;
36
+ byRoute: Map<string, { count: number; errors: number; lastSeen: number }>;
37
+ };
38
+ };
39
+
40
+ export type GatewayRouteSnapshot = {
41
+ instanceId: string;
42
+ baseUrl: string;
43
+ startedAt: number;
44
+ updatedAt: number;
45
+ tableVersion: number;
46
+ tableBuiltAt: number;
47
+ routeCount: number;
48
+ routeHash: string;
49
+ routeWarnings: GatewayRouteWarning[];
50
+ routes: Array<{ prefix: string; appId: string }>;
51
+ stats: {
52
+ totalRequests: number;
53
+ noRouteCount: number;
54
+ byApp: Array<{ appId: string; count: number; totalMs: number; errors: number }>;
55
+ byRoute: Array<{ prefix: string; count: number; errors: number; lastSeen: number }>;
56
+ };
57
+ };
58
+
59
+ export type GatewayTelemetryEvent = {
60
+ v: 1;
61
+ kind: "request";
62
+ appId: string;
63
+ routePrefix: string;
64
+ method: string;
65
+ status: number;
66
+ durationMs: number;
67
+ errorKind: "upstream_unavailable" | "unmatched_route" | null;
68
+ occurredAt: string;
69
+ };
70
+
71
+ const snapshots = ephemeral<GatewayRouteSnapshot>({
72
+ id: "gateway-route-snapshots",
73
+ ttlMs: SNAPSHOT_TTL_MS,
74
+ limits: { maxPayloadBytes: 128_000 },
75
+ });
76
+
77
+ export const buildGatewayRouteSnapshot = (input: GatewayRouteSnapshotInput): GatewayRouteSnapshot => ({
78
+ instanceId: input.instanceId,
79
+ baseUrl: input.baseUrl,
80
+ startedAt: input.startedAt,
81
+ updatedAt: Date.now(),
82
+ tableVersion: input.table.version,
83
+ tableBuiltAt: input.table.builtAt,
84
+ routeCount: input.table.routeCount,
85
+ routeHash: input.routeHash,
86
+ routeWarnings: input.routeWarnings,
87
+ routes: input.table.routes,
88
+ stats: {
89
+ totalRequests: input.stats.totalRequests,
90
+ noRouteCount: input.stats.noRouteCount,
91
+ byApp: [...input.stats.byApp.entries()].map(([appId, value]) => ({ appId, ...value })),
92
+ byRoute: [...input.stats.byRoute.entries()].map(([prefix, value]) => ({ prefix, ...value })),
93
+ },
94
+ });
95
+
96
+ export const publishGatewayRouteSnapshot = async (snapshot: GatewayRouteSnapshot): Promise<void> => {
97
+ await snapshots.upsert({ key: `instances/${snapshot.instanceId}`, value: snapshot });
98
+ };
99
+
100
+ export const removeGatewayRouteSnapshot = async (instanceId: string): Promise<void> => {
101
+ await snapshots.remove({ key: `instances/${instanceId}` });
102
+ };
103
+
104
+ export const listGatewayRouteSnapshots = async (): Promise<GatewayRouteSnapshot[]> => {
105
+ const snap = await snapshots.snapshot({ prefix: "instances/" });
106
+ return snap.entries.map((entry) => entry.value).sort((a, b) => a.instanceId.localeCompare(b.instanceId));
107
+ };
108
+
109
+ export const latestGatewayRouteSnapshot = async (): Promise<GatewayRouteSnapshot | null> => {
110
+ const all = await listGatewayRouteSnapshots();
111
+ return all.sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null;
112
+ };
113
+
114
+ export const gatewayTelemetryTopic = topic<GatewayTelemetryEvent>({
115
+ id: TOPIC_ID,
116
+ prefix: TOPIC_PREFIX,
117
+ retentionMs: TOPIC_RETENTION_MS,
118
+ limits: { payloadBytes: 8_000 },
119
+ });
120
+
121
+ export const GATEWAY_TELEMETRY_TENANT = TOPIC_TENANT;
122
+
123
+ const normalizeMethod = (method: string): string => method.toUpperCase().slice(0, 16);
124
+ const normalizeStatus = (status: number): number => (Number.isFinite(status) ? Math.max(0, Math.min(999, Math.round(status))) : 0);
125
+ const normalizeDuration = (durationMs: number): number => (Number.isFinite(durationMs) ? Math.max(0, Math.round(durationMs)) : 0);
126
+ const normalizeText = (value: string, fallback: string): string => {
127
+ const trimmed = value.trim();
128
+ return (trimmed.length ? trimmed : fallback).slice(0, 200);
129
+ };
130
+
131
+ export const publishRequestTelemetry = (event: Omit<GatewayTelemetryEvent, "v" | "kind" | "occurredAt">): void => {
132
+ const payload: GatewayTelemetryEvent = {
133
+ v: 1,
134
+ kind: "request",
135
+ appId: normalizeText(event.appId, "unknown"),
136
+ routePrefix: normalizeText(event.routePrefix, "(unknown)"),
137
+ method: normalizeMethod(event.method),
138
+ status: normalizeStatus(event.status),
139
+ durationMs: normalizeDuration(event.durationMs),
140
+ errorKind: event.errorKind,
141
+ occurredAt: new Date().toISOString(),
142
+ };
143
+
144
+ void gatewayTelemetryTopic
145
+ .pub({
146
+ tenantId: GATEWAY_TELEMETRY_TENANT,
147
+ orderingKey: payload.appId,
148
+ data: payload,
149
+ })
150
+ .catch((error) => {
151
+ const now = Date.now();
152
+ if (now - lastPublishErrorAt < DROP_LOG_INTERVAL_MS) return;
153
+ lastPublishErrorAt = now;
154
+ log.warn("Dropped gateway telemetry event", {
155
+ appId: payload.appId,
156
+ routePrefix: payload.routePrefix,
157
+ error: error instanceof Error ? error.message : String(error),
158
+ });
159
+ });
160
+ };
161
+
162
+ let lastPublishErrorAt = 0;