@valentinkolb/cloud 0.3.1 → 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 (194) hide show
  1. package/package.json +18 -8
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +119 -47
  4. package/src/_internal/runtime-context.ts +1 -0
  5. package/src/api/accounts-entities.ts +4 -0
  6. package/src/api/admin-core-settings.ts +98 -0
  7. package/src/api/announcements.ts +131 -0
  8. package/src/api/auth/schemas.ts +24 -0
  9. package/src/api/auth.ts +113 -10
  10. package/src/api/index.ts +15 -25
  11. package/src/api/me.ts +203 -14
  12. package/src/api/search/schemas.ts +1 -0
  13. package/src/api/search.ts +62 -8
  14. package/src/config/ssr.ts +2 -9
  15. package/src/contracts/announcements.test.ts +37 -0
  16. package/src/contracts/announcements.ts +121 -0
  17. package/src/contracts/app.ts +4 -0
  18. package/src/contracts/index.ts +3 -2
  19. package/src/contracts/registry.ts +4 -0
  20. package/src/contracts/shared.ts +108 -1
  21. package/src/desktop/index.ts +704 -0
  22. package/src/desktop/solid.tsx +938 -0
  23. package/src/server/api/index.ts +1 -1
  24. package/src/server/api/respond.ts +50 -10
  25. package/src/server/index.ts +44 -38
  26. package/src/server/middleware/auth.ts +98 -9
  27. package/src/server/middleware/index.ts +2 -1
  28. package/src/server/middleware/settings.ts +26 -0
  29. package/src/server/services/access.test.ts +197 -0
  30. package/src/server/services/access.ts +254 -6
  31. package/src/server/services/index.ts +14 -11
  32. package/src/server/services/pagination.ts +22 -0
  33. package/src/server/time.ts +45 -0
  34. package/src/services/account-lifecycle/index.ts +142 -18
  35. package/src/services/accounts/app.ts +658 -170
  36. package/src/services/accounts/authz.test.ts +77 -0
  37. package/src/services/accounts/authz.ts +22 -0
  38. package/src/services/accounts/entities.ts +84 -5
  39. package/src/services/accounts/groups.ts +30 -24
  40. package/src/services/accounts/model.test.ts +30 -0
  41. package/src/services/accounts/switching.test.ts +14 -0
  42. package/src/services/accounts/switching.ts +15 -6
  43. package/src/services/accounts/users.ts +75 -52
  44. package/src/services/announcements/index.test.ts +32 -0
  45. package/src/services/announcements/index.ts +224 -0
  46. package/src/services/audit/index.test.ts +84 -0
  47. package/src/services/audit/index.ts +431 -0
  48. package/src/services/auth-flows/index.ts +9 -2
  49. package/src/services/auth-flows/ipa.ts +0 -2
  50. package/src/services/auth-flows/magic-link.ts +3 -2
  51. package/src/services/auth-flows/password-reset.ts +284 -0
  52. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  53. package/src/services/auth-flows/proxy-return.ts +49 -0
  54. package/src/services/gateway.ts +162 -0
  55. package/src/services/index.ts +44 -2
  56. package/src/services/ipa/effective-groups.test.ts +33 -0
  57. package/src/services/ipa/effective-groups.ts +70 -0
  58. package/src/services/ipa/profile.ts +45 -3
  59. package/src/services/ipa/search.ts +3 -5
  60. package/src/services/ipa/service-account.ts +15 -0
  61. package/src/services/ipa/sync-planning.test.ts +32 -0
  62. package/src/services/ipa/sync-planning.ts +22 -0
  63. package/src/services/ipa/sync.ts +110 -38
  64. package/src/services/oauth-tokens.ts +104 -0
  65. package/src/services/postgres.ts +21 -6
  66. package/src/services/providers/local/auth.test.ts +22 -0
  67. package/src/services/providers/local/auth.ts +46 -3
  68. package/src/services/secrets.ts +10 -0
  69. package/src/services/service-account-credentials.test.ts +210 -0
  70. package/src/services/service-account-credentials.ts +715 -0
  71. package/src/services/service-accounts.ts +188 -0
  72. package/src/services/session/index.ts +7 -8
  73. package/src/services/settings/app.ts +4 -20
  74. package/src/services/settings/defaults.ts +64 -22
  75. package/src/services/settings/store.ts +47 -0
  76. package/src/services/weather/forecast.ts +40 -7
  77. package/src/services/webauthn.test.ts +36 -0
  78. package/src/services/webauthn.ts +384 -0
  79. package/src/shared/icons.ts +391 -100
  80. package/src/shared/index.ts +7 -0
  81. package/src/shared/markdown/extensions/code.ts +38 -1
  82. package/src/shared/markdown/extensions/images.ts +39 -3
  83. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  84. package/src/shared/markdown/extensions/mark.ts +48 -0
  85. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  86. package/src/shared/markdown/extensions/tables.ts +79 -58
  87. package/src/shared/markdown/formula.test.ts +1089 -0
  88. package/src/shared/markdown/formula.ts +1187 -0
  89. package/src/shared/markdown/index.ts +76 -2
  90. package/src/shared/mock-cover.ts +130 -0
  91. package/src/shared/redirect.test.ts +49 -0
  92. package/src/shared/redirect.ts +52 -0
  93. package/src/shared/theme.test.ts +24 -0
  94. package/src/shared/theme.ts +68 -0
  95. package/src/shared/time.ts +13 -0
  96. package/src/ssr/AdminLayout.tsx +7 -3
  97. package/src/ssr/AdminSidebar.tsx +115 -49
  98. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  99. package/src/ssr/Footer.island.tsx +3 -8
  100. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  101. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  102. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  103. package/src/ssr/Layout.tsx +74 -66
  104. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  105. package/src/ssr/LayoutHelp.tsx +266 -0
  106. package/src/ssr/NavMenu.island.tsx +0 -39
  107. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  108. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  109. package/src/ssr/islands/index.ts +13 -0
  110. package/src/styles/base-popover.css +5 -2
  111. package/src/styles/effects.css +87 -6
  112. package/src/styles/global.css +146 -9
  113. package/src/styles/input.css +3 -1
  114. package/src/styles/utilities-buttons.css +133 -27
  115. package/src/styles/utilities-code-display.css +67 -0
  116. package/src/styles/utilities-completion.css +223 -0
  117. package/src/styles/utilities-detail.css +73 -0
  118. package/src/styles/utilities-feedback.css +16 -15
  119. package/src/styles/utilities-layout.css +42 -2
  120. package/src/styles/utilities-markdown-editor.css +472 -0
  121. package/src/styles/utilities-navigation.css +63 -8
  122. package/src/styles/utilities-script.css +84 -0
  123. package/src/styles/utilities-table-tile.css +229 -0
  124. package/src/types/ambient.d.ts +9 -0
  125. package/src/ui/completion/behaviors.test.ts +95 -0
  126. package/src/ui/completion/behaviors.ts +205 -0
  127. package/src/ui/completion/engine.ts +368 -0
  128. package/src/ui/completion/index.ts +40 -0
  129. package/src/ui/completion/overlay.ts +92 -0
  130. package/src/ui/dialog-core.ts +173 -45
  131. package/src/ui/filter/FilterChip.tsx +42 -40
  132. package/src/ui/index.ts +11 -12
  133. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  134. package/src/ui/input/CheckboxCard.tsx +91 -0
  135. package/src/ui/input/Combobox.tsx +375 -0
  136. package/src/ui/input/DatePicker.tsx +846 -0
  137. package/src/ui/input/DateTimeInput.tsx +29 -4
  138. package/src/ui/input/FileDropzone.tsx +116 -0
  139. package/src/ui/input/IconInput.tsx +116 -0
  140. package/src/ui/input/ImageInput.tsx +19 -2
  141. package/src/ui/input/MultiSelectInput.tsx +448 -0
  142. package/src/ui/input/NumberInput.tsx +417 -61
  143. package/src/ui/input/SegmentedControl.tsx +2 -2
  144. package/src/ui/input/Select.tsx +172 -10
  145. package/src/ui/input/Slider.tsx +3 -4
  146. package/src/ui/input/Switch.tsx +3 -2
  147. package/src/ui/input/TemplateEditor.tsx +212 -0
  148. package/src/ui/input/TextInput.tsx +144 -13
  149. package/src/ui/input/index.ts +53 -8
  150. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  151. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  152. package/src/ui/input/markdown/actions.ts +233 -0
  153. package/src/ui/input/markdown/active-formats.ts +94 -0
  154. package/src/ui/input/markdown/behaviors.ts +193 -0
  155. package/src/ui/input/markdown/code-zone.ts +23 -0
  156. package/src/ui/input/markdown/highlight.ts +316 -0
  157. package/src/ui/layout.ts +22 -0
  158. package/src/ui/misc/AppOverview.tsx +105 -0
  159. package/src/ui/misc/AppWorkspace.tsx +607 -0
  160. package/src/ui/misc/Calendar.tsx +1291 -0
  161. package/src/ui/misc/Chart.tsx +162 -0
  162. package/src/ui/misc/CodeDisplay.tsx +54 -0
  163. package/src/ui/misc/ContextMenu.tsx +2 -2
  164. package/src/ui/misc/DataTable.tsx +269 -0
  165. package/src/ui/misc/DockWorkspace.tsx +425 -0
  166. package/src/ui/misc/Docs.tsx +153 -0
  167. package/src/ui/misc/Dropdown.tsx +2 -2
  168. package/src/ui/misc/EntitySearch.tsx +260 -129
  169. package/src/ui/misc/LinkCard.tsx +14 -2
  170. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  171. package/src/ui/misc/Pagination.tsx +31 -12
  172. package/src/ui/misc/PanelDialog.tsx +109 -0
  173. package/src/ui/misc/Panes.tsx +873 -0
  174. package/src/ui/misc/PermissionEditor.tsx +358 -262
  175. package/src/ui/misc/Placeholder.tsx +40 -0
  176. package/src/ui/misc/ProgressBar.tsx +1 -1
  177. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  178. package/src/ui/misc/SettingsModal.tsx +150 -0
  179. package/src/ui/misc/StatCell.tsx +182 -40
  180. package/src/ui/misc/StatGrid.tsx +149 -0
  181. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  182. package/src/ui/misc/code-highlight.ts +213 -0
  183. package/src/ui/misc/index.ts +93 -12
  184. package/src/ui/prompts.tsx +362 -312
  185. package/src/ui/toast.ts +384 -0
  186. package/src/ui/widgets/Widget.tsx +12 -4
  187. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  188. package/src/ui/ipa/GroupView.tsx +0 -36
  189. package/src/ui/ipa/LoginBtn.tsx +0 -16
  190. package/src/ui/ipa/UserView.tsx +0 -58
  191. package/src/ui/ipa/index.ts +0 -4
  192. package/src/ui/navigation.ts +0 -32
  193. package/src/ui/sidebar.tsx +0 -468
  194. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -0,0 +1,188 @@
