@valentinkolb/cloud 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/package.json +18 -6
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +53 -46
  4. package/src/api/accounts-entities.ts +4 -0
  5. package/src/api/admin-core-settings.ts +98 -0
  6. package/src/api/announcements.ts +131 -0
  7. package/src/api/auth/schemas.ts +24 -0
  8. package/src/api/auth.ts +113 -10
  9. package/src/api/index.ts +7 -2
  10. package/src/api/me.ts +203 -14
  11. package/src/api/search/schemas.ts +1 -0
  12. package/src/api/search.ts +62 -8
  13. package/src/config/ssr.ts +2 -9
  14. package/src/contracts/announcements.test.ts +37 -0
  15. package/src/contracts/announcements.ts +121 -0
  16. package/src/contracts/app.ts +2 -0
  17. package/src/contracts/index.ts +3 -2
  18. package/src/contracts/registry.ts +2 -0
  19. package/src/contracts/shared.ts +108 -1
  20. package/src/desktop/index.ts +704 -0
  21. package/src/desktop/solid.tsx +938 -0
  22. package/src/server/api/index.ts +1 -1
  23. package/src/server/api/respond.ts +50 -10
  24. package/src/server/index.ts +44 -38
  25. package/src/server/middleware/auth.ts +98 -9
  26. package/src/server/middleware/index.ts +2 -1
  27. package/src/server/middleware/settings.ts +26 -0
  28. package/src/server/services/access.test.ts +197 -0
  29. package/src/server/services/access.ts +254 -6
  30. package/src/server/services/index.ts +14 -11
  31. package/src/server/services/pagination.ts +22 -0
  32. package/src/server/time.ts +45 -0
  33. package/src/services/account-lifecycle/index.ts +142 -18
  34. package/src/services/accounts/app.ts +658 -170
  35. package/src/services/accounts/authz.test.ts +77 -0
  36. package/src/services/accounts/authz.ts +22 -0
  37. package/src/services/accounts/entities.ts +84 -5
  38. package/src/services/accounts/groups.ts +30 -24
  39. package/src/services/accounts/model.test.ts +30 -0
  40. package/src/services/accounts/switching.test.ts +14 -0
  41. package/src/services/accounts/switching.ts +15 -6
  42. package/src/services/accounts/users.ts +75 -52
  43. package/src/services/announcements/index.test.ts +32 -0
  44. package/src/services/announcements/index.ts +224 -0
  45. package/src/services/audit/index.test.ts +84 -0
  46. package/src/services/audit/index.ts +431 -0
  47. package/src/services/auth-flows/index.ts +9 -2
  48. package/src/services/auth-flows/ipa.ts +0 -2
  49. package/src/services/auth-flows/magic-link.ts +3 -2
  50. package/src/services/auth-flows/password-reset.ts +284 -0
  51. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  52. package/src/services/auth-flows/proxy-return.ts +49 -0
  53. package/src/services/gateway.ts +162 -0
  54. package/src/services/index.ts +44 -2
  55. package/src/services/ipa/effective-groups.test.ts +33 -0
  56. package/src/services/ipa/effective-groups.ts +70 -0
  57. package/src/services/ipa/profile.ts +45 -3
  58. package/src/services/ipa/search.ts +3 -5
  59. package/src/services/ipa/service-account.ts +15 -0
  60. package/src/services/ipa/sync-planning.test.ts +32 -0
  61. package/src/services/ipa/sync-planning.ts +22 -0
  62. package/src/services/ipa/sync.ts +110 -38
  63. package/src/services/oauth-tokens.ts +104 -0
  64. package/src/services/postgres.ts +21 -6
  65. package/src/services/providers/local/auth.test.ts +22 -0
  66. package/src/services/providers/local/auth.ts +46 -3
  67. package/src/services/secrets.ts +10 -0
  68. package/src/services/service-account-credentials.test.ts +210 -0
  69. package/src/services/service-account-credentials.ts +715 -0
  70. package/src/services/service-accounts.ts +188 -0
  71. package/src/services/session/index.ts +7 -8
  72. package/src/services/settings/app.ts +4 -20
  73. package/src/services/settings/defaults.ts +64 -22
  74. package/src/services/settings/store.ts +47 -0
  75. package/src/services/weather/forecast.ts +40 -7
  76. package/src/services/webauthn.test.ts +36 -0
  77. package/src/services/webauthn.ts +384 -0
  78. package/src/shared/icons.ts +391 -100
  79. package/src/shared/index.ts +7 -0
  80. package/src/shared/markdown/extensions/code.ts +38 -1
  81. package/src/shared/markdown/extensions/images.ts +39 -3
  82. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  83. package/src/shared/markdown/extensions/mark.ts +48 -0
  84. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  85. package/src/shared/markdown/extensions/tables.ts +79 -58
  86. package/src/shared/markdown/formula.test.ts +1089 -0
  87. package/src/shared/markdown/formula.ts +1187 -0
  88. package/src/shared/markdown/index.ts +76 -2
  89. package/src/shared/mock-cover.ts +130 -0
  90. package/src/shared/redirect.test.ts +49 -0
  91. package/src/shared/redirect.ts +52 -0
  92. package/src/shared/theme.test.ts +24 -0
  93. package/src/shared/theme.ts +68 -0
  94. package/src/shared/time.ts +13 -0
  95. package/src/ssr/AdminLayout.tsx +7 -3
  96. package/src/ssr/AdminSidebar.tsx +115 -49
  97. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  98. package/src/ssr/Footer.island.tsx +3 -8
  99. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  100. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  101. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  102. package/src/ssr/Layout.tsx +74 -66
  103. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  104. package/src/ssr/LayoutHelp.tsx +266 -0
  105. package/src/ssr/NavMenu.island.tsx +0 -39
  106. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  107. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  108. package/src/ssr/islands/index.ts +13 -0
  109. package/src/styles/base-popover.css +5 -2
  110. package/src/styles/effects.css +87 -6
  111. package/src/styles/global.css +146 -9
  112. package/src/styles/input.css +3 -1
  113. package/src/styles/utilities-buttons.css +133 -27
  114. package/src/styles/utilities-code-display.css +67 -0
  115. package/src/styles/utilities-completion.css +223 -0
  116. package/src/styles/utilities-detail.css +73 -0
  117. package/src/styles/utilities-feedback.css +16 -15
  118. package/src/styles/utilities-layout.css +42 -2
  119. package/src/styles/utilities-markdown-editor.css +472 -0
  120. package/src/styles/utilities-navigation.css +63 -8
  121. package/src/styles/utilities-script.css +84 -0
  122. package/src/styles/utilities-table-tile.css +229 -0
  123. package/src/types/ambient.d.ts +9 -0
  124. package/src/ui/completion/behaviors.test.ts +95 -0
  125. package/src/ui/completion/behaviors.ts +205 -0
  126. package/src/ui/completion/engine.ts +368 -0
  127. package/src/ui/completion/index.ts +40 -0
  128. package/src/ui/completion/overlay.ts +92 -0
  129. package/src/ui/dialog-core.ts +173 -45
  130. package/src/ui/filter/FilterChip.tsx +42 -40
  131. package/src/ui/index.ts +11 -12
  132. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  133. package/src/ui/input/CheckboxCard.tsx +91 -0
  134. package/src/ui/input/Combobox.tsx +375 -0
  135. package/src/ui/input/DatePicker.tsx +846 -0
  136. package/src/ui/input/DateTimeInput.tsx +29 -4
  137. package/src/ui/input/FileDropzone.tsx +116 -0
  138. package/src/ui/input/IconInput.tsx +116 -0
  139. package/src/ui/input/ImageInput.tsx +19 -2
  140. package/src/ui/input/MultiSelectInput.tsx +448 -0
  141. package/src/ui/input/NumberInput.tsx +417 -61
  142. package/src/ui/input/SegmentedControl.tsx +2 -2
  143. package/src/ui/input/Select.tsx +172 -10
  144. package/src/ui/input/Slider.tsx +3 -4
  145. package/src/ui/input/Switch.tsx +3 -2
  146. package/src/ui/input/TemplateEditor.tsx +212 -0
  147. package/src/ui/input/TextInput.tsx +144 -13
  148. package/src/ui/input/index.ts +53 -8
  149. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  150. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  151. package/src/ui/input/markdown/actions.ts +233 -0
  152. package/src/ui/input/markdown/active-formats.ts +94 -0
  153. package/src/ui/input/markdown/behaviors.ts +193 -0
  154. package/src/ui/input/markdown/code-zone.ts +23 -0
  155. package/src/ui/input/markdown/highlight.ts +316 -0
  156. package/src/ui/layout.ts +22 -0
  157. package/src/ui/misc/AppOverview.tsx +105 -0
  158. package/src/ui/misc/AppWorkspace.tsx +607 -0
  159. package/src/ui/misc/Calendar.tsx +1291 -0
  160. package/src/ui/misc/Chart.tsx +162 -0
  161. package/src/ui/misc/CodeDisplay.tsx +54 -0
  162. package/src/ui/misc/ContextMenu.tsx +2 -2
  163. package/src/ui/misc/DataTable.tsx +269 -0
  164. package/src/ui/misc/DockWorkspace.tsx +425 -0
  165. package/src/ui/misc/Docs.tsx +153 -0
  166. package/src/ui/misc/Dropdown.tsx +2 -2
  167. package/src/ui/misc/EntitySearch.tsx +260 -129
  168. package/src/ui/misc/LinkCard.tsx +14 -2
  169. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  170. package/src/ui/misc/Pagination.tsx +31 -12
  171. package/src/ui/misc/PanelDialog.tsx +109 -0
  172. package/src/ui/misc/Panes.tsx +873 -0
  173. package/src/ui/misc/PermissionEditor.tsx +358 -262
  174. package/src/ui/misc/Placeholder.tsx +40 -0
  175. package/src/ui/misc/ProgressBar.tsx +1 -1
  176. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  177. package/src/ui/misc/SettingsModal.tsx +150 -0
  178. package/src/ui/misc/StatCell.tsx +182 -40
  179. package/src/ui/misc/StatGrid.tsx +149 -0
  180. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  181. package/src/ui/misc/code-highlight.ts +213 -0
  182. package/src/ui/misc/index.ts +93 -12
  183. package/src/ui/prompts.tsx +362 -312
  184. package/src/ui/toast.ts +384 -0
  185. package/src/ui/widgets/Widget.tsx +12 -4
  186. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  187. package/src/ui/ipa/GroupView.tsx +0 -36
  188. package/src/ui/ipa/LoginBtn.tsx +0 -16
  189. package/src/ui/ipa/UserView.tsx +0 -58
  190. package/src/ui/ipa/index.ts +0 -4
  191. package/src/ui/navigation.ts +0 -32
  192. package/src/ui/sidebar.tsx +0 -468
  193. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valentinkolb/cloud",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Modular Hono+SolidJS framework for building per-app docker services behind a dynamic gateway. Powers cloud.stuve-ulm.de.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,37 +19,49 @@
