@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
@@ -0,0 +1,431 @@
1
+ import { sql, type SQL } from "bun";
2
+ import type { PaginationParams, UserProvider } from "../../contracts/shared";
3
+ import { err, fail, ok, paginate, type PageParams, type Paginated, type Result, type ServiceError } from "../../server/services";
4
+ import { escapeLikePattern, parsePgJsonRecord, toPgTextArray } from "../postgres";
5
+ import { logger } from "../logging";
6
+
7
+ export type AuditOutcome = "allowed" | "denied" | "failed";
8
+ export type AuditActionGroup = "service_accounts";
9
+
10
+ export type AuditActor = {
11
+ userId?: string | null;
12
+ uid?: string | null;
13
+ provider?: UserProvider | string | null;
14
+ roles?: readonly string[] | null;
15
+ };
16
+
17
+ export type AuditTarget = {
18
+ type?: string | null;
19
+ id?: string | null;
20
+ label?: string | null;
21
+ provider?: UserProvider | string | null;
22
+ };
23
+
24
+ export type AuditEvent = {
25
+ id: number;
26
+ createdAt: string;
27
+ action: string;
28
+ outcome: AuditOutcome;
29
+ actor: {
30
+ userId: string | null;
31
+ uid: string | null;
32
+ provider: string | null;
33
+ roles: string[];
34
+ };
35
+ target: {
36
+ type: string | null;
37
+ id: string | null;
38
+ label: string | null;
39
+ provider: string | null;
40
+ };
41
+ reason: string | null;
42
+ errorCode: string | null;
43
+ errorMessage: string | null;
44
+ requestId: string | null;
45
+ metadata: Record<string, unknown>;
46
+ };
47
+
48
+ export type AuditListFilter = {
49
+ search?: string;
50
+ actor?: string;
51
+ target?: string;
52
+ action?: string;
53
+ actionGroup?: AuditActionGroup;
54
+ serviceAccountId?: string;
55
+ outcome?: AuditOutcome;
56
+ provider?: UserProvider | string;
57
+ days?: number;
58
+ };
59
+
60
+ export type SelfServiceAuditActivity = {
61
+ id: number;
62
+ createdAt: string;
63
+ action: string;
64
+ label: string;
65
+ outcome: AuditOutcome;
66
+ context: string | null;
67
+ };
68
+
69
+ export type AuditRecordParams = {
70
+ action: string;
71
+ outcome: AuditOutcome;
72
+ actor?: AuditActor | null;
73
+ target?: AuditTarget | null;
74
+ reason?: string | null;
75
+ error?: Pick<ServiceError, "code" | "message"> | { code?: string | null; message?: string | null } | null;
76
+ requestId?: string | null;
77
+ metadata?: Record<string, unknown> | null;
78
+ };
79
+
80
+ type AuditDb = typeof sql;
81
+
82
+ type DbAuditRow = {
83
+ id: number;
84
+ created_at: Date | string;
85
+ action: string;
86
+ outcome: string;
87
+ actor_user_id: string | null;
88
+ actor_uid: string | null;
89
+ actor_provider: string | null;
90
+ actor_roles: string[] | null;
91
+ target_type: string | null;
92
+ target_id: string | null;
93
+ target_label: string | null;
94
+ target_provider: string | null;
95
+ reason: string | null;
96
+ error_code: string | null;
97
+ error_message: string | null;
98
+ request_id: string | null;
99
+ metadata: Record<string, unknown> | string | null;
100
+ };
101
+
102
+ const log = logger("audit");
103
+ const SENSITIVE_KEY_PATTERN = /(password|secret|token|cookie|authorization|api[_-]?key|private[_-]?key|session|ipa[_-]?session)/i;
104
+ const REDACTED = "[REDACTED]";
105
+ const MAX_STRING_LENGTH = 500;
106
+ const MAX_ARRAY_LENGTH = 50;
107
+ const MAX_DEPTH = 8;
108
+ const SELF_SERVICE_ACTION_LABELS = {
109
+ "accounts.user.change_own_password": "Password changed",
110
+ "accounts.user.remove_self": "Account deleted",
111
+ "accounts.user.update": "Profile updated",
112
+ "accounts.request.create": "Account request submitted",
113
+ "accounts.request.withdraw": "Account request withdrawn",
114
+ "accounts.user.extend_account": "Account extended",
115
+ "service_account_credential.create": "API key created",
116
+ "service_account_credential.revoke": "API key revoked",
117
+ "service_account_credential.authenticate": "API key used",
118
+ "webauthn_credential.create": "Passkey added",
119
+ "webauthn_credential.delete": "Passkey removed",
120
+ "webauthn_credential.authenticate": "Passkey used",
121
+ } as const satisfies Record<string, string>;
122
+ const SELF_SERVICE_ACTIONS = Object.keys(SELF_SERVICE_ACTION_LABELS);
123
+
124
+ const asString = (value: string | null | undefined): string | null => {
125
+ const trimmed = value?.trim();
126
+ return trimmed ? trimmed : null;
127
+ };
128
+
129
+ const asRoleArray = (roles: readonly string[] | null | undefined): string[] => [...new Set((roles ?? []).map((role) => role.trim()).filter(Boolean))];
130
+
131
+ export const sanitizeAuditMetadata = (value: unknown, depth = 0): unknown => {
132
+ if (value == null) return null;
133
+ if (value instanceof Date) return value.toISOString();
134
+ if (typeof value === "string") return value.length > MAX_STRING_LENGTH ? `${value.slice(0, MAX_STRING_LENGTH)}...` : value;
135
+ if (typeof value === "number" || typeof value === "boolean") return value;
136
+ if (typeof value === "bigint") return value.toString();
137
+ if (typeof value !== "object") return null;
138
+ if (depth >= MAX_DEPTH) return "[MAX_DEPTH]";
139
+
140
+ if (Array.isArray(value)) {
141
+ const items = value.slice(0, MAX_ARRAY_LENGTH).map((item) => sanitizeAuditMetadata(item, depth + 1));
142
+ if (value.length > MAX_ARRAY_LENGTH) items.push(`[${value.length - MAX_ARRAY_LENGTH} more]`);
143
+ return items;
144
+ }
145
+
146
+ const out: Record<string, unknown> = {};
147
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
148
+ out[key] = SENSITIVE_KEY_PATTERN.test(key) ? REDACTED : sanitizeAuditMetadata(child, depth + 1);
149
+ }
150
+ return out;
151
+ };
152
+
153
+ export const sanitizeAuditText = (value: string | null | undefined): string | null => {
154
+ const text = asString(value);
155
+ if (!text) return null;
156
+ if (SENSITIVE_KEY_PATTERN.test(text)) return REDACTED;
157
+ return text.length > MAX_STRING_LENGTH ? `${text.slice(0, MAX_STRING_LENGTH)}...` : text;
158
+ };
159
+
160
+ const mapRow = (row: DbAuditRow): AuditEvent => ({
161
+ id: Number(row.id),
162
+ createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
163
+ action: row.action,
164
+ outcome: row.outcome as AuditOutcome,
165
+ actor: {
166
+ userId: row.actor_user_id,
167
+ uid: row.actor_uid,
168
+ provider: row.actor_provider,
169
+ roles: row.actor_roles ?? [],
170
+ },
171
+ target: {
172
+ type: row.target_type,
173
+ id: row.target_id,
174
+ label: row.target_label,
175
+ provider: row.target_provider,
176
+ },
177
+ reason: row.reason,
178
+ errorCode: row.error_code,
179
+ errorMessage: row.error_message,
180
+ requestId: row.request_id,
181
+ metadata: parsePgJsonRecord(row.metadata) ?? {},
182
+ });
183
+
184
+ const mapSelfServiceActivityRow = (row: DbAuditRow): SelfServiceAuditActivity => ({
185
+ id: Number(row.id),
186
+ createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
187
+ action: row.action,
188
+ label: SELF_SERVICE_ACTION_LABELS[row.action as keyof typeof SELF_SERVICE_ACTION_LABELS] ?? row.action,
189
+ outcome: row.outcome as AuditOutcome,
190
+ context: row.target_label ?? null,
191
+ });
192
+
193
+ const outcomeForError = (error: Pick<ServiceError, "status"> | null | undefined): AuditOutcome => {
194
+ if (!error) return "allowed";
195
+ return error.status === 401 || error.status === 403 ? "denied" : "failed";
196
+ };
197
+
198
+ const record = async (params: AuditRecordParams, db: AuditDb = sql): Promise<void> => {
199
+ try {
200
+ const actorRoles = asRoleArray(params.actor?.roles);
201
+ const metadata = (sanitizeAuditMetadata(params.metadata ?? {}) as Record<string, unknown>) ?? {};
202
+ await db`
203
+ INSERT INTO audit.events (
204
+ action,
205
+ outcome,
206
+ actor_user_id,
207
+ actor_uid,
208
+ actor_provider,
209
+ actor_roles,
210
+ target_type,
211
+ target_id,
212
+ target_label,
213
+ target_provider,
214
+ reason,
215
+ error_code,
216
+ error_message,
217
+ request_id,
218
+ metadata
219
+ )
220
+ VALUES (
221
+ ${params.action},
222
+ ${params.outcome},
223
+ ${asString(params.actor?.userId)}::uuid,
224
+ ${asString(params.actor?.uid)},
225
+ ${asString(params.actor?.provider)},
226
+ ${toPgTextArray(actorRoles)}::text[],
227
+ ${asString(params.target?.type)},
228
+ ${asString(params.target?.id)},
229
+ ${asString(params.target?.label)},
230
+ ${asString(params.target?.provider)},
231
+ ${sanitizeAuditText(params.reason)},
232
+ ${asString(params.error?.code ?? null)},
233
+ ${sanitizeAuditText(params.error?.message ?? null)},
234
+ ${asString(params.requestId)},
235
+ ${JSON.stringify(metadata)}::jsonb
236
+ )
237
+ `;
238
+ } catch (error) {
239
+ log.error("Audit write failed", {
240
+ action: params.action,
241
+ outcome: params.outcome,
242
+ error: error instanceof Error ? error.message : String(error),
243
+ });
244
+ throw error;
245
+ }
246
+ };
247
+
248
+ const recordResult = async <T,>(params: {
249
+ action: string;
250
+ actor?: AuditActor | null;
251
+ target?: AuditTarget | null;
252
+ metadata?: Record<string, unknown> | null;
253
+ result: Result<T>;
254
+ db?: AuditDb;
255
+ }): Promise<Result<T>> => {
256
+ await record({
257
+ action: params.action,
258
+ actor: params.actor,
259
+ target: params.target,
260
+ metadata: params.metadata,
261
+ outcome: params.result.ok ? "allowed" : outcomeForError(params.result.error),
262
+ reason: params.result.ok ? null : params.result.error.message,
263
+ error: params.result.ok ? null : params.result.error,
264
+ }, params.db);
265
+ return params.result;
266
+ };
267
+
268
+ /**
269
+ * Use only after an irreversible side effect already happened. Authorization
270
+ * checks and pre-mutation validation must keep using `record`/`recordResult`
271
+ * so missing audit storage can still fail closed before anything changes.
272
+ */
273
+ const recordResultAfterSideEffect = async <T,>(params: {
274
+ action: string;
275
+ actor?: AuditActor | null;
276
+ target?: AuditTarget | null;
277
+ metadata?: Record<string, unknown> | null;
278
+ result: Result<T>;
279
+ }): Promise<Result<T>> => {
280
+ try {
281
+ await recordResult(params);
282
+ } catch (error) {
283
+ log.error("Post-side-effect audit write failed", {
284
+ action: params.action,
285
+ outcome: params.result.ok ? "allowed" : outcomeForError(params.result.error),
286
+ error: error instanceof Error ? error.message : String(error),
287
+ });
288
+ }
289
+ return params.result;
290
+ };
291
+
292
+ const deny = async <T,>(params: {
293
+ action: string;
294
+ actor?: AuditActor | null;
295
+ target?: AuditTarget | null;
296
+ message?: string;
297
+ metadata?: Record<string, unknown> | null;
298
+ }): Promise<Result<T>> => {
299
+ const error = err.forbidden(params.message ?? "Access denied");
300
+ await record({
301
+ action: params.action,
302
+ actor: params.actor,
303
+ target: params.target,
304
+ metadata: params.metadata,
305
+ outcome: "denied",
306
+ reason: error.message,
307
+ error,
308
+ });
309
+ return fail(error);
310
+ };
311
+
312
+ const buildWhere = (filter: AuditListFilter = {}) => {
313
+ const conditions: SQL.Query<unknown>[] = [sql`TRUE`];
314
+ const search = filter.search?.trim();
315
+ const actor = filter.actor?.trim();
316
+ const target = filter.target?.trim();
317
+ const provider = filter.provider?.trim();
318
+ const days = filter.days && Number.isFinite(filter.days) && filter.days > 0 ? Math.min(Math.floor(filter.days), 3650) : null;
319
+
320
+ if (search) {
321
+ const pattern = `%${escapeLikePattern(search)}%`;
322
+ conditions.push(sql`(
323
+ action ILIKE ${pattern} ESCAPE '\\'
324
+ OR COALESCE(actor_user_id::text, '') ILIKE ${pattern} ESCAPE '\\'
325
+ OR COALESCE(actor_uid, '') ILIKE ${pattern} ESCAPE '\\'
326
+ OR COALESCE(target_label, '') ILIKE ${pattern} ESCAPE '\\'
327
+ OR COALESCE(target_id, '') ILIKE ${pattern} ESCAPE '\\'
328
+ OR COALESCE(reason, '') ILIKE ${pattern} ESCAPE '\\'
329
+ OR COALESCE(error_message, '') ILIKE ${pattern} ESCAPE '\\'
330
+ OR metadata::text ILIKE ${pattern} ESCAPE '\\'
331
+ )`);
332
+ }
333
+
334
+ if (actor) {
335
+ const pattern = `%${escapeLikePattern(actor)}%`;
336
+ conditions.push(sql`(actor_user_id::text = ${actor} OR COALESCE(actor_uid, '') ILIKE ${pattern} ESCAPE '\\')`);
337
+ }
338
+ if (target) {
339
+ const pattern = `%${escapeLikePattern(target)}%`;
340
+ conditions.push(sql`(target_id = ${target} OR COALESCE(target_label, '') ILIKE ${pattern} ESCAPE '\\')`);
341
+ }
342
+ if (filter.action?.trim()) conditions.push(sql`action = ${filter.action.trim()}`);
343
+ if (filter.actionGroup === "service_accounts") {
344
+ conditions.push(sql`(
345
+ action LIKE 'service_account%'
346
+ OR target_type IN ('service_account', 'service_account_credential')
347
+ OR metadata ? 'serviceAccountId'
348
+ )`);
349
+ }
350
+ if (filter.serviceAccountId?.trim()) {
351
+ const serviceAccountId = filter.serviceAccountId.trim();
352
+ conditions.push(sql`(
353
+ (target_type = 'service_account' AND target_id = ${serviceAccountId})
354
+ OR metadata->>'serviceAccountId' = ${serviceAccountId}
355
+ )`);
356
+ }
357
+ if (filter.outcome) conditions.push(sql`outcome = ${filter.outcome}`);
358
+ if (provider) conditions.push(sql`(actor_provider = ${provider} OR target_provider = ${provider})`);
359
+ if (days) conditions.push(sql`created_at >= now() - ${`${days} days`}::interval`);
360
+
361
+ return conditions.reduce((acc, condition) => sql`${acc} AND ${condition}`);
362
+ };
363
+
364
+ const list = async (config: {
365
+ pagination?: PageParams;
366
+ filter?: AuditListFilter;
367
+ }): Promise<Paginated<AuditEvent>> => {
368
+ const { page, perPage, offset } = paginate(config.pagination);
369
+ const where = buildWhere(config.filter);
370
+ const [countRows, rows] = await Promise.all([
371
+ sql<{ count: number }[]>`SELECT COUNT(*)::int AS count FROM audit.events WHERE ${where}`,
372
+ sql<DbAuditRow[]>`
373
+ SELECT *
374
+ FROM audit.events
375
+ WHERE ${where}
376
+ ORDER BY created_at DESC, id DESC
377
+ LIMIT ${perPage}
378
+ OFFSET ${offset}
379
+ `,
380
+ ]);
381
+ const total = countRows[0]?.count ?? 0;
382
+ return {
383
+ items: rows.map(mapRow),
384
+ page,
385
+ perPage,
386
+ total,
387
+ hasNext: page * perPage < total,
388
+ };
389
+ };
390
+
391
+ const listSelfServiceActivity = async (config: {
392
+ userId: string;
393
+ pagination?: PageParams;
394
+ days?: number;
395
+ }): Promise<Paginated<SelfServiceAuditActivity>> => {
396
+ const { page, perPage, offset } = paginate(config.pagination);
397
+ const days = config.days && Number.isFinite(config.days) && config.days > 0 ? Math.min(Math.floor(config.days), 365) : 30;
398
+ const where = sql`
399
+ actor_user_id = ${config.userId}::uuid
400
+ AND action = ANY(${toPgTextArray(SELF_SERVICE_ACTIONS)}::text[])
401
+ AND created_at >= now() - ${`${days} days`}::interval
402
+ `;
403
+ const [countRows, rows] = await Promise.all([
404
+ sql<{ count: number }[]>`SELECT COUNT(*)::int AS count FROM audit.events WHERE ${where}`,
405
+ sql<DbAuditRow[]>`
406
+ SELECT *
407
+ FROM audit.events
408
+ WHERE ${where}
409
+ ORDER BY created_at DESC, id DESC
410
+ LIMIT ${perPage}
411
+ OFFSET ${offset}
412
+ `,
413
+ ]);
414
+ const total = countRows[0]?.count ?? 0;
415
+ return {
416
+ items: rows.map(mapSelfServiceActivityRow),
417
+ page,
418
+ perPage,
419
+ total,
420
+ hasNext: page * perPage < total,
421
+ };
422
+ };
423
+
424
+ export const audit = {
425
+ record,
426
+ recordResult,
427
+ recordResultAfterSideEffect,
428
+ deny,
429
+ list,
430
+ listSelfServiceActivity,
431
+ };
@@ -1,6 +1,13 @@
1
1
  import * as ipa from "./ipa";
