@valentinkolb/cloud 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/package.json +69 -0
  2. package/public/logo.svg +1 -0
  3. package/scripts/build.ts +113 -0
  4. package/scripts/preload.ts +73 -0
  5. package/src/_internal/define-app.ts +399 -0
  6. package/src/_internal/heartbeat.ts +33 -0
  7. package/src/_internal/registry.ts +100 -0
  8. package/src/_internal/runtime-context.ts +38 -0
  9. package/src/api/accounts-entities.ts +134 -0
  10. package/src/api/admin-lifecycle.ts +210 -0
  11. package/src/api/auth/schemas.ts +28 -0
  12. package/src/api/auth.ts +230 -0
  13. package/src/api/index.ts +66 -0
  14. package/src/api/me.ts +206 -0
  15. package/src/api/search/schemas.ts +43 -0
  16. package/src/api/search.ts +130 -0
  17. package/src/clients/core.ts +19 -0
  18. package/src/config/env.ts +23 -0
  19. package/src/config/index.ts +6 -0
  20. package/src/config/ssr.ts +58 -0
  21. package/src/contracts/app.ts +140 -0
  22. package/src/contracts/index.ts +5 -0
  23. package/src/contracts/profile.ts +67 -0
  24. package/src/contracts/registry.ts +50 -0
  25. package/src/contracts/settings-types.ts +84 -0
  26. package/src/contracts/shared.ts +258 -0
  27. package/src/contracts/widgets.ts +121 -0
  28. package/src/index.ts +6 -0
  29. package/src/server/api/index.ts +1 -0
  30. package/src/server/api/respond.ts +55 -0
  31. package/src/server/api-client.ts +54 -0
  32. package/src/server/app-context.ts +39 -0
  33. package/src/server/index.ts +62 -0
  34. package/src/server/middleware/auth.ts +168 -0
  35. package/src/server/middleware/index.ts +7 -0
  36. package/src/server/middleware/middleware.ts +47 -0
  37. package/src/server/middleware/openapi.ts +126 -0
  38. package/src/server/middleware/rate-limit.ts +126 -0
  39. package/src/server/middleware/request-logger.ts +41 -0
  40. package/src/server/middleware/validator.ts +35 -0
  41. package/src/server/services/access.ts +294 -0
  42. package/src/server/services/freeipa/client.ts +100 -0
  43. package/src/server/services/freeipa/index.ts +9 -0
  44. package/src/server/services/freeipa/session.ts +78 -0
  45. package/src/server/services/freeipa/tls.ts +48 -0
  46. package/src/server/services/freeipa/util.ts +60 -0
  47. package/src/server/services/geo.ts +154 -0
  48. package/src/server/services/index.ts +28 -0
  49. package/src/server/services/services.ts +13 -0
  50. package/src/services/account-lifecycle/audit.ts +41 -0
  51. package/src/services/account-lifecycle/index.ts +907 -0
  52. package/src/services/account-lifecycle/scheduler.ts +347 -0
  53. package/src/services/account-model.ts +21 -0
  54. package/src/services/accounts/app.ts +966 -0
  55. package/src/services/accounts/authz.ts +22 -0
  56. package/src/services/accounts/base-group.ts +11 -0
  57. package/src/services/accounts/base-user.ts +45 -0
  58. package/src/services/accounts/entities.ts +529 -0
  59. package/src/services/accounts/group-sql.ts +106 -0
  60. package/src/services/accounts/groups.ts +246 -0
  61. package/src/services/accounts/index.ts +14 -0
  62. package/src/services/accounts/ipa-data.ts +64 -0
  63. package/src/services/accounts/lifecycle.ts +2 -0
  64. package/src/services/accounts/local-groups.ts +491 -0
  65. package/src/services/accounts/model.ts +135 -0
  66. package/src/services/accounts/switching.ts +117 -0
  67. package/src/services/accounts/users.ts +714 -0
  68. package/src/services/auth-flows/index.ts +6 -0
  69. package/src/services/auth-flows/ipa.ts +128 -0
  70. package/src/services/auth-flows/magic-link.ts +119 -0
  71. package/src/services/freeipa-config.ts +89 -0
  72. package/src/services/index.ts +46 -0
  73. package/src/services/ipa/auth.ts +122 -0
  74. package/src/services/ipa/groups.ts +684 -0
  75. package/src/services/ipa/guard.ts +17 -0
  76. package/src/services/ipa/index.ts +17 -0
  77. package/src/services/ipa/profile.ts +90 -0
  78. package/src/services/ipa/search.ts +154 -0
  79. package/src/services/ipa/sync.ts +740 -0
  80. package/src/services/ipa/users.ts +794 -0
  81. package/src/services/logging/index.ts +294 -0
  82. package/src/services/notifications/email.ts +123 -0
  83. package/src/services/notifications/index.ts +413 -0
  84. package/src/services/postgres.ts +51 -0
  85. package/src/services/providers/index.ts +27 -0
  86. package/src/services/providers/local/auth.ts +13 -0
  87. package/src/services/providers/local/index.ts +4 -0
  88. package/src/services/providers/local/users.ts +255 -0
  89. package/src/services/session/index.ts +137 -0
  90. package/src/services/settings/api.ts +61 -0
  91. package/src/services/settings/app.ts +101 -0
  92. package/src/services/settings/crypto.ts +69 -0
  93. package/src/services/settings/defaults.ts +824 -0
  94. package/src/services/settings/index.ts +203 -0
  95. package/src/services/settings/namespace.ts +9 -0
  96. package/src/services/settings/snapshot.ts +49 -0
  97. package/src/services/settings/store.ts +179 -0
  98. package/src/services/settings/templates.ts +10 -0
  99. package/src/services/weather/forecast.ts +287 -0
  100. package/src/services/weather/geo.ts +110 -0
  101. package/src/services/weather/index.ts +99 -0
  102. package/src/services/weather/location.ts +24 -0
  103. package/src/services/weather/locations.ts +125 -0
  104. package/src/services/weather/migrate.ts +22 -0
  105. package/src/services/weather/types.ts +61 -0
  106. package/src/services/weather/ui.ts +50 -0
  107. package/src/shared/account-display.ts +17 -0
  108. package/src/shared/account-session.ts +15 -0
  109. package/src/shared/icons.ts +109 -0
  110. package/src/shared/index.ts +10 -0
  111. package/src/shared/markdown/client.ts +130 -0
  112. package/src/shared/markdown/extensions/code.ts +58 -0
  113. package/src/shared/markdown/extensions/images.ts +43 -0
  114. package/src/shared/markdown/extensions/info-blocks.ts +93 -0
  115. package/src/shared/markdown/extensions/katex.ts +120 -0
  116. package/src/shared/markdown/extensions/links.ts +34 -0
  117. package/src/shared/markdown/extensions/tables.ts +88 -0
  118. package/src/shared/markdown/extensions/task-list.ts +53 -0
  119. package/src/shared/markdown/index.ts +97 -0
  120. package/src/shared/markdown/shared.ts +36 -0
  121. package/src/ssr/AdminLayout.tsx +42 -0
  122. package/src/ssr/AdminSidebar.tsx +95 -0
  123. package/src/ssr/Footer.island.tsx +62 -0
  124. package/src/ssr/GlobalSearchDialog.tsx +389 -0
  125. package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
  126. package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
  127. package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
  128. package/src/ssr/Layout.tsx +326 -0
  129. package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
  130. package/src/ssr/NavMenu.island.tsx +108 -0
  131. package/src/ssr/ThemeToggleRail.island.tsx +27 -0
  132. package/src/ssr/index.ts +5 -0
  133. package/src/ssr/islands/SearchBar.island.tsx +77 -0
  134. package/src/ssr/islands/index.ts +1 -0
  135. package/src/ssr/runtime.ts +22 -0
  136. package/src/styles/base-popover.css +28 -0
  137. package/src/styles/effects.css +65 -0
  138. package/src/styles/global.css +133 -0
  139. package/src/styles/input.css +54 -0
  140. package/src/styles/tokens.css +35 -0
  141. package/src/styles/utilities-buttons.css +125 -0
  142. package/src/styles/utilities-feedback.css +65 -0
  143. package/src/styles/utilities-layout.css +122 -0
  144. package/src/styles/utilities-navigation.css +196 -0
  145. package/src/types/ambient.d.ts +8 -0
  146. package/src/ui/admin-settings.tsx +148 -0
  147. package/src/ui/dialog-core.ts +146 -0
  148. package/src/ui/filter/FilterChip.tsx +196 -0
  149. package/src/ui/filter/index.ts +2 -0
  150. package/src/ui/index.ts +19 -0
  151. package/src/ui/input/Checkbox.tsx +55 -0
  152. package/src/ui/input/ColorInput.tsx +122 -0
  153. package/src/ui/input/DateTimeInput.tsx +86 -0
  154. package/src/ui/input/ImageInput.tsx +170 -0
  155. package/src/ui/input/NumberInput.tsx +113 -0
  156. package/src/ui/input/PinInput.tsx +169 -0
  157. package/src/ui/input/SegmentedControl.tsx +99 -0
  158. package/src/ui/input/Select.tsx +288 -0
  159. package/src/ui/input/SelectChip.tsx +61 -0
  160. package/src/ui/input/Slider.tsx +118 -0
  161. package/src/ui/input/Switch.tsx +62 -0
  162. package/src/ui/input/TagsInput.tsx +115 -0
  163. package/src/ui/input/TextInput.tsx +160 -0
  164. package/src/ui/input/index.ts +13 -0
  165. package/src/ui/input/types.ts +42 -0
  166. package/src/ui/input/util.tsx +105 -0
  167. package/src/ui/ipa/Avatar.tsx +28 -0
  168. package/src/ui/ipa/GroupView.tsx +36 -0
  169. package/src/ui/ipa/LoginBtn.tsx +16 -0
  170. package/src/ui/ipa/UserView.tsx +58 -0
  171. package/src/ui/ipa/index.ts +4 -0
  172. package/src/ui/misc/ContextMenu.tsx +211 -0
  173. package/src/ui/misc/CopyButton.tsx +28 -0
  174. package/src/ui/misc/Dropdown.tsx +194 -0
  175. package/src/ui/misc/EntitySearch.tsx +213 -0
  176. package/src/ui/misc/Lightbox.tsx +194 -0
  177. package/src/ui/misc/LinkCard.tsx +34 -0
  178. package/src/ui/misc/LogEntriesTable.tsx +61 -0
  179. package/src/ui/misc/MarkdownView.tsx +65 -0
  180. package/src/ui/misc/Pagination.tsx +51 -0
  181. package/src/ui/misc/PermissionEditor.tsx +379 -0
  182. package/src/ui/misc/ProgressBar.tsx +47 -0
  183. package/src/ui/misc/RemoveBtn.tsx +27 -0
  184. package/src/ui/misc/StatCell.tsx +90 -0
  185. package/src/ui/misc/index.ts +18 -0
  186. package/src/ui/navigation.ts +32 -0
  187. package/src/ui/prompts.tsx +854 -0
  188. package/src/ui/sidebar.tsx +468 -0
  189. package/src/ui/widgets/Widget.tsx +62 -0
  190. package/src/ui/widgets/WidgetCard.tsx +19 -0
  191. package/src/ui/widgets/WidgetHero.tsx +39 -0
  192. package/src/ui/widgets/WidgetList.tsx +84 -0
  193. package/src/ui/widgets/WidgetPills.tsx +68 -0
  194. package/src/ui/widgets/WidgetStat.tsx +67 -0
  195. package/src/ui/widgets/WidgetStatus.tsx +62 -0
  196. package/src/ui/widgets/index.ts +9 -0
