@valentinkolb/cloud 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/package.json +18 -6
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +53 -46
  4. package/src/api/accounts-entities.ts +4 -0
  5. package/src/api/admin-core-settings.ts +98 -0
  6. package/src/api/announcements.ts +131 -0
  7. package/src/api/auth/schemas.ts +24 -0
  8. package/src/api/auth.ts +113 -10
  9. package/src/api/index.ts +7 -2
  10. package/src/api/me.ts +203 -14
  11. package/src/api/search/schemas.ts +1 -0
  12. package/src/api/search.ts +62 -8
  13. package/src/config/ssr.ts +2 -9
  14. package/src/contracts/announcements.test.ts +37 -0
  15. package/src/contracts/announcements.ts +121 -0
  16. package/src/contracts/app.ts +2 -0
  17. package/src/contracts/index.ts +3 -2
  18. package/src/contracts/registry.ts +2 -0
  19. package/src/contracts/shared.ts +108 -1
  20. package/src/desktop/index.ts +704 -0
  21. package/src/desktop/solid.tsx +938 -0
  22. package/src/server/api/index.ts +1 -1
  23. package/src/server/api/respond.ts +50 -10
  24. package/src/server/index.ts +44 -38
  25. package/src/server/middleware/auth.ts +98 -9
  26. package/src/server/middleware/index.ts +2 -1
  27. package/src/server/middleware/settings.ts +26 -0
  28. package/src/server/services/access.test.ts +197 -0
  29. package/src/server/services/access.ts +254 -6
  30. package/src/server/services/index.ts +14 -11
  31. package/src/server/services/pagination.ts +22 -0
  32. package/src/server/time.ts +45 -0
  33. package/src/services/account-lifecycle/index.ts +142 -18
  34. package/src/services/accounts/app.ts +658 -170
  35. package/src/services/accounts/authz.test.ts +77 -0
  36. package/src/services/accounts/authz.ts +22 -0
  37. package/src/services/accounts/entities.ts +84 -5
  38. package/src/services/accounts/groups.ts +30 -24
  39. package/src/services/accounts/model.test.ts +30 -0
  40. package/src/services/accounts/switching.test.ts +14 -0
  41. package/src/services/accounts/switching.ts +15 -6
  42. package/src/services/accounts/users.ts +75 -52
  43. package/src/services/announcements/index.test.ts +32 -0
  44. package/src/services/announcements/index.ts +224 -0
  45. package/src/services/audit/index.test.ts +84 -0
  46. package/src/services/audit/index.ts +431 -0
  47. package/src/services/auth-flows/index.ts +9 -2
  48. package/src/services/auth-flows/ipa.ts +0 -2
  49. package/src/services/auth-flows/magic-link.ts +3 -2
  50. package/src/services/auth-flows/password-reset.ts +284 -0
  51. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  52. package/src/services/auth-flows/proxy-return.ts +49 -0
  53. package/src/services/gateway.ts +162 -0
  54. package/src/services/index.ts +44 -2
  55. package/src/services/ipa/effective-groups.test.ts +33 -0
  56. package/src/services/ipa/effective-groups.ts +70 -0
  57. package/src/services/ipa/profile.ts +45 -3
  58. package/src/services/ipa/search.ts +3 -5
  59. package/src/services/ipa/service-account.ts +15 -0
  60. package/src/services/ipa/sync-planning.test.ts +32 -0
  61. package/src/services/ipa/sync-planning.ts +22 -0
  62. package/src/services/ipa/sync.ts +110 -38
  63. package/src/services/oauth-tokens.ts +104 -0
  64. package/src/services/postgres.ts +21 -6
  65. package/src/services/providers/local/auth.test.ts +22 -0
  66. package/src/services/providers/local/auth.ts +46 -3
  67. package/src/services/secrets.ts +10 -0
  68. package/src/services/service-account-credentials.test.ts +210 -0
  69. package/src/services/service-account-credentials.ts +715 -0
  70. package/src/services/service-accounts.ts +188 -0
  71. package/src/services/session/index.ts +7 -8
  72. package/src/services/settings/app.ts +4 -20
  73. package/src/services/settings/defaults.ts +64 -22
  74. package/src/services/settings/store.ts +47 -0
  75. package/src/services/weather/forecast.ts +40 -7
  76. package/src/services/webauthn.test.ts +36 -0
  77. package/src/services/webauthn.ts +384 -0
  78. package/src/shared/icons.ts +391 -100
  79. package/src/shared/index.ts +7 -0
  80. package/src/shared/markdown/extensions/code.ts +38 -1
  81. package/src/shared/markdown/extensions/images.ts +39 -3
  82. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  83. package/src/shared/markdown/extensions/mark.ts +48 -0
  84. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  85. package/src/shared/markdown/extensions/tables.ts +79 -58
  86. package/src/shared/markdown/formula.test.ts +1089 -0
  87. package/src/shared/markdown/formula.ts +1187 -0
  88. package/src/shared/markdown/index.ts +76 -2
  89. package/src/shared/mock-cover.ts +130 -0
  90. package/src/shared/redirect.test.ts +49 -0
  91. package/src/shared/redirect.ts +52 -0
  92. package/src/shared/theme.test.ts +24 -0
  93. package/src/shared/theme.ts +68 -0
  94. package/src/shared/time.ts +13 -0
  95. package/src/ssr/AdminLayout.tsx +7 -3
  96. package/src/ssr/AdminSidebar.tsx +115 -49
  97. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  98. package/src/ssr/Footer.island.tsx +3 -8
  99. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  100. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  101. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  102. package/src/ssr/Layout.tsx +74 -66
  103. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  104. package/src/ssr/LayoutHelp.tsx +266 -0
  105. package/src/ssr/NavMenu.island.tsx +0 -39
  106. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  107. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  108. package/src/ssr/islands/index.ts +13 -0
  109. package/src/styles/base-popover.css +5 -2
  110. package/src/styles/effects.css +87 -6
  111. package/src/styles/global.css +146 -9
  112. package/src/styles/input.css +3 -1
  113. package/src/styles/utilities-buttons.css +133 -27
  114. package/src/styles/utilities-code-display.css +67 -0
  115. package/src/styles/utilities-completion.css +223 -0
  116. package/src/styles/utilities-detail.css +73 -0
  117. package/src/styles/utilities-feedback.css +16 -15
  118. package/src/styles/utilities-layout.css +42 -2
  119. package/src/styles/utilities-markdown-editor.css +472 -0
  120. package/src/styles/utilities-navigation.css +63 -8
  121. package/src/styles/utilities-script.css +84 -0
  122. package/src/styles/utilities-table-tile.css +229 -0
  123. package/src/types/ambient.d.ts +9 -0
  124. package/src/ui/completion/behaviors.test.ts +95 -0
  125. package/src/ui/completion/behaviors.ts +205 -0
  126. package/src/ui/completion/engine.ts +368 -0
  127. package/src/ui/completion/index.ts +40 -0
  128. package/src/ui/completion/overlay.ts +92 -0
  129. package/src/ui/dialog-core.ts +173 -45
  130. package/src/ui/filter/FilterChip.tsx +42 -40
  131. package/src/ui/index.ts +11 -12
  132. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  133. package/src/ui/input/CheckboxCard.tsx +91 -0
  134. package/src/ui/input/Combobox.tsx +375 -0
  135. package/src/ui/input/DatePicker.tsx +846 -0
  136. package/src/ui/input/DateTimeInput.tsx +29 -4
  137. package/src/ui/input/FileDropzone.tsx +116 -0
  138. package/src/ui/input/IconInput.tsx +116 -0
  139. package/src/ui/input/ImageInput.tsx +19 -2
  140. package/src/ui/input/MultiSelectInput.tsx +448 -0
  141. package/src/ui/input/NumberInput.tsx +417 -61
  142. package/src/ui/input/SegmentedControl.tsx +2 -2
  143. package/src/ui/input/Select.tsx +172 -10
  144. package/src/ui/input/Slider.tsx +3 -4
  145. package/src/ui/input/Switch.tsx +3 -2
  146. package/src/ui/input/TemplateEditor.tsx +212 -0
  147. package/src/ui/input/TextInput.tsx +144 -13
  148. package/src/ui/input/index.ts +53 -8
  149. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  150. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  151. package/src/ui/input/markdown/actions.ts +233 -0
  152. package/src/ui/input/markdown/active-formats.ts +94 -0
  153. package/src/ui/input/markdown/behaviors.ts +193 -0
  154. package/src/ui/input/markdown/code-zone.ts +23 -0
  155. package/src/ui/input/markdown/highlight.ts +316 -0
  156. package/src/ui/layout.ts +22 -0
  157. package/src/ui/misc/AppOverview.tsx +105 -0
  158. package/src/ui/misc/AppWorkspace.tsx +607 -0
  159. package/src/ui/misc/Calendar.tsx +1291 -0
  160. package/src/ui/misc/Chart.tsx +162 -0
  161. package/src/ui/misc/CodeDisplay.tsx +54 -0
  162. package/src/ui/misc/ContextMenu.tsx +2 -2
  163. package/src/ui/misc/DataTable.tsx +269 -0
  164. package/src/ui/misc/DockWorkspace.tsx +425 -0
  165. package/src/ui/misc/Docs.tsx +153 -0
  166. package/src/ui/misc/Dropdown.tsx +2 -2
  167. package/src/ui/misc/EntitySearch.tsx +260 -129
  168. package/src/ui/misc/LinkCard.tsx +14 -2
  169. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  170. package/src/ui/misc/Pagination.tsx +31 -12
  171. package/src/ui/misc/PanelDialog.tsx +109 -0
  172. package/src/ui/misc/Panes.tsx +873 -0
  173. package/src/ui/misc/PermissionEditor.tsx +358 -262
  174. package/src/ui/misc/Placeholder.tsx +40 -0
  175. package/src/ui/misc/ProgressBar.tsx +1 -1
  176. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  177. package/src/ui/misc/SettingsModal.tsx +150 -0
  178. package/src/ui/misc/StatCell.tsx +182 -40
  179. package/src/ui/misc/StatGrid.tsx +149 -0
  180. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  181. package/src/ui/misc/code-highlight.ts +213 -0
  182. package/src/ui/misc/index.ts +93 -12
  183. package/src/ui/prompts.tsx +362 -312
  184. package/src/ui/toast.ts +384 -0
  185. package/src/ui/widgets/Widget.tsx +12 -4
  186. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  187. package/src/ui/ipa/GroupView.tsx +0 -36
  188. package/src/ui/ipa/LoginBtn.tsx +0 -16
  189. package/src/ui/ipa/UserView.tsx +0 -58
  190. package/src/ui/ipa/index.ts +0 -4
  191. package/src/ui/navigation.ts +0 -32
  192. package/src/ui/sidebar.tsx +0 -468
  193. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -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;
