@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
@@ -11,6 +11,7 @@ export const LoginSchema = z.object({
11
11
  export const EmailLoginSchema = z.object({
12
12
  email: z.email(),
13
13
  acceptedAgb: z.literal(true),
14
+ redirectTo: z.string().max(2048).optional(),
14
15
  });
15
16
 
16
17
  export const VerifyTokenSchema = z.object({
@@ -18,6 +19,24 @@ export const VerifyTokenSchema = z.object({
18
19
  acceptedAgb: z.literal(true),
19
20
  });
20
21
 
22
+ export const PasswordResetRequestSchema = z.object({
23
+ email: z.email(),
24
+ acceptedAgb: z.literal(true),
25
+ redirectTo: z.string().max(2048).optional(),
26
+ });
27
+
28
+ export const PasswordResetCompleteSchema = z
29
+ .object({
30
+ token: z.uuid(),
31
+ newPassword: z.string().min(8),
32
+ confirmPassword: z.string().min(1),
33
+ acceptedAgb: z.literal(true),
34
+ })
35
+ .refine((data) => data.newPassword === data.confirmPassword, {
36
+ message: "Passwords do not match",
37
+ path: ["confirmPassword"],
38
+ });
39
+
21
40
  export const AdminLoginSchema = z.object({
22
41
  token: z.string().min(1),
23
42
  });
@@ -26,3 +45,8 @@ export const AuthResponseSchema = z.object({
26
45
  session_token: z.string(),
27
46
  user: UserSchema,
28
47
  });
48
+
49
+ export const VerifyPasskeyAuthenticationSchema = z.object({
50
+ response: z.unknown(),
51
+ acceptedAgb: z.literal(true),
52
+ });
package/src/api/auth.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { Hono, type Context } from "hono";
2
2
  import { describeRoute } from "hono-openapi";
3
+ import { z } from "zod";
3
4
  import { v } from "../server";
4
5
  import { jsonResponse } from "../server";
5
6
  import { auth, type AuthContext } from "../server";
6
7
  import { rateLimit } from "../server";
7
- import { authFlows, accounts, getFreeIpaConfig, logger } from "../services";
8
+ import { respond } from "../server";
9
+ import { authFlows, accounts, getFreeIpaConfig, logger, webauthn } from "../services";
8
10
  import { sql } from "bun";
9
11
  import { env } from "../config";
10
12
  import { ChangeExpiredPasswordSchema } from "../contracts";
@@ -14,8 +16,11 @@ import {
14
16
  LoginSchema,
15
17
  EmailLoginSchema,
16
18
  VerifyTokenSchema,
19
+ PasswordResetRequestSchema,
20
+ PasswordResetCompleteSchema,
17
21
  AdminLoginSchema,
18
22
  AuthResponseSchema,
23
+ VerifyPasskeyAuthenticationSchema,
19
24
  } from "./auth/schemas";
20
25
  import { ErrorResponseSchema, MessageResponseSchema } from "../contracts";
21
26
 
@@ -56,7 +61,7 @@ const app = new Hono<AuthContext>()
56
61
  }
57
62
 
58
63
  // Store minimal session in Redis
59
- const sessionToken = await auth.session.create(c, loginResult.userId, loginResult.ipaSession);
64
+ const sessionToken = await auth.session.create(c, loginResult.userId);
60
65
 
61
66
  log.info("Login successful", { uid: username });
62
67
  return c.json({
@@ -65,13 +70,51 @@ const app = new Hono<AuthContext>()
65
70
  });
66
71
  },
67
72
  )
73
+ .post(
74
+ "/passkeys/authentication/start",
75
+ describeRoute({
76
+ tags: ["Auth"],
77
+ summary: "Start passkey login",
78
+ description: "Create WebAuthn authentication options for passkey sign-in.",
79
+ responses: {
80
+ 200: jsonResponse(z.unknown(), "Passkey authentication options"),
81
+ 400: jsonResponse(ErrorResponseSchema, "Passkey login is not available"),
82
+ },
83
+ }),
84
+ async (c) => respond(c, webauthn.beginAuthentication()),
85
+ )
86
+ .post(
87
+ "/passkeys/authentication/verify",
88
+ describeRoute({
89
+ tags: ["Auth"],
90
+ summary: "Verify passkey login",
91
+ description: "Verify a WebAuthn authentication response and create a normal Cloud session.",
92
+ responses: {
93
+ 200: jsonResponse(AuthResponseSchema, "Passkey login successful"),
94
+ 401: jsonResponse(ErrorResponseSchema, "Passkey verification failed"),
95
+ },
96
+ }),
97
+ v("json", VerifyPasskeyAuthenticationSchema),
98
+ async (c) => {
99
+ const result = await webauthn.finishAuthentication({
100
+ response: c.req.valid("json").response as never,
101
+ });
102
+ if (!result.ok) {
103
+ const status = result.error.status;
104
+ return c.json({ message: result.error.message, code: result.error.code }, status as 400 | 401 | 403 | 404 | 409 | 500);
105
+ }
106
+
107
+ const sessionToken = await auth.session.create(c, result.data.user.id);
108
+ log.info("Passkey login successful", { uid: result.data.user.uid });
109
+ return c.json({ session_token: sessionToken, user: result.data.user });
110
+ },
111
+ )
68
112
  .post(
69
113
  "/logout",
70
114
  describeRoute({
71
115
  tags: ["Auth"],
72
116
  summary: "Logout",
73
- description:
74
- "Idempotent: clears the session cookie and deletes the session key if present. No authentication required — logout must always succeed.",
117
+ description: "Idempotent: clears the session cookie and deletes the session key if present. No authentication required — logout must always succeed.",
75
118
  responses: {
76
119
  200: jsonResponse(MessageResponseSchema, "Session invalidated"),
77
120
  },
@@ -116,7 +159,7 @@ const app = new Hono<AuthContext>()
116
159
  return jsonError(c, changeResult.message, changeResult.status === 401 ? 401 : 400);
117
160
  }
118
161
 
119
- const sessionToken = await auth.session.create(c, changeResult.userId, changeResult.ipaSession);
162
+ const sessionToken = await auth.session.create(c, changeResult.userId);
120
163
 
121
164
  log.info("Password changed via expired flow", { uid: username });
122
165
  return c.json({ session_token: sessionToken, user: changeResult.user });
@@ -135,9 +178,12 @@ const app = new Hono<AuthContext>()
135
178
  }),
136
179
  v("json", EmailLoginSchema),
137
180
  async (c) => {
138
- const { email } = c.req.valid("json");
181
+ const { email, redirectTo } = c.req.valid("json");
139
182
 
140
- const requestResult = await authFlows.magicLink.request({ email });
183
+ const requestResult = await authFlows.magicLink.request({
184
+ email,
185
+ redirectTo,
186
+ });
141
187
  if (!requestResult.ok) {
142
188
  return c.json({ message: requestResult.message }, requestResult.status);
143
189
  }
@@ -171,15 +217,72 @@ const app = new Hono<AuthContext>()
171
217
  }
172
218
 
173
219
  // Create session (no IPA session for email-only users)
174
- const sessionToken = await auth.session.create(c, verifyResult.userId, null);
220
+ const sessionToken = await auth.session.create(c, verifyResult.userId);
175
221
 
176
222
  if (verifyResult.createdGuest) {
177
- log.info("Guest user created", { email: verifyResult.email, uid: verifyResult.user.uid });
223
+ log.info("Guest user created", {
224
+ email: verifyResult.email,
225
+ uid: verifyResult.user.uid,
226
+ });
178
227
  }
179
228
  log.info("Token verified", { email: verifyResult.email });
180
229
  return c.json({ session_token: sessionToken, user: verifyResult.user });
181
230
  },