19
19
  "exports": {
20
20
  ".": "./src/index.ts",
21
21
  "./ui": "./src/ui/index.ts",
22
+ "./ui/styles.css": "./src/styles/global.css",
23
+ "./desktop": "./src/desktop/index.ts",
24
+ "./desktop/solid": "./src/desktop/solid.tsx",
22
25
  "./server": "./src/server/index.ts",
23
26
  "./browser": "./src/server/api-client.ts",
24
27
  "./shared": "./src/shared/index.ts",
25
28
  "./services": "./src/services/index.ts",
29
+ "./services/ipa/service-account": null,
26
30
  "./services/*": "./src/services/*",
27
31
  "./ssr": "./src/ssr/index.ts",
28
32
  "./ssr/islands": "./src/ssr/islands/index.ts",
29
33
  "./config": "./src/config/index.ts",
30
34
  "./config/*": "./src/config/*",
31
35
  "./contracts": "./src/contracts/index.ts",
36
+ "./api": "./src/api/index.ts",
37
+ "./clients/core": "./src/clients/core.ts",
32
38
  "./styles/global.css": "./src/styles/global.css"
33
39
  },
34
40
  "scripts": {
41
+ "test": "bun test",
35
42
  "typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.typecheck.json --noEmit --pretty false 2>&1 | grep -v node_modules | (! grep -q 'error TS')"
36
43
  },