2
2
  import * as magicLink from "./magic-link";
3
+ import * as passwordReset from "./password-reset";
4
+ import * as proxyReturn from "./proxy-return";
3
5
 
4
- export { ipa, magicLink };
6
+ export { ipa, magicLink, passwordReset, proxyReturn };
5
7
 
6
- export const authFlows = { ipa, magicLink } as const;
8
+ export const authFlows = {
9
+ ipa,
10
+ magicLink,
11
+ passwordReset,
12
+ proxyReturn,
13
+ } as const;
@@ -1,10 +1,11 @@
1
1
  import { sql } from "bun";
2
2
  import { accounts } from "../accounts";
3
+ import { logger } from "../logging";
3
4
  import { providers } from "../providers";
4
5
  import type { User } from "../../contracts/shared";
5
6
 
6
7
  type IpaLoginFailure =
7
- | { ok: false; status: 401; reason: "password_expired"; message: string }
8
+ | { ok: false; status: 401; reason: "password_expired"; message: string; uid: string }
8
9
  | { ok: false; status: 401; reason: "invalid_credentials"; message: string }
9
10
  | { ok: false; status: 400; reason: "user_not_synced"; message: string }
10
11
  | { ok: false; status: 400; reason: "user_not_found"; message: string }
@@ -14,13 +15,48 @@ type IpaLoginFailure =
14
15
 