182
231
  )
232
+ .post(
233
+ "/password-reset/request",
234
+ describeRoute({
235
+ tags: ["Auth"],
236
+ summary: "Request password reset",
237
+ description: "Request a one-time password reset email for an IPA-backed account. The response is always generic to avoid account enumeration.",
238
+ responses: {
239
+ 200: jsonResponse(MessageResponseSchema, "Request accepted"),
240
+ },
241
+ }),
242
+ v("json", PasswordResetRequestSchema),
243
+ async (c) => {
244
+ const { email, redirectTo } = c.req.valid("json");
245
+
246
+ const result = await authFlows.passwordReset.request({
247
+ email,
248
+ redirectTo,
249
+ });
250
+ return c.json({ message: result.message });
251
+ },
252
+ )
253
+ .post(
254
+ "/password-reset/complete",
255
+ describeRoute({
256
+ tags: ["Auth"],
257
+ summary: "Complete password reset",
258
+ description: "Set a new password using a one-time reset token. Creates a normal Cloud session only after the password was changed successfully.",
259
+ responses: {
260
+ 200: jsonResponse(AuthResponseSchema, "Password reset completed and session created"),
261
+ 400: jsonResponse(ErrorResponseSchema, "Password reset failed"),
262
+ 401: jsonResponse(ErrorResponseSchema, "Invalid or expired reset token"),
263
+ },
264
+ }),
265
+ v("json", PasswordResetCompleteSchema),
266
+ async (c) => {
267
+ const { token, newPassword } = c.req.valid("json");
268
+
269
+ const result = await authFlows.passwordReset.complete({
270
+ token,
271
+ newPassword,
272
+ });
273
+ if (!result.ok) {
274
+ if (result.reason === "policy_failed") {
275
+ return c.json({ message: result.message }, 400);
276
+ }
277
+ const status = result.status >= 500 ? 500 : result.status === 401 ? 401 : 400;
278
+ return jsonError(c, result.message, status);
279
+ }
280
+
281
+ const sessionToken = await auth.session.create(c, result.userId);
282
+ log.info("Password reset completed", { uid: result.user.uid });
283
+ return c.json({ user: result.user, session_token: sessionToken });
284
+ },
285
+ )
183
286
  .post(
184
287
  "/admin-login",
185
288
  describeRoute({
@@ -221,7 +324,7 @@ const app = new Hono<AuthContext>()
221
324
  const user = await accounts.users.get({ uid: "admin" });
222
325
  if (!user) return jsonError(c, "Failed to resolve admin user.", 500);
223
326
 
224
- const sessionToken = await auth.session.create(c, user.id, null);
327
+ const sessionToken = await auth.session.create(c, user.id);
225
328
  log.info("Admin login successful");
226
329
  return c.json({ session_token: sessionToken, user });
227
330
  },
package/src/api/index.ts CHANGED
@@ -15,10 +15,12 @@
15
15
  */
16
16
  import { Hono } from "hono";
17
17
  import { prettyJSON } from "hono/pretty-json";
18
+ import accountsEntitiesRoutes from "./accounts-entities";
19
+ import adminCoreSettingsRoutes from "./admin-core-settings";
20
+ import adminLifecycleRoutes from "./admin-lifecycle";
21
+ import { adminAnnouncementRoutes, announcementRoutes } from "./announcements";
18
22
  import authRoutes from "./auth";
19
23
  import meRoutes from "./me";
20
- import adminLifecycleRoutes from "./admin-lifecycle";
21
- import accountsEntitiesRoutes from "./accounts-entities";
22
24
  import { createSearchRoutes } from "./search";
23
25
 
24
26
  /**
@@ -33,6 +35,9 @@ const buildCoreApi = () => {
33
35
  .route("/auth", authRoutes)
34
36
  .route("/me", meRoutes)
35
37
  .route("/accounts", accountsEntitiesRoutes)
38
+ .route("/announcements", announcementRoutes)
39
+ .route("/admin/core/announcements", adminAnnouncementRoutes)
40
+ .route("/admin/core/settings", adminCoreSettingsRoutes)
36
41
  .route("/admin/lifecycle", adminLifecycleRoutes)
37
42
  .route("/", searchRoutes);
38
43
  };
package/src/api/me.ts CHANGED
@@ -10,17 +10,34 @@ import { auth, jsonResponse, rateLimit, requiresAuth, respond, v, type AuthConte
10
10
  import { ok } from "@valentinkolb/stdlib";
11
11
  import {
12
12
  ChangePasswordSchema,
13
+ AccountActivityListResponseSchema,
13
14
  CreateAccountRequestSchema,
15
+ CreateUserApiKeyResponseSchema,
16
+ CreateUserApiKeySchema,
17
+ CreateWebAuthnPasskeySchema,
14
18
  ErrorResponseSchema,
19
+ ListWebAuthnPasskeysResponseSchema,
15
20
  MessageResponseSchema,
21
+ ServiceAccountCredentialSchema,
16
22
  UpdateProfileSchema,
17
23
  UserSchema,
24
+ WebAuthnPasskeySchema,
18
25
  } from "../contracts";
19
26
  import {
20
27
  accountLifecycle,
21
28
  accountsAppService as accountsService,
29
+ audit,
30
+ serviceAccountCredentials,
31
+ webauthn,
22
32
  } from "../services";
23
33
 
34
+ const toAccountsActor = (user: AuthContext["Variables"]["user"]) => ({
35
+ userId: user.id,
36
+ uid: user.uid,
37
+ roles: user.roles,
38
+ provider: user.provider,
39
+ });
40
+
24
41
  const ExtendAccountResponseSchema = z.object({
25
42
  message: z.string(),
26
43
  newExpiry: z.string().datetime().optional(),
@@ -31,10 +48,42 @@ const AccountRequestResponseSchema = z.object({
31
48
  message: z.string(),
32
49
  });
33
50
 
51
+ const ListApiKeysResponseSchema = z.object({
52
+ items: z.array(ServiceAccountCredentialSchema),
53
+ });
54
+
55
+ const AccountActivityQuerySchema = z.object({
56
+ days: z.coerce.number().int().pipe(z.union([z.literal(7), z.literal(30), z.literal(90)])).optional().default(30),
57
+ });
58
+
34
59
  const app = new Hono<AuthContext>()
35
60
  .use(rateLimit())
36
61
  .use(auth.requireRole("authenticated"))
37
62
 
63
+ .get(
64
+ "/activity",
65
+ describeRoute({
66
+ tags: ["Me"],
67
+ summary: "List current user account activity",
68
+ description: "List safe self-service audit activity for the authenticated account.",
69
+ ...requiresAuth,
70
+ responses: {
71
+ 200: jsonResponse(AccountActivityListResponseSchema, "Account activity"),
72
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
73
+ },
74
+ }),
75
+ v("query", AccountActivityQuerySchema),
76
+ async (c) =>
77
+ respond(c, async () => {
78
+ const page = await audit.listSelfServiceActivity({
79
+ userId: c.get("user").id,
80
+ days: c.req.valid("query").days,
81
+ pagination: { page: 1, perPage: 50 },
82
+ });
83
+ return ok({ items: page.items });
84
+ }),
85
+ )
86
+
38
87
  .get(
39
88
  "/",
40
89
  describeRoute({
@@ -67,15 +116,162 @@ const app = new Hono<AuthContext>()
67
116
  async (c) =>
68
117
  respond(c, async () => {
69
118
  const user = c.get("user");
70
- const token = c.get("sessionToken");
71
119
  const data = c.req.valid("json");
72
- const ipaSession = user.provider === "ipa" ? await auth.session.getIpaSession(token) : null;
73
- const result = await accountsService.user.update({ ipaSession, id: user.id, data });
120
+ const result = await accountsService.user.update({ actor: toAccountsActor(user), id: user.id, data });
74
121
  if (!result.ok) return result;
75
122
  return ok({ message: "Profile updated." });
76
123
  }),
77
124
  )
78
125
 
126
+ .get(
127
+ "/passkeys",
128
+ describeRoute({
129
+ tags: ["Me"],
130
+ summary: "List current user passkeys",
131
+ description: "List WebAuthn passkeys registered to the authenticated account.",
132
+ ...requiresAuth,
133
+ responses: {
134
+ 200: jsonResponse(ListWebAuthnPasskeysResponseSchema, "Passkeys"),
135
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
136
+ },
137
+ }),
138
+ async (c) =>
139
+ respond(c, async () => {
140
+ const items = await webauthn.listForUser({ userId: c.get("user").id });
141
+ return ok({ items });
142
+ }),
143
+ )
144
+
145
+ .post(
146
+ "/passkeys/registration/start",
147
+ describeRoute({
148
+ tags: ["Me"],
149
+ summary: "Start passkey registration",
150
+ description: "Create WebAuthn registration options for the authenticated account.",
151
+ ...requiresAuth,
152
+ responses: {
153
+ 200: jsonResponse(z.unknown(), "Passkey registration options"),
154
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
155
+ },
156
+ }),
157
+ async (c) => respond(c, webauthn.beginRegistration({ user: c.get("user") })),
158
+ )
159
+
160
+ .post(
161
+ "/passkeys/registration/verify",
162
+ describeRoute({
163
+ tags: ["Me"],
164
+ summary: "Verify passkey registration",
165
+ description: "Verify a WebAuthn registration response and store the passkey public credential.",
166
+ ...requiresAuth,
167
+ responses: {
168
+ 201: jsonResponse(WebAuthnPasskeySchema, "Passkey created"),
169
+ 400: jsonResponse(ErrorResponseSchema, "Passkey registration failed"),
170
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
171
+ },
172
+ }),
173
+ v("json", CreateWebAuthnPasskeySchema),
174
+ async (c) =>
175
+ respond(c, () => {
176
+ const data = c.req.valid("json");
177
+ return webauthn.finishRegistration({
178
+ user: c.get("user"),
179
+ name: data.name,
180
+ response: data.response as never,
181
+ });
182
+ }, 201),
183
+ )
184
+
185
+ .delete(
186
+ "/passkeys/:id",
187
+ describeRoute({
188
+ tags: ["Me"],
189
+ summary: "Delete current user passkey",
190
+ description: "Delete a WebAuthn passkey registered to the authenticated account.",
191
+ ...requiresAuth,
192
+ responses: {
193
+ 200: jsonResponse(MessageResponseSchema, "Passkey deleted"),
194
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
195
+ 404: jsonResponse(ErrorResponseSchema, "Passkey not found"),
196
+ },
197
+ }),
198
+ async (c) =>
199
+ respond(c, async () => {
200
+ const result = await webauthn.deleteForUser({ user: c.get("user"), id: c.req.param("id") });
201
+ if (!result.ok) return result;
202
+ return ok({ message: "Passkey deleted." });
203
+ }),
204
+ )
205
+
206
+ .post(
207
+ "/api-keys",
208
+ describeRoute({
209
+ tags: ["Me"],
210
+ summary: "Create current user API key",
211
+ description: "Create a user-bound API key for the authenticated account. The raw token is returned once.",
212
+ ...requiresAuth,
213
+ responses: {
214
+ 201: jsonResponse(CreateUserApiKeyResponseSchema, "API key created"),
215
+ 400: jsonResponse(ErrorResponseSchema, "Failed to create API key"),
216
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
217
+ },
218
+ }),
219
+ v("json", CreateUserApiKeySchema),
220
+ async (c) =>
221
+ respond(c, async () => {
222
+ const user = c.get("user");
223
+ const data = c.req.valid("json");
224
+ return serviceAccountCredentials.createUserApiToken({
225
+ user,
226
+ name: data.name,
227
+ expiresAt: data.expiresAt ?? null,
228
+ });
229
+ }, 201),
230
+ )
231
+
232
+ .get(
233
+ "/api-keys",
234
+ describeRoute({
235
+ tags: ["Me"],
236
+ summary: "List current user API keys",
237
+ description: "List active user-bound API keys owned by the authenticated account.",
238
+ ...requiresAuth,
239
+ responses: {
240
+ 200: jsonResponse(ListApiKeysResponseSchema, "API keys"),
241
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
242
+ },
243
+ }),
244
+ async (c) =>
245
+ respond(c, async () => {
246
+ const items = await serviceAccountCredentials.listForDelegatedUser({ userId: c.get("user").id });
247
+ return ok({ items });
248
+ }),
249
+ )
250
+
251
+ .delete(
252
+ "/api-keys/:id",
253
+ describeRoute({
254
+ tags: ["Me"],
255
+ summary: "Revoke current user API key",
256
+ description: "Revoke an API key owned by the authenticated account.",
257
+ ...requiresAuth,
258
+ responses: {
259
+ 200: jsonResponse(MessageResponseSchema, "API key revoked"),
260
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
261
+ 404: jsonResponse(ErrorResponseSchema, "API key not found"),
262
+ },
263
+ }),
264
+ async (c) =>
265
+ respond(c, async () => {
266
+ const result = await serviceAccountCredentials.revokeForDelegatedUser({
267
+ credentialId: c.req.param("id"),
268
+ user: c.get("user"),
269
+ });
270
+ if (!result.ok) return result;
271
+ return ok({ message: "API key revoked." });
272
+ }),
273
+ )
274
+
79
275
  .post(
80
276
  "/password",
81
277
  describeRoute({
@@ -118,10 +314,8 @@ const app = new Hono<AuthContext>()
118
314
  async (c) =>
119
315
  respond(c, async () => {
120
316
  const user = c.get("user");
121
- const token = c.get("sessionToken");
122
- const ipaSession = user.provider === "ipa" ? await auth.session.getIpaSession(token) : null;
123
- const result = await accountLifecycle.extendCurrentUserAccount({ user, ipaSession });
124
- return ok(result);
317
+ const result = await accountLifecycle.extendCurrentUserAccount({ user });
318
+ return result;
125
319
  }),
126
320
  )
127
321
 
@@ -142,12 +336,7 @@ const app = new Hono<AuthContext>()
142
336
  async (c) =>
143
337
  respond(c, async () => {
144
338
  const user = c.get("user");
145
- if (user.profile !== "guest") {
146
- return { ok: false, error: "Only guest accounts can be self-deleted.", status: 403 };
147
- }
148
- const token = c.get("sessionToken");
149
- const ipaSession = user.provider === "ipa" ? await auth.session.getIpaSession(token) : null;
150
- const result = await accountsService.user.removeSelf({ user, ipaSession });
339
+ const result = await accountsService.user.removeSelf({ user });
151
340
  if (!result.ok) return result;
152
341
  await auth.session.delete(c);
153
342
  return ok({ message: "Account deleted." });
@@ -197,7 +386,7 @@ const app = new Hono<AuthContext>()
197
386
  if (!pending) {
198
387
  return { ok: false, error: "No pending request", status: 404 };
199
388
  }
200
- const result = await accountsService.accountRequest.withdraw({ id: pending.id, userId: user.id });
389
+ const result = await accountsService.accountRequest.withdraw({ id: pending.id, actor: toAccountsActor(user) });
201
390
  if (!result.ok) return result;
202
391
  return ok({ message: "Request withdrawn" });
203
392
  }),
@@ -37,6 +37,7 @@ export const SearchResponseSchema = z.object({
37
37
  query: z.string(),
38
38
  count: z.number().int().nonnegative(),
39
39
  items: z.array(SearchItemSchema),
40
+ unsupportedTags: z.array(z.string()).optional(),
40
41
  });
41
42
 
42
43
  export type SearchItem = z.infer<typeof SearchItemSchema>;
package/src/api/search.ts CHANGED
@@ -8,11 +8,18 @@ import { SearchItemSchema, SearchQuerySchema, SearchResponseSchema, type SearchI
8
8
 
9
9
  const log = logger("search");
10
10
 
11
+ /**
12
+ * Maximum items returned to the client after merging across providers.
13
+ * The frontend has no further limit — this caps the rendered list.
14
+ */
15
+ const GLOBAL_RESULT_LIMIT = 30;
16
+
11
17
  type HttpSearchProvider = {
12
18
  appId: string;
13
19
  appName: string;
14
20
  appIcon: string;
15
21
  endpoint: string;
22
+ tags: string[];
16
23
  };
17
24
 
18
25
  /**
@@ -28,6 +35,7 @@ const getSearchProviders = async (): Promise<HttpSearchProvider[]> => {
28
35
  appName: e.name,
29
36
  appIcon: e.icon,
30
37
  endpoint: e.search!.endpoint,
38
+ tags: [...e.search!.tags],
31
39
  }));
32
40
  };
33
41
 
@@ -50,26 +58,69 @@ export const createSearchRoutes = () =>
50
58
  200: jsonResponse(SearchResponseSchema, "Merged search results"),
51
59
  400: jsonResponse(ErrorResponseSchema, "Invalid query"),
52
60
  401: jsonResponse(ErrorResponseSchema, "Authentication required"),
61
+ 403: jsonResponse(ErrorResponseSchema, "User-backed actor required"),
53
62
  },
54
63
  }),
55
64
  v("query", SearchQuerySchema),
56
65
  async (c) => {
66
+ if (!c.get("user")) {
67
+ return c.json({ message: "Global search requires a user-backed actor", code: "FORBIDDEN" }, 403);
68
+ }
69
+
57
70
  const query = c.req.valid("query");
58
71
  const providers = await getSearchProviders();
72
+ const cookie = c.req.raw.headers.get("Cookie") ?? "";
73
+ const authorization = c.req.raw.headers.get("Authorization");
74
+
75
+ // Pre-filter providers by tag overlap. With no tags, every provider
76
+ // runs (text-only search). With tags, only providers that own at least
77
+ // one requested tag participate — saves fanout to apps that can't
78
+ // contribute. Tags the user typed that no provider declares are
79
+ // returned to the client so it can render a helpful empty state.
80
+ const knownTags = new Set(providers.flatMap((p) => p.tags));
81
+ const unsupportedTags = query.tag.filter((t) => !knownTags.has(t));
82
+ const active =
83
+ query.tag.length === 0
84
+ ? providers
85
+ : providers.filter((p) => p.tags.some((t) => query.tag.includes(t)));
86
+
87
+ if (query.tag.length > 0 && active.length === 0) {
88
+ return c.json({
89
+ query: query.q,
90
+ count: 0,
91
+ items: [],
92
+ unsupportedTags,
93
+ });
94
+ }
95
+
96
+ // Single-provider queries get a larger sample for better local
97
+ // ranking — the global slice below still caps the response. Capped
98
+ // at GLOBAL_RESULT_LIMIT so a single app can saturate the response
99
+ // but no more.
100
+ const effectiveProviderLimit =
101
+ active.length === 1
102
+ ? Math.min(GLOBAL_RESULT_LIMIT, query.provider_limit * 3)
103
+ : query.provider_limit;
59
104
 
60
105
  const settled = await Promise.allSettled(
61
- providers.map(async (provider) => {
106
+ active.map(async (provider) => {
107
+ // Scope tags to those this provider declared. Apps no longer need
108
+ // their own gate — the framework guarantees they only see tags
109
+ // they understand.
110
+ const scopedTags = query.tag.filter((t) => provider.tags.includes(t));
111
+
62
112
  const res = await fetch(provider.endpoint, {
63
113
  method: "POST",
64
114
  headers: {
65
115
  "Content-Type": "application/json",
66
- // Forward the session cookie for auth on the app's internal search endpoint
67
- Cookie: c.req.raw.headers.get("Cookie") ?? "",
116
+ // Forward the authenticated user context to app search providers.
117
+ Cookie: cookie,
118
+ ...(authorization ? { Authorization: authorization } : {}),
68
119
  },
69
120
  body: JSON.stringify({
70
121
  query: query.q,
71
- tags: query.tag,
72
- limit: query.provider_limit,
122
+ tags: scopedTags,
123
+ limit: effectiveProviderLimit,
73
124
  }),
74
125
  });
75
126
 
@@ -106,7 +157,7 @@ export const createSearchRoutes = () =>
106
157
  if (result.status === "fulfilled") return result.value;
107
158
 
108
159
  log.warn("Search provider failed", {
109
- appId: providers[index]?.appId ?? "unknown",
160
+ appId: active[index]?.appId ?? "unknown",
110
161
  tags: query.tag,
111
162
  error: result.reason instanceof Error ? result.reason.message : String(result.reason),
112
163
  });
@@ -119,10 +170,13 @@ export const createSearchRoutes = () =>
119
170
  return a.title.localeCompare(b.title);
120
171
  });
121
172
 
173
+ const sliced = items.slice(0, GLOBAL_RESULT_LIMIT);
174
+
122
175
  return c.json({
123
176
  query: query.q,
124
- count: items.length,
125
- items,
177
+ count: sliced.length,
178
+ items: sliced,
179
+ ...(unsupportedTags.length > 0 ? { unsupportedTags } : {}),
126
180
  });
127
181
  },
128
182
  );