37
44
  "dependencies": {
38
- "@fontsource-variable/jetbrains-mono": "^5.2.8",
45
+ "@fontsource/ibm-plex-mono": "^5.2.7",
46
+ "@fontsource/ibm-plex-sans": "^5.2.8",
47
+ "@fontsource/ibm-plex-sans-condensed": "^5.2.8",
48
+ "@simplewebauthn/server": "^13.3.1",
39
49
  "@tabler/icons-webfont": "^3.36.1",
40
50
  "@tailwindcss/typography": "^0.5.19",
41
- "@valentinkolb/ssr": "0.9.0",
42
- "@valentinkolb/stdlib": "0.3.0",
51
+ "@valentinkolb/ssr": "0.10.0",
52
+ "@valentinkolb/stdlib": "0.12.0",
43
53
  "@valentinkolb/sync": "^5.0.0",
44
54
  "bun-plugin-tailwind": "^0.1.2",
45
55
  "hono": "^4.11.1",
46
56
  "hono-openapi": "^1.1.2",
57
+ "jose": "^6.1.3",
47
58
  "katex": "^0.16.28",
48
59
  "marked": "^17.0.1",
49
60
  "mermaid": "^11.12.2",
50
61
  "mustache": "^4.2.0",
51
- "nodemailer": "^7.0.12",
52
- "sanitize-html": "^2.17.0",
62
+ "nodemailer": "^8.0.10",
63
+ "sanitize-html": "^2.17.4",
64
+ "seroval": "^1.5.4",
53
65
  "solid-js": "^1.9.10",
54
66
  "tailwindcss": "^4.1.18",
55
67
  "zod": "^4.3.4"
@@ -7,10 +7,11 @@
7
7
  * uses /app as projectRoot → auto-detect scans all packages → all classes generated.
8
8
  * No @source directives needed in CSS files.
9
9
  */
10
- import tailwind from "bun-plugin-tailwind";
10
+ import { existsSync, watch } from "node:fs";
11
11
  import { cp, mkdir } from "node:fs/promises";
12
- import { resolve, dirname } from "node:path";
12
+ import { dirname, resolve } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
+ import tailwind from "bun-plugin-tailwind";
14
15
 
15
16
  const appId = process.env.APP_ID ?? "core";
16
17
  const { plugin } = await import(`../../${appId}/src/config`);
@@ -21,8 +22,7 @@ const workspaceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..
21
22
  const publicDir = resolve(workspaceRoot, "public");
22
23
  await mkdir(publicDir, { recursive: true });
23
24
 
24
- // global.css + branding are only served by core (Traefik routes them there)
25
- if (appId === "core") {
25
+ const buildGlobalCss = async () => {
26
26
  // Use styles.css at workspace root as entrypoint so the oxide scanner
27
27
  // walks up to /app/package.json and scans ALL packages for utility classes.
28
28
  // (If the CSS file is inside packages/lib/, it only scans packages/lib/.)
@@ -32,24 +32,74 @@ if (appId === "core") {
32
32
  naming: "global.css",
33
33
  plugins: [tailwind],
34
34
  });
35
+ };
36
+
37
+ const buildAppCss = async () => {
38
+ // `naming: "app.css"` is essential — without it, Bun.build with `root: workspaceRoot`
39
+ // preserves the directory structure (packages/<id>/src/styles/app.css) inside outdir,
40
+ // so Layout's `<link href="/public/<id>/app.css">` 404s and only global.css's classes
41
+ // reach the browser. That's why responsive grid utilities went missing on dashboard.
42
+ await Bun.build({
43
+ entrypoints: [resolve(workspaceRoot, `packages/${appId}/src/styles/app.css`)],
44
+ outdir: resolve(publicDir, appId),
45
+ naming: "app.css",
46
+ root: workspaceRoot, // same: auto-detect from /app
47
+ plugins: [tailwind],
48
+ });
49
+ };
50
+
51
+ const watchDevCss = (label: string, paths: string[], build: () => Promise<void>) => {
52
+ if (process.env.NODE_ENV !== "development") return;
53
+
54
+ let timer: ReturnType<typeof setTimeout> | undefined;
55
+ let running = false;
56
+ let queued = false;
57
+
58
+ const rebuild = () => {
59
+ if (running) {
60
+ queued = true;
61
+ return;
62
+ }
63
+
64
+ running = true;
65
+ void build()
66
+ .then(() => console.log(`[preload] rebuilt ${label}`))
67
+ .catch((error) => console.error(`[preload] failed to rebuild ${label}`, error))
68
+ .finally(() => {
69
+ running = false;
70
+ if (queued) {
71
+ queued = false;
72
+ rebuild();
73
+ }
74
+ });
75
+ };
76
+
77
+ const schedule = () => {
78
+ if (timer) clearTimeout(timer);
79
+ timer = setTimeout(rebuild, 75);
80
+ };
81
+
82
+ for (const path of paths) {
83
+ if (!existsSync(path)) continue;
84
+ watch(path, { persistent: true }, schedule);
85
+ }
86
+ };
87
+
88
+ // global.css + branding are only served by core (Traefik routes them there)
89
+ if (appId === "core") {
90
+ await buildGlobalCss();
35
91
 
36
92
  // Default branding asset: copy the tracked logo.svg into the runtime
37
93
  // public dir so serveBranding can fall back to it when no admin-uploaded
38
94
  // logo (data URI) is configured. User uploads are stored as base64 data
39
95
  // URIs in settings — no image processing (sharp etc.) needed.
40
- await cp(
41
- resolve(workspaceRoot, "packages/cloud/public/logo.svg"),
42
- resolve(publicDir, "logo.svg"),
43
- );
96
+ await cp(resolve(workspaceRoot, "packages/cloud/public/logo.svg"), resolve(publicDir, "logo.svg"));
44
97
  }
45
98
 
46
99
  // katex.css is only needed by notebooks (served by core via Traefik /public/katex.css)
47
100
  if (appId === "notebooks") {
48
101
  try {
49
- await cp(
50
- resolve(workspaceRoot, "node_modules/katex/dist/katex.min.css"),
51
- resolve(publicDir, "katex.css"),
52
- );
102
+ await cp(resolve(workspaceRoot, "node_modules/katex/dist/katex.min.css"), resolve(publicDir, "katex.css"));
53
103
  } catch {
54
104
  console.warn("[preload] katex.css not found, skipping");
55
105
  }
@@ -60,14 +110,19 @@ const appCssPath = resolve(workspaceRoot, `packages/${appId}/src/styles/app.css`
60
110
  const appPublicDir = resolve(publicDir, appId);
61
111
  await mkdir(appPublicDir, { recursive: true });
62
112
 
63
- // `naming: "app.css"` is essential — without it, Bun.build with `root: workspaceRoot`
64
- // preserves the directory structure (packages/<id>/src/styles/app.css) inside outdir,
65
- // so Layout's `<link href="/public/<id>/app.css">` 404s and only global.css's classes
66
- // reach the browser. That's why responsive grid utilities went missing on dashboard.
67
- await Bun.build({
68
- entrypoints: [appCssPath],
69
- outdir: appPublicDir,
70
- naming: "app.css",
71
- root: workspaceRoot, // same: auto-detect from /app
72
- plugins: [tailwind],
73
- });
113
+ await buildAppCss();
114
+
115
+ watchDevCss("app.css", [resolve(appCssPath, "..")], buildAppCss);
116
+ if (appId === "core") {
117
+ watchDevCss("global.css", [resolve(workspaceRoot, "styles.css"), resolve(workspaceRoot, "packages/cloud/src/styles")], buildGlobalCss);
118
+ }
119
+
120
+ // Optional app-owned dev assets. Production builds already have
121
+ // scripts/build-extras.ts; this mirrors that hook for watch mode without
122
+ // forcing app-specific asset logic into the framework preload.
123
+ const devExtras = resolve(workspaceRoot, `packages/${appId}/scripts/dev-extras.ts`);
124
+ if (existsSync(devExtras)) {
125
+ process.env.WORKSPACE_ROOT = workspaceRoot;
126
+ process.env.PUBLIC_DIR = publicDir;
127
+ await import(devExtras);
128
+ }
@@ -4,27 +4,23 @@
4
4
  * Merges SSR config, app meta, and server bootstrap into one call.
5
5
  * Returns `{ ssr, plugin, config, meta, start }`.
6
6
  */
7
+
8
+ import type { SsrConfig } from "@valentinkolb/ssr";
7
9
  import { createConfig as createSsrConfig } from "@valentinkolb/ssr";
8
10
  import { createSSRHandler, routes } from "@valentinkolb/ssr/hono";
9
11
  import { Hono } from "hono";
10
12
  import { serveStatic } from "hono/bun";
11
13
  import { generateSpecs } from "hono-openapi";
12
- import type { SsrConfig } from "@valentinkolb/ssr";
13
- import type {
14
- AppMeta,
15
- AppLifecycle,
16
- AppCapabilities,
17
- AppSearchContext,
18
- CloudContext,
19
- } from "../contracts/app";
14
+ import type { AppCapabilities, AppLifecycle, AppMeta, AppSearchContext, CloudContext } from "../contracts/app";
20
15
  import type { AppRegistryEntry } from "../contracts/registry";
21
- import type { Role } from "../contracts/shared";
22
16
  import type { AppSettingsMap, KindToType } from "../contracts/settings-types";
23
- import { createSettingsAPI, type SettingsAPI } from "../services/settings/api";
24
- import { registerSettings, type SettingDef } from "../services/settings/defaults";
17
+ import type { Role } from "../contracts/shared";
18
+ import { themeBootstrapScript } from "../shared/theme";
25
19
  import { auth } from "../server/middleware/auth";
26
20
  import { logger } from "../services/logging";
27
- import { get, set, loadCache as loadSettingsCache } from "../services/settings";
21
+ import { get, loadCache as loadSettingsCache, set } from "../services/settings";
22
+ import { createSettingsAPI, type SettingsAPI } from "../services/settings/api";
23
+ import { registerSettings, type SettingDef } from "../services/settings/defaults";
28
24
  import { createHeartbeat } from "./heartbeat";
29
25
  import { ensureRuntimeWatcher, getCurrentRuntime, stopRuntimeWatcher } from "./runtime-watcher";
30
26
 
@@ -81,7 +77,7 @@ export type AppOptions<S extends AppSettingsMap = {}> = {
81
77
  /**
82
78
  * Legal/info links contributed by this app — aggregated app-wide via
83
79
  * `listLegalLinks()` and rendered in login footer, app Footer, rail more
84
- * dropdown. Each app contributes its own (e.g. settings owns
80
+ * dropdown. Each app contributes its own (e.g. core owns
85
81
  * Imprint/Privacy/Terms; faq owns FAQ). KISS: no `external` flag, links
86
82
  * always open in a new tab from the login footer.
87
83
  */
@@ -102,8 +98,8 @@ export type AppOptions<S extends AppSettingsMap = {}> = {
102
98
  * `/admin/<id>` — admin SSR pages
103
99
  * `/public/<id>` — built CSS and other static assets
104
100
  *
105
- * Apps with non-standard URLs (core's `/auth`, `/me`; oauth's `/oauth`,
106
- * `/.well-known/...`; settings' `/legal/*`, `/impressum`) list whatever
101
+ * Apps with non-standard URLs (core's `/auth`, `/me`, `/legal/*`, `/impressum`;
102
+ * oauth's `/oauth`, `/.well-known/...`) list whatever
107
103
  * top-level paths they own. The gateway is dumb — it just builds a
108
104
  * prefix-trie from these strings.
109
105
  */
@@ -151,7 +147,7 @@ export type StartOptions = {
151
147
  * before this fetch — they take precedence over any catch-all the app
152
148
  * might register.
153
149
  */
154
- fetch: (req: Request) => Response | Promise<Response>;
150
+ fetch: (req: Request, env?: unknown) => Response | Promise<Response>;
155
151
  /**
156
152
  * Hono router to scan for OpenAPI route metadata. When set together with
157
153
  * `defineApp({ openapi: "..." })`, the framework generates an OpenAPI
@@ -172,6 +168,7 @@ export type StartOptions = {
172
168
 
173
169
  export type StartResult = {
174
170
  port: number;
171
+ development: boolean;
175
172
  fetch: Hono["fetch"];
176
173
  };
177
174
 
@@ -204,6 +201,8 @@ export type AppDefinition<S extends AppSettingsMap = {}> = {
204
201
  // ── Implementation ──────────────────────────────────────────────────────────
205
202
 
206
203
  export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<S>): AppDefinition<S> => {
204
+ const isDevelopment = process.env.NODE_ENV === "development";
205
+
207
206
  // ── 0. Register declared settings into the runtime registry ──────────
208
207
  // SETTINGS_MAP is the single source of truth for validation in store.ts
209
208
  // (writeKey checks SETTINGS_MAP.get(key)) and for snapshot.ts (allKnownKeys
@@ -237,7 +236,7 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
237
236
 
238
237
  // ── 1. SSR config ─────────────────────────────────────────────────────
239
238
  const { config, plugin, html } = createSsrConfig<PageOptions>({
240
- dev: process.env.NODE_ENV !== "production",
239
+ dev: isDevelopment,
241
240
  verbose: true,
242
241
  rootDir: opts.appRoot ?? process.cwd(),
243
242
  basePath: opts.basePath,
@@ -252,17 +251,10 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
252
251
  <meta name="theme-color" content="#09090b">
253
252
  <meta name="mobile-web-app-capable" content="yes">
254
253
  <link rel="icon" href="/branding/favicon">
255
- <link rel="stylesheet" href="/public/global.css?v=${v}">
254
+ <style data-cloud-css-layers>@layer theme, base, components, utilities;</style>
256
255
  <link rel="stylesheet" href="/public/${opts.id}/app.css?v=${v}">
257
- <script>
258
- (function() {
259
- var el = document.documentElement;
260
- if (!el.hasAttribute('data-theme-fixed')) {
261
- var theme = document.cookie.match(/theme=([^;]+)/)?.[1] || 'light';
262
- el.classList.add(theme);
263
- }
264
- })();
265
- </script>
256
+ <link rel="stylesheet" href="/public/global.css?v=${v}">
257
+ <script>${themeBootstrapScript}</script>
266
258
  </head>
267
259
  <body>
268
260
  ${body}
@@ -288,6 +280,7 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
288
280
  nav: opts.nav,
289
281
  legalLinks: opts.legalLinks ? [...opts.legalLinks] : undefined,
290
282
  widgets: opts.widgets ? opts.widgets.map((w) => ({ ...w })) : undefined,
283
+ settingKeys: opts.settings ? Object.keys(opts.settings) : undefined,
291
284
  openapi: opts.openapi,
292
285
  };
293
286
 
@@ -310,16 +303,17 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
310
303
  description: meta.description,
311
304
  baseUrl,
312
305
  routes: [...meta.routes],
313
- nav: (meta.nav || meta.adminHref)
314
- ? {
315
- href: meta.nav?.href ?? "",
316
- match: meta.nav?.match,
317
- section: meta.nav?.section ?? "hidden",
318
- requiresAuth: meta.nav?.requiresAuth,
319
- requiresRoles: meta.nav?.requiresRoles,
320
- adminHref: meta.adminHref,
321
- }
322
- : undefined,
306
+ nav:
307
+ meta.nav || meta.adminHref
308
+ ? {
309
+ href: meta.nav?.href ?? "",
310
+ match: meta.nav?.match,
311
+ section: meta.nav?.section ?? "hidden",
312
+ requiresAuth: meta.nav?.requiresAuth,
313
+ requiresRoles: meta.nav?.requiresRoles,
314
+ adminHref: meta.adminHref,
315
+ }
316
+ : undefined,
323
317
  search: startOpts.capabilities?.search
324
318
  ? {
325
319
  tags: [...(startOpts.capabilities.search.tags ?? [])],
@@ -330,6 +324,7 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
330
324
  : undefined,
331
325
  legalLinks: meta.legalLinks ? meta.legalLinks.map((l) => ({ ...l })) : undefined,
332
326
  widgets: meta.widgets ? meta.widgets.map((w) => ({ ...w })) : undefined,
327
+ settingKeys: meta.settingKeys ? [...meta.settingKeys] : undefined,
333
328
  openapi: advertiseOpenapi ? opts.openapi : undefined,
334
329
  };
335
330
 
@@ -354,12 +349,15 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
354
349
 
355
350
  const server = new Hono()
356
351
  .route(ssrMountPath, routes(config))
357
- .use("/public/*", serveStatic({
358
- root: "./",
359
- onFound: (_path, c) => {
360
- c.header("Cache-Control", "public, max-age=31536000, immutable");
361
- },
362
- }))
352
+ .use(
353
+ "/public/*",
354
+ serveStatic({
355
+ root: "./",
356
+ onFound: (_path, c) => {
357
+ c.header("Cache-Control", isDevelopment ? "no-store" : "public, max-age=31536000, immutable");
358
+ },
359
+ }),
360
+ )
363
361
  // serveStatic calls next() on miss — terminate /public/* here so a
364
362
  // missing asset is a clean 404 instead of falling through to the app
365
363
  // fetch (which might render an HTML page for the missing path).
@@ -368,6 +366,9 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
368
366
  if (startOpts.capabilities?.search) {
369
367
  const searchRun = startOpts.capabilities.search.run;
370
368
  server.post("/api/_internal/search", auth.requireRole("authenticated"), async (c) => {
369
+ if (!c.get("user")) {
370
+ return c.json({ message: "Search providers require a user-backed actor", code: "FORBIDDEN" }, 403);
371
+ }
371
372
  const body = await c.req.json<{ query: string; tags: string[]; limit: number }>();
372
373
  const ctx: AppSearchContext = { get: (key) => c.get(key) as never };
373
374
  const results = await searchRun({ query: body.query, tags: body.tags, limit: body.limit, ctx });
@@ -404,7 +405,11 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
404
405
  // User's fetch handles everything else. The framework doesn't inject any
405
406
  // context vars here — the user's router is expected to register the
406
407
  // middlewares it needs (middleware.runtime, middleware.settings, …).
407
- server.all("*", (c) => Promise.resolve(startOpts.fetch(c.req.raw)));
408
+ //
409
+ // env is threaded through so Bun-specific helpers inside the user's
410
+ // router still work — most importantly `upgradeWebSocket` from hono/bun,
411
+ // which reads the Bun server off `c.env`.
412
+ server.all("*", (c) => Promise.resolve(startOpts.fetch(c.req.raw, c.env)));
408
413
 
409
414
  // Lifecycle
410
415
  const cloudCtx: CloudContext = {
@@ -431,7 +436,9 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
431
436
  if (stopping) return;
432
437
  stopping = true;
433
438
  log.info(`Stopping: ${meta.id}`);
434
- try { if (startOpts.lifecycle?.stop) await startOpts.lifecycle.stop(cloudCtx); } catch {}
439
+ try {
440
+ if (startOpts.lifecycle?.stop) await startOpts.lifecycle.stop(cloudCtx);
441
+ } catch {}
435
442
  await stopRuntimeWatcher();
436
443
  await heartbeat.stop();
437
444
  };
@@ -439,7 +446,7 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
439
446
  process.on("SIGTERM", () => void shutdown().then(() => process.exit(0)));
440
447
  process.on("SIGINT", () => void shutdown().then(() => process.exit(0)));
441
448
 
442
- return { port, fetch: server.fetch };
449
+ return { port, development: isDevelopment, fetch: server.fetch };
443
450
  };
444
451
 
445
452
  return {
@@ -32,6 +32,7 @@ const QuerySchema = z
32
32
  profile: UserProfileSchema.optional(),
33
33
  exclude_user_ids: z.string().optional(),
34
34
  exclude_group_ids: z.string().optional(),
35
+ exclude_service_account_ids: z.string().optional(),
35
36
  user_member_of_group_ids: z.string().optional(),
36
37
  member_of_group_id: z.uuid().optional(),
37
38
  manager_of_group_id: z.uuid().optional(),
@@ -102,6 +103,8 @@ const app = new Hono()
102
103
  if (!excludeUserIds.ok) return respond(c, fail(err.badInput(excludeUserIds.message)));
103
104
  const excludeGroupIds = parseUuidList(query.exclude_group_ids, "exclude_group_ids");
104
105
  if (!excludeGroupIds.ok) return respond(c, fail(err.badInput(excludeGroupIds.message)));
106
+ const excludeServiceAccountIds = parseUuidList(query.exclude_service_account_ids, "exclude_service_account_ids");
107
+ if (!excludeServiceAccountIds.ok) return respond(c, fail(err.badInput(excludeServiceAccountIds.message)));
105
108
  const userMemberOfGroupIds = parseUuidList(query.user_member_of_group_ids, "user_member_of_group_ids");
106
109
  if (!userMemberOfGroupIds.ok) return respond(c, fail(err.badInput(userMemberOfGroupIds.message)));
107
110
 
@@ -113,6 +116,7 @@ const app = new Hono()
113
116
  profile: query.profile,
114
117
  excludeUserIds: excludeUserIds.value,
115
118
  excludeGroupIds: excludeGroupIds.value,
119
+ excludeServiceAccountIds: excludeServiceAccountIds.value,
116
120
  userMemberOfGroupIds: userMemberOfGroupIds.value,
117
121
  memberOfGroupId: query.member_of_group_id,
118
122
  managerOfGroupId: query.manager_of_group_id,
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Admin API for platform-wide runtime settings.
3
+ *
4
+ * This route lives in cloud-lib because `/admin/settings` is a platform page
5
+ * and needs a typed client without depending on the core app package. The
6
+ * core app mounts it under `/api/admin/core/settings`.
7
+ */
8
+ import { sql } from "bun";
9
+ import { Hono } from "hono";
10
+ import { z } from "zod";
11
+ import { listApps } from "../_internal/registry";
12
+ import { auth, v, type AuthContext } from "../server";
13
+ import { settingsDeleteLegacyKeys, settingsListLegacyKeys } from "../services";
14
+ import * as settings from "../services/settings";
15
+ import { SETTINGS_MAP } from "../services/settings/defaults";
16
+
17
+ const BulkUpdateSchema = z.record(z.string(), z.unknown());
18
+
19
+ type FieldErrors = Record<string, string>;
20
+
21
+ const isKnownSetting = (key: string): boolean => SETTINGS_MAP.has(key);
22
+ const liveSettingKeys = async () => (await listApps()).flatMap((app) => [...(app.settingKeys ?? [])]);
23
+
24
+ const app = new Hono<AuthContext>()
25
+ .get("/legacy", auth.requireRole("admin"), async (c) => {
26
+ return c.json(await settingsListLegacyKeys(await liveSettingKeys()));
27
+ })
28
+ .delete("/legacy", auth.requireRole("admin"), async (c) => {
29
+ return c.json(await settingsDeleteLegacyKeys(await liveSettingKeys()));
30
+ })
31
+ .put(
32
+ "/",
33
+ auth.requireRole("admin"),
34
+ v("json", BulkUpdateSchema),
35
+ async (c) => {
36
+ const updates = c.req.valid("json");
37
+ const keys = Object.keys(updates);
38
+
39
+ if (keys.length === 0) {
40
+ return c.body(null, 204);
41
+ }
42
+
43
+ const ownership: FieldErrors = {};
44
+ for (const key of keys) {
45
+ if (!isKnownSetting(key)) {
46
+ ownership[key] = `Unknown setting "${key}"`;
47
+ }
48
+ }
49
+ if (Object.keys(ownership).length > 0) {
50
+ return c.json({ message: "Invalid keys", errors: ownership }, 400);
51
+ }
52
+
53
+ const fieldErrors: FieldErrors = {};
54
+ try {
55
+ await sql.begin(async () => {
56
+ for (const [key, value] of Object.entries(updates)) {
57
+ try {
58
+ await settings.set(key, value);
59
+ } catch (error) {
60
+ fieldErrors[key] = error instanceof Error ? error.message : `Failed to update ${key}`;
61
+ throw error;
62
+ }
63
+ }
64
+ });
65
+ } catch (error) {
66
+ const message = error instanceof Error ? error.message : "Save failed";
67
+ return c.json(
68
+ {
69
+ message,
70
+ errors: Object.keys(fieldErrors).length > 0 ? fieldErrors : { _form: message },
71
+ },
72
+ 400,
73
+ );
74
+ }
75
+
76
+ return c.body(null, 204);
77
+ },
78
+ )
79
+ .delete(
80
+ "/:key{.+}",
81
+ auth.requireRole("admin"),
82
+ async (c) => {
83
+ const key = c.req.param("key");
84
+ if (!isKnownSetting(key)) {
85
+ return c.json({ message: `Unknown setting "${key}"` }, 400);
86
+ }
87
+ try {
88
+ await settings.remove(key);
89
+ return c.body(null, 204);
90
+ } catch (error) {
91
+ const message = error instanceof Error ? error.message : "Reset failed";
92
+ return c.json({ message }, 500);
93
+ }
94
+ },
95
+ );
96
+
97
+ export default app;
98
+ export type ApiType = typeof app;
@@ -0,0 +1,131 @@
1
+ import { ok } from "@valentinkolb/stdlib";
2
+ import { Hono } from "hono";
3
+ import { describeRoute } from "hono-openapi";
4
+ import { z } from "zod";
5
+ import {
6
+ ActiveAnnouncementsResponseSchema,
7
+ AnnouncementEntrySchema,
8
+ AnnouncementListResponseSchema,
9
+ CreateAnnouncementSchema,
10
+ ErrorResponseSchema,
11
+ MessageResponseSchema,
12
+ parseAnnouncementCookieHeader,
13
+ UpdateAnnouncementSchema,
14
+ } from "../contracts";
15
+ import { type AuthContext, auth, jsonResponse, requiresAdmin, requiresAuth, respond, v } from "../server";
16
+ import { announcements } from "../services";
17
+
18
+ const IdParamSchema = z.object({ id: z.uuid() });
19
+
20
+ const withMessage = async <T>(operation: Promise<import("@valentinkolb/stdlib").Result<T>>, message: string) => {
21
+ const result = await operation;
22
+ if (!result.ok) return result;
23
+ return ok({ message });
24
+ };
25
+
26
+ export const announcementRoutes = new Hono<AuthContext>().get(
27
+ "/active",
28
+ auth.requireRole("authenticated"),
29
+ describeRoute({
30
+ tags: ["Announcements"],
31
+ summary: "List active user announcements",
32
+ description: "Returns active banners and unseen announcements for the current request cookie state.",
33
+ ...requiresAuth,
34
+ responses: {
35
+ 200: jsonResponse(ActiveAnnouncementsResponseSchema, "Active announcements"),
36
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
37
+ },
38
+ }),
39
+ async (c) => {
40
+ const state = parseAnnouncementCookieHeader(c.req.header("Cookie"));
41
+ return respond(c, ok(await announcements.active.forState({ state })));
42
+ },
43
+ );
44
+
45
+ export const adminAnnouncementRoutes = new Hono<AuthContext>()
46
+ .use(auth.requireRole("admin"))
47
+ .get(
48
+ "/",
49
+ describeRoute({
50
+ tags: ["Admin Announcements"],
51
+ summary: "List announcements",
52
+ ...requiresAdmin,
53
+ responses: {
54
+ 200: jsonResponse(AnnouncementListResponseSchema, "Announcements"),
55
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
56
+ 403: jsonResponse(ErrorResponseSchema, "Admin access required"),
57
+ },
58
+ }),
59
+ v(
60
+ "query",
61
+ z.object({
62
+ kind: z.enum(["announcement", "banner"]).optional(),
63
+ search: z.string().optional(),
64
+ }),
65
+ ),
66
+ async (c) => {
67
+ const query = c.req.valid("query");
68
+ const items = await announcements.admin.list({
69
+ filter: { kind: query.kind, query: query.search },
70
+ });
71
+ return respond(c, ok({ items }));
72
+ },
73
+ )
74
+ .post(
75
+ "/",
76
+ describeRoute({
77
+ tags: ["Admin Announcements"],
78
+ summary: "Create announcement",
79
+ ...requiresAdmin,
80
+ responses: {
81
+ 201: jsonResponse(AnnouncementEntrySchema, "Created announcement"),
82
+ 400: jsonResponse(ErrorResponseSchema, "Validation error"),
83
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
84
+ 403: jsonResponse(ErrorResponseSchema, "Admin access required"),
85
+ },
86
+ }),
87
+ v("json", CreateAnnouncementSchema),
88
+ async (c) => respond(c, announcements.admin.create({ data: c.req.valid("json"), actorId: c.get("user").id }), 201),
89
+ )
90
+ .patch(
91
+ "/:id",
92
+ describeRoute({
93
+ tags: ["Admin Announcements"],
94
+ summary: "Update announcement",
95
+ ...requiresAdmin,
96
+ responses: {
97
+ 200: jsonResponse(AnnouncementEntrySchema, "Updated announcement"),
98
+ 400: jsonResponse(ErrorResponseSchema, "Validation error"),
99
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
100
+ 403: jsonResponse(ErrorResponseSchema, "Admin access required"),
101
+ 404: jsonResponse(ErrorResponseSchema, "Announcement not found"),
102
+ },
103
+ }),
104
+ v("param", IdParamSchema),
105
+ v("json", UpdateAnnouncementSchema),
106
+ async (c) =>
107
+ respond(
108
+ c,
109
+ announcements.admin.update({
110
+ id: c.req.valid("param").id,
111
+ data: c.req.valid("json"),
112
+ actorId: c.get("user").id,
113
+ }),
114
+ ),
115
+ )
116
+ .delete(
117
+ "/:id",
118
+ describeRoute({
119
+ tags: ["Admin Announcements"],
120
+ summary: "Delete announcement",
121
+ ...requiresAdmin,
122
+ responses: {
123
+ 200: jsonResponse(MessageResponseSchema, "Announcement deleted"),
124
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
125
+ 403: jsonResponse(ErrorResponseSchema, "Admin access required"),
126
+ 404: jsonResponse(ErrorResponseSchema, "Announcement not found"),
127
+ },
128
+ }),
129
+ v("param", IdParamSchema),
130
+ async (c) => respond(c, withMessage(announcements.admin.remove({ id: c.req.valid("param").id }), "Announcement deleted.")),
131
+ );