@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valentinkolb/cloud",
3
- "version": "0.3.1",
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,39 +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",
39
- "@scalar/hono-api-reference": "^0.10.9",
40
- "@scalar/openapi-to-markdown": "^0.5.6",
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",
41
49
  "@tabler/icons-webfont": "^3.36.1",
42
50
  "@tailwindcss/typography": "^0.5.19",
43
- "@valentinkolb/ssr": "0.9.0",
44
- "@valentinkolb/stdlib": "0.3.0",
51
+ "@valentinkolb/ssr": "0.10.0",
52
+ "@valentinkolb/stdlib": "0.12.0",
45
53
  "@valentinkolb/sync": "^5.0.0",
46
54
  "bun-plugin-tailwind": "^0.1.2",
47
55
  "hono": "^4.11.1",
48
56
  "hono-openapi": "^1.1.2",
57
+ "jose": "^6.1.3",
49
58
  "katex": "^0.16.28",
50
59
  "marked": "^17.0.1",
51
60
  "mermaid": "^11.12.2",
52
61
  "mustache": "^4.2.0",
53
- "nodemailer": "^7.0.12",
54
- "sanitize-html": "^2.17.0",
62
+ "nodemailer": "^8.0.10",
63
+ "sanitize-html": "^2.17.4",
64
+ "seroval": "^1.5.4",
55
65
  "solid-js": "^1.9.10",
56
66
  "tailwindcss": "^4.1.18",
57
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,26 +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
- import type { SsrConfig } from "@valentinkolb/ssr";
12
- import type {
13
- AppMeta,
14
- AppLifecycle,
15
- AppCapabilities,
16
- AppSearchContext,
17
- CloudContext,
18
- } from "../contracts/app";
13
+ import { generateSpecs } from "hono-openapi";
14
+ import type { AppCapabilities, AppLifecycle, AppMeta, AppSearchContext, CloudContext } from "../contracts/app";
19
15
  import type { AppRegistryEntry } from "../contracts/registry";
20
- import type { Role } from "../contracts/shared";
21
16
  import type { AppSettingsMap, KindToType } from "../contracts/settings-types";
22
- import { createSettingsAPI, type SettingsAPI } from "../services/settings/api";
23
- import { registerSettings, type SettingDef } from "../services/settings/defaults";
17
+ import type { Role } from "../contracts/shared";
18
+ import { themeBootstrapScript } from "../shared/theme";
24
19
  import { auth } from "../server/middleware/auth";
25
20
  import { logger } from "../services/logging";
26
- 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";
27
24
  import { createHeartbeat } from "./heartbeat";
28
25
  import { ensureRuntimeWatcher, getCurrentRuntime, stopRuntimeWatcher } from "./runtime-watcher";
29
26
 