@@ -1,12 +1,27 @@
1
+ // biome-ignore-all assist/source/organizeImports: Preserve grouped service barrel exports.
1
2
  export { ipa } from "./ipa";
2
3
  export { accounts } from "./accounts";
3
4
  export { accountsAppService } from "./accounts";
4
5
  export { providers } from "./providers";
5
6
  export { authFlows } from "./auth-flows";
6
- export { toPgTextArray, toPgUuidArray, escapeLikePattern } from "./postgres";
7
+ export { toPgTextArray, toPgUuidArray, escapeLikePattern, isUniqueViolation } from "./postgres";
7
8
 
8
9
  export { logger, logging } from "./logging";
9
10
  export type { LogEntry } from "./logging";
11
+ export { audit } from "./audit";
12
+ export type { AuditActionGroup, AuditActor, AuditEvent, AuditListFilter, AuditOutcome, AuditRecordParams, AuditTarget } from "./audit";
13
+
14
+ export {
15
+ GATEWAY_TELEMETRY_TENANT,
16
+ buildGatewayRouteSnapshot,
17
+ gatewayTelemetryTopic,
18
+ latestGatewayRouteSnapshot,
19
+ listGatewayRouteSnapshots,
20
+ publishGatewayRouteSnapshot,
21
+ publishRequestTelemetry,
22
+ removeGatewayRouteSnapshot,
23
+ } from "./gateway";
24
+ export type { GatewayRouteSnapshot, GatewayRouteSnapshotInput, GatewayRouteWarning, GatewayTelemetryEvent } from "./gateway";
10
25
 