15
16
  type IpaLoginSuccess = {
16
17
  ok: true;
17
- ipaSession: string;
18
18
  userId: string;
19
19
  user: User;
20
20
  };
21
21
 
22
22
  export type IpaLoginFlowResult = IpaLoginSuccess | IpaLoginFailure;
23
23
 
24
+ const log = logger("auth:ipa");
25
+ const DUMMY_LOGIN_UID = "__cloud_invalid_ipa_email_login__";
26
+
27
+ const normalizeEmail = (value: string): string => value.trim().toLowerCase();
28
+
29
+ const resolveIpaLoginUid = async (identifier: string): Promise<string | null> => {
30
+ const trimmed = identifier.trim();
31
+ if (!trimmed) return null;
32
+ if (!trimmed.includes("@")) return trimmed;
33
+
34
+ const rows = await sql<{ uid: string }[]>`
35
+ SELECT uid
36
+ FROM auth.users
37
+ WHERE provider = 'ipa'
38
+ AND lower(btrim(mail)) = ${normalizeEmail(trimmed)}
39
+ `;
40
+
41
+ if (rows.length !== 1) {
42
+ if (rows.length > 1) {
43
+ log.warn("FreeIPA email login skipped: ambiguous email", {
44
+ email: normalizeEmail(trimmed),
45
+ matches: rows.length,
46
+ });
47
+ }
48
+ return null;
49
+ }
50
+ return rows[0]!.uid;
51
+ };
52
+
53
+ const failInvalidCredentials = async (params: { identifier: string; password: string }): Promise<IpaLoginFailure> => {
54
+ if (params.identifier.trim().includes("@")) {
55
+ await providers.ipa.auth.login(DUMMY_LOGIN_UID, params.password).catch(() => undefined);
56
+ }
57
+ return { ok: false, status: 401, reason: "invalid_credentials", message: "Invalid username or password" };
58
+ };
59
+
24
60
  const loadSyncedIpaUser = async (uid: string): Promise<{ ok: true; userId: string; user: User } | IpaLoginFailure> => {
25
61
  const userRows = await sql`
26
62
  SELECT id FROM auth.users
@@ -51,9 +87,14 @@ const loadSyncedIpaUser = async (uid: string): Promise<{ ok: true; userId: strin
51
87
  };
52
88
 
53
89
  export const login = async (params: { username: string; password: string }): Promise<IpaLoginFlowResult> => {
54
- const loginResult = await providers.ipa.auth.login(params.username, params.password);
90
+ const uid = await resolveIpaLoginUid(params.username);
91
+ if (!uid) {
92
+ return failInvalidCredentials({ identifier: params.username, password: params.password });
93
+ }
94
+
95
+ const loginResult = await providers.ipa.auth.login(uid, params.password);
55
96
  if (loginResult.status === "password_expired") {
56
- return { ok: false, status: 401, reason: "password_expired", message: "Password expired" };
97
+ return { ok: false, status: 401, reason: "password_expired", message: "Password expired", uid };
57
98
  }
58
99
  if (loginResult.status !== "success") {
59
100
  return { ok: false, status: 401, reason: "invalid_credentials", message: "Invalid username or password" };
@@ -62,7 +103,7 @@ export const login = async (params: { username: string; password: string }): Pro
62
103
  // Must reach a "synced" outcome before granting a session. Stale mirror rows
63
104
  // (expired remotely, dropped from sync scope, or fetch failures) must never
64
105
  // grant a fresh local session on the back of successful FreeIPA credentials.
65
- const syncOutcome = await providers.ipa.sync.user(params.username);
106
+ const syncOutcome = await providers.ipa.sync.user(uid);
66
107
  switch (syncOutcome.status) {
67
108
  case "synced":
68
109
  break;
@@ -98,12 +139,11 @@ export const login = async (params: { username: string; password: string }): Pro
98
139
  };
99
140
  }
100
141
 
101
- const userResult = await loadSyncedIpaUser(params.username);
142
+ const userResult = await loadSyncedIpaUser(uid);
102
143
  if (!userResult.ok) return userResult;
103
144
 
104
145
  return {
105
146
  ok: true,
106
- ipaSession: loginResult.session,
107
147
  userId: userResult.userId,
108
148
  user: userResult.user,
109
149
  };