@@ -80,7 +77,7 @@ export type AppOptions<S extends AppSettingsMap = {}> = {
80
77
  /**
81
78
  * Legal/info links contributed by this app — aggregated app-wide via
82
79
  * `listLegalLinks()` and rendered in login footer, app Footer, rail more
83
- * dropdown. Each app contributes its own (e.g. settings owns
80
+ * dropdown. Each app contributes its own (e.g. core owns
84
81
  * Imprint/Privacy/Terms; faq owns FAQ). KISS: no `external` flag, links
85
82
  * always open in a new tab from the login footer.
86
83
  */
@@ -101,12 +98,29 @@ export type AppOptions<S extends AppSettingsMap = {}> = {
101
98
  * `/admin/<id>` — admin SSR pages
102
99
  * `/public/<id>` — built CSS and other static assets
103
100
  *
104
- * Apps with non-standard URLs (core's `/auth`, `/me`; oauth's `/oauth`,
105
- * `/.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
106
103
  * top-level paths they own. The gateway is dumb — it just builds a
107
104
  * prefix-trie from these strings.
108
105
  */
109
106
  routes: readonly string[];
107
+ /**
108
+ * Gateway-relative URL at which this app's OpenAPI 3.x JSON spec is
109
+ * served, e.g. `"/api/notebooks/openapi.json"`. Opt-in: only set this
110
+ * for apps whose api router is documented with `middleware.openapi()`
111
+ * (i.e. `describeRoute()`) and worth surfacing in the api-docs aggregator.
112
+ *
113
+ * Pair this with `app.start({ openapi: <api router> })` — `defineApp`
114
+ * generates the spec from that router at boot, mounts it on the
115
+ * framework server (before the user's fetch, so it bypasses any
116
+ * auth/rate-limit middleware), and advertises the URL via the registry
117
+ * so `app-api-docs` picks it up automatically.
118
+ *
119
+ * The path must be reachable through the gateway — usually that means
120
+ * the standard form `"/api/<id>/openapi.json"` (covered by the
121
+ * `/api/<id>` entry in `routes`).
122
+ */
123
+ openapi?: string;
110
124
  /**
111
125
  * Project root used by the SSR plugin to discover island/client files.
112
126
  * Defaults to `process.cwd()`. Override only if you run the entrypoint
@@ -133,7 +147,19 @@ export type StartOptions = {
133
147
  * before this fetch — they take precedence over any catch-all the app
134
148
  * might register.
135
149
  */
136
- fetch: (req: Request) => Response | Promise<Response>;
150
+ fetch: (req: Request, env?: unknown) => Response | Promise<Response>;
151
+ /**
152
+ * Hono router to scan for OpenAPI route metadata. When set together with
153
+ * `defineApp({ openapi: "..." })`, the framework generates an OpenAPI
154
+ * spec from this router and mounts it at the configured URL on the
155
+ * framework server (before the user's fetch, so the spec stays public).
156
+ *
157
+ * Pass the BARE api router — the one with `describeRoute()` annotations.
158
+ * `generateSpecs` walks the route tree without executing middleware, so
159
+ * the inner auth/rate-limit `.use(...)` calls don't matter for spec
160
+ * generation; they only run if the router is hit by an actual request.
161
+ */
162
+ openapi?: Hono<any>;
137
163
  lifecycle?: AppLifecycle;
138
164
  capabilities?: AppCapabilities;
139
165
  port?: number;
@@ -142,6 +168,7 @@ export type StartOptions = {
142
168
 
143
169
  export type StartResult = {
144
170
  port: number;
171
+ development: boolean;
145
172
  fetch: Hono["fetch"];
146
173
  };
147
174
 
@@ -174,6 +201,8 @@ export type AppDefinition<S extends AppSettingsMap = {}> = {
174
201
  // ── Implementation ──────────────────────────────────────────────────────────
175
202
 
176
203
  export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<S>): AppDefinition<S> => {
204
+ const isDevelopment = process.env.NODE_ENV === "development";
205
+
177
206
  // ── 0. Register declared settings into the runtime registry ──────────
178
207
  // SETTINGS_MAP is the single source of truth for validation in store.ts
179
208
  // (writeKey checks SETTINGS_MAP.get(key)) and for snapshot.ts (allKnownKeys
@@ -207,7 +236,7 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
207
236
 
208
237
  // ── 1. SSR config ─────────────────────────────────────────────────────
209
238
  const { config, plugin, html } = createSsrConfig<PageOptions>({
210
- dev: process.env.NODE_ENV !== "production",
239
+ dev: isDevelopment,
211
240
  verbose: true,
212
241
  rootDir: opts.appRoot ?? process.cwd(),
213
242
  basePath: opts.basePath,
@@ -222,17 +251,10 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
222
251
  <meta name="theme-color" content="#09090b">
223
252
  <meta name="mobile-web-app-capable" content="yes">
224
253
  <link rel="icon" href="/branding/favicon">
225
- <link rel="stylesheet" href="/public/global.css?v=${v}">
254
+ <style data-cloud-css-layers>@layer theme, base, components, utilities;</style>
226
255
  <link rel="stylesheet" href="/public/${opts.id}/app.css?v=${v}">
227
- <script>
228
- (function() {
229
- var el = document.documentElement;
230
- if (!el.hasAttribute('data-theme-fixed')) {
231
- var theme = document.cookie.match(/theme=([^;]+)/)?.[1] || 'light';
232
- el.classList.add(theme);
233
- }
234
- })();
235
- </script>
256
+ <link rel="stylesheet" href="/public/global.css?v=${v}">
257
+ <script>${themeBootstrapScript}</script>
236
258
  </head>
237
259
  <body>
238
260
  ${body}
@@ -258,6 +280,8 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
258
280
  nav: opts.nav,
259
281
  legalLinks: opts.legalLinks ? [...opts.legalLinks] : undefined,
260
282
  widgets: opts.widgets ? opts.widgets.map((w) => ({ ...w })) : undefined,
283
+ settingKeys: opts.settings ? Object.keys(opts.settings) : undefined,
284
+ openapi: opts.openapi,
261
285
  };
262
286
 
263
287
  // ── 3. start() — builds and boots the Hono server ────────────────────
@@ -266,6 +290,11 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
266
290
  const baseUrl = opts.baseUrl;
267
291
  const log = logger("app");
268
292
 
293
+ // OpenAPI advertised in the registry only when there's a router to
294
+ // derive the spec from. The mount block lower down uses the same
295
+ // flag so the registry never points at a URL that 404s.
296
+ const advertiseOpenapi = !!(opts.openapi && startOpts.openapi);
297
+
269
298
  // Registry entry
270
299
  const entry: AppRegistryEntry = {
271
300
  id: meta.id,
@@ -274,16 +303,17 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
274
303
  description: meta.description,
275
304
  baseUrl,
276
305
  routes: [...meta.routes],
277
- nav: (meta.nav || meta.adminHref)
278
- ? {
279
- href: meta.nav?.href ?? "",
280
- match: meta.nav?.match,
281
- section: meta.nav?.section ?? "hidden",
282
- requiresAuth: meta.nav?.requiresAuth,
283
- requiresRoles: meta.nav?.requiresRoles,
284
- adminHref: meta.adminHref,
285
- }
286
- : 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,
287
317
  search: startOpts.capabilities?.search
288
318
  ? {
289
319
  tags: [...(startOpts.capabilities.search.tags ?? [])],
@@ -294,6 +324,8 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
294
324
  : undefined,
295
325
  legalLinks: meta.legalLinks ? meta.legalLinks.map((l) => ({ ...l })) : undefined,
296
326
  widgets: meta.widgets ? meta.widgets.map((w) => ({ ...w })) : undefined,
327
+ settingKeys: meta.settingKeys ? [...meta.settingKeys] : undefined,
328
+ openapi: advertiseOpenapi ? opts.openapi : undefined,
297
329
  };
298
330
 
299
331
  // Heartbeat
@@ -306,21 +338,26 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
306
338
  // watcher is a module-level singleton, only one runs per process.
307
339
  await ensureRuntimeWatcher();
308
340
 
309
- // Build Hono server. Framework owns three mounts (registered first so
341
+ // Build Hono server. Framework owns these mounts (registered first so
310
342
  // they take precedence over any catch-all in the user's fetch):
311
343
  // /_ssr/* island chunks (SSR adapter)
312
344
  // /public/* serveStatic + terminal 404
313
345
  // /api/_internal/search only when capabilities.search is declared
346
+ // <opts.openapi> OpenAPI JSON spec, when both opts.openapi
347
+ // and startOpts.openapi are set
314
348
  const ssrMountPath = config.basePath ? `${config.basePath}/_ssr` : "/_ssr";
315
349
 
316
350
  const server = new Hono()
317
351
  .route(ssrMountPath, routes(config))
318
- .use("/public/*", serveStatic({
319
- root: "./",
320
- onFound: (_path, c) => {
321
- c.header("Cache-Control", "public, max-age=31536000, immutable");
322
- },
323
- }))
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
+ )
324
361
  // serveStatic calls next() on miss — terminate /public/* here so a
325
362
  // missing asset is a clean 404 instead of falling through to the app
326
363
  // fetch (which might render an HTML page for the missing path).
@@ -329,6 +366,9 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
329
366
  if (startOpts.capabilities?.search) {
330
367
  const searchRun = startOpts.capabilities.search.run;
331
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
+ }
332
372
  const body = await c.req.json<{ query: string; tags: string[]; limit: number }>();
333
373
  const ctx: AppSearchContext = { get: (key) => c.get(key) as never };
334
374
  const results = await searchRun({ query: body.query, tags: body.tags, limit: body.limit, ctx });
@@ -336,10 +376,40 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
336
376
  });
337
377
  }
338
378
 
379
+ // OpenAPI spec mount. Registered on the framework server (before the
380
+ // user-fetch catch-all below) so it bypasses any auth / rate-limit
381
+ // middleware on the api router — the spec must stay reachable without
382
+ // a session for `app-api-docs` to render it.
383
+ //
384
+ // The `servers` override is load-bearing: hono-openapi walks the
385
+ // BARE api router and emits paths relative to its own root (e.g.
386
+ // `/{id}`, `/{id}/notes`), without the `/api/<id>` prefix it ends
387
+ // up under in the user's outer router. We derive that prefix from
388
+ // `opts.openapi` (everything before the trailing `/openapi.json`)
389
+ // so combined Scalar URLs resolve to the real public paths.
390
+ if (advertiseOpenapi) {
391
+ const apiPrefix = opts.openapi!.replace(/\/openapi\.json$/, "") || "/";
392
+ const spec = await generateSpecs(startOpts.openapi!, {
393
+ documentation: {
394
+ info: {
395
+ title: meta.name,
396
+ version: "0.0.1",
397
+ description: meta.description,
398
+ },
399
+ servers: [{ url: apiPrefix }],
400
+ },
401
+ });
402
+ server.get(opts.openapi!, (c) => c.json(spec));
403
+ }
404
+
339
405
  // User's fetch handles everything else. The framework doesn't inject any
340
406
  // context vars here — the user's router is expected to register the
341
407
  // middlewares it needs (middleware.runtime, middleware.settings, …).
342
- 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)));
343
413
 
344
414
  // Lifecycle
345
415
  const cloudCtx: CloudContext = {
@@ -366,7 +436,9 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
366
436
  if (stopping) return;
367
437
  stopping = true;
368
438
  log.info(`Stopping: ${meta.id}`);
369
- 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 {}
370
442
  await stopRuntimeWatcher();
371
443
  await heartbeat.stop();
372
444
  };
@@ -374,7 +446,7 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
374
446
  process.on("SIGTERM", () => void shutdown().then(() => process.exit(0)));
375
447
  process.on("SIGINT", () => void shutdown().then(() => process.exit(0)));
376
448
 
377
- return { port, fetch: server.fetch };
449
+ return { port, development: isDevelopment, fetch: server.fetch };
378
450
  };
379
451
 
380
452
  return {
@@ -33,6 +33,7 @@ export const buildRuntimeFromRegistry = (entries: AppRegistryEntry[]): CloudRunt
33
33
  searchHelp: e.search?.help,
34
34
  searchTagHelp: e.search?.tagHelp,
35
35
  legalLinks: e.legalLinks ? e.legalLinks.map((l) => ({ ...l })) : undefined,
36
+ openapi: e.openapi,
36
37
  }),
37
38
  ),
38
39
  });
@@ -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;