@valentinkolb/cloud 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/package.json +69 -0
  2. package/public/logo.svg +1 -0
  3. package/scripts/build.ts +113 -0
  4. package/scripts/preload.ts +73 -0
  5. package/src/_internal/define-app.ts +399 -0
  6. package/src/_internal/heartbeat.ts +33 -0
  7. package/src/_internal/registry.ts +100 -0
  8. package/src/_internal/runtime-context.ts +38 -0
  9. package/src/api/accounts-entities.ts +134 -0
  10. package/src/api/admin-lifecycle.ts +210 -0
  11. package/src/api/auth/schemas.ts +28 -0
  12. package/src/api/auth.ts +230 -0
  13. package/src/api/index.ts +66 -0
  14. package/src/api/me.ts +206 -0
  15. package/src/api/search/schemas.ts +43 -0
  16. package/src/api/search.ts +130 -0
  17. package/src/clients/core.ts +19 -0
  18. package/src/config/env.ts +23 -0
  19. package/src/config/index.ts +6 -0
  20. package/src/config/ssr.ts +58 -0
  21. package/src/contracts/app.ts +140 -0
  22. package/src/contracts/index.ts +5 -0
  23. package/src/contracts/profile.ts +67 -0
  24. package/src/contracts/registry.ts +50 -0
  25. package/src/contracts/settings-types.ts +84 -0
  26. package/src/contracts/shared.ts +258 -0
  27. package/src/contracts/widgets.ts +121 -0
  28. package/src/index.ts +6 -0
  29. package/src/server/api/index.ts +1 -0
  30. package/src/server/api/respond.ts +55 -0
  31. package/src/server/api-client.ts +54 -0
  32. package/src/server/app-context.ts +39 -0
  33. package/src/server/index.ts +62 -0
  34. package/src/server/middleware/auth.ts +168 -0
  35. package/src/server/middleware/index.ts +7 -0
  36. package/src/server/middleware/middleware.ts +47 -0
  37. package/src/server/middleware/openapi.ts +126 -0
  38. package/src/server/middleware/rate-limit.ts +126 -0
  39. package/src/server/middleware/request-logger.ts +41 -0
  40. package/src/server/middleware/validator.ts +35 -0
  41. package/src/server/services/access.ts +294 -0
  42. package/src/server/services/freeipa/client.ts +100 -0
  43. package/src/server/services/freeipa/index.ts +9 -0
  44. package/src/server/services/freeipa/session.ts +78 -0
  45. package/src/server/services/freeipa/tls.ts +48 -0
  46. package/src/server/services/freeipa/util.ts +60 -0
  47. package/src/server/services/geo.ts +154 -0
  48. package/src/server/services/index.ts +28 -0
  49. package/src/server/services/services.ts +13 -0
  50. package/src/services/account-lifecycle/audit.ts +41 -0
  51. package/src/services/account-lifecycle/index.ts +907 -0
  52. package/src/services/account-lifecycle/scheduler.ts +347 -0
  53. package/src/services/account-model.ts +21 -0
  54. package/src/services/accounts/app.ts +966 -0
  55. package/src/services/accounts/authz.ts +22 -0
  56. package/src/services/accounts/base-group.ts +11 -0
  57. package/src/services/accounts/base-user.ts +45 -0
  58. package/src/services/accounts/entities.ts +529 -0
  59. package/src/services/accounts/group-sql.ts +106 -0
  60. package/src/services/accounts/groups.ts +246 -0
  61. package/src/services/accounts/index.ts +14 -0
  62. package/src/services/accounts/ipa-data.ts +64 -0
  63. package/src/services/accounts/lifecycle.ts +2 -0
  64. package/src/services/accounts/local-groups.ts +491 -0
  65. package/src/services/accounts/model.ts +135 -0
  66. package/src/services/accounts/switching.ts +117 -0
  67. package/src/services/accounts/users.ts +714 -0
  68. package/src/services/auth-flows/index.ts +6 -0
  69. package/src/services/auth-flows/ipa.ts +128 -0
  70. package/src/services/auth-flows/magic-link.ts +119 -0
  71. package/src/services/freeipa-config.ts +89 -0
  72. package/src/services/index.ts +46 -0
  73. package/src/services/ipa/auth.ts +122 -0
  74. package/src/services/ipa/groups.ts +684 -0
  75. package/src/services/ipa/guard.ts +17 -0
  76. package/src/services/ipa/index.ts +17 -0
  77. package/src/services/ipa/profile.ts +90 -0
  78. package/src/services/ipa/search.ts +154 -0
  79. package/src/services/ipa/sync.ts +740 -0
  80. package/src/services/ipa/users.ts +794 -0
  81. package/src/services/logging/index.ts +294 -0
  82. package/src/services/notifications/email.ts +123 -0
  83. package/src/services/notifications/index.ts +413 -0
  84. package/src/services/postgres.ts +51 -0
  85. package/src/services/providers/index.ts +27 -0
  86. package/src/services/providers/local/auth.ts +13 -0
  87. package/src/services/providers/local/index.ts +4 -0
  88. package/src/services/providers/local/users.ts +255 -0
  89. package/src/services/session/index.ts +137 -0
  90. package/src/services/settings/api.ts +61 -0
  91. package/src/services/settings/app.ts +101 -0
  92. package/src/services/settings/crypto.ts +69 -0
  93. package/src/services/settings/defaults.ts +824 -0
  94. package/src/services/settings/index.ts +203 -0
  95. package/src/services/settings/namespace.ts +9 -0
  96. package/src/services/settings/snapshot.ts +49 -0
  97. package/src/services/settings/store.ts +179 -0
  98. package/src/services/settings/templates.ts +10 -0
  99. package/src/services/weather/forecast.ts +287 -0
  100. package/src/services/weather/geo.ts +110 -0
  101. package/src/services/weather/index.ts +99 -0
  102. package/src/services/weather/location.ts +24 -0
  103. package/src/services/weather/locations.ts +125 -0
  104. package/src/services/weather/migrate.ts +22 -0
  105. package/src/services/weather/types.ts +61 -0
  106. package/src/services/weather/ui.ts +50 -0
  107. package/src/shared/account-display.ts +17 -0
  108. package/src/shared/account-session.ts +15 -0
  109. package/src/shared/icons.ts +109 -0
  110. package/src/shared/index.ts +10 -0
  111. package/src/shared/markdown/client.ts +130 -0
  112. package/src/shared/markdown/extensions/code.ts +58 -0
  113. package/src/shared/markdown/extensions/images.ts +43 -0
  114. package/src/shared/markdown/extensions/info-blocks.ts +93 -0
  115. package/src/shared/markdown/extensions/katex.ts +120 -0
  116. package/src/shared/markdown/extensions/links.ts +34 -0
  117. package/src/shared/markdown/extensions/tables.ts +88 -0
  118. package/src/shared/markdown/extensions/task-list.ts +53 -0
  119. package/src/shared/markdown/index.ts +97 -0
  120. package/src/shared/markdown/shared.ts +36 -0
  121. package/src/ssr/AdminLayout.tsx +42 -0
  122. package/src/ssr/AdminSidebar.tsx +95 -0
  123. package/src/ssr/Footer.island.tsx +62 -0
  124. package/src/ssr/GlobalSearchDialog.tsx +389 -0
  125. package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
  126. package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
  127. package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
  128. package/src/ssr/Layout.tsx +326 -0
  129. package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
  130. package/src/ssr/NavMenu.island.tsx +108 -0
  131. package/src/ssr/ThemeToggleRail.island.tsx +27 -0
  132. package/src/ssr/index.ts +5 -0
  133. package/src/ssr/islands/SearchBar.island.tsx +77 -0
  134. package/src/ssr/islands/index.ts +1 -0
  135. package/src/ssr/runtime.ts +22 -0
  136. package/src/styles/base-popover.css +28 -0
  137. package/src/styles/effects.css +65 -0
  138. package/src/styles/global.css +133 -0
  139. package/src/styles/input.css +54 -0
  140. package/src/styles/tokens.css +35 -0
  141. package/src/styles/utilities-buttons.css +125 -0
  142. package/src/styles/utilities-feedback.css +65 -0
  143. package/src/styles/utilities-layout.css +122 -0
  144. package/src/styles/utilities-navigation.css +196 -0
  145. package/src/types/ambient.d.ts +8 -0
  146. package/src/ui/admin-settings.tsx +148 -0
  147. package/src/ui/dialog-core.ts +146 -0
  148. package/src/ui/filter/FilterChip.tsx +196 -0
  149. package/src/ui/filter/index.ts +2 -0
  150. package/src/ui/index.ts +19 -0
  151. package/src/ui/input/Checkbox.tsx +55 -0
  152. package/src/ui/input/ColorInput.tsx +122 -0
  153. package/src/ui/input/DateTimeInput.tsx +86 -0
  154. package/src/ui/input/ImageInput.tsx +170 -0
  155. package/src/ui/input/NumberInput.tsx +113 -0
  156. package/src/ui/input/PinInput.tsx +169 -0
  157. package/src/ui/input/SegmentedControl.tsx +99 -0
  158. package/src/ui/input/Select.tsx +288 -0
  159. package/src/ui/input/SelectChip.tsx +61 -0
  160. package/src/ui/input/Slider.tsx +118 -0
  161. package/src/ui/input/Switch.tsx +62 -0
  162. package/src/ui/input/TagsInput.tsx +115 -0
  163. package/src/ui/input/TextInput.tsx +160 -0
  164. package/src/ui/input/index.ts +13 -0
  165. package/src/ui/input/types.ts +42 -0
  166. package/src/ui/input/util.tsx +105 -0
  167. package/src/ui/ipa/Avatar.tsx +28 -0
  168. package/src/ui/ipa/GroupView.tsx +36 -0
  169. package/src/ui/ipa/LoginBtn.tsx +16 -0
  170. package/src/ui/ipa/UserView.tsx +58 -0
  171. package/src/ui/ipa/index.ts +4 -0
  172. package/src/ui/misc/ContextMenu.tsx +211 -0
  173. package/src/ui/misc/CopyButton.tsx +28 -0
  174. package/src/ui/misc/Dropdown.tsx +194 -0
  175. package/src/ui/misc/EntitySearch.tsx +213 -0
  176. package/src/ui/misc/Lightbox.tsx +194 -0
  177. package/src/ui/misc/LinkCard.tsx +34 -0
  178. package/src/ui/misc/LogEntriesTable.tsx +61 -0
  179. package/src/ui/misc/MarkdownView.tsx +65 -0
  180. package/src/ui/misc/Pagination.tsx +51 -0
  181. package/src/ui/misc/PermissionEditor.tsx +379 -0
  182. package/src/ui/misc/ProgressBar.tsx +47 -0
  183. package/src/ui/misc/RemoveBtn.tsx +27 -0
  184. package/src/ui/misc/StatCell.tsx +90 -0
  185. package/src/ui/misc/index.ts +18 -0
  186. package/src/ui/navigation.ts +32 -0
  187. package/src/ui/prompts.tsx +854 -0
  188. package/src/ui/sidebar.tsx +468 -0
  189. package/src/ui/widgets/Widget.tsx +62 -0
  190. package/src/ui/widgets/WidgetCard.tsx +19 -0
  191. package/src/ui/widgets/WidgetHero.tsx +39 -0
  192. package/src/ui/widgets/WidgetList.tsx +84 -0
  193. package/src/ui/widgets/WidgetPills.tsx +68 -0
  194. package/src/ui/widgets/WidgetStat.tsx +67 -0
  195. package/src/ui/widgets/WidgetStatus.tsx +62 -0
  196. package/src/ui/widgets/index.ts +9 -0
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@valentinkolb/cloud",
3
+ "version": "0.1.0",
4
+ "description": "Modular Hono+SolidJS framework for building per-app docker services behind a dynamic gateway. Powers cloud.stuve-ulm.de.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/ValentinKolb/cloud.git",
9
+ "directory": "packages/cloud"
10
+ },
11
+ "homepage": "https://github.com/ValentinKolb/cloud",
12
+ "bugs": "https://github.com/ValentinKolb/cloud/issues",
13
+ "type": "module",
14
+ "files": [
15
+ "src",
16
+ "scripts",
17
+ "public"
18
+ ],
19
+ "exports": {
20
+ ".": "./src/index.ts",
21
+ "./ui": "./src/ui/index.ts",
22
+ "./server": "./src/server/index.ts",
23
+ "./browser": "./src/server/api-client.ts",
24
+ "./shared": "./src/shared/index.ts",
25
+ "./services": "./src/services/index.ts",
26
+ "./services/*": "./src/services/*",
27
+ "./ssr": "./src/ssr/index.ts",
28
+ "./ssr/islands": "./src/ssr/islands/index.ts",
29
+ "./config": "./src/config/index.ts",
30
+ "./config/*": "./src/config/*",
31
+ "./contracts": "./src/contracts/index.ts",
32
+ "./styles/global.css": "./src/styles/global.css"
33
+ },
34
+ "scripts": {
35
+ "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
+ },
37
+ "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",
41
+ "@tabler/icons-webfont": "^3.36.1",
42
+ "@valentinkolb/ssr": "0.9.0",
43
+ "@valentinkolb/stdlib": "0.3.0",
44
+ "@valentinkolb/sync": "^5.0.0",
45
+ "hono": "^4.11.1",
46
+ "hono-openapi": "^1.1.2",
47
+ "katex": "^0.16.28",
48
+ "marked": "^17.0.1",
49
+ "mermaid": "^11.12.2",
50
+ "mustache": "^4.2.0",
51
+ "nodemailer": "^7.0.12",
52
+ "sanitize-html": "^2.17.0",
53
+ "solid-js": "^1.9.10",
54
+ "zod": "^4.3.4"
55
+ },
56
+ "devDependencies": {
57
+ "@babel/core": "^7.28.5",
58
+ "@babel/preset-typescript": "^7.28.5",
59
+ "@tailwindcss/typography": "^0.5.19",
60
+ "@types/bun": "1.3.9",
61
+ "@types/mustache": "^4.2.6",
62
+ "@types/nodemailer": "^7.0.5",
63
+ "@types/sanitize-html": "^2.16.0",
64
+ "babel-preset-solid": "^1.9.10",
65
+ "bun-plugin-tailwind": "^0.1.2",
66
+ "tailwindcss": "^4.1.18",
67
+ "typescript": "^5.9.3"
68
+ }
69
+ }
@@ -0,0 +1 @@
1
+ <?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="ArtBoard1" x="0" y="0" width="24" height="24" style="fill:none;"/><g id="ArtBoard11" serif:id="ArtBoard1"><path d="M9.974,4.062c2.269,-0.689 4.771,-0.231 6.574,1.216c1.433,1.145 2.262,2.777 2.328,4.486l0.003,0.219l0.094,0.004c2.378,0.11 4.283,2.027 4.394,4.414l0.004,0.218c-0,2.488 -1.949,4.519 -4.399,4.633l-0.217,0.005l-12.278,-0l-0.23,-0.008c-3.039,-0.114 -5.496,-2.48 -5.613,-5.441l-0.005,-0.223c0,-2.84 2.15,-5.179 4.945,-5.6l0.118,-0.016l0.073,-0.187c0.685,-1.675 2.125,-3.004 3.958,-3.637l0.252,-0.083l-0.001,0Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/><path d="M0.156,13.767l-0.001,-0.019l-0.005,-0.205l-0,-0.026c-0,-3.044 2.204,-5.581 5.143,-6.192c0.789,-1.718 2.309,-3.082 4.227,-3.743c-0.121,0.041 0.018,-0.007 0.018,-0.007l0.231,-0.076l0.024,-0.007c2.464,-0.749 5.179,-0.246 7.138,1.326c1.439,1.15 2.321,2.745 2.528,4.443c2.416,0.425 4.269,2.487 4.386,5.006l0.001,0.029l0.004,0.2l-0.034,0.035l-0.009,0.029l0.043,-0.043c0,2.864 -2.245,5.2 -5.066,5.331l-0.039,0.001l-0.21,0.005l-11.878,0c0.097,0 0.097,-0.015 0,0l-0.222,-0.008l-0.032,-0.001c-3.389,-0.126 -6.117,-2.777 -6.247,-6.078Zm9.088,-9.164c-0.161,0.076 -0.094,0.044 -0.232,0.109c-1.411,0.656 -2.482,1.819 -3.044,3.191l-0.07,0.181c0,0 -0.57,0.103 -0.943,0.204c-2.31,0.624 -3.955,2.77 -3.955,5.229l0.005,0.216c0.01,0.259 0.039,0.514 0.085,0.762c0,0 0.008,0.083 0.016,0.082c0.495,2.458 2.684,4.32 5.329,4.419l0.016,0.001l0.002,0l0.204,0.007l0.013,-0c0.324,0.047 11.865,-0 11.865,-0l0.21,-0.005c2.37,-0.11 4.255,-2.075 4.255,-4.482l-0.004,-0.211c-0.107,-2.309 -1.95,-4.163 -4.25,-4.27l-0.091,-0.003l-0.003,-0.212c-0.064,-1.654 -0.866,-3.232 -2.252,-4.34c-1.745,-1.4 -4.164,-1.843 -6.359,-1.176l-0.244,0.08c-0.189,0.065 -0.375,0.134 -0.553,0.218Z" style="fill:url(#_Linear2);"/></g><defs><linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0.28477,14.9308,-14.9308,0.28477,12.4271,4.32609)"><stop offset="0" style="stop-color:#f0f6ff;stop-opacity:1"/><stop offset="1" style="stop-color:#5a9cff;stop-opacity:1"/></linearGradient><linearGradient id="_Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.11193e-15,18.1593,-18.1593,1.11193e-15,12,1.69474)"><stop offset="0" style="stop-color:#a6d1ff;stop-opacity:1"/><stop offset="1" style="stop-color:#1f8bff;stop-opacity:1"/></linearGradient></defs></svg>
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Production build for a single app.
3
+ *
4
+ * APP_ID=<id> bun run packages/cloud/scripts/build.ts
5
+ *
6
+ * Output goes to /<workspace-root>/dist:
7
+ * server.js bundled Bun entry
8
+ * _ssr/<island>.js hydration bundles (auto-emitted by the SSR plugin)
9
+ * public/<id>/app.css Tailwind, if the app has src/styles/app.css
10
+ * public/<id>/... anything from packages/<id>/public/
11
+ *
12
+ * If the app needs additional artefacts (core's global.css, logo, katex),
13
+ * it ships a `scripts/build-extras.ts` that this script runs at the end.
14
+ */
15
+ import { existsSync } from "node:fs";
16
+ import { cp, mkdir, readdir, rm } from "node:fs/promises";
17
+ import { dirname, resolve } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import tailwind from "bun-plugin-tailwind";
20
+ import { Glob, CryptoHasher } from "bun";
21
+
22
+ // Mirrors @valentinkolb/ssr's island-id (md5 of POSIX path relative to the
23
+ // SSR plugin's rootDir, truncated to 12 chars). define-app sets rootDir to
24
+ // the `packages` directory.
25
+ const ssrRootDir = "packages";
26
+ const islandId = (file: string): string => {
27
+ const rel = file.slice(root.length + 1 + ssrRootDir.length + 1).replace(/\\/g, "/");
28
+ return new CryptoHasher("md5").update(rel).digest("hex").slice(0, 12);
29
+ };
30
+
31
+ const appId = process.env.APP_ID;
32
+ if (!appId) throw new Error("APP_ID env var required");
33
+
34
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
35
+ const appDir = resolve(root, "packages", appId);
36
+ if (!existsSync(appDir)) throw new Error(`Unknown app: ${appId} (no packages/${appId})`);
37
+
38
+ const dist = resolve(root, "dist");
39
+ const distPublic = resolve(dist, "public");
40
+
41
+ await rm(dist, { recursive: true, force: true });
42
+ await mkdir(distPublic, { recursive: true });
43
+
44
+ // Register the app's SSR plugin (Solid JSX transform + island bundler).
45
+ const { plugin } = await import(`../../${appId}/src/config`);
46
+
47
+ // 1. Server entry — also emits dist/_ssr/<island>.js via the SSR plugin.
48
+ const server = await Bun.build({
49
+ entrypoints: [resolve(appDir, "src/index.ts")],
50
+ outdir: dist,
51
+ naming: "server.js",
52
+ target: "bun",
53
+ minify: true,
54
+ plugins: [plugin()],
55
+ });
56
+ if (!server.success) {
57
+ for (const m of server.logs) console.error(m);
58
+ throw new Error("Server bundle failed");
59
+ }
60
+
61
+ // 1b. The SSR plugin scans the workspace root and emits one chunk per
62
+ // island/client file across every package. Drop the ones from other apps
63
+ // so this image only carries its own + the framework's. Chunk files
64
+ // (chunk-<hash>.js) are shared splits and always kept.
65
+ const ssrDir = resolve(dist, "_ssr");
66
+ if (existsSync(ssrDir)) {
67
+ const allowedDirs = [resolve(root, "packages/cloud"), appDir];
68
+ const allowedIds = new Set<string>();
69
+ for (const dir of allowedDirs) {
70
+ for await (const file of new Glob("**/*.{island,client}.tsx").scan({ cwd: dir, absolute: true })) {
71
+ allowedIds.add(islandId(file));
72
+ }
73
+ }
74
+ for (const entry of await readdir(ssrDir)) {
75
+ if (entry.startsWith("chunk-") || !entry.endsWith(".js")) continue;
76
+ const id = entry.slice(0, -3);
77
+ if (!allowedIds.has(id)) await rm(resolve(ssrDir, entry));
78
+ }
79
+ }
80
+
81
+ // 2. Per-app Tailwind stylesheet.
82
+ const appCss = resolve(appDir, "src/styles/app.css");
83
+ if (existsSync(appCss)) {
84
+ const out = resolve(distPublic, appId);
85
+ await mkdir(out, { recursive: true });
86
+ const css = await Bun.build({
87
+ entrypoints: [appCss],
88
+ outdir: out,
89
+ naming: "app.css",
90
+ root,
91
+ plugins: [tailwind],
92
+ });
93
+ if (!css.success) {
94
+ for (const m of css.logs) console.error(m);
95
+ throw new Error("App CSS build failed");
96
+ }
97
+ }
98
+
99
+ // 3. Per-app static assets.
100
+ const appPublic = resolve(appDir, "public");
101
+ if (existsSync(appPublic)) {
102
+ await cp(appPublic, resolve(distPublic, appId), { recursive: true });
103
+ }
104
+
105
+ // 4. Optional app-specific extras (e.g. core's global.css + logo + katex).
106
+ const extras = resolve(appDir, "scripts/build-extras.ts");
107
+ if (existsSync(extras)) {
108
+ process.env.WORKSPACE_ROOT = root;
109
+ process.env.DIST_DIR = dist;
110
+ await import(extras);
111
+ }
112
+
113
+ console.log(`Built ${appId} → ${dist}`);
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Preload script for dev mode.
3
+ * Registers the SSR plugin (Solid.js JSX transform + island bundling)
4
+ * and builds CSS before any app code is imported.
5
+ *
6
+ * Uses bun-plugin-tailwind with root=workspaceRoot so the oxide scanner
7
+ * uses /app as projectRoot → auto-detect scans all packages → all classes generated.
8
+ * No @source directives needed in CSS files.
9
+ */
10
+ import tailwind from "bun-plugin-tailwind";
11
+ import { cp, mkdir } from "node:fs/promises";
12
+ import { resolve, dirname } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const appId = process.env.APP_ID ?? "core";
16
+ const { plugin } = await import(`../../${appId}/src/config`);
17
+ Bun.plugin(plugin());
18
+
19
+ // ── Build CSS ───────────────────────────────────────────────────────────────
20
+ const workspaceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
21
+ const publicDir = resolve(workspaceRoot, "public");
22
+ await mkdir(publicDir, { recursive: true });
23
+
24
+ // global.css + branding are only served by core (Traefik routes them there)
25
+ if (appId === "core") {
26
+ // Use styles.css at workspace root as entrypoint so the oxide scanner
27
+ // walks up to /app/package.json and scans ALL packages for utility classes.
28
+ // (If the CSS file is inside packages/lib/, it only scans packages/lib/.)
29
+ await Bun.build({
30
+ entrypoints: [resolve(workspaceRoot, "styles.css")],
31
+ outdir: publicDir,
32
+ naming: "global.css",
33
+ plugins: [tailwind],
34
+ });
35
+
36
+ // Default branding asset: copy the tracked logo.svg into the runtime
37
+ // public dir so serveBranding can fall back to it when no admin-uploaded
38
+ // logo (data URI) is configured. User uploads are stored as base64 data
39
+ // 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
+ );
44
+ }
45
+
46
+ // katex.css is only needed by notebooks (served by core via Traefik /public/katex.css)
47
+ if (appId === "notebooks") {
48
+ try {
49
+ await cp(
50
+ resolve(workspaceRoot, "node_modules/katex/dist/katex.min.css"),
51
+ resolve(publicDir, "katex.css"),
52
+ );
53
+ } catch {
54
+ console.warn("[preload] katex.css not found, skipping");
55
+ }
56
+ }
57
+
58
+ // Each app builds its own app.css
59
+ const appCssPath = resolve(workspaceRoot, `packages/${appId}/src/styles/app.css`);
60
+ const appPublicDir = resolve(publicDir, appId);
61
+ await mkdir(appPublicDir, { recursive: true });
62
+
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
+ });
@@ -0,0 +1,399 @@
1
+ /**
2
+ * defineApp() — The single entry point for every cloud app.
3
+ *
4
+ * Merges SSR config, app meta, and server bootstrap into one call.
5
+ * Returns `{ ssr, plugin, config, meta, start }`.
6
+ */
7
+ import { createConfig as createSsrConfig } from "@valentinkolb/ssr";
8
+ import { createSSRHandler, routes } from "@valentinkolb/ssr/hono";
9
+ import { Hono } from "hono";
10
+ import { serveStatic } from "hono/bun";
11
+ import type { SsrConfig } from "@valentinkolb/ssr";
12
+ import { resolve, dirname } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import type {
15
+ AppMeta,
16
+ AppLifecycle,
17
+ AppCapabilities,
18
+ AppSearchContext,
19
+ CloudContext,
20
+ } from "../contracts/app";
21
+ import type { AppRegistryEntry } from "../contracts/registry";
22
+ import type { Role } from "../contracts/shared";
23
+ import type { AppSettingsMap, KindToType } from "../contracts/settings-types";
24
+ import { createSettingsAPI, type SettingsAPI } from "../services/settings/api";
25
+ import { loadSnapshot } from "../services/settings/snapshot";
26
+ import { registerSettings, type SettingDef } from "../services/settings/defaults";
27
+ import { auth } from "../server/middleware/auth";
28
+ import { logger } from "../services/logging";
29
+ import { get, set, loadCache as loadSettingsCache } from "../services/settings";
30
+ import { appRegistry, listApps } from "./registry";
31
+ import { createHeartbeat } from "./heartbeat";
32
+ import { buildRuntimeFromRegistry } from "./runtime-context";
33
+
34
+ /** Cache-busting version stamp — changes on every server start / rebuild. */
35
+ const v = Date.now();
36
+
37
+ type PageOptions = {
38
+ title?: string;
39
+ description?: string;
40
+ theme?: "light" | "dark";
41
+ };
42
+
43
+ // ── Public types ────────────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * App definition options.
47
+ *
48
+ * `S` is the inferred per-app settings map (see `AppSettingsMap`). Apps that
49
+ * declare `settings: { ... } as const` get S inferred to the literal shape;
50
+ * `AppContext<typeof app>` then exposes the typed snapshot on `c.get("settings")`.
51
+ *
52
+ * Apps that omit `settings` get S = {} (no own settings — only core's are
53
+ * available in their snapshot, populated by core's own defineApp.settings).
54
+ */
55
+ export type AppOptions<S extends AppSettingsMap = {}> = {
56
+ id: string;
57
+ name: string;
58
+ icon: string;
59
+ description: string;
60
+ /** URL prefix for SSR asset isolation. Omit for the global `/_ssr/` path (core). */
61
+ basePath?: string;
62
+ /** Base URL as seen by other containers (e.g. "http://app-notebooks:3000"). */
63
+ baseUrl: string;
64
+ adminHref?: string;
65
+ nav?: {
66
+ href: string;
67
+ match?: string;
68
+ section: "primary" | "more" | "hidden";
69
+ requiresAuth?: boolean;
70
+ requiresRoles?: Role[];
71
+ };
72
+ /**
73
+ * Settings owned by this app, declared as a map of dotted-key → definition.
74
+ *
75
+ * Example: `{ "files.filegate_url": { kind: "url", default: "" } }`.
76
+ *
77
+ * These keys are exposed as a typed nested snapshot on `c.get("settings")`
78
+ * for any Hono route using `Hono<AppContext<typeof app>>`. Writes go through
79
+ * `app.settings.set(key, value)` (also typed). The runtime registry
80
+ * (`SETTINGS_MAP` in `services/settings/defaults.ts`) is populated from
81
+ * this map automatically on `defineApp()` call.
82
+ */
83
+ settings?: S;
84
+ /**
85
+ * Legal/info links contributed by this app — aggregated app-wide via
86
+ * `listLegalLinks()` and rendered in login footer, app Footer, rail more
87
+ * dropdown. Each app contributes its own (e.g. settings owns
88
+ * Imprint/Privacy/Terms; faq owns FAQ). KISS: no `external` flag, links
89
+ * always open in a new tab from the login footer.
90
+ */
91
+ legalLinks?: ReadonlyArray<{ label: string; href: string; icon?: string }>;
92
+ /**
93
+ * Dashboard widget endpoints this app exposes. Each entry references an
94
+ * HTTP path on this app that returns a `WidgetResponse`. The dashboard
95
+ * fetches them with the user's cookie forwarded; the endpoint is
96
+ * responsible for permission gating (200 = render, 204 = skip silently).
97
+ */
98
+ widgets?: ReadonlyArray<{ id: string; path: string }>;
99
+ /**
100
+ * Top-level URL prefixes the gateway should route to this app.
101
+ *
102
+ * Standard apps follow a four-prefix convention:
103
+ * `/api/<id>` — widget, admin, ws, crud — everything HTTP API
104
+ * `/app/<id>` — user-facing SSR pages
105
+ * `/admin/<id>` — admin SSR pages
106
+ * `/public/<id>` — built CSS and other static assets
107
+ *
108
+ * Apps with non-standard URLs (core's `/auth`, `/me`; oauth's `/oauth`,
109
+ * `/.well-known/...`; settings' `/legal/*`, `/impressum`) list whatever
110
+ * top-level paths they own. The gateway is dumb — it just builds a
111
+ * prefix-trie from these strings.
112
+ */
113
+ routes: readonly string[];
114
+ };
115
+
116
+ export type StartOptions = {
117
+ routes: {
118
+ api?: Hono<any>;
119
+ pages?: Hono<any>;
120
+ };
121
+ lifecycle?: AppLifecycle;
122
+ capabilities?: AppCapabilities;
123
+ port?: number;
124
+ skipSetup?: boolean;
125
+ };
126
+
127
+ export type StartResult = {
128
+ port: number;
129
+ fetch: Hono["fetch"];
130
+ };
131
+
132
+ export type AppDefinition<S extends AppSettingsMap = {}> = {
133
+ // Bind the generic explicitly — without it, ssr collapses to the constraint
134
+ // `object` and apps lose the typed `c.get("page")` (title/description/theme).
135
+ ssr: ReturnType<typeof createSSRHandler<PageOptions>>;
136
+ plugin: () => import("bun").BunPlugin;
137
+ config: SsrConfig;
138
+ meta: AppMeta;
139
+ baseUrl: string;
140
+ start: (opts: StartOptions) => Promise<StartResult>;
141
+ /**
142
+ * Phantom field — type-only carrier for the per-app settings shape. Always
143
+ * `undefined` at runtime; do not read or assign. Used by `AppContext<App>`
144
+ * to extract the inferred settings map via `App["_settings"]`.
145
+ */
146
+ readonly _settings: S;
147
+ /**
148
+ * Typed async settings API for this app. Keys constrained to those declared
149
+ * in `defineApp({ settings: ... })`. Backed by Redis cache-aside (see store.ts).
150
+ *
151
+ * Use for read/write outside of request-scoped sync access. Inside HTTP
152
+ * handlers, prefer `c.get("settings").x.y` (the per-request snapshot, sync,
153
+ * frozen for the duration of the request).
154
+ */
155
+ readonly settings: SettingsAPI<{ [K in keyof S]: KindToType<S[K]["kind"]> }>;
156
+ };
157
+
158
+ // ── Implementation ──────────────────────────────────────────────────────────
159
+
160
+ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<S>): AppDefinition<S> => {
161
+ // ── 0. Register declared settings into the runtime registry ──────────
162
+ // SETTINGS_MAP is the single source of truth for validation in store.ts
163
+ // (writeKey checks SETTINGS_MAP.get(key)) and for snapshot.ts (allKnownKeys
164
+ // returns SETTINGS.map(d => d.key)). Without this registration, app-declared
165
+ // settings would be type-known but runtime-unknown.
166
+ if (opts.settings) {
167
+ const legacyDefs: SettingDef[] = Object.entries(opts.settings).map(([key, def]) => {
168
+ const d = def as Record<string, unknown>;
169
+ return {
170
+ key,
171
+ // Group derived from the dotted prefix (admin UIs use this for tab
172
+ // grouping; the new bespoke admin UIs ignore it but legacy paths use it).
173
+ group: key.split(".")[0] ?? "app",
174
+ kind: d.kind as SettingDef["kind"],
175
+ // The cast loses the per-kind discriminated default type but the data
176
+ // is correct; legacy validateSettingValue re-validates against kind anyway.
177
+ default: d.default as never,
178
+ label: d.label as string | undefined,
179
+ description: (d.description as string | undefined) ?? "",
180
+ placeholder: d.placeholder as string | undefined,
181
+ envFallback: d.envFallback as (() => unknown) | undefined,
182
+ envBootstrap: d.envBootstrap as (() => unknown) | undefined,
183
+ templateVars: d.templateVars as readonly string[] | undefined,
184
+ options: d.options as ReadonlyArray<{ value: string; label: string }> | undefined,
185
+ min: d.min as number | undefined,
186
+ max: d.max as number | undefined,
187
+ } as SettingDef;
188
+ });
189
+ registerSettings(legacyDefs);
190
+ }
191
+
192
+ // ── 1. SSR config ─────────────────────────────────────────────────────
193
+ const { config, plugin, html } = createSsrConfig<PageOptions>({
194
+ dev: process.env.NODE_ENV !== "production",
195
+ verbose: true,
196
+ rootDir: resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."),
197
+ basePath: opts.basePath,
198
+ template: ({ body, scripts, title, description, theme }) => {
199
+ const themeFixed = theme !== undefined;
200
+ return `<!DOCTYPE html>
201
+ <html lang="de" class="${theme ?? "light"}"${themeFixed ? " data-theme-fixed" : ""}>
202
+ <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
203
+ <meta name="view-transition" content="same-origin">
204
+ <title>${title ?? "Cloud"}</title>
205
+ <meta name="description" content="${description ?? "Cloud workspace"}">
206
+ <meta name="theme-color" content="#09090b">
207
+ <meta name="mobile-web-app-capable" content="yes">
208
+ <link rel="icon" href="/branding/favicon">
209
+ <link rel="stylesheet" href="/public/global.css?v=${v}">
210
+ <link rel="stylesheet" href="/public/${opts.id}/app.css?v=${v}">
211
+ <script>
212
+ (function() {
213
+ var el = document.documentElement;
214
+ if (!el.hasAttribute('data-theme-fixed')) {
215
+ var theme = document.cookie.match(/theme=([^;]+)/)?.[1] || 'light';
216
+ el.classList.add(theme);
217
+ }
218
+ })();
219
+ </script>
220
+ </head>
221
+ <body>
222
+ ${body}
223
+ </body>
224
+ ${scripts}
225
+ </html>`;
226
+ },
227
+ });
228
+
229
+ // Pass PageOptions explicitly so c.get("page") in apps' SSR handlers is
230
+ // typed as Partial<PageOptions> (with title/description/theme), not the
231
+ // bare `object` fallback the constraint would otherwise produce.
232
+ const ssr = createSSRHandler<PageOptions>(html);
233
+
234
+ // ── 2. Meta ───────────────────────────────────────────────────────────
235
+ const meta: AppMeta = {
236
+ id: opts.id,
237
+ name: opts.name,
238
+ icon: opts.icon,
239
+ description: opts.description,
240
+ adminHref: opts.adminHref,
241
+ routes: [...opts.routes],
242
+ nav: opts.nav,
243
+ legalLinks: opts.legalLinks ? [...opts.legalLinks] : undefined,
244
+ widgets: opts.widgets ? opts.widgets.map((w) => ({ ...w })) : undefined,
245
+ };
246
+
247
+ // ── 3. start() — builds and boots the Hono server ────────────────────
248
+ const start = async (startOpts: StartOptions): Promise<StartResult> => {
249
+ const port = startOpts.port ?? 3000;
250
+ const baseUrl = opts.baseUrl;
251
+ const log = logger("app");
252
+
253
+ // Registry entry
254
+ const entry: AppRegistryEntry = {
255
+ id: meta.id,
256
+ name: meta.name,
257
+ icon: meta.icon,
258
+ description: meta.description,
259
+ baseUrl,
260
+ routes: [...meta.routes],
261
+ nav: (meta.nav || meta.adminHref)
262
+ ? {
263
+ href: meta.nav?.href ?? "",
264
+ match: meta.nav?.match,
265
+ section: meta.nav?.section ?? "hidden",
266
+ requiresAuth: meta.nav?.requiresAuth,
267
+ requiresRoles: meta.nav?.requiresRoles,
268
+ adminHref: meta.adminHref,
269
+ }
270
+ : undefined,
271
+ search: startOpts.capabilities?.search
272
+ ? {
273
+ tags: [...(startOpts.capabilities.search.tags ?? [])],
274
+ help: startOpts.capabilities.search.help ?? "",
275
+ tagHelp: [...(startOpts.capabilities.search.tagHelp ?? [])],
276
+ endpoint: `${baseUrl}/api/_internal/search`,
277
+ }
278
+ : undefined,
279
+ legalLinks: meta.legalLinks ? meta.legalLinks.map((l) => ({ ...l })) : undefined,
280
+ widgets: meta.widgets ? meta.widgets.map((w) => ({ ...w })) : undefined,
281
+ };
282
+
283
+ // Heartbeat
284
+ const heartbeat = createHeartbeat(meta.id, entry);
285
+ await heartbeat.start();
286
+ log.info(`Registered "${meta.id}"`, { baseUrl });
287
+
288
+ // Runtime context (navigation, app discovery)
289
+ const ac = new AbortController();
290
+ let currentRuntime = buildRuntimeFromRegistry(await listApps());
291
+
292
+ const refreshRuntime = async () => {
293
+ currentRuntime = buildRuntimeFromRegistry(await listApps());
294
+ };
295
+
296
+ (async () => {
297
+ try {
298
+ const snap = await appRegistry.snapshot({ prefix: "apps/" });
299
+ for await (const ev of appRegistry.reader({ prefix: "apps/", after: snap.cursor }).stream({ signal: ac.signal })) {
300
+ if (ev.type === "overflow") {
301
+ await refreshRuntime();
302
+ continue;
303
+ }
304
+ await refreshRuntime();
305
+ }
306
+ } catch (err) {
307
+ if (err instanceof Error && err.name === "AbortError") return;
308
+ log.error("Registry watcher failed", { error: err instanceof Error ? err.message : String(err) });
309
+ }
310
+ })();
311
+
312
+ // Build Hono server
313
+ const ssrMountPath = config.basePath ? `${config.basePath}/_ssr` : "/_ssr";
314
+
315
+ // Per-request settings snapshot: skip static-asset paths so each /public,
316
+ // /branding, /favicon, /_ssr request isn't paying the snapshot cost.
317
+ const SNAPSHOT_SKIP_PREFIXES = ["/public/", "/_ssr/", "/branding/", "/favicon"];
318
+
319
+ const server = new Hono()
320
+ .use("*", async (c, next) => {
321
+ (c as any).set("runtime", currentRuntime);
322
+ const path = c.req.path;
323
+ const skip = SNAPSHOT_SKIP_PREFIXES.some((p) => path.startsWith(p));
324
+ if (!skip) {
325
+ (c as any).set("settings", await loadSnapshot());
326
+ }
327
+ await next();
328
+ })
329
+ .route(ssrMountPath, routes(config))
330
+ .use("/public/*", serveStatic({
331
+ root: "./",
332
+ onFound: (_path, c) => {
333
+ c.header("Cache-Control", "public, max-age=31536000, immutable");
334
+ },
335
+ }));
336
+
337
+ // Mount app routes
338
+ if (startOpts.routes.api) server.route("/api", startOpts.routes.api);
339
+ if (startOpts.routes.pages) server.route("/", startOpts.routes.pages);
340
+
341
+ // Internal search endpoint
342
+ if (startOpts.capabilities?.search) {
343
+ const searchRun = startOpts.capabilities.search.run;
344
+ server.post("/api/_internal/search", auth.requireRole("authenticated"), async (c) => {
345
+ const body = await c.req.json<{ query: string; tags: string[]; limit: number }>();
346
+ const ctx: AppSearchContext = { get: (key) => c.get(key) as never };
347
+ const results = await searchRun({ query: body.query, tags: body.tags, limit: body.limit, ctx });
348
+ return c.json(results);
349
+ });
350
+ }
351
+
352
+ // Lifecycle
353
+ const cloudCtx: CloudContext = {
354
+ logger,
355
+ settings: { get, set },
356
+ runtime: currentRuntime,
357
+ };
358
+
359
+ if (!startOpts.skipSetup && startOpts.lifecycle?.setup) {
360
+ log.info(`Setup: ${meta.id}`);
361
+ await startOpts.lifecycle.setup(cloudCtx);
362
+ }
363
+
364
+ await loadSettingsCache();
365
+
366
+ if (startOpts.lifecycle?.start) {
367
+ log.info(`Start: ${meta.id}`);
368
+ await startOpts.lifecycle.start(cloudCtx);
369
+ }
370
+
371
+ // Graceful shutdown
372
+ let stopping = false;
373
+ const shutdown = async () => {
374
+ if (stopping) return;
375
+ stopping = true;
376
+ log.info(`Stopping: ${meta.id}`);
377
+ try { if (startOpts.lifecycle?.stop) await startOpts.lifecycle.stop(cloudCtx); } catch {}
378
+ ac.abort();
379
+ await heartbeat.stop();
380
+ };
381
+
382
+ process.on("SIGTERM", () => void shutdown().then(() => process.exit(0)));
383
+ process.on("SIGINT", () => void shutdown().then(() => process.exit(0)));
384
+
385
+ return { port, fetch: server.fetch };
386
+ };
387
+
388
+ return {
389
+ ssr,
390
+ plugin,
391
+ config,
392
+ meta,
393
+ baseUrl: opts.baseUrl,
394
+ start,
395
+ // Phantom — see AppDefinition._settings doc. Do not read at runtime.
396
+ _settings: undefined as unknown as S,
397
+ settings: createSettingsAPI<S>(),
398
+ };
399
+ };