1
+ import { sql } from "bun";
2
+ import { err, fail, ok, type Result } from "@valentinkolb/stdlib";
3
+ import { isUniqueViolation } from "./postgres";
4
+
5
+ export type ServiceAccountKind = "user_delegated" | "resource_bound";
6
+ export type ServiceAccountStatus = "active" | "disabled";
7
+
8
+ export type ServiceAccount = {
9
+ id: string;
10
+ name: string;
11
+ kind: ServiceAccountKind;
12
+ status: ServiceAccountStatus;
13
+ delegatedUserId: string | null;
14
+ appId: string | null;
15
+ resourceType: string | null;
16
+ resourceId: string | null;
17
+ createdBy: string | null;
18
+ createdAt: string;
19
+ };
20
+
21
+ type DbServiceAccount = {
22
+ id: string;
23
+ name: string;
24
+ kind: ServiceAccountKind;
25
+ status: ServiceAccountStatus;
26
+ delegated_user_id: string | null;
27
+ app_id: string | null;
28
+ resource_type: string | null;
29
+ resource_id: string | null;
30
+ created_by: string | null;
31
+ created_at: Date;
32
+ };
33
+
34
+ const mapServiceAccount = (row: DbServiceAccount): ServiceAccount => ({
35
+ id: row.id,
36
+ name: row.name,
37
+ kind: row.kind,
38
+ status: row.status,
39
+ delegatedUserId: row.delegated_user_id,
40
+ appId: row.app_id,
41
+ resourceType: row.resource_type,
42
+ resourceId: row.resource_id,
43
+ createdBy: row.created_by,
44
+ createdAt: row.created_at.toISOString(),
45
+ });
46
+
47
+ const trimRequired = (value: string): string => value.trim();
48
+
49
+ const isForeignKeyViolation = (error: unknown): boolean => (error as { code?: string } | null)?.code === "23503";
50
+ const RESOURCE_BOUND_UNIQUE_CONSTRAINT = "uniq_service_accounts_resource_bound";
51
+
52
+ export const getByResource = async (params: {
53
+ appId: string;
54
+ resourceType: string;
55
+ resourceId: string;
56
+ }): Promise<ServiceAccount | null> => {
57
+ const [row] = await sql<DbServiceAccount[]>`
58
+ SELECT id, name, kind, status, delegated_user_id, app_id, resource_type, resource_id, created_by, created_at
59
+ FROM auth.service_accounts
60
+ WHERE kind = 'resource_bound'
61
+ AND app_id = ${params.appId}
62
+ AND resource_type = ${params.resourceType}
63
+ AND resource_id = ${params.resourceId}
64
+ ORDER BY created_at ASC
65
+ LIMIT 1
66
+ `;
67
+ return row ? mapServiceAccount(row) : null;
68
+ };
69
+
70
+ export const get = async (params: { id: string }): Promise<ServiceAccount | null> => {
71
+ const [row] = await sql<DbServiceAccount[]>`
72
+ SELECT id, name, kind, status, delegated_user_id, app_id, resource_type, resource_id, created_by, created_at
73
+ FROM auth.service_accounts
74
+ WHERE id = ${params.id}::uuid
75
+ `;
76
+ return row ? mapServiceAccount(row) : null;
77
+ };
78
+
79
+ export const createUserDelegated = async (params: {
80
+ name: string;
81
+ delegatedUserId: string;
82
+ createdBy?: string | null;
83
+ }): Promise<Result<ServiceAccount>> => {
84
+ const name = trimRequired(params.name);
85
+ if (!name) return fail(err.badInput("Service account name is required"));
86
+
87
+ try {
88
+ const [row] = await sql<DbServiceAccount[]>`
89
+ INSERT INTO auth.service_accounts (name, kind, delegated_user_id, created_by)
90
+ VALUES (${name}, 'user_delegated', ${params.delegatedUserId}::uuid, ${params.createdBy ?? null}::uuid)
91
+ RETURNING id, name, kind, status, delegated_user_id, app_id, resource_type, resource_id, created_by, created_at
92
+ `;
93
+ return row ? ok(mapServiceAccount(row)) : fail(err.internal("Failed to create service account"));
94
+ } catch (error) {
95
+ if (isForeignKeyViolation(error)) return fail(err.notFound("Delegated user"));
96
+ throw error;
97
+ }
98
+ };
99
+
100
+ export const createResourceBound = async (params: {
101
+ name: string;
102
+ appId: string;
103
+ resourceType: string;
104
+ resourceId: string;
105
+ createdBy?: string | null;
106
+ }): Promise<Result<ServiceAccount>> => {
107
+ const name = trimRequired(params.name);
108
+ const appId = trimRequired(params.appId);
109
+ const resourceType = trimRequired(params.resourceType);
110
+ const resourceId = trimRequired(params.resourceId);
111
+ if (!name) return fail(err.badInput("Service account name is required"));
112
+ if (!appId || !resourceType || !resourceId) return fail(err.badInput("Resource binding is required"));
113
+
114
+ try {
115
+ const [row] = await sql<DbServiceAccount[]>`
116
+ INSERT INTO auth.service_accounts (name, kind, app_id, resource_type, resource_id, created_by)
117
+ VALUES (${name}, 'resource_bound', ${appId}, ${resourceType}, ${resourceId}, ${params.createdBy ?? null}::uuid)
118
+ RETURNING id, name, kind, status, delegated_user_id, app_id, resource_type, resource_id, created_by, created_at
119
+ `;
120
+ return row ? ok(mapServiceAccount(row)) : fail(err.internal("Failed to create service account"));
121
+ } catch (error) {
122
+ if (isForeignKeyViolation(error)) return fail(err.notFound("Creator"));
123
+ if (isUniqueViolation(error, RESOURCE_BOUND_UNIQUE_CONSTRAINT)) return fail(err.conflict("Resource service account"));
124
+ throw error;
125
+ }
126
+ };
127
+
128
+ export const getOrCreateResourceBound = async (params: {
129
+ name: string;
130
+ appId: string;
131
+ resourceType: string;
132
+ resourceId: string;
133
+ createdBy?: string | null;
134
+ }): Promise<Result<ServiceAccount>> => {
135
+ const existing = await getByResource(params);
136
+ if (existing) return ok(existing);
137
+
138
+ const created = await createResourceBound(params);
139
+ if (created.ok || created.error.code !== "CONFLICT") return created;
140
+
141
+ const raced = await getByResource(params);
142
+ return raced ? ok(raced) : fail(err.internal("Failed to load resource service account"));
143
+ };
144
+
145
+ export const setStatus = async (params: { id: string; status: ServiceAccountStatus }): Promise<Result<void>> => {
146
+ const result = await sql`
147
+ UPDATE auth.service_accounts
148
+ SET status = ${params.status}
149
+ WHERE id = ${params.id}::uuid
150
+ `;
151
+ if (result.count === 0) return fail(err.notFound("Service account"));
152
+ return ok();
153
+ };
154
+
155
+ export const delete_ = async (params: { id: string }): Promise<Result<void>> => {
156
+ const result = await sql`
157
+ DELETE FROM auth.service_accounts
158
+ WHERE id = ${params.id}::uuid
159
+ `;
160
+ if (result.count === 0) return fail(err.notFound("Service account"));
161
+ return ok();
162
+ };
163
+
164
+ export const deleteForResource = async (params: {
165
+ appId: string;
166
+ resourceType: string;
167
+ resourceId: string;
168
+ }): Promise<number> => {
169
+ const result = await sql`
170
+ DELETE FROM auth.service_accounts
171
+ WHERE kind = 'resource_bound'
172
+ AND app_id = ${params.appId}
173
+ AND resource_type = ${params.resourceType}
174
+ AND resource_id = ${params.resourceId}
175
+ `;
176
+ return result.count;
177
+ };
178
+
179
+ export const serviceAccounts = {
180
+ get,
181
+ createUserDelegated,
182
+ createResourceBound,
183
+ getByResource,
184
+ getOrCreateResourceBound,
185
+ setStatus,
186
+ delete: delete_,
187
+ deleteForResource,
188
+ };
@@ -14,7 +14,6 @@ import * as settings from "../settings";
14
14
  */
