@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,255 @@
1
+ import { sql } from "bun";
2
+ import { writeDeletedAccountAudit } from "../../account-lifecycle/audit";
3
+ import { session } from "../../session";
4
+ import * as settings from "../../settings";
5
+ import { generateUniqueAbbreviation } from "../../ipa/users";
6
+ import { resolveStoredAdminState } from "../../accounts/model";
7
+ import { isUniqueViolation } from "../../postgres";
8
+ import type { MutationResult, UserProfile } from "../../../contracts/shared";
9
+
10
+ type DbRow = Record<string, unknown>;
11
+
12
+ export type LocalUserCreateData = {
13
+ email: string;
14
+ givenname?: string;
15
+ sn?: string;
16
+ displayName?: string;
17
+ };
18
+
19
+ const createLocalUid = async (): Promise<string> => {
20
+ const abbrLen = await settings.get<number>("user.abbr_length");
21
+ return generateUniqueAbbreviation(abbrLen);
22
+ };
23
+
24
+ export const create = async (params: {
25
+ data: LocalUserCreateData;
26
+ profile: UserProfile;
27
+ accountExpires: Date | null;
28
+ admin?: boolean;
29
+ }): Promise<MutationResult<{ id: string }>> => {
30
+ const uid = await createLocalUid();
31
+ const admin = resolveStoredAdminState({
32
+ provider: "local",
33
+ profile: params.profile,
34
+ requestedAdmin: params.admin,
35
+ });
36
+
37
+ try {
38
+ const rows = await sql<DbRow[]>`
39
+ INSERT INTO auth.users (
40
+ uid,
41
+ provider,
42
+ profile,
43
+ mail,
44
+ given_name,
45
+ sn,
46
+ display_name,
47
+ admin,
48
+ account_expires
49
+ )
50
+ VALUES (
51
+ ${uid},
52
+ 'local',
53
+ ${params.profile},
54
+ ${params.data.email},
55
+ ${params.data.givenname ?? ""},
56
+ ${params.data.sn ?? ""},
57
+ ${params.data.displayName ?? ""},
58
+ ${admin},
59
+ ${params.accountExpires}
60
+ )
61
+ RETURNING id
62
+ `;
63
+ return { ok: true, data: { id: rows[0]!.id as string } };
64
+ } catch (error) {
65
+ // Map Postgres unique violations to a typed 409 instead of leaking raw DB
66
+ // errors. Two possible collisions: generated uid (extremely rare, retry at
67
+ // next call) or email already used by another local account.
68
+ if (isUniqueViolation(error, "users_uid_key")) {
69
+ return { ok: false, error: "Generated UID collided. Please retry.", status: 409 };
70
+ }
71
+ if (isUniqueViolation(error, "idx_users_provider_mail")) {
72
+ return { ok: false, error: "A local account with this email already exists.", status: 409 };
73
+ }
74
+ throw error;
75
+ }
76
+ };
77
+
78
+ export const createGuest = async (params: {
79
+ email: string;
80
+ givenname?: string;
81
+ sn?: string;
82
+ displayName?: string;
83
+ accountExpires?: Date | null;
84
+ }): Promise<MutationResult<{ id: string }>> => {
85
+ let accountExpires = params.accountExpires ?? null;
86
+ if (params.accountExpires === undefined) {
87
+ const configured = await settings.get<number | null>("user.account.local_guest_expires_days");
88
+ const days = typeof configured === "number" ? configured : 365;
89
+ accountExpires = days > 0 ? new Date(Date.now() + days * 24 * 60 * 60 * 1000) : null;
90
+ }
91
+
92
+ return create({
93
+ data: {
94
+ email: params.email,
95
+ givenname: params.givenname,
96
+ sn: params.sn,
97
+ displayName: params.displayName,
98
+ },
99
+ profile: "guest",
100
+ accountExpires,
101
+ });
102
+ };
103
+
104
+ export const update = async (params: {
105
+ id: string;
106
+ data: {
107
+ givenname?: string;
108
+ sn?: string;
109
+ displayName?: string;
110
+ mail?: string;
111
+ };
112
+ }): Promise<MutationResult<void>> => {
113
+ const existingRows = await sql<DbRow[]>`SELECT id FROM auth.users WHERE id = ${params.id}::uuid AND provider = 'local'`;
114
+ if (existingRows.length === 0) {
115
+ return { ok: false, error: "Local user not found", status: 404 };
116
+ }
117
+
118
+ await sql`
119
+ UPDATE auth.users
120
+ SET given_name = CASE WHEN ${params.data.givenname !== undefined} THEN ${params.data.givenname ?? ""} ELSE given_name END,
121
+ sn = CASE WHEN ${params.data.sn !== undefined} THEN ${params.data.sn ?? ""} ELSE sn END,
122
+ display_name = CASE WHEN ${params.data.displayName !== undefined} THEN ${params.data.displayName ?? ""} ELSE display_name END,
123
+ mail = CASE WHEN ${params.data.mail !== undefined} THEN ${params.data.mail ?? null} ELSE mail END
124
+ WHERE id = ${params.id}::uuid
125
+ `;
126
+
127
+ return { ok: true, data: undefined };
128
+ };
129
+
130
+ export const setProfile = async (params: {
131
+ id: string;
132
+ profile: UserProfile;
133
+ accountExpires: Date | null;
134
+ }): Promise<MutationResult<void>> => {
135
+ const rows = await sql<DbRow[]>`
136
+ SELECT provider, admin
137
+ FROM auth.users
138
+ WHERE id = ${params.id}::uuid
139
+ `;
140
+ if (rows.length === 0) return { ok: false, error: "User not found", status: 404 };
141
+ if ((rows[0]!.provider as string) !== "local") {
142
+ return { ok: false, error: "Only local accounts can change profile locally", status: 400 };
143
+ }
144
+
145
+ const admin = resolveStoredAdminState({
146
+ provider: "local",
147
+ profile: params.profile,
148
+ currentAdmin: Boolean(rows[0]!.admin),
149
+ });
150
+
151
+ await sql`
152
+ UPDATE auth.users
153
+ SET provider = 'local',
154
+ profile = ${params.profile},
155
+ admin = ${admin},
156
+ account_expires = ${params.accountExpires}
157
+ WHERE id = ${params.id}::uuid
158
+ `;
159
+
160
+ return { ok: true, data: undefined };
161
+ };
162
+
163
+ export const setExpiry = async (params: {
164
+ id: string;
165
+ profile: UserProfile;
166
+ accountExpires: Date | null;
167
+ }): Promise<MutationResult<void>> => {
168
+ const rows = await sql<DbRow[]>`
169
+ SELECT provider
170
+ FROM auth.users
171
+ WHERE id = ${params.id}::uuid
172
+ `;
173
+ if (rows.length === 0) return { ok: false, error: "User not found", status: 404 };
174
+ if ((rows[0]!.provider as string) !== "local") {
175
+ return { ok: false, error: "Only local accounts support local expiry changes", status: 400 };
176
+ }
177
+
178
+ await sql`
179
+ UPDATE auth.users
180
+ SET account_expires = ${params.accountExpires}
181
+ WHERE id = ${params.id}::uuid
182
+ `;
183
+
184
+ return { ok: true, data: undefined };
185
+ };
186
+
187
+ export const setAdmin = async (params: {
188
+ id: string;
189
+ admin: boolean;
190
+ }): Promise<MutationResult<void>> => {
191
+ const rows = await sql<DbRow[]>`
192
+ SELECT provider, profile, admin
193
+ FROM auth.users
194
+ WHERE id = ${params.id}::uuid
195
+ `;
196
+ if (rows.length === 0) return { ok: false, error: "User not found", status: 404 };
197
+ const row = rows[0]!;
198
+ if ((row.provider as string) !== "local") {
199
+ return { ok: false, error: "Only local full accounts can be granted admin access", status: 400 };
200
+ }
201
+ if ((row.profile as string) !== "user") {
202
+ return { ok: false, error: "Guest accounts cannot be granted admin access", status: 400 };
203
+ }
204
+ const currentAdmin = Boolean(row.admin);
205
+ if (currentAdmin === params.admin) {
206
+ return { ok: false, error: params.admin ? "Account is already an admin" : "Account is not an admin", status: 409 };
207
+ }
208
+
209
+ await sql`
210
+ UPDATE auth.users
211
+ SET admin = ${params.admin}
212
+ WHERE id = ${params.id}::uuid
213
+ `;
214
+
215
+ return { ok: true, data: undefined };
216
+ };
217
+
218
+ export const remove = async (params: {
219
+ id: string;
220
+ actor: { userId: string; uid: string };
221
+ }): Promise<MutationResult<void>> => {
222
+ const rows = await sql<DbRow[]>`
223
+ SELECT uid, profile, mail, display_name
224
+ FROM auth.users
225
+ WHERE id = ${params.id}::uuid
226
+ AND provider = 'local'
227
+ `;
228
+ if (rows.length === 0) return { ok: false, error: "Local user not found", status: 404 };
229
+
230
+ const row = rows[0]!;
231
+ const uid = row.uid as string;
232
+ await sql.begin(async (tx) => {
233
+ await writeDeletedAccountAudit({
234
+ db: tx,
235
+ userId: params.id,
236
+ uid,
237
+ mail: (row.mail as string) ?? null,
238
+ displayName: (row.display_name as string) ?? null,
239
+ previousProvider: "local",
240
+ previousProfile: (row.profile as UserProfile | null) ?? "guest",
241
+ reason: "manual_delete",
242
+ meta: {
243
+ actorUserId: params.actor.userId,
244
+ actorUid: params.actor.uid,
245
+ deletedFromFreeIpa: false,
246
+ freeIpaUserAlreadyMissing: false,
247
+ },
248
+ });
249
+ await tx`DELETE FROM auth.users WHERE id = ${params.id}::uuid`;
250
+ });
251
+
252
+ await session.revokeAllForUser(params.id);
253
+
254
+ return { ok: true, data: undefined };
255
+ };
@@ -0,0 +1,137 @@
1
+ import type { Context } from "hono";
2
+ import { getCookie, setCookie, deleteCookie } from "hono/cookie";
3
+ import { redis, sql } from "bun";
4
+ import { env } from "../../config/env";
5
+ import * as settings from "../settings";
6
+
7
+ /**
8
+ * Session data stored in Redis per session key.
9
+ *
10
+ * `gen` captures the user's session-generation counter at the time the session
11
+ * was created. Revoking all sessions for a user is an atomic INCR on that
12
+ * counter; any stored session whose `gen` is below the current counter is
13
+ * rejected by `getData()` without touching the session key itself.
14
+ */
15
+ type SessionData = {
16
+ userId: string;
17
+ ipaSession: string | null;
18
+ gen: number;
19
+ };
20
+
21
+ const sessionKey = (userId: string, randomToken: string) => `session:${userId}:${randomToken}`;
22
+ const genKey = (userId: string) => `session:gen:${userId}`;
23
+
24
+ /**
25
+ * Read the current generation counter for a user. Missing key is treated as 0.
26
+ * The counter never resets, only increments, so a user's earliest session has
27
+ * `gen = 0` implicitly even before the first revocation.
28
+ */
29
+ const readGen = async (userId: string): Promise<number> => {
30
+ const raw = await redis.get(genKey(userId));
31
+ if (!raw) return 0;
32
+ const n = Number(raw);
33
+ return Number.isFinite(n) ? n : 0;
34
+ };
35
+
36
+ const parseToken = (token: string): { userId: string; randomToken: string } | null => {
37
+ const colonIndex = token.indexOf(":");
38
+ if (colonIndex === -1) return null;
39
+ const userId = token.slice(0, colonIndex);
40
+ const randomToken = token.slice(colonIndex + 1);
41
+ if (!userId || !randomToken) return null;
42
+ return { userId, randomToken };
43
+ };
44
+
45
+ const parseBearer = (header: string | undefined): string | null => {
46
+ if (!header) return null;
47
+ const match = header.match(/^Bearer\s+(\S+)$/i);
48
+ return match?.[1] ?? null;
49
+ };
50
+
51
+ export const session = {
52
+ /**
53
+ * Get session token from cookie or Authorization header.
54
+ * Token format: userId:randomToken
55
+ *
56
+ * Note: the userId is embedded in the token to enable efficient generation
57
+ * lookup and per-user key namespacing. The userId (UUID) is not secret.
58
+ */
59
+ getToken: (c: Context): string | null => {
60
+ const cookie = getCookie(c, "session_token");
61
+ const bearer = parseBearer(c.req.header("Authorization"));
62
+ return cookie || bearer || null;
63
+ },
64
+
65
+ parseToken,
66
+
67
+ create: async (c: Context, userId: string, ipaSession: string | null = null): Promise<string> => {
68
+ const randomToken = crypto.randomUUID();
69
+ const expiryHours = await settings.get<number>("user.session.expiry_hours");
70
+ const ttl = expiryHours * 60 * 60;
71
+
72
+ const gen = await readGen(userId);
73
+ const data: SessionData = { userId, ipaSession, gen };
74
+ await redis.set(sessionKey(userId, randomToken), JSON.stringify(data), "EX", ttl);
75
+
76
+ await sql`UPDATE auth.users SET last_login_local = now() WHERE id = ${userId}`;
77
+
78
+ const clientToken = `${userId}:${randomToken}`;
79
+
80
+ setCookie(c, "session_token", clientToken, {
81
+ httpOnly: true,
82
+ secure: !env.IS_DEVELOPMENT,
83
+ sameSite: "Lax",
84
+ maxAge: ttl,
85
+ path: "/",
86
+ });
87
+
88
+ return clientToken;
89
+ },
90
+
91
+ /** Explicit logout — drops the current session key and cookie. Does not affect other sessions. */
92
+ delete: async (c: Context): Promise<void> => {
93
+ const token = session.getToken(c);
94
+ if (token) {
95
+ const parsed = parseToken(token);
96
+ if (parsed) {
97
+ await redis.del(sessionKey(parsed.userId, parsed.randomToken));
98
+ }
99
+ }
100
+ deleteCookie(c, "session_token", { path: "/" });
101
+ },
102
+
103
+ /**
104
+ * Atomically revoke every existing session for a user by bumping the
105
+ * generation counter. Replaces the former SCAN+DEL loop. Future reads
106
+ * via `getData()` will reject any session stored with the previous `gen`.
107
+ *
108
+ * Race-safe against concurrent `session.create()`: the newly-created session
109
+ * either writes the pre-INCR gen (and is immediately invalid) or the
110
+ * post-INCR gen (and is intentionally valid if the caller ordered INCR
111
+ * before granting the new login).
112
+ */
113
+ revokeAllForUser: async (userId: string): Promise<void> => {
114
+ await redis.incr(genKey(userId));
115
+ },
116
+
117
+ /**
118
+ * Load session data, enforcing the generation check. Returns `null` when the
119
+ * token is malformed, the key is missing/expired, or the stored `gen` is
120
+ * below the user's current counter.
121
+ */
122
+ getData: async (token: string): Promise<SessionData | null> => {
123
+ const parsed = parseToken(token);
124
+ if (!parsed) return null;
125
+ const raw = await redis.get(sessionKey(parsed.userId, parsed.randomToken));
126
+ if (!raw) return null;
127
+ const data = JSON.parse(raw) as SessionData;
128
+ const currentGen = await readGen(parsed.userId);
129
+ if ((data.gen ?? 0) < currentGen) return null;
130
+ return data;
131
+ },
132
+
133
+ getIpaSession: async (token: string): Promise<string | null> => {
134
+ const data = await session.getData(token);
135
+ return data?.ipaSession ?? null;
136
+ },
137
+ };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Typed async settings API.
3
+ *
4
+ * Two surfaces are exposed:
5
+ *
6
+ * 1. `app.settings` — created by `defineApp`, typed against that app's own
7
+ * declared settings (plus any core settings since core's snapshot is
8
+ * loaded into every container). Available on the AppDefinition return value.
9
+ *
10
+ * 2. `coreSettings` — for cloud-lib internal services that don't have an
11
+ * `app` reference (e.g. `services/notifications/email.ts`,
12
+ * `services/weather/forecast.ts`, OpenAPI middleware, cron callbacks).
13
+ * Loosely typed (caller provides the value type via the `T` generic).
14
+ *
15
+ * Backend: cache-aside via `store.ts` (Redis 5min TTL + DB fallback). All
16
+ * functions are async — sync render-time access uses the per-request snapshot
17
+ * exposed via `c.get("settings")`.
18
+ */
19
+
20
+ import type { AppSettingsMap, KindToType } from "../../contracts/settings-types";
21
+ import { deleteKey, readKey, writeKey } from "./store";
22
+
23
+ /**
24
+ * Per-app typed settings API. Keys constrained to those declared in the
25
+ * app's `defineApp({ settings: ... })` block.
26
+ */
27
+ export interface SettingsAPI<F extends Record<string, unknown>> {
28
+ get<K extends keyof F & string>(key: K): Promise<F[K]>;
29
+ set<K extends keyof F & string>(key: K, value: F[K]): Promise<void>;
30
+ remove<K extends keyof F & string>(key: K): Promise<void>;
31
+ }
32
+
33
+ /**
34
+ * Build a typed SettingsAPI for a given AppSettingsMap.
35
+ *
36
+ * The runtime is identical for every app (delegates to store.ts); the typing
37
+ * is purely a TS construct that constrains keys/values to what was declared.
38
+ */
39
+ export const createSettingsAPI = <S extends AppSettingsMap>(): SettingsAPI<{
40
+ [K in keyof S]: KindToType<S[K]["kind"]>;
41
+ }> => ({
42
+ get: async (key) => readKey(key) as never,
43
+ set: async (key, value) => writeKey(key, value),
44
+ remove: async (key) => deleteKey(key),
45
+ });
46
+
47
+ /**
48
+ * cloud-lib internal services and other apps use `coreSettings` for any
49
+ * setting access outside of a per-request context. Loose-typed (the caller
50
+ * specifies the expected value type via the `T` generic) — apps that need
51
+ * tight typing for their OWN settings use `app.settings.get/set` instead.
52
+ *
53
+ * Usage:
54
+ * await coreSettings.get<string>("app.name"); // string
55
+ * await coreSettings.set("freeipa.enable", true); // value type free
56
+ */
57
+ export const coreSettings = {
58
+ get: <T = unknown>(key: string): Promise<T> => readKey(key) as Promise<T>,
59
+ set: (key: string, value: unknown): Promise<void> => writeKey(key, value),
60
+ remove: (key: string): Promise<void> => deleteKey(key),
61
+ };
@@ -0,0 +1,101 @@
1
+ /**
2
+ * App-facing settings service. Wraps the lower-level primitives in
3
+ * `services/settings` (get, set, getAll, remove + SETTINGS_MAP) with the
4
+ * filtered list / validated update / reset surface the admin UIs need.
5
+ *
6
+ * Lives in cloud-lib because every app that has app-scoped settings needs
7
+ * the same API to render its admin form (files, weather, etc.).
8
+ */
9
+ import { err, fail, ok, paginate, type PageParams, type Paginated } from "@valentinkolb/stdlib";
10
+ import * as settingsPrimitives from ".";
11
+ import type { SettingEntry } from ".";
12
+ import { SETTINGS_MAP, validateSettingValue } from "./defaults";
13
+
14
+ const paginateEntries = <T>(items: T[], pagination?: PageParams): Paginated<T> => {
15
+ if (!pagination) {
16
+ return { items, page: 1, perPage: items.length, total: items.length, hasNext: false };
17
+ }
18
+ const { page, perPage, offset } = paginate(pagination);
19
+ return {
20
+ items: items.slice(offset, offset + perPage),
21
+ page,
22
+ perPage,
23
+ total: items.length,
24
+ hasNext: page * perPage < items.length,
25
+ };
26
+ };
27
+
28
+ /**
29
+ * Redact secret-kind setting values before they leave the server.
30
+ *
31
+ * Secret values are encrypted at rest and decrypted on read for runtime use,
32
+ * but the admin UI receives them via SSR `data-props` (visible in HTML
33
+ * source, browser caches, devtools). For secrets we hide the actual value
34
+ * and rely on the form's change-tracking: if the admin doesn't type a new
35
+ * value, no PUT is sent and the stored value stays. The placeholder hint in
36
+ * the UI signals "leave empty to keep current".
37
+ */
38
+ const redactSecretValue = (entry: SettingEntry): SettingEntry => {
39
+ if (entry.kind !== "secret") return entry;
40
+ return { ...entry, value: "" };
41
+ };
42
+
43
+ export const settingsService = {
44
+ entry: {
45
+ list: async (config?: {
46
+ pagination?: PageParams;
47
+ filter?: { query?: string; group?: string };
48
+ }): Promise<Paginated<SettingEntry>> => {
49
+ const entries = await settingsPrimitives.getAll();
50
+ const query = config?.filter?.query?.trim().toLowerCase();
51
+ const group = config?.filter?.group?.trim().toLowerCase();
52
+
53
+ const filtered = entries
54
+ .filter((entry) => {
55
+ if (group && entry.group.toLowerCase() !== group) return false;
56
+ if (!query) return true;
57
+ return (
58
+ entry.key.toLowerCase().includes(query) ||
59
+ entry.label.toLowerCase().includes(query) ||
60
+ entry.description.toLowerCase().includes(query)
61
+ );
62
+ })
63
+ .map(redactSecretValue);
64
+
65
+ return paginateEntries(filtered, config?.pagination);
66
+ },
67
+ update: async (config: { key: string; value: unknown }) => {
68
+ if (!SETTINGS_MAP.has(config.key)) {
69
+ return fail(err.badInput(`Unknown setting: ${config.key}`));
70
+ }
71
+ const def = SETTINGS_MAP.get(config.key)!;
72
+ const validated = validateSettingValue(def, config.value);
73
+ if (!validated.ok) {
74
+ return fail(err.badInput(validated.error));
75
+ }
76
+ try {
77
+ await settingsPrimitives.set(config.key, validated.value);
78
+ return ok(undefined);
79
+ } catch (error) {
80
+ const message = error instanceof Error ? error.message : String(error);
81
+ if (message.startsWith("Unknown setting:")) {
82
+ return fail(err.badInput(message));
83
+ }
84
+ return fail(err.internal(`Failed to update setting: ${message}`));
85
+ }
86
+ },
87
+ reset: async (config: { key: string }) => {
88
+ if (!SETTINGS_MAP.has(config.key)) {
89
+ return fail(err.badInput(`Unknown setting: ${config.key}`));
90
+ }
91
+ try {
92
+ await settingsPrimitives.remove(config.key);
93
+ return ok(undefined);
94
+ } catch (error) {
95
+ return fail(err.internal(`Failed to reset setting: ${error instanceof Error ? error.message : String(error)}`));
96
+ }
97
+ },
98
+ },
99
+ };
100
+
101
+ export type SettingsService = typeof settingsService;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Encryption helpers for settings at-rest.
3
+ *
4
+ * Settings DB rows are encrypted via stdlib's `crypto.symmetric.encrypt`
5
+ * (AES-256-GCM with HKDF key derivation). The key material comes from
6
+ * `APP_SECRET` — see `getAppSecret()` for the hex/passphrase handling.
7
+ */
8
+
9
+ import { createHash } from "node:crypto";
10
+ import { env } from "../../config/env";
11
+ import { crypto } from "../../server/services";
12
+
13
+ /**
14
+ * Resolve the encryption key passed to stdlib's `crypto.symmetric.encrypt`.
15
+ *
16
+ * Stdlib derives keys via HKDF and requires the input to be a hex string
17
+ * (it calls `fromHex(key)` internally). Two cases:
18
+ *
19
+ * 1. APP_SECRET is already a valid hex string (any even length).
20
+ * Pass it through unchanged. THIS IS THE PRODUCTION PATH — existing
21
+ * deployments use hex secrets and have data encrypted with the secret
22
+ * verbatim. Any transformation here would change the derived HKDF key
23
+ * and make every previously encrypted settings row unreadable.
24
+ *
25
+ * 2. APP_SECRET is not valid hex (e.g. a passphrase like "supersecret").
26
+ * Deterministically derive a 32-byte hex key via SHA-256. Same input
27
+ * always produces the same hex output, so settings encrypted with a
28
+ * non-hex secret stay decryptable across restarts.
29
+ *
30
+ * ⚠️ DO NOT change the hex-passthrough branch. Hashing a hex secret would
31
+ * silently break every prod instance: encrypted settings could no longer
32
+ * be decrypted because the HKDF input would differ from what was used at
33
+ * write time. The branch exists specifically to preserve backward compat
34
+ * with deployments that have always used hex-format APP_SECRET.
35
+ */
36
+ export const getAppSecret = (): string => {
37
+ const raw = env.APP_SECRET.trim();
38
+ if (!raw) {
39
+ throw new Error("APP_SECRET is required to read or write encrypted settings");
40
+ }
41
+ // Case 1: already hex — pass through (backward-compat, see doc above).
42
+ if (raw.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(raw)) {
43
+ return raw;
44
+ }
45
+ // Case 2: non-hex passphrase — deterministically derive hex via SHA-256.
46
+ return createHash("sha256").update(raw).digest("hex");
47
+ };
48
+
49
+ export const encryptValue = async (value: unknown): Promise<string> =>
50
+ crypto.symmetric.encrypt({
51
+ payload: JSON.stringify(value),
52
+ key: getAppSecret(),
53
+ stretched: false,
54
+ });
55
+
56
+ export const decryptValue = async (value: string): Promise<unknown> => {
57
+ const decrypted = await crypto.symmetric.decrypt({
58
+ payload: value,
59
+ key: getAppSecret(),
60
+ });
61
+ try {
62
+ return JSON.parse(decrypted);
63
+ } catch {
64
+ // Decrypt succeeded but the plaintext isn't JSON. Either pre-JSON-encoding
65
+ // legacy data or external write — return the raw string so callers can
66
+ // decide. Throwing here would corrupt the read path entirely.
67
+ return decrypted;
68
+ }
69
+ };