@@ -0,0 +1,230 @@
1
+ import { Hono, type Context } from "hono";
2
+ import { describeRoute } from "hono-openapi";
3
+ import { v } from "../server";
4
+ import { jsonResponse } from "../server";
5
+ import { auth, type AuthContext } from "../server";
6
+ import { rateLimit } from "../server";
7
+ import { authFlows, accounts, getFreeIpaConfig, logger } from "../services";
8
+ import { sql } from "bun";
9
+ import { env } from "../config";
10
+ import { ChangeExpiredPasswordSchema } from "../contracts";
11
+
12
+ const log = logger("auth");
13
+ import {
14
+ LoginSchema,
15
+ EmailLoginSchema,
16
+ VerifyTokenSchema,
17
+ AdminLoginSchema,
18
+ AuthResponseSchema,
19
+ } from "./auth/schemas";
20
+ import { ErrorResponseSchema, MessageResponseSchema } from "../contracts";
21
+
22
+ const jsonError = (c: Context, message: string, status: 400 | 401 | 500) => c.json({ message }, status);
23
+
24
+ /** Authentication routes: login, logout. */
25
+ const app = new Hono<AuthContext>()
26
+ .use(rateLimit())
27
+ .post(
28
+ "/login",
29
+ describeRoute({
30
+ tags: ["Auth"],
31
+ summary: "Login via FreeIPA",
32
+ description: "Authenticate with FreeIPA username and password. Returns a session token and sets a session cookie.",
33
+ responses: {
34
+ 200: jsonResponse(AuthResponseSchema, "Login successful"),
35
+ 401: jsonResponse(ErrorResponseSchema, "Invalid username or password"),
36
+ },
37
+ }),
38
+ v("json", LoginSchema),
39
+ async (c) => {
40
+ const { username, password, acceptedAgb: _ } = c.req.valid("json");
41
+ if (!(await getFreeIpaConfig()).enabled) {
42
+ return c.json({ message: "FreeIPA is disabled." }, 400);
43
+ }
44
+
45
+ const loginResult = await authFlows.ipa.login({ username, password });
46
+ if (!loginResult.ok && loginResult.reason === "password_expired") {
47
+ log.info("Login failed", { uid: username, reason: "password_expired" });
48
+ return c.json({ message: "Password expired", passwordExpired: true }, 401);
49
+ }
50
+ if (!loginResult.ok) {
51
+ log.info("Login failed", {
52
+ uid: username,
53
+ reason: loginResult.reason,
54
+ });
55
+ return c.json({ message: loginResult.message }, loginResult.status);
56
+ }
57
+
58
+ // Store minimal session in Redis
59
+ const sessionToken = await auth.session.create(c, loginResult.userId, loginResult.ipaSession);
60
+
61
+ log.info("Login successful", { uid: username });
62
+ return c.json({
63
+ session_token: sessionToken,
64
+ user: loginResult.user,
65
+ });
66
+ },
67
+ )
68
+ .post(
69
+ "/logout",
70
+ describeRoute({
71
+ tags: ["Auth"],
72
+ summary: "Logout",
73
+ description:
74
+ "Idempotent: clears the session cookie and deletes the session key if present. No authentication required — logout must always succeed.",
75
+ responses: {
76
+ 200: jsonResponse(MessageResponseSchema, "Session invalidated"),
77
+ },
78
+ }),
79
+ async (c) => {
80
+ await auth.session.delete(c);
81
+ log.info("Logout");
82
+ return c.json({ message: "Logged out" });
83
+ },
84
+ )
85
+ .post(
86
+ "/change-expired-password",
87
+ describeRoute({
88
+ tags: ["Auth"],
89
+ summary: "Change expired password",
90
+ description: "Change an expired or temporary password using FreeIPA's change_password endpoint. No active session required. For regular password changes of a logged-in user, use POST /api/me/password instead.",
91
+ responses: {
92
+ 200: jsonResponse(AuthResponseSchema, "Password changed and logged in"),
93
+ 400: jsonResponse(ErrorResponseSchema, "Failed to change password"),
94
+ },
95
+ }),
96
+ v("json", ChangeExpiredPasswordSchema),
97
+ async (c) => {
98
+ const { username, currentPassword, newPassword } = c.req.valid("json");
99
+ if (!(await getFreeIpaConfig()).enabled) {
100
+ return c.json({ message: "FreeIPA is disabled." }, 400);
101
+ }
102
+
103
+ const changeResult = await authFlows.ipa.changeExpiredPassword({
104
+ username,
105
+ currentPassword,
106
+ newPassword,
107
+ });
108
+ if (!changeResult.ok) {
109
+ if (changeResult.reason === "change_failed") {
110
+ const status = changeResult.status >= 500 ? 500 : 400;
111
+ return jsonError(c, changeResult.message, status);
112
+ }
113
+ if (changeResult.reason === "password_expired") {
114
+ return c.json({ message: "Password expired", passwordExpired: true }, 401);
115
+ }
116
+ return jsonError(c, changeResult.message, changeResult.status === 401 ? 401 : 400);
117
+ }
118
+
119
+ const sessionToken = await auth.session.create(c, changeResult.userId, changeResult.ipaSession);
120
+
121
+ log.info("Password changed via expired flow", { uid: username });
122
+ return c.json({ session_token: sessionToken, user: changeResult.user });
123
+ },
124
+ )
125
+ .post(
126
+ "/email-login",
127
+ describeRoute({
128
+ tags: ["Auth"],
129
+ summary: "Request magic link login",
130
+ description: "Request a magic link token via email for local account sign-in.",
131
+ responses: {
132
+ 200: jsonResponse(MessageResponseSchema, "Request accepted"),
133
+ 400: jsonResponse(ErrorResponseSchema, "Email sign-in not available"),
134
+ },
135
+ }),
136
+ v("json", EmailLoginSchema),
137
+ async (c) => {
138
+ const { email } = c.req.valid("json");
139
+
140
+ const requestResult = await authFlows.magicLink.request({ email });
141
+ if (!requestResult.ok) {
142
+ return c.json({ message: requestResult.message }, requestResult.status);
143
+ }
144
+
145
+ log.info("Magic link requested", { email });
146
+ return c.json({
147
+ message: "If this email can sign in with a login code, a code has been sent.",
148
+ });
149
+ },
150
+ )
151
+ .post(
152
+ "/verify-token",
153
+ describeRoute({
154
+ tags: ["Auth"],
155
+ summary: "Verify magic link token",
156
+ description: "Verify a magic link token and create a session.",
157
+ responses: {
158
+ 200: jsonResponse(AuthResponseSchema, "Token verified, session created"),
159
+ 401: jsonResponse(ErrorResponseSchema, "Invalid or expired token"),
160
+ },
161
+ }),
162
+ v("json", VerifyTokenSchema),
163
+ async (c) => {
164
+ const { token } = c.req.valid("json");
165
+
166
+ const verifyResult = await authFlows.magicLink.verify({ token });
167
+ if (!verifyResult.ok) {
168
+ log.info("Token invalid/expired");
169
+ const status = verifyResult.status >= 500 ? 500 : verifyResult.status === 400 ? 400 : 401;
170
+ return jsonError(c, verifyResult.message, status);
171
+ }
172
+
173
+ // Create session (no IPA session for email-only users)
174
+ const sessionToken = await auth.session.create(c, verifyResult.userId, null);
175
+
176
+ if (verifyResult.createdGuest) {
177
+ log.info("Guest user created", { email: verifyResult.email, uid: verifyResult.user.uid });
178
+ }
179
+ log.info("Token verified", { email: verifyResult.email });
180
+ return c.json({ session_token: sessionToken, user: verifyResult.user });
181
+ },
182
+ )
183
+ .post(
184
+ "/admin-login",
185
+ describeRoute({
186
+ tags: ["Auth"],
187
+ summary: "Admin token login",
188
+ description: "Hidden emergency login using a static token. Creates/ensures the local admin account.",
189
+ responses: {
190
+ 200: jsonResponse(AuthResponseSchema, "Login successful"),
191
+ 401: jsonResponse(ErrorResponseSchema, "Invalid token"),
192
+ 503: jsonResponse(ErrorResponseSchema, "Admin login not configured"),
193
+ },
194
+ }),
195
+ v("json", AdminLoginSchema),
196
+ async (c) => {
197
+ if (!env.ADMIN_LOGIN_TOKEN) {
198
+ return jsonError(c, "Admin login is not configured.", 500);
199
+ }
200
+ const { token } = c.req.valid("json");
201
+ const a = Buffer.from(token);
202
+ const b = Buffer.from(env.ADMIN_LOGIN_TOKEN);
203
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
204
+ log.warn("Admin login failed: invalid token");
205
+ return jsonError(c, "Invalid token.", 401);
206
+ }
207
+
208
+ // Ensure the admin user exists and has emergency-admin semantics. The
209
+ // prior `DO NOTHING` could log into a pre-existing `uid='admin'` row that
210
+ // was locally demoted (non-admin local user, or even an IPA row named
211
+ // admin), silently bypassing the intent of the emergency endpoint.
212
+ await sql`
213
+ INSERT INTO auth.users (uid, provider, profile, admin, given_name, sn, display_name)
214
+ VALUES ('admin', 'local', 'user', true, 'Admin', '', 'Admin')
215
+ ON CONFLICT (uid) DO UPDATE SET
216
+ provider = 'local',
217
+ profile = 'user',
218
+ admin = true
219
+ `;
220
+
221
+ const user = await accounts.users.get({ uid: "admin" });
222
+ if (!user) return jsonError(c, "Failed to resolve admin user.", 500);
223
+
224
+ const sessionToken = await auth.session.create(c, user.id, null);
225
+ log.info("Admin login successful");
226
+ return c.json({ session_token: sessionToken, user });
227
+ },
228
+ );
229
+
230
+ export default app;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Core platform API surface — owned by cloud-lib, mounted by the core-app.
3
+ *
4
+ * Lives here (not in an app) because every container shares the same auth
5
+ * model, self-service shape, lifecycle controls and entity search; redefining
6
+ * any of these per-app would break the platform invariant.
7
+ *
8
+ * Apps that need a typed client to these routes import from
9
+ * `@valentinkolb/cloud/clients/core`. The client and the routes share their
10
+ * type via `CoreApiType` below.
11
+ */
12
+ import { Hono } from "hono";
13
+ import { Scalar } from "@scalar/hono-api-reference";
14
+ import { generateSpecs } from "hono-openapi";
15
+ import { prettyJSON } from "hono/pretty-json";
16
+ import { createMarkdownFromOpenApi } from "@scalar/openapi-to-markdown";
17
+ import { openApiMeta } from "../server";
18
+ import authRoutes from "./auth";
19
+ import meRoutes from "./me";
20
+ import adminLifecycleRoutes from "./admin-lifecycle";
21
+ import accountsEntitiesRoutes from "./accounts-entities";
22
+ import { createSearchRoutes } from "./search";
23
+
24
+ /**
25
+ * Single-expression chain so `typeof buildCoreApi()` captures every route's
26
+ * input/output. Splitting into `new Hono()` + `.route(...)` calls would erase
27
+ * the typed shape for the client.
28
+ */
29
+ const buildCoreApi = () => {
30
+ const searchRoutes = createSearchRoutes();
31
+ return new Hono()
32
+ .use(prettyJSON())
33
+ .route("/auth", authRoutes)
34
+ .route("/me", meRoutes)
35
+ .route("/accounts", accountsEntitiesRoutes)
36
+ .route("/admin/lifecycle", adminLifecycleRoutes)
37
+ .route("/", searchRoutes);
38
+ };
39
+
40
+ /** Type of the entire core HTTP surface — consumed by the client builder. */
41
+ export type CoreApiType = ReturnType<typeof buildCoreApi>;
42
+
43
+ /**
44
+ * Build the core router and accompanying OpenAPI assets. The core-app calls
45
+ * this and mounts the returned router under `/api`.
46
+ */
47
+ export const createCoreApiRouter = async () => {
48
+ const api = buildCoreApi();
49
+
50
+ const spec = await generateSpecs(api, openApiMeta);
51
+ const llmsTxt = await createMarkdownFromOpenApi(JSON.stringify(spec));
52
+
53
+ api.get("/openapi.json", (c) => c.json(spec));
54
+ api.get(
55
+ "/docs",
56
+ Scalar({
57
+ theme: "saturn",
58
+ url: "/api/openapi.json",
59
+ hideClientButton: true,
60
+ }),
61
+ );
62
+
63
+ api.all("/*", (c) => c.json({ message: "API route not found" }, 404));
64
+
65
+ return { api, llmsTxt };
66
+ };
package/src/api/me.ts ADDED
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Self-service endpoints — everything a logged-in user does with their OWN
3
+ * account. Owned by core, mounted at /api/me/*. Auth flows live in /auth;
4
+ * third-party management lives in the accounts admin app.
5
+ */
6
+ import { Hono } from "hono";
7
+ import { describeRoute } from "hono-openapi";
8
+ import { z } from "zod";
9
+ import { auth, jsonResponse, rateLimit, requiresAuth, respond, v, type AuthContext } from "../server";
10
+ import { ok } from "@valentinkolb/stdlib";
11
+ import {
12
+ ChangePasswordSchema,
13
+ CreateAccountRequestSchema,
14
+ ErrorResponseSchema,
15
+ MessageResponseSchema,
16
+ UpdateProfileSchema,
17
+ UserSchema,
18
+ } from "../contracts";
19
+ import {
20
+ accountLifecycle,
21
+ accountsAppService as accountsService,
22
+ } from "../services";
23
+
24
+ const ExtendAccountResponseSchema = z.object({
25
+ message: z.string(),
26
+ newExpiry: z.string().datetime().optional(),
27
+ });
28
+
29
+ const AccountRequestResponseSchema = z.object({
30
+ id: z.uuid(),
31
+ message: z.string(),
32
+ });
33
+
34
+ const app = new Hono<AuthContext>()
35
+ .use(rateLimit())
36
+ .use(auth.requireRole("authenticated"))
37
+
38
+ .get(
39
+ "/",
40
+ describeRoute({
41
+ tags: ["Me"],
42
+ summary: "Get current user",
43
+ description: "Return the authenticated user resource.",
44
+ ...requiresAuth,
45
+ responses: {
46
+ 200: jsonResponse(UserSchema, "Current user"),
47
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
48
+ },
49
+ }),
50
+ async (c) => respond(c, ok(c.get("user"))),
51
+ )
52
+
53
+ .patch(
54
+ "/",
55
+ describeRoute({
56
+ tags: ["Me"],
57
+ summary: "Update current user",
58
+ description: "Update the authenticated user's canonical profile fields and IPA-only self-service fields.",
59
+ ...requiresAuth,
60
+ responses: {
61
+ 200: jsonResponse(MessageResponseSchema, "Profile updated"),
62
+ 400: jsonResponse(ErrorResponseSchema, "Failed to update profile"),
63
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
64
+ },
65
+ }),
66
+ v("json", UpdateProfileSchema),
67
+ async (c) =>
68
+ respond(c, async () => {
69
+ const user = c.get("user");
70
+ const token = c.get("sessionToken");
71
+ 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 });
74
+ if (!result.ok) return result;
75
+ return ok({ message: "Profile updated." });
76
+ }),
77
+ )
78
+
79
+ .post(
80
+ "/password",
81
+ describeRoute({
82
+ tags: ["Me"],
83
+ summary: "Change current user password",
84
+ description: "Change the authenticated user's password. Requires the current password for verification.",
85
+ ...requiresAuth,
86
+ responses: {
87
+ 200: jsonResponse(MessageResponseSchema, "Password changed"),
88
+ 400: jsonResponse(ErrorResponseSchema, "Failed to change password"),
89
+ 401: jsonResponse(ErrorResponseSchema, "Current password is incorrect"),
90
+ },
91
+ }),
92
+ v("json", ChangePasswordSchema),
93
+ async (c) =>
94
+ respond(c, async () => {
95
+ const result = await accountsService.user.changeOwnPassword({
96
+ user: c.get("user"),
97
+ currentPassword: c.req.valid("json").currentPassword,
98
+ newPassword: c.req.valid("json").newPassword,
99
+ });
100
+ if (!result.ok) return result;
101
+ return ok({ message: "Password changed successfully." });
102
+ }),
103
+ )
104
+
105
+ .post(
106
+ "/account-extension",
107
+ describeRoute({
108
+ tags: ["Me"],
109
+ summary: "Extend current user account",
110
+ description: "Extends the authenticated account according to lifecycle settings.",
111
+ ...requiresAuth,
112
+ responses: {
113
+ 200: jsonResponse(ExtendAccountResponseSchema, "Account extension result"),
114
+ 400: jsonResponse(ErrorResponseSchema, "Unable to extend account"),
115
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
116
+ },
117
+ }),
118
+ async (c) =>
119
+ respond(c, async () => {
120
+ 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);
125
+ }),
126
+ )
127
+
128
+ .delete(
129
+ "/",
130
+ describeRoute({
131
+ tags: ["Me"],
132
+ summary: "Delete current user",
133
+ description: "Delete the authenticated user's account. Only available for guest-profile users.",
134
+ ...requiresAuth,
135
+ responses: {
136
+ 200: jsonResponse(MessageResponseSchema, "Account deleted"),
137
+ 400: jsonResponse(ErrorResponseSchema, "Failed to delete account"),
138
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
139
+ 403: jsonResponse(ErrorResponseSchema, "Only guest accounts can be self-deleted"),
140
+ },
141
+ }),
142
+ async (c) =>
143
+ respond(c, async () => {
144
+ 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 });
151
+ if (!result.ok) return result;
152
+ await auth.session.delete(c);
153
+ return ok({ message: "Account deleted." });
154
+ }),
155
+ )
156
+
157
+ // Account request: a local user asks for an IPA-backed account. Each user
158
+ // has at most one pending request; `POST` creates it, `DELETE` withdraws
159
+ // the current one. Admin processing of requests lives in the accounts app.
160
+ .post(
161
+ "/account-request",
162
+ describeRoute({
163
+ tags: ["Me"],
164
+ summary: "Submit account request",
165
+ description: "Local accounts can request a centrally managed FreeIPA account. Must accept terms of service.",
166
+ ...requiresAuth,
167
+ responses: {
168
+ 201: jsonResponse(AccountRequestResponseSchema, "Request created"),
169
+ 400: jsonResponse(ErrorResponseSchema, "FreeIPA is disabled"),
170
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
171
+ 403: jsonResponse(ErrorResponseSchema, "Only local accounts can request FreeIPA access"),
172
+ 409: jsonResponse(ErrorResponseSchema, "Pending request already exists"),
173
+ },
174
+ }),
175
+ v("json", CreateAccountRequestSchema),
176
+ async (c) =>
177
+ respond(c, accountsService.accountRequest.create({ user: c.get("user"), data: c.req.valid("json") }), 201),
178
+ )
179
+
180
+ .delete(
181
+ "/account-request",
182
+ describeRoute({
183
+ tags: ["Me"],
184
+ summary: "Withdraw pending account request",
185
+ description: "Withdraws the current user's pending FreeIPA account request, if any.",
186
+ ...requiresAuth,
187
+ responses: {
188
+ 200: jsonResponse(MessageResponseSchema, "Request withdrawn"),
189
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
190
+ 404: jsonResponse(ErrorResponseSchema, "No pending request"),
191
+ },
192
+ }),
193
+ async (c) =>
194
+ respond(c, async () => {
195
+ const user = c.get("user");
196
+ const pending = await accountsService.accountRequest.getPendingForUser({ userId: user.id });
197
+ if (!pending) {
198
+ return { ok: false, error: "No pending request", status: 404 };
199
+ }
200
+ const result = await accountsService.accountRequest.withdraw({ id: pending.id, userId: user.id });
201
+ if (!result.ok) return result;
202
+ return ok({ message: "Request withdrawn" });
203
+ }),
204
+ );
205
+
206
+ export default app;
@@ -0,0 +1,43 @@
1
+ import { z } from "zod";
2
+
3
+ const TAG_PATTERN = /^[^\s#]+$/;
4
+
5
+ const TagArraySchema = z.preprocess(
6
+ (value) => {
7
+ if (Array.isArray(value)) return value;
8
+ if (typeof value === "string") return [value];
9
+ return [];
10
+ },
11
+ z
12
+ .array(z.string().trim().min(1).regex(TAG_PATTERN))
13
+ .transform((tags) => [...new Set(tags.map((tag) => tag.toLowerCase()))]),
14
+ );
15
+
16
+ export const SearchQuerySchema = z.object({
17
+ q: z.string().optional().default("").transform((query) => query.trim()),
18
+ tag: TagArraySchema.optional().default([]),
19
+ provider_limit: z.coerce.number().int().min(1).max(99).optional().default(10),
20
+ });
21
+
22
+ export const SearchItemSchema = z.object({
23
+ appId: z.string(),
24
+ appName: z.string(),
25
+ appIcon: z.string(),
26
+ id: z.string(),
27
+ title: z.string(),
28
+ href: z.string().startsWith("/"),
29
+ preview: z.string().optional(),
30
+ icon: z.string().optional(),
31
+ priority: z.number().int().min(0).max(9).optional(),
32
+ metadata: z.array(z.object({ label: z.string(), value: z.string() })).optional(),
33
+ previewUrl: z.string().startsWith("/").optional(),
34
+ });
35
+
36
+ export const SearchResponseSchema = z.object({
37
+ query: z.string(),
38
+ count: z.number().int().nonnegative(),
39
+ items: z.array(SearchItemSchema),
40
+ });
41
+
42
+ export type SearchItem = z.infer<typeof SearchItemSchema>;
43
+ export type SearchResponse = z.infer<typeof SearchResponseSchema>;
@@ -0,0 +1,130 @@
1
+ import { Hono } from "hono";
2
+ import { describeRoute } from "hono-openapi";
3
+ import { auth, jsonResponse, requiresAuth, v, type AuthContext } from "../server";
4
+ import { ErrorResponseSchema } from "../contracts";
5
+ import { logger } from "../services";
6
+ import { listApps } from "..";
7
+ import { SearchItemSchema, SearchQuerySchema, SearchResponseSchema, type SearchItem } from "./search/schemas";
8
+
9
+ const log = logger("search");
10
+
11
+ type HttpSearchProvider = {
12
+ appId: string;
13
+ appName: string;
14
+ appIcon: string;
15
+ endpoint: string;
16
+ };
17
+
18
+ /**
19
+ * Discovers search providers from the app registry.
20
+ * Only apps with a `search` capability (and therefore a search endpoint) are included.
21
+ */
22
+ const getSearchProviders = async (): Promise<HttpSearchProvider[]> => {
23
+ const entries = await listApps();
24
+ return entries
25
+ .filter((e) => !!e.search)
26
+ .map((e) => ({
27
+ appId: e.id,
28
+ appName: e.name,
29
+ appIcon: e.icon,
30
+ endpoint: e.search!.endpoint,
31
+ }));
32
+ };
33
+
34
+ /**
35
+ * Creates the global search route.
36
+ * Discovers search providers from the registry and fetches results via HTTP,
37
+ * forwarding the session cookie for authentication.
38
+ */
39
+ export const createSearchRoutes = () =>
40
+ new Hono<AuthContext>()
41
+ .use(auth.requireRole("authenticated"))
42
+ .get(
43
+ "/search",
44
+ describeRoute({
45
+ tags: ["Search"],
46
+ summary: "Global search",
47
+ description: "Searches across app providers discovered via the service registry with optional tag filters.",
48
+ ...requiresAuth,
49
+ responses: {
50
+ 200: jsonResponse(SearchResponseSchema, "Merged search results"),
51
+ 400: jsonResponse(ErrorResponseSchema, "Invalid query"),
52
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
53
+ },
54
+ }),
55
+ v("query", SearchQuerySchema),
56
+ async (c) => {
57
+ const query = c.req.valid("query");
58
+ const providers = await getSearchProviders();
59
+
60
+ const settled = await Promise.allSettled(
61
+ providers.map(async (provider) => {
62
+ const res = await fetch(provider.endpoint, {
63
+ method: "POST",
64
+ headers: {
65
+ "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") ?? "",
68
+ },
69
+ body: JSON.stringify({
70
+ query: query.q,
71
+ tags: query.tag,
72
+ limit: query.provider_limit,
73
+ }),
74
+ });
75
+
76
+ if (!res.ok) {
77
+ throw new Error(`Search provider ${provider.appId} returned ${res.status}`);
78
+ }
79
+
80
+ const results: unknown[] = await res.json();
81
+ const validItems: SearchItem[] = [];
82
+
83
+ for (const item of results) {
84
+ const parsed = SearchItemSchema.safeParse({
85
+ ...(item as Record<string, unknown>),
86
+ appId: provider.appId,
87
+ appName: provider.appName,
88
+ appIcon: provider.appIcon,
89
+ });
90
+ if (!parsed.success) {
91
+ log.warn("Search provider returned invalid item", {
92
+ appId: provider.appId,
93
+ tags: query.tag,
94
+ issues: parsed.error.issues.map((issue) => issue.message),
95
+ });
96
+ continue;
97
+ }
98
+ validItems.push(parsed.data);
99
+ }
100
+
101
+ return validItems;
102
+ }),
103
+ );
104
+
105
+ const items = settled.flatMap((result, index) => {
106
+ if (result.status === "fulfilled") return result.value;
107
+
108
+ log.warn("Search provider failed", {
109
+ appId: providers[index]?.appId ?? "unknown",
110
+ tags: query.tag,
111
+ error: result.reason instanceof Error ? result.reason.message : String(result.reason),
112
+ });
113
+ return [];
114
+ });
115
+
116
+ items.sort((a, b) => {
117
+ const priorityDiff = (b.priority ?? 0) - (a.priority ?? 0);
118
+ if (priorityDiff !== 0) return priorityDiff;
119
+ return a.title.localeCompare(b.title);
120
+ });
121
+
122
+ return c.json({
123
+ query: query.q,
124
+ count: items.length,
125
+ items,
126
+ });
127
+ },
128
+ );
129
+
130
+ export type SearchApiType = ReturnType<typeof createSearchRoutes>;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Typed client for the core platform API.
3
+ *
4
+ * Apps that need to call core endpoints (`/api/auth/*`, `/api/me/*`,
5
+ * `/api/admin/lifecycle/*`, `/api/accounts/entities`) import this client
6
+ * instead of constructing their own — the type stays in lockstep with the
7
+ * actual route definitions in `@valentinkolb/cloud/api`.
8
+ */
9
+ import { api } from "../server/api-client";
10
+ import type { CoreApiType } from "../api";
11
+
12
+ export const coreClient = api.create<CoreApiType>({ baseUrl: "/api" });
13
+
14
+ /**
15
+ * Alias for islands that already use the conventional name `apiClient`.
16
+ * Both names refer to the same client; use whichever fits the call site.
17
+ */
18
+ export const apiClient = coreClient;
19
+ export type { CoreApiType };