15
15
  type SessionData = {
16
16
  userId: string;
17
- ipaSession: string | null;
18
17
  gen: number;
19
18
  };
20
19
 
@@ -48,6 +47,8 @@ const parseBearer = (header: string | undefined): string | null => {
48
47
  return match?.[1] ?? null;
49
48
  };
50
49
 
50
+ const isCloudApiToken = (token: string | null): boolean => Boolean(token?.startsWith("cld_"));
51
+
51
52
  export const session = {
52
53
  /**
53
54
  * Get session token from cookie or Authorization header.
@@ -59,18 +60,20 @@ export const session = {
59
60
  getToken: (c: Context): string | null => {
60
61
  const cookie = getCookie(c, "session_token");
61
62
  const bearer = parseBearer(c.req.header("Authorization"));
62
- return cookie || bearer || null;
63
+ return cookie || (isCloudApiToken(bearer) ? null : bearer) || null;
63
64
  },
64
65
 
66
+ getBearerToken: (c: Context): string | null => parseBearer(c.req.header("Authorization")),
67
+
65
68
  parseToken,
66
69
 
67
- create: async (c: Context, userId: string, ipaSession: string | null = null): Promise<string> => {
70
+ create: async (c: Context, userId: string): Promise<string> => {
68
71
  const randomToken = crypto.randomUUID();
69
72
  const expiryHours = await settings.get<number>("user.session.expiry_hours");
70
73
  const ttl = expiryHours * 60 * 60;
71
74
 
72
75
  const gen = await readGen(userId);
73
- const data: SessionData = { userId, ipaSession, gen };
76
+ const data: SessionData = { userId, gen };
74
77
  await redis.set(sessionKey(userId, randomToken), JSON.stringify(data), "EX", ttl);
75
78
 
76
79
  await sql`UPDATE auth.users SET last_login_local = now() WHERE id = ${userId}`;
@@ -130,8 +133,4 @@ export const session = {
130
133
  return data;
131
134
  },
132
135
 
133
- getIpaSession: async (token: string): Promise<string | null> => {
134
- const data = await session.getData(token);
135
- return data?.ipaSession ?? null;
136
- },
137
136
  };
@@ -6,25 +6,12 @@
6
6
  * Lives in cloud-lib because every app that has app-scoped settings needs
7
7
  * the same API to render its admin form (files, weather, etc.).
8
8
  */
9
- import { err, fail, ok, paginate, type PageParams, type Paginated } from "@valentinkolb/stdlib";
9
+ import { paginateItems } from "../../server/services";
10
+ import { err, fail, ok, type PageParams, type Paginated } from "@valentinkolb/stdlib";
10
11
  import * as settingsPrimitives from ".";
11
12
  import type { SettingEntry } from ".";
12
13
  import { SETTINGS_MAP, validateSettingValue } from "./defaults";
13
14
 
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
15
  /**
29
16
  * Redact secret-kind setting values before they leave the server.
30
17
  *
@@ -42,10 +29,7 @@ const redactSecretValue = (entry: SettingEntry): SettingEntry => {
42
29
 
43
30
  export const settingsService = {
44
31
  entry: {
45
- list: async (config?: {
46
- pagination?: PageParams;
47
- filter?: { query?: string; group?: string };
48
- }): Promise<Paginated<SettingEntry>> => {
32
+ list: async (config?: { pagination?: PageParams; filter?: { query?: string; group?: string } }): Promise<Paginated<SettingEntry>> => {
49
33
  const entries = await settingsPrimitives.getAll();
50
34
  const query = config?.filter?.query?.trim().toLowerCase();
51
35
  const group = config?.filter?.group?.trim().toLowerCase();
@@ -62,7 +46,7 @@ export const settingsService = {
62
46
  })
63
47
  .map(redactSecretValue);
64
48
 
65
- return paginateEntries(filtered, config?.pagination);
49
+ return paginateItems(filtered, config?.pagination);
66
50
  },
67
51
  update: async (config: { key: string; value: unknown }) => {
68
52
  if (!SETTINGS_MAP.has(config.key)) {
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import type { SettingKind, SettingOption } from "../../contracts/shared";
13
+
13
14
  export type { SettingKind, SettingOption };
14
15
 
15
16
  type SettingEnvResolver = () => unknown;
@@ -24,16 +25,7 @@ type SettingCommon = {
24
25
  envBootstrap?: SettingEnvResolver;
25
26
  };
26
27
 
27
- type SettingStringLikeKind =
28
- | "string"
29
- | "text"
30
- | "email"
31
- | "url"
32
- | "secret"
33
- | "image"
34
- | "cron"
35
- | "timezone"
36
- | "template";
28
+ type SettingStringLikeKind = "string" | "text" | "email" | "url" | "secret" | "image" | "cron" | "timezone" | "template";
37
29
 
38
30
  type StringLikeSettingDef = SettingCommon & {
39
31
  kind: SettingStringLikeKind;
@@ -77,9 +69,7 @@ export type SettingDef =
77
69
  | StringListSettingDef
78
70
  | NumberListSettingDef;
79
71
 
80
- export type SettingValidationResult =
81
- | { ok: true; value: SettingDef["default"] }
82
- | { ok: false; error: string };
72
+ export type SettingValidationResult = { ok: true; value: SettingDef["default"] } | { ok: false; error: string };
83
73
 
84
74
  const envString = (key: string): string | undefined => {
85
75
  const value = process.env[key]?.trim();
@@ -116,7 +106,8 @@ export const SETTINGS: SettingDef[] = [
116
106
  label: "URL",
117
107
  kind: "string",
118
108
  default: "localhost:3000",
119
- description: "Public-facing application URL used for links in emails, OAuth redirects, and WebSocket connections (with or without scheme)",
109
+ description:
110
+ "Public-facing application URL used for links in emails, OAuth redirects, and WebSocket connections (with or without scheme)",
120
111
  placeholder: "e.g. https://cloud.example.org",
121
112
  group: "app",
122
113
  envFallback: () => envString("APP_URL"),
@@ -209,7 +200,8 @@ export const SETTINGS: SettingDef[] = [
209
200
  label: "CA Certificate (PEM)",
210
201
  kind: "text",
211
202
  default: "",
212
- description: "Paste the FreeIPA root CA in PEM format to trust self-signed/private-CA servers without disabling validation. Preferred over allow_insecure.",
203
+ description:
204
+ "Paste the FreeIPA root CA in PEM format to trust self-signed/private-CA servers without disabling validation. Preferred over allow_insecure.",
213
205
  placeholder: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
214
206
  group: "freeipa",
215
207
  },
@@ -218,7 +210,8 @@ export const SETTINGS: SettingDef[] = [
218
210
  label: "Allow Insecure TLS",
219
211
  kind: "boolean",
220
212
  default: false,
221
- description: "Skip TLS certificate validation entirely. Use only for local dev — disables MITM protection. Ignored when ca_cert is set.",
213
+ description:
214
+ "Skip TLS certificate validation entirely. Use only for local dev — disables MITM protection. Ignored when ca_cert is set.",
222
215
  group: "freeipa",
223
216
  },
224
217
  {
@@ -445,6 +438,20 @@ export const SETTINGS: SettingDef[] = [
445
438
  group: "mail",
446
439
  templateVars: ["TOKEN", "MAGIC_LINK", "APP_NAME"],
447
440
  },
441
+ {
442
+ key: "mail.password_reset",
443
+ label: "Password Reset Template",
444
+ kind: "template",
445
+ default: `<p>You requested a password reset for your {{APP_NAME}} account.</p>
446
+ <p style="text-align:center;margin:24px 0;">
447
+ <a href="{{RESET_LINK}}" target="_blank" style="color:#3b82f6;text-decoration:underline;">Set a new password</a>
448
+ </p>
449
+ <p style="color:#71717a;font-size:12px;margin:0 0 8px 0;">This link expires in 15 minutes. Never share this link with anyone. If you didn't request this, you can ignore this email.</p>
450
+ {{#CONTACT_EMAIL}}<p style="color:#71717a;font-size:12px;margin:0;">If you need help, contact <a href="mailto:{{CONTACT_EMAIL}}">{{CONTACT_EMAIL}}</a>.</p>{{/CONTACT_EMAIL}}`,
451
+ description: "Password reset email template (HTML). Subject: {{APP_NAME}} Password Reset",
452
+ group: "mail",
453
+ templateVars: ["RESET_LINK", "APP_NAME", "CONTACT_EMAIL"],
454
+ },
448
455
  {
449
456
  key: "mail.account_expiry_reminder",
450
457
  label: "Account Expiry Reminder Template",
@@ -530,7 +537,7 @@ export const SETTINGS: SettingDef[] = [
530
537
  // Three pages, three modes each:
531
538
  // mode = "local" → render markdown from `legal.<kind>.content`
532
539
  // mode = "external" → 302-redirect to `legal.<kind>.url`
533
- // All three pages live in the settings app (mounts: /impressum,
540
+ // All three pages live in Core (mounts: /impressum,
534
541
  // /legal/privacy, /legal/terms).
535
542
  {
536
543
  key: "legal.terms.mode",
@@ -622,6 +629,45 @@ export const SETTINGS: SettingDef[] = [
622
629
  placeholder: "https://example.org/imprint",
623
630
  group: "legal",
624
631
  },
632
+
633
+ {
634
+ key: "notebooks.reindex_cron",
635
+ label: "Reindex Cron",
636
+ kind: "cron",
637
+ default: "0 */12 * * *",
638
+ description: "Five-field cron schedule for the periodic note-refs reindex job (links, tags, attachments) in app.timezone.",
639
+ group: "notebooks",
640
+ },
641
+ {
642
+ key: "notebooks.snapshot_cron",
643
+ label: "Snapshot Cron",
644
+ kind: "cron",
645
+ default: "0 3 * * *",
646
+ description: "Five-field cron schedule for automatic notebook S3 snapshots in app.timezone.",
647
+ group: "notebooks",
648
+ },
649
+ {
650
+ key: "notebooks.max_attachment_size_mb",
651
+ label: "Max Attachment Size",
652
+ kind: "number",
653
+ default: 10,
654
+ min: 1,
655
+ max: 200,
656
+ description:
657
+ "Per-file upload limit for notebook attachments (megabytes). Oversize images are auto-resized client-side before the upload hits this gate; non-image files exceeding the limit are rejected with a clear error.",
658
+ group: "notebooks",
659
+ },
660
+ {
661
+ key: "notebooks.max_image_dimension_px",
662
+ label: "Max Image Side",
663
+ kind: "number",
664
+ default: 2048,
665
+ min: 256,
666
+ max: 8192,
667
+ description:
668
+ "Longest-side cap (pixels) applied when an oversize image is auto-resized before upload. Aspect ratio is preserved; PNG inputs stay PNG, everything else becomes WebP at quality 0.85.",
669
+ group: "notebooks",
670
+ },
625
671
  ];