11
26
  export { notifications } from "./notifications";
12
27
  export type {
@@ -16,8 +31,25 @@ export type {
16
31
  SendToUserParams,
17
32
  NotificationMessage,
18
33
  } from "./notifications";
34
+ export { announcements } from "./announcements";
35
+ export type { AnnouncementsService } from "./announcements";
19
36
 
20
37
  export { session } from "./session";
38
+ export { serviceAccounts } from "./service-accounts";
39
+ export type { ServiceAccount, ServiceAccountKind, ServiceAccountStatus } from "./service-accounts";
40
+ export { serviceAccountCredentials } from "./service-account-credentials";
41
+ export type {
42
+ AuthenticatedServiceAccountCredential,
43
+ ServiceAccountCredential,
44
+ ServiceAccountCredentialKind,
45
+ ServiceAccountCredentialOverview,
46
+ ServiceAccountCredentialOwner,
47
+ ServiceAccountCredentialStatus,
48
+ } from "./service-account-credentials";
49
+ export { oauthTokens } from "./oauth-tokens";
50
+ export type { AuthenticatedOAuthToken } from "./oauth-tokens";
51
+ export { webauthn } from "./webauthn";
52
+ export type { WebAuthnRp } from "./webauthn";
21
53
 
22
54
  export { accountLifecycle } from "./account-lifecycle";
23
55
  export type { AccountLifecycleService } from "./account-lifecycle";
@@ -32,11 +64,21 @@ export type { SettingDef, SettingKind, SettingOption } from "./settings/defaults
32
64
  export { renderTemplate } from "./settings/templates";
33
65
  export { settingsService } from "./settings/app";
34
66
  export type { SettingsService } from "./settings/app";
67
+ export { decryptSecret, encryptSecret, secrets } from "./secrets";
35
68
 
36
69
  // Typed async API + cache-aside primitives.
37
70
  export { coreSettings, createSettingsAPI } from "./settings/api";
38
71
  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";
72
+ export {
73
+ readKey as settingsReadKey,
74
+ writeKey as settingsWriteKey,
75
+ deleteKey as settingsDeleteKey,
76
+ bulkRead as settingsBulkRead,
77
+ allKnownKeys as settingsAllKnownKeys,
78
+ listLegacyKeys as settingsListLegacyKeys,
79
+ deleteLegacyKeys as settingsDeleteLegacyKeys,
80
+ } from "./settings/store";
81
+ export type { LegacySettingRow } from "./settings/store";
40
82
  export { loadSnapshot as loadSettingsSnapshot } from "./settings/snapshot";
41
83
 
42
84
  export { weatherService } from "./weather";
@@ -0,0 +1,33 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildEffectiveIpaGroupsByUid } from "./effective-groups";
3
+
4
+ describe("buildEffectiveIpaGroupsByUid", () => {
5
+ test("includes direct and inherited parent groups", () => {
6
+ const effective = buildEffectiveIpaGroupsByUid([
7
+ { cn: "base-sync", users: [], groups: ["team"] },
8
+ { cn: "base-realm", users: [], groups: ["base-sync"] },
9
+ { cn: "team", users: ["eva"], groups: [] },
10
+ ]);
11
+
12
+ expect(effective.get("eva")).toEqual(["base-realm", "base-sync", "team"]);
13
+ });
14
+
15
+ test("keeps transit groups available even when callers hide them from display", () => {
16
+ const effective = buildEffectiveIpaGroupsByUid([
17
+ { cn: "base-realm", users: [], groups: ["excluded-transit"] },
18
+ { cn: "excluded-transit", users: [], groups: ["team"] },
19
+ { cn: "team", users: ["eva"], groups: [] },
20
+ ]);
21
+
22
+ expect(effective.get("eva")).toEqual(["base-realm", "excluded-transit", "team"]);
23
+ });
24
+
25
+ test("terminates on cyclic group nesting", () => {
26
+ const effective = buildEffectiveIpaGroupsByUid([
27
+ { cn: "a", users: ["eva"], groups: ["b"] },
28
+ { cn: "b", users: [], groups: ["a"] },
29
+ ]);
30
+
31
+ expect(effective.get("eva")).toEqual(["a", "b"]);
32
+ });
33
+ });
@@ -0,0 +1,70 @@
1
+ export type IpaGroupMembership = {
2
+ cn: string;
3
+ users: string[];
4
+ groups: string[];
5
+ };
6
+
7
+ const sorted = (values: Iterable<string>): string[] => [...values].sort((a, b) => a.localeCompare(b));
8
+
9
+ /**
10
+ * Build effective IPA group membership from authoritative group records.
11
+ * A group contains direct users and child groups; user effective membership is
12
+ * direct groups plus every parent group reached through group nesting.
13
+ */
14
+ export const buildEffectiveIpaGroupsByUid = (groups: IpaGroupMembership[]): Map<string, string[]> => {
15
+ const groupNames = new Set(groups.map((group) => group.cn).filter(Boolean));
16
+ const childToParents = new Map<string, Set<string>>();
17
+ const directGroupsByUid = new Map<string, Set<string>>();
18
+
19
+ for (const group of groups) {
20
+ if (!group.cn) continue;
21
+
22
+ for (const uid of group.users) {
23
+ if (!uid) continue;
24
+ const directGroups = directGroupsByUid.get(uid) ?? new Set<string>();
25
+ directGroups.add(group.cn);
26
+ directGroupsByUid.set(uid, directGroups);
27
+ }
28
+
29
+ for (const child of group.groups) {
30
+ if (!child || !groupNames.has(child)) continue;
31
+ const parents = childToParents.get(child) ?? new Set<string>();
32
+ parents.add(group.cn);
33
+ childToParents.set(child, parents);
34
+ }
35
+ }
36
+
37
+ const memo = new Map<string, Set<string>>();
38
+ const resolveGroupClosure = (groupName: string, visiting = new Set<string>()): Set<string> => {
39
+ const cached = memo.get(groupName);
40
+ if (cached) return cached;
41
+
42
+ const closure = new Set<string>([groupName]);
43
+ if (visiting.has(groupName)) return closure;
44
+
45
+ const nextVisiting = new Set(visiting);
46
+ nextVisiting.add(groupName);
47
+
48
+ for (const parent of childToParents.get(groupName) ?? []) {
49
+ for (const inherited of resolveGroupClosure(parent, nextVisiting)) {
50
+ closure.add(inherited);
51
+ }
52
+ }
53
+
54
+ memo.set(groupName, closure);
55
+ return closure;
56
+ };
57
+
58
+ const effectiveByUid = new Map<string, string[]>();
59
+ for (const [uid, directGroups] of directGroupsByUid) {
60
+ const effective = new Set<string>();
61
+ for (const groupName of directGroups) {
62
+ for (const inherited of resolveGroupClosure(groupName)) {
63
+ effective.add(inherited);
64
+ }
65
+ }
66
+ effectiveByUid.set(uid, sorted(effective));
67
+ }
68
+
69
+ return effectiveByUid;
70
+ };