626
672
 
627
673
  const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -645,11 +691,7 @@ const parseStringList = (value: unknown): string[] | null => {
645
691
  };
646
692
 
647
693
  const parseNumberList = (value: unknown): number[] | null => {
648
- const rawValues = Array.isArray(value)
649
- ? value
650
- : typeof value === "string"
651
- ? value.split(/[,\n]/).map((entry) => entry.trim())
652
- : null;
694
+ const rawValues = Array.isArray(value) ? value : typeof value === "string" ? value.split(/[,\n]/).map((entry) => entry.trim()) : null;
653
695
 
654
696
  if (!rawValues) return null;
655
697
 
@@ -22,6 +22,13 @@ const REDIS_KEY = (k: string) => `settings:${k}`;
22
22
  const REDIS_TTL_SEC = 300;
23
23
 
24
24
  type StoredRow = { key: string; value: string };
25
+ type LegacyStoredRow = { key: string; value: string; updated_at: Date | string | null };
26
+
27
+ export type LegacySettingRow = {
28
+ key: string;
29
+ updatedAt: string | null;
30
+ decryptable: boolean;
31
+ };
25
32
 
26
33
  /**
27
34
  * Resolve the env-fallback or default value for a key whose DB row is missing
@@ -147,6 +154,46 @@ export const bulkRead = async (keys: readonly string[]): Promise<Map<string, unk
147
154
  */
148
155
  export const allKnownKeys = (): string[] => SETTINGS.map((d) => d.key);
149
156
 
157
+ const knownKeysWith = (extraKnownKeys: readonly string[]) => Array.from(new Set([...allKnownKeys(), ...extraKnownKeys]));
158
+
159
+ export const listLegacyKeys = async (extraKnownKeys: readonly string[] = []): Promise<LegacySettingRow[]> => {
160
+ const knownKeys = knownKeysWith(extraKnownKeys);
161
+ const rows = await sql<LegacyStoredRow[]>`
162
+ SELECT key, value, updated_at
163
+ FROM settings.entries
164
+ WHERE NOT (key = ANY(${toPgTextArray(knownKeys)}::text[]))
165
+ ORDER BY updated_at DESC NULLS LAST, key ASC
166
+ `;
167
+
168
+ const legacy: LegacySettingRow[] = [];
169
+ for (const row of rows) {
170
+ let decryptable = true;
171
+ try {
172
+ await decryptValue(row.value);
173
+ } catch {
174
+ decryptable = false;
175
+ }
176
+ legacy.push({
177
+ key: row.key,
178
+ updatedAt: row.updated_at ? new Date(row.updated_at).toISOString() : null,
179
+ decryptable,
180
+ });
181
+ }
182
+ return legacy;
183
+ };
184
+
185
+ export const deleteLegacyKeys = async (extraKnownKeys: readonly string[] = []): Promise<{ deleted: string[] }> => {
186
+ const knownKeys = knownKeysWith(extraKnownKeys);
187
+ const rows = await sql<{ key: string }[]>`
188
+ DELETE FROM settings.entries
189
+ WHERE NOT (key = ANY(${toPgTextArray(knownKeys)}::text[]))
190
+ RETURNING key
191
+ `;
192
+ const deleted = rows.map((row) => row.key);
193
+ if (deleted.length > 0) await redis.del(...deleted.map(REDIS_KEY));
194
+ return { deleted };
195
+ };
196
+
150
197
  /**
151
198
  * Encrypt the value, upsert the DB row, invalidate the Redis key.
152
199
  *
@@ -1,17 +1,40 @@
1
1
  import { redis } from "bun";
2
- import { coreSettings } from "../settings/api";
3
2
  import { logger } from "../logging";
3
+ import { coreSettings } from "../settings/api";
4
4
  import type { CurrentWeather, DailyForecast, HourlyForecast, WeatherData, WeatherIcon } from "./types";
5
5
 
6
6
  const log = logger("weather");
7
7
 
8
8
  const BRIGHTSKY_API = "https://api.brightsky.dev";
9
+ const BRIGHTSKY_TIMEOUT_MS = 400;
9
10
 
10
11
  export type ForecastLocationConfig = {
11
12
  lat?: string;
12
13
  lon?: string;
13
14
  };
14
15
 
16
+ type ResolvedForecastLocation = {
17
+ lat: string;
18
+ lon: string;
19
+ };
20
+
21
+ const resolveForecastLocation = async (config?: ForecastLocationConfig): Promise<ResolvedForecastLocation | null> => {
22
+ const lat = (config?.lat ?? (await coreSettings.get<string>("weather.default_lat"))).trim();
23
+ const lon = (config?.lon ?? (await coreSettings.get<string>("weather.default_lon"))).trim();
24
+
25
+ if (!lat || !lon) {
26
+ log.error("Weather default coordinates are not configured", {
27
+ missing: [
28
+ !lat ? "weather.default_lat" : null,
29
+ !lon ? "weather.default_lon" : null,
30
+ ].filter(Boolean),
31
+ });
32
+ return null;
33
+ }
34
+
35
+ return { lat, lon };
36
+ };
37
+
15
38
  /** Brightsky current_weather API response */
16
39
  type BrightskyCurrentResponse = {
17
40
  weather: {
@@ -81,9 +104,12 @@ const getCacheKey = (lat: string, lon: string): string => {
81
104
 
82
105
  /** Fetch current weather from Brightsky API. */
83
106
  const fetchCurrentFromApi = async (lat: string, lon: string): Promise<CurrentWeather | null> => {
107
+ const controller = new AbortController();
108
+ const timeout = setTimeout(() => controller.abort(), BRIGHTSKY_TIMEOUT_MS);
109
+
84
110
  try {
85
111
  const url = `${BRIGHTSKY_API}/current_weather?lat=${lat}&lon=${lon}`;
86
- const response = await fetch(url);
112
+ const response = await fetch(url, { signal: controller.signal });
87
113
 
88
114
  if (!response.ok) {
89
115
  log.error("Brightsky API error", { status: response.status });
@@ -119,15 +145,20 @@ const fetchCurrentFromApi = async (lat: string, lon: string): Promise<CurrentWea
119
145
  } catch (error) {
120
146
  log.error("Failed to fetch from Brightsky", {
121
147
  error: error instanceof Error ? error.message : String(error),
148
+ timeoutMs: BRIGHTSKY_TIMEOUT_MS,
122
149
  });
123
150
  return null;
151
+ } finally {
152
+ clearTimeout(timeout);
124
153
  }
125
154
  };
126
155
 
127
156
  /** Get current weather, using Redis cache. */
128
157
  export const getCurrentWeather = async (config?: ForecastLocationConfig): Promise<CurrentWeather | null> => {
129
- const lat = config?.lat ?? (await coreSettings.get<string>("weather.default_lat"));
130
- const lon = config?.lon ?? (await coreSettings.get<string>("weather.default_lon"));
158
+ const location = await resolveForecastLocation(config);
159
+ if (!location) return null;
160
+
161
+ const { lat, lon } = location;
131
162
  const cacheKey = getCacheKey(lat, lon);
132
163
 
133
164
  const cached = await redis.get(cacheKey);
@@ -257,8 +288,10 @@ const fetchForecastFromApi = async (lat: string, lon: string): Promise<{ hourly:
257
288
 
258
289
  /** Get full weather data including forecasts. */
259
290
  export const getWeatherData = async (config?: ForecastLocationConfig): Promise<WeatherData | null> => {
260
- const lat = config?.lat ?? (await coreSettings.get<string>("weather.default_lat"));
261
- const lon = config?.lon ?? (await coreSettings.get<string>("weather.default_lon"));
291
+ const location = await resolveForecastLocation(config);
292
+ if (!location) return null;
293
+
294
+ const { lat, lon } = location;
262
295
  const cacheKey = `weather:full:${lat}:${lon}`;
263
296
 
264
297
  const cached = await redis.get(cacheKey);
@@ -270,7 +303,7 @@ export const getWeatherData = async (config?: ForecastLocationConfig): Promise<W
270
303
  }
271
304
  }
272
305
 
273
- const [current, forecast] = await Promise.all([getCurrentWeather(config), fetchForecastFromApi(lat, lon)]);
306
+ const [current, forecast] = await Promise.all([getCurrentWeather(location), fetchForecastFromApi(lat, lon)]);
274
307
 
275
308
  if (!current) return null;
276
309
 
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolveWebAuthnRp } from "./webauthn";
3
+
4
+ describe("resolveWebAuthnRp", () => {
5
+ test("derives rp id and origin from https app url", () => {
6
+ expect(resolveWebAuthnRp({ appUrl: "https://cloud.example/app", appName: "Cloud" })).toEqual({
7
+ rpName: "Cloud",
8
+ rpID: "cloud.example",
9
+ origin: "https://cloud.example",
10
+ });
11
+ });
12
+
13
+ test("accepts localhost over http for development", () => {
14
+ expect(resolveWebAuthnRp({ appUrl: "http://localhost:3000", appName: "" })).toEqual({
15
+ rpName: "Cloud",
16
+ rpID: "localhost",
17
+ origin: "http://localhost:3000",
18
+ });
19
+ expect(resolveWebAuthnRp({ appUrl: "localhost:3000", appName: "Cloud" })).toEqual({
20
+ rpName: "Cloud",
21
+ rpID: "localhost",
22
+ origin: "http://localhost:3000",
23
+ });
24
+ expect(resolveWebAuthnRp({ appUrl: "http://[::1]:3000", appName: "Cloud" })).toEqual({
25
+ rpName: "Cloud",
26
+ rpID: "[::1]",
27
+ origin: "http://[::1]:3000",
28
+ });
29
+ });
30
+
31
+ test("rejects non-local insecure origins", () => {
32
+ expect(() => resolveWebAuthnRp({ appUrl: "http://cloud.example", appName: "Cloud" })).toThrow(
33
+ "WebAuthn requires an HTTPS app.url",
34
+ );
35
+ });
36
+ });