@k2b/cloud 0.38.0 → 0.39.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 (99) hide show
  1. package/package.json +6 -3
  2. package/scripts/build-canvas-workers.ts +109 -29
  3. package/scripts/build.ts +3 -5
  4. package/src/_internal/app-path.ts +25 -0
  5. package/src/_internal/app-presentation.ts +4 -0
  6. package/src/_internal/canvas-worker.ts +5 -3
  7. package/src/_internal/define-app.ts +28 -2
  8. package/src/_internal/panel-cookie.ts +39 -0
  9. package/src/_internal/registry-validation.ts +21 -26
  10. package/src/_internal/runtime-context.ts +1 -0
  11. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  12. package/src/access/PermissionEditor.tsx +30 -5
  13. package/src/access/ResourceApiKeys.tsx +5 -1
  14. package/src/ai/approval-routes.ts +5 -2
  15. package/src/ai/approvals.ts +139 -28
  16. package/src/ai/browser.ts +10 -0
  17. package/src/ai/chat/action-sentences.ts +1 -44
  18. package/src/ai/chat/turn-view.tsx +35 -3
  19. package/src/ai/chat/view-block.tsx +7 -2
  20. package/src/ai/chat-task-contracts.ts +28 -3
  21. package/src/ai/chat-task-reasons-migrate.ts +68 -0
  22. package/src/ai/chat-task-reasons.ts +92 -0
  23. package/src/ai/chat-tasks.ts +396 -66
  24. package/src/ai/index.ts +12 -0
  25. package/src/ai/migrate.ts +28 -2
  26. package/src/ai/request-context.ts +7 -1
  27. package/src/ai/routes.ts +27 -1
  28. package/src/ai/runtime.ts +12 -27
  29. package/src/ai/store.ts +5 -3
  30. package/src/ai/timeline.ts +9 -1
  31. package/src/ai/tool-wording.ts +45 -0
  32. package/src/ai/turn-abort.ts +33 -0
  33. package/src/ai/types.ts +12 -1
  34. package/src/api/admin-core-settings.ts +190 -139
  35. package/src/api/admin-outgoing-mail.ts +4 -2
  36. package/src/api/app-approval.ts +9 -2
  37. package/src/api/approvals.ts +1 -1
  38. package/src/api/index.ts +2 -1
  39. package/src/browser/CloudResourceSearch.tsx +10 -6
  40. package/src/browser/app-approval.ts +7 -2
  41. package/src/browser/composer-catalog.ts +103 -0
  42. package/src/browser/composer-insert-messages.ts +31 -0
  43. package/src/browser/composer-insert.ts +150 -0
  44. package/src/browser/panel-messages.ts +24 -0
  45. package/src/browser/panel-slot.ts +354 -0
  46. package/src/browser/panel.ts +28 -0
  47. package/src/browser/resource-picker.tsx +12 -1
  48. package/src/contracts/app-approval.ts +49 -9
  49. package/src/contracts/app.ts +14 -0
  50. package/src/contracts/registry.ts +3 -1
  51. package/src/contracts/shared.ts +2 -0
  52. package/src/server/services/access.ts +5 -1
  53. package/src/services/accounts/posix.ts +2 -1
  54. package/src/services/app-approval.ts +158 -38
  55. package/src/services/approvals/device.ts +2 -0
  56. package/src/services/index.ts +21 -3
  57. package/src/services/notifications/browser.ts +16 -5
  58. package/src/services/pdf/image-protocol.ts +135 -0
  59. package/src/services/pdf/image-variants.ts +124 -0
  60. package/src/services/pdf/image-worker.ts +31 -0
  61. package/src/services/pdf/image.ts +109 -0
  62. package/src/services/pdf/index.ts +9 -0
  63. package/src/services/settings/api.ts +11 -6
  64. package/src/services/settings/app.ts +5 -4
  65. package/src/services/settings/core-settings.ts +4 -3
  66. package/src/services/settings/index.ts +19 -17
  67. package/src/services/settings/namespace.ts +2 -1
  68. package/src/services/settings/store.ts +83 -11
  69. package/src/shared/ai-platform-prompt.ts +1 -1
  70. package/src/shared/app-presentation.ts +1 -0
  71. package/src/shared/redirect.ts +12 -0
  72. package/src/ssr/CloudShell.island.tsx +86 -9
  73. package/src/ssr/Layout.tsx +42 -9
  74. package/src/ssr/LayoutHeader.tsx +15 -9
  75. package/src/ssr/LayoutHelp.tsx +1 -1
  76. package/src/ssr/LayoutRail.tsx +2 -2
  77. package/src/ssr/LayoutTabBar.tsx +29 -0
  78. package/src/ssr/PanelLayout.tsx +27 -0
  79. package/src/ssr/PanelRuntime.island.tsx +87 -0
  80. package/src/ssr/PanelSlot.tsx +108 -0
  81. package/src/ssr/PanelTrigger.tsx +49 -0
  82. package/src/ssr/ShellRuntime.island.tsx +60 -5
  83. package/src/ssr/app-appearance.ts +3 -2
  84. package/src/ssr/app-color.ts +100 -0
  85. package/src/ssr/app-launchpad.tsx +18 -1
  86. package/src/ssr/app-navigation.ts +15 -0
  87. package/src/ssr/compact-shell.baseline.json +5270 -0
  88. package/src/ssr/compact-shell.browser-harness.ts +431 -0
  89. package/src/ssr/index.ts +3 -0
  90. package/src/ssr/panel-slot.browser-harness.tsx +110 -0
  91. package/src/ssr/platform-messages.ts +30 -0
  92. package/src/ssr/rail-navigation.ts +44 -0
  93. package/src/ssr/shell-data.tsx +4 -0
  94. package/src/ssr/shell-shortcuts.ts +13 -2
  95. package/src/styles/effects.css +3 -2
  96. package/src/styles/global.css +269 -55
  97. package/src/styles/panel.css +373 -0
  98. package/src/styles/tokens.css +23 -6
  99. package/src/styles/utilities-navigation.css +5 -21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.38.0",
3
+ "version": "0.39.0",
4
4
  "description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -38,10 +38,12 @@
38
38
  "./browser/notifications": "./src/browser/notifications.ts",
39
39
  "./browser/resource-clipboard": "./src/browser/resource-clipboard.ts",
40
40
  "./browser/commands": "./src/browser/commands.ts",
41
+ "./browser/panel": "./src/browser/panel.ts",
41
42
  "./browser/search": "./src/browser/search.ts",
42
43
  "./browser/widgets": "./src/browser/widgets.ts",
43
44
  "./browser/testing": "./src/browser/testing.ts",
44
45
  "./browser/resource-picker": "./src/browser/resource-picker.tsx",
46
+ "./browser/composer-insert": "./src/browser/composer-insert.ts",
45
47
  "./browser/files": "./src/browser/files.tsx",
46
48
  "./capabilities": "./src/capabilities/client.ts",
47
49
  "./capabilities/server": "./src/capabilities/server.ts",
@@ -103,12 +105,13 @@
103
105
  "@firecrawl/anydoc": "0.2.4",
104
106
  "@napi-rs/canvas": "1.0.9",
105
107
  "pdfjs-dist": "6.3.289",
108
+ "sharp": "0.35.4",
106
109
  "@simplewebauthn/server": "14.0.3",
107
110
  "@tabler/icons": "3.48.0",
108
111
  "@tailwindcss/typography": "0.5.20",
109
112
  "@k2b/nessi": "0.18.0",
110
113
  "@k2b/ssr": "0.17.0",
111
- "@k2b/ui": "0.28.0",
114
+ "@k2b/ui": "0.29.0",
112
115
  "@k2b/stdlib": "0.29.0",
113
116
  "@k2b/sync": "7.0.0",
114
117
  "@nats-io/jetstream": "3.4.0",
@@ -141,7 +144,7 @@
141
144
  "@types/bun": "1.4.2",
142
145
  "babel-preset-solid": "1.9.15",
143
146
  "hono": "4.13.9",
144
- "playwright": "1.63.0",
147
+ "playwright": "1.64.1",
145
148
  "solid-js": "1.9.15",
146
149
  "typescript": "7.0.2",
147
150
  "zod": "4.6.5"
@@ -1,58 +1,138 @@
1
- import { cp, mkdir } from "node:fs/promises";
2
- import { dirname, resolve } from "node:path";
1
+ import { cp, mkdir, realpath } from "node:fs/promises";
2
+ import { basename, dirname, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { z } from "zod";
5
+ import { readImageRecords } from "../src/services/pdf/image-protocol";
5
6
 
6
- /** Isolated native canvas workers, each packaged only when the server bundle reaches the module that starts it. */
7
7
  const CANVAS_WORKERS = [
8
- { reachedBy: "/ai/pdf-render.ts", entry: "src/ai/pdf-render-worker.ts", output: "pdf-worker.js", packages: ["pdfjs-dist"] },
9
8
  {
10
- reachedBy: "/services/branding/app-icons.ts",
9
+ define: "__CLOUD_PDF_RENDER_WORKER__",
10
+ entry: "src/ai/pdf-render-worker.ts",
11
+ output: "pdf-worker.js",
12
+ packages: ["@napi-rs/canvas", "pdfjs-dist"],
13
+ },
14
+ {
15
+ define: "__CLOUD_ICON_RENDER_WORKER__",
11
16
  entry: "src/services/branding/icon-render-worker.ts",
12
17
  output: "icon-worker.js",
13
- packages: [],
18
+ packages: ["@napi-rs/canvas"],
14
19
  },
20
+ { define: "__CLOUD_PDF_IMAGE_WORKER__", entry: "src/services/pdf/image-worker.ts", output: "image-worker.js", packages: ["sharp"] },
15
21
  ] as const;
16
22
 
23
+ /** Server bundle defines: each caller starts its worker from `canvas/` next to `server.js`. */
24
+ export const CANVAS_WORKER_DEFINES = Object.fromEntries(
25
+ CANVAS_WORKERS.map((worker) => [worker.define, JSON.stringify(`./canvas/${worker.output}`)]),
26
+ );
27
+
28
+ const Manifest = z.object({
29
+ version: z.string(),
30
+ dependencies: z.record(z.string(), z.string()).default({}),
31
+ optionalDependencies: z.record(z.string(), z.string()).default({}),
32
+ os: z.array(z.string()).optional(),
33
+ cpu: z.array(z.string()).optional(),
34
+ libc: z.array(z.string()).optional(),
35
+ });
36
+ const matches = (values: string[] | undefined, target: string) =>
37
+ !values || (!values.includes(`!${target}`) && (values.every((value) => value.startsWith("!")) || values.includes(target)));
38
+
17
39
  /**
18
- * Writes `dist/canvas/<worker>.js` for every canvas worker the bundle needs, with one copy of `@napi-rs/canvas`, its
19
- * installed platform binary and the workers' other packages in `dist/canvas/node_modules`.
40
+ * Bundle each reached native worker with only its runtime packages external. Copy their real dependency closures flat,
41
+ * dereferenced and filtered by OS, CPU and libc; reject version collisions and missing native binaries. Smoke-test sharp
42
+ * in the packaged subprocess so the build proves that its native closure loads on this architecture.
20
43
  */
21
- export async function buildCanvasWorkers(dist: string, bundledInputs: readonly string[]) {
22
- const workers = CANVAS_WORKERS.filter((worker) => bundledInputs.some((path) => path.endsWith(worker.reachedBy)));
44
+ export async function buildCanvasWorkers(dist: string, serverCode: string) {
45
+ // A worker ships only when the server code still contains its path, which only the code that starts it carries.
46
+ // Metafile inputs also list tree-shaken modules, and a retained module binding contributes bytes without its caller.
47
+ const workers = CANVAS_WORKERS.filter((worker) => serverCode.includes(`./canvas/${worker.output}`));
23
48
  if (workers.length === 0) return;
24
49
  const framework = resolve(dirname(fileURLToPath(import.meta.url)), "..");
25
50
  const out = resolve(dist, "canvas");
26
51
  await mkdir(out, { recursive: true });
27
- const external = ["@napi-rs/canvas", ...workers.flatMap((worker) => worker.packages)];
28
52
  for (const worker of workers) {
29
53
  const build = await Bun.build({
30
54
  entrypoints: [resolve(framework, worker.entry)],
31
55
  target: "bun",
32
56
  outdir: out,
33
57
  naming: worker.output,
34
- external,
58
+ external: [...worker.packages],
35
59
  });
36
- if (!build.success) throw new Error(`Canvas worker ${worker.output} build failed: ${build.logs.join("\n")}`);
60
+ if (!build.success) throw new Error(`Native worker ${worker.output} build failed: ${build.logs.join("\n")}`);
37
61
  }
38
- for (const name of external) {
39
- const source = dirname(Bun.resolveSync(`${name}/package.json`, framework));
40
- await cp(source, resolve(out, "node_modules", name), { recursive: true, dereference: true });
41
- if (name !== "@napi-rs/canvas") continue;
42
- const manifest = z
43
- .object({ optionalDependencies: z.record(z.string(), z.string()) })
44
- .parse(await Bun.file(resolve(source, "package.json")).json());
62
+ const libc = z.object({ header: z.object({ glibcVersionRuntime: z.string().optional() }) }).parse(process.report.getReport()).header
63
+ .glibcVersionRuntime
64
+ ? "glibc"
65
+ : "musl";
66
+ const copied = new Map<string, string>();
67
+ const copyPackage = async (name: string, dependent: string, optional = false): Promise<boolean> => {
68
+ let source: string;
69
+ try {
70
+ source = await realpath(dirname(Bun.resolveSync(`${name}/package.json`, dependent)));
71
+ } catch (error) {
72
+ if (optional) return false;
73
+ throw error;
74
+ }
75
+ const manifest = Manifest.parse(await Bun.file(resolve(source, "package.json")).json());
76
+ if (!matches(manifest.os, process.platform) || !matches(manifest.cpu, process.arch) || !matches(manifest.libc, libc)) {
77
+ if (optional) return false;
78
+ throw new Error(`Native worker dependency ${name} does not support the build platform.`);
79
+ }
80
+ const existing = copied.get(name);
81
+ if (existing) {
82
+ if (existing !== manifest.version)
83
+ throw new Error(`Native worker dependency collision: ${name} ${existing} and ${manifest.version}.`);
84
+ return true;
85
+ }
86
+ copied.set(name, manifest.version);
87
+ await cp(source, resolve(out, "node_modules", name), {
88
+ recursive: true,
89
+ dereference: true,
90
+ filter: (path) => basename(path) !== "node_modules",
91
+ });
92
+ for (const dependency of Object.keys(manifest.dependencies)) {
93
+ if (dependency in manifest.optionalDependencies) continue;
94
+ await copyPackage(dependency, source);
95
+ }
45
96
  let nativePackages = 0;
46
97
  for (const dependency of Object.keys(manifest.optionalDependencies)) {
47
- let location: string;
48
- try {
49
- location = dirname(Bun.resolveSync(`${dependency}/package.json`, source));
50
- } catch {
51
- continue;
52
- } // Only the installed target-platform binary is needed.
53
- await cp(location, resolve(out, "node_modules", dependency), { recursive: true, dereference: true });
54
- nativePackages++;
98
+ if (await copyPackage(dependency, source, true)) {
99
+ if (
100
+ (name === "sharp" && dependency.startsWith("@img/sharp-") && !dependency.startsWith("@img/sharp-libvips-")) ||
101
+ (name === "@napi-rs/canvas" && dependency.startsWith("@napi-rs/canvas-"))
102
+ )
103
+ nativePackages++;
104
+ }
105
+ }
106
+ if ((name === "sharp" || name === "@napi-rs/canvas") && !nativePackages) {
107
+ throw new Error(`Native workers need an installed ${name} binary for the build platform.`);
108
+ }
109
+ return true;
110
+ };
111
+ for (const name of new Set(workers.flatMap((worker) => [...worker.packages]))) await copyPackage(name, framework);
112
+ if (workers.some((worker) => worker.output === "image-worker.js")) await smokeImageWorker(resolve(out, "image-worker.js"));
113
+ }
114
+
115
+ async function smokeImageWorker(entry: string) {
116
+ // A fixed 2x2 RGBA PNG; the build process never loads sharp.
117
+ const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAEUlEQVR4nGP4z8DQAMIMMAYAOOgF/REzMMkAAAAASUVORK5CYII=", "base64");
118
+ const child = Bun.spawn([process.execPath, "--no-env-file", entry], {
119
+ stdin: new Blob([
120
+ `${JSON.stringify({ bytes: png.byteLength, boxes: [{ width: 2, height: 2 }], maxBytes: 4096, timeoutMs: 10_000 })}\n`,
121
+ png,
122
+ ]),
123
+ stdout: "pipe",
124
+ stderr: "ignore",
125
+ env: { PATH: process.env.PATH },
126
+ });
127
+ const timer = setTimeout(() => child.kill("SIGKILL"), 15_000);
128
+ try {
129
+ const result = await readImageRecords(child.stdout, { boxes: [{ width: 2, height: 2 }], maxBytes: 4096 });
130
+ if ((await child.exited) !== 0 || !result.ok || result.variants.length !== 1 || !result.variants[0]) {
131
+ throw new Error("Packaged image worker could not convert its PNG: sharp's native dependency closure is incomplete or unusable.");
55
132
  }
56
- if (!nativePackages) throw new Error("Canvas workers need an installed native canvas binary for the build platform.");
133
+ } finally {
134
+ clearTimeout(timer);
135
+ child.kill("SIGKILL");
136
+ await child.exited;
57
137
  }
58
138
  }
package/scripts/build.ts CHANGED
@@ -35,7 +35,7 @@ import tailwind from "bun-plugin-tailwind";
35
35
  import type { AppCliModules } from "../src/contracts/app";
36
36
  import { writeAppFavicon } from "./app-favicon";
37
37
  import { buildBrowserPerformance } from "./browser-performance";
38
- import { buildCanvasWorkers } from "./build-canvas-workers";
38
+ import { buildCanvasWorkers, CANVAS_WORKER_DEFINES } from "./build-canvas-workers";
39
39
 
40
40
  const appId = process.env.APP_ID;
41
41
  if (!appId) throw new Error("APP_ID env var required");
@@ -127,13 +127,11 @@ try {
127
127
  naming: "server.js",
128
128
  target: "bun",
129
129
  minify: true,
130
- metafile: true,
131
130
  define: {
132
131
  __CLOUD_VERSION__: JSON.stringify(version),
133
132
  __CLOUD_RELEASE__: JSON.stringify(release),
134
133
  __CLOUD_SYNC_VERSION__: JSON.stringify(syncVersion),
135
- __CLOUD_PDF_RENDER_WORKER__: JSON.stringify("./canvas/pdf-worker.js"),
136
- __CLOUD_ICON_RENDER_WORKER__: JSON.stringify("./canvas/icon-worker.js"),
134
+ ...CANVAS_WORKER_DEFINES,
137
135
  __CLOUD_APP_ASSETS__: JSON.stringify("./assets/"),
138
136
  __CLOUD_CLI_PLUGINS__: JSON.stringify("./cli/"),
139
137
  },
@@ -146,7 +144,7 @@ if (!server.success) {
146
144
  for (const m of server.logs) console.error(m);
147
145
  throw new Error("Server bundle failed");
148
146
  }
149
- await buildCanvasWorkers(dist, Object.keys(server.metafile?.inputs ?? {}));
147
+ await buildCanvasWorkers(dist, await Bun.file(resolve(dist, "server.js")).text());
150
148
 
151
149
  // 2. Per-app Tailwind stylesheet.
152
150
  const appCss = resolve(appDir, "src/styles/app.css");
@@ -0,0 +1,25 @@
1
+ /** An absolute path on the current origin: one leading slash, no backslash or whitespace. */
2
+ export const SAME_ORIGIN_PATH = /^\/(?![\/\\])[^\\\s]*$/;
3
+
4
+ /** A route as the gateway reads it: trimmed and without empty segments, so `//pwa/x/` is `/pwa/x`. */
5
+ export const canonicalRoute = (route: string): string => {
6
+ const trimmed = route.trim();
7
+ return trimmed.startsWith("/") ? `/${trimmed.split("/").filter(Boolean).join("/")}` : trimmed;
8
+ };
9
+
10
+ /**
11
+ * Whether an application path stays below one of the app's own `routes`. It is
12
+ * checked on the path the browser actually requests, after dot segments, and
13
+ * never starts with `//`, which a URL resolver reads as another host.
14
+ */
15
+ export const isOwnAppPath = (href: string, routes: readonly string[]): boolean => {
16
+ if (!SAME_ORIGIN_PATH.test(href)) return false;
17
+ const path = new URL(href, "https://cloud.invalid").pathname;
18
+ return (
19
+ !path.startsWith("//") &&
20
+ routes.some((route) => {
21
+ const prefix = canonicalRoute(route);
22
+ return prefix === "/" || path === prefix || path.startsWith(`${prefix}/`);
23
+ })
24
+ );
25
+ };
@@ -92,7 +92,11 @@ export const compileAppPresentation = (app: AppMeta, catalog: AppPresentationCat
92
92
  if (!canonical) throw new Error(`App presentation locale ${JSON.stringify(locale)} is not a valid BCP 47 locale`);
93
93
  if (canonical === baseLocale) throw new Error(`App presentation translations must not repeat base locale ${baseLocale}`);
94
94
  if (translations[canonical]) throw new Error(`App presentation locale ${canonical} is duplicated after canonicalization`);
95
+ if (translation.panelLabel !== undefined && !app.panel) {
96
+ throw new Error(`${canonical}.panelLabel contains unknown key "panel"`);
97
+ }
95
98
  translations[canonical] = {
99
+ ...(translation.panelLabel !== undefined ? { panelLabel: text(translation.panelLabel, `${canonical}.panelLabel`) } : {}),
96
100
  ...(translation.name !== undefined ? { name: text(translation.name, `${canonical}.name`) } : {}),
97
101
  ...(translation.description !== undefined
98
102
  ? { description: text(translation.description, `${canonical}.description`, MAX_DESCRIPTION) }
@@ -1,24 +1,26 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
 
3
3
  /**
4
- * Starts a native canvas worker (PDF pages, app icons) in a process of its own. RLIMIT_DATA bounds the decoder heap
4
+ * Starts a native worker (PDF pages, app icons, print images) in a process of its own. RLIMIT_DATA bounds the decoder heap
5
5
  * and native writable mappings on Linux. Positional arguments keep paths out of shell code; no elevated privileges.
6
6
  * The caller owns the deadline and kills the process on abort.
7
7
  */
8
- export const spawnCanvasWorker = (worker: URL, name: string, input: string) =>
8
+ export const spawnCanvasWorker = (worker: URL, name: string, input: string | Blob) =>
9
9
  Bun.spawn(["/bin/sh", "-c", 'ulimit -d 524288 || exit 70; exec "$@"', name, process.execPath, "--no-env-file", fileURLToPath(worker)], {
10
10
  stdin: new Blob([input]),
11
11
  stdout: "pipe",
12
12
  stderr: "ignore",
13
13
  // No application credentials or configuration reach the decoder, only fixed
14
- // thread pool sizes: the Bun work pool, canvas tokio runtime, and JSC GC
14
+ // runtime settings: the Bun work pool, canvas tokio runtime, and JSC GC
15
15
  // markers otherwise grow with the host's cores, and RLIMIT_DATA also counts
16
16
  // each thread's stack and allocator memory.
17
+ // Bun's allocator otherwise commits whole arenas up front, which RLIMIT_DATA counts as used.
17
18
  env: {
18
19
  PATH: process.env.PATH,
19
20
  LANG: "C.UTF-8",
20
21
  UV_THREADPOOL_SIZE: "2",
21
22
  TOKIO_WORKER_THREADS: "2",
22
23
  BUN_JSC_numberOfGCMarkers: "2",
24
+ MIMALLOC_ARENA_EAGER_COMMIT: "0",
23
25
  },
24
26
  });
@@ -89,7 +89,7 @@ import { configurePostgresApplicationName } from "./postgres-application-name";
89
89
  import { getProcessSync, startProcessSync } from "./process-sync";
90
90
  import { APP_READINESS_PATH, appReadinessResponse } from "./readiness";
91
91
  import { appRegistry, type CapabilityRegistryRecord, capabilityRegistry } from "./registry";
92
- import { canonicalRoute, isOwnNavBadge } from "./registry-validation";
92
+ import { canonicalRoute, isOwnAppPath } from "./registry-validation";
93
93
  import { ensureRuntimeWatcher, getCurrentRuntime, stopRuntimeWatcher } from "./runtime-watcher";
94
94
  import { servePublicAsset } from "./static-assets";
95
95
  import { createStatusPreservingSsrHandler } from "./status-preserving-ssr";
@@ -144,6 +144,7 @@ export type AppOptions<S extends AppSettingsMap = {}, N extends NotificationDefi
144
144
  label: string;
145
145
  links: ReadonlyArray<{ label: string; href: string; icon: string }>;
146
146
  }>;
147
+ panel?: AppMeta["panel"];
147
148
  nav?: {
148
149
  href: string;
149
150
  match?: string;
@@ -371,11 +372,31 @@ const validateAppPwaPart = (opts: Pick<AppOptions, "id" | "routes" | "pwa">): Ap
371
372
  /** The badge route is read by the browser through the gateway, so it must be one of this app's own paths. */
372
373
  const validateAppNavBadge = (opts: Pick<AppOptions, "id" | "routes" | "nav">): void => {
373
374
  const badge = opts.nav?.badge;
374
- if (badge !== undefined && !isOwnNavBadge(badge, opts.routes)) {
375
+ if (badge !== undefined && !isOwnAppPath(badge, opts.routes)) {
375
376
  throw new Error(`App "${opts.id}" declares nav.badge "${badge}"; it must be a same-origin path below one of its routes`);
376
377
  }
377
378
  };
378
379
 
380
+ const validateAppPanel = (opts: Pick<AppOptions, "id" | "routes" | "panel">): AppMeta["panel"] => {
381
+ const panel = opts.panel;
382
+ if (panel === undefined) return undefined;
383
+ if (!panel || typeof panel !== "object" || Array.isArray(panel)) throw new Error(`App "${opts.id}" declares panel; it must be an object`);
384
+ if (typeof panel.href !== "string" || !isOwnAppPath(panel.href, opts.routes)) {
385
+ throw new Error(`App "${opts.id}" declares panel.href "${panel.href}"; it must be a same-origin path below one of its routes`);
386
+ }
387
+ for (const field of ["label", "icon"] as const) {
388
+ if (typeof panel[field] !== "string" || !panel[field].trim())
389
+ throw new Error(`App "${opts.id}" declares panel.${field}; it must be a non-empty string`);
390
+ }
391
+ if (
392
+ panel.requiresRoles !== undefined &&
393
+ (!Array.isArray(panel.requiresRoles) || !panel.requiresRoles.every((role) => typeof role === "string"))
394
+ ) {
395
+ throw new Error(`App "${opts.id}" declares panel.requiresRoles; it must be an array of strings`);
396
+ }
397
+ return { ...panel, requiresRoles: panel.requiresRoles ? [...panel.requiresRoles] : undefined };
398
+ };
399
+
379
400
  export const defineApp = <
380
401
  const S extends AppSettingsMap = {},
381
402
  const N extends NotificationDefinitionMap = {},
@@ -391,6 +412,7 @@ export const defineApp = <
391
412
  const cliModules = validateAppCliModules(opts.id, opts.cli);
392
413
  const pwaPart = validateAppPwaPart(opts);
393
414
  validateAppNavBadge(opts);
415
+ const panel = validateAppPanel(opts);
394
416
 
395
417
  // ── 0. Register declared settings into the runtime registry ──────────
396
418
  // SETTINGS_MAP is the single source of truth for validation in store.ts
@@ -528,6 +550,7 @@ export const defineApp = <
528
550
  legalLinks: opts.legalLinks ? [...opts.legalLinks] : undefined,
529
551
  searchLinks: opts.searchLinks?.map((link) => ({ ...link, keywords: link.keywords ? [...link.keywords] : undefined })),
530
552
  pwa: pwaPart,
553
+ panel,
531
554
  widgets: compileWidgetDeclarations(opts.id, opts.widgets),
532
555
  settingKeys: opts.settings ? Object.keys(opts.settings) : undefined,
533
556
  openapi: opts.openapi,
@@ -627,6 +650,9 @@ export const defineApp = <
627
650
  pwa: meta.pwa
628
651
  ? { href: meta.pwa.href, requiresRoles: meta.pwa.requiresRoles ? [...meta.pwa.requiresRoles] : undefined }
629
652
  : undefined,
653
+ panel: meta.panel
654
+ ? { ...meta.panel, requiresRoles: meta.panel.requiresRoles ? [...meta.panel.requiresRoles] : undefined }
655
+ : undefined,
630
656
  widgets: meta.widgets?.map((widget) => ({
631
657
  ...widget,
632
658
  ...(widget.settings ? { settings: structuredClone(widget.settings) } : {}),
@@ -0,0 +1,39 @@
1
+ export const PANEL_COOKIE = "cloud_panel";
2
+ export const PANEL_MIN_WIDTH_REM = 20;
3
+ export const PANEL_MAX_WIDTH_REM = 36;
4
+
5
+ export type PanelCookieState = { open: boolean; provider?: string; widthRem?: number };
6
+ const clampWidth = (value: number) => Math.min(PANEL_MAX_WIDTH_REM, Math.max(PANEL_MIN_WIDTH_REM, value));
7
+
8
+ export function readPanelCookie(cookieHeader: string | null | undefined, providers: readonly string[]): PanelCookieState {
9
+ const value = cookieHeader
10
+ ?.split(";")
11
+ .map((part) => part.trim())
12
+ .find((part) => part.startsWith(`${PANEL_COOKIE}=`))
13
+ ?.slice(PANEL_COOKIE.length + 1);
14
+ if (value === undefined) return { open: false };
15
+ const parts = value.split(":");
16
+ const [open, encodedProvider, width] = parts;
17
+ if (
18
+ parts.length !== 3 ||
19
+ (open !== "0" && open !== "1") ||
20
+ encodedProvider === undefined ||
21
+ width === undefined ||
22
+ (width !== "" && !/^\d{1,2}(\.\d{1,2})?$/.test(width))
23
+ )
24
+ return { open: false };
25
+ let provider: string;
26
+ try {
27
+ provider = decodeURIComponent(encodedProvider);
28
+ if (encodeURIComponent(provider) !== encodedProvider) return { open: false };
29
+ } catch {
30
+ return { open: false };
31
+ }
32
+ if ((provider && !providers.includes(provider)) || (open === "1" && !provider)) return { open: false };
33
+ return { open: open === "1", ...(provider ? { provider } : {}), ...(width ? { widthRem: clampWidth(Number(width)) } : {}) };
34
+ }
35
+
36
+ export function serializePanelCookie(state: PanelCookieState, secure: boolean): string {
37
+ const width = state.widthRem !== undefined && Number.isFinite(state.widthRem) ? Math.round(clampWidth(state.widthRem) * 100) / 100 : "";
38
+ return `${PANEL_COOKIE}=${state.open && state.provider ? "1" : "0"}:${encodeURIComponent(state.provider ?? "")}:${width}; Path=/; Max-Age=31536000; SameSite=Lax${secure ? "; Secure" : ""}`;
39
+ }
@@ -1,37 +1,14 @@
1
1
  import { CLOUD_CLI_MODULE_NAME } from "../cli/plugin";
2
2
  import { PlatformPermissionSchema } from "../contracts/outgoing-mail";
3
3
  import type { AppRegistryEntry } from "../contracts/registry";
4
+ import { isOwnAppPath, SAME_ORIGIN_PATH } from "./app-path";
4
5
 
5
6
  const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
6
7
  const isString = (value: unknown): value is string => typeof value === "string";
7
8
  const isStringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every(isString);
8
9
  const isStringRecord = (value: unknown): value is Record<string, string> => isRecord(value) && Object.values(value).every(isString);
9
10
 
10
- /** An absolute path on the current origin: one leading slash, no backslash or whitespace. */
11
- const SAME_ORIGIN_PATH = /^\/(?![\/\\])[^\\\s]*$/;
12
-
13
- /** A route as the gateway reads it: trimmed and without empty segments, so `//pwa/x/` is `/pwa/x`. */
14
- export const canonicalRoute = (route: string): string => {
15
- const trimmed = route.trim();
16
- return trimmed.startsWith("/") ? `/${trimmed.split("/").filter(Boolean).join("/")}` : trimmed;
17
- };
18
-
19
- /**
20
- * Whether a `nav.badge` path stays below one of the app's own `routes`. It is
21
- * checked on the path the browser actually requests, after dot segments, and
22
- * never starts with `//`, which a URL resolver reads as another host.
23
- */
24
- export const isOwnNavBadge = (badge: string, routes: readonly string[]): boolean => {
25
- if (!SAME_ORIGIN_PATH.test(badge)) return false;
26
- const path = new URL(badge, "https://cloud.invalid").pathname;
27
- return (
28
- !path.startsWith("//") &&
29
- routes.some((route) => {
30
- const prefix = canonicalRoute(route);
31
- return prefix === "/" || path === prefix || path.startsWith(`${prefix}/`);
32
- })
33
- );
34
- };
11
+ export { canonicalRoute, isOwnAppPath } from "./app-path";
35
12
 
36
13
  const invalid = (path: string, expected: string): string => `${path} must be ${expected}`;
37
14
 
@@ -69,6 +46,12 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
69
46
  if (!isRecord(translation)) return invalid(`presentation.translations.${locale}`, "an object");
70
47
  if (translation.name !== undefined && !isString(translation.name))
71
48
  return invalid(`presentation.translations.${locale}.name`, "a string");
49
+ if (
50
+ translation.panelLabel !== undefined &&
51
+ (!isString(translation.panelLabel) || !translation.panelLabel.trim() || value.panel === undefined)
52
+ ) {
53
+ return invalid(`presentation.translations.${locale}.panelLabel`, "a non-empty string for a declared panel");
54
+ }
72
55
  if (translation.description !== undefined && !isString(translation.description)) {
73
56
  return invalid(`presentation.translations.${locale}.description`, "a string");
74
57
  }
@@ -87,10 +70,22 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
87
70
  if (value.nav.requiresRoles !== undefined && !isStringArray(value.nav.requiresRoles)) {
88
71
  return invalid("nav.requiresRoles", "an array of strings");
89
72
  }
90
- if (value.nav.badge !== undefined && (!isString(value.nav.badge) || !isOwnNavBadge(value.nav.badge, value.routes))) {
73
+ if (value.nav.badge !== undefined && (!isString(value.nav.badge) || !isOwnAppPath(value.nav.badge, value.routes))) {
91
74
  return invalid("nav.badge", "a same-origin path below one of the entry's routes");
92
75
  }
93
76
  }
77
+ if (value.panel !== undefined) {
78
+ if (!isRecord(value.panel)) return invalid("panel", "an object");
79
+ if (!isString(value.panel.href) || !isOwnAppPath(value.panel.href, value.routes)) {
80
+ return invalid("panel.href", "a same-origin path below one of the entry's routes");
81
+ }
82
+ for (const field of ["label", "icon"] as const) {
83
+ if (!isString(value.panel[field]) || !value.panel[field].trim()) return invalid(`panel.${field}`, "a non-empty string");
84
+ }
85
+ if (value.panel.requiresRoles !== undefined && !isStringArray(value.panel.requiresRoles)) {
86
+ return invalid("panel.requiresRoles", "an array of strings");
87
+ }
88
+ }
94
89
  if (value.adminNav !== undefined) {
95
90
  if (
96
91
  !Array.isArray(value.adminNav) ||
@@ -58,6 +58,7 @@ export const buildRuntimeFromRegistry = (entries: AppRegistryEntry[], capabiliti
58
58
  badge: e.nav.badge,
59
59
  }
60
60
  : undefined,
61
+ panel: e.panel ? { ...e.panel, requiresRoles: e.panel.requiresRoles ? [...e.panel.requiresRoles] : undefined } : undefined,
61
62
  help: e.help ? { ...e.help } : undefined,
62
63
  searchTags,
63
64
  searchHelp: searchQueries?.map((query) => query.description).join(" "),
@@ -1,46 +1,65 @@
1
1
  import { LocaleProvider } from "@k2b/ui";
2
+ import { createSignal, onCleanup, Show } from "solid-js";
2
3
  import { render } from "solid-js/web";
4
+ import type { AccessEntry } from "../contracts/shared";
3
5
  import PermissionEditor from "./PermissionEditor";
4
6
 
5
7
  const host = document.getElementById("root");
6
8
  if (!host) throw new Error("Missing harness root");
7
- render(
8
- () => (
9
+ render(() => {
10
+ let saved: AccessEntry[] = ["Mara Lind", "Lea Sommer", "Jonas Weber"].map((displayName, index) => ({
11
+ id: `access-${index}`,
12
+ principal: { type: "user", userId: `user-${index}` },
13
+ displayName,
14
+ permission: "read",
15
+ createdAt: "2026-10-15T07:00:00.000Z",
16
+ }));
17
+ const [data, setData] = createSignal(saved);
18
+ const refetch = () => {
19
+ // Like a consumer's query: fresh objects contain the saved level and another person's change.
20
+ saved = saved.map((entry, index) => ({
21
+ ...entry,
22
+ principal: { ...entry.principal },
23
+ ...(index === 2 ? { permission: "write" as const } : {}),
24
+ }));
25
+ setData(saved);
26
+ };
27
+ document.addEventListener("refetch-access", refetch);
28
+ onCleanup(() => document.removeEventListener("refetch-access", refetch));
29
+ return (
9
30
  <LocaleProvider locale="en">
10
- <PermissionEditor
11
- initialEntries={["Mara Lind", "Lea Sommer", "Jonas Weber"].map((displayName, index) => ({
12
- id: `access-${index}`,
13
- principal: { type: "user", userId: `user-${index}` },
14
- displayName,
15
- permission: "read",
16
- createdAt: "2026-10-15T07:00:00.000Z",
17
- }))}
18
- // Equal-width labels isolate pending-state layout from a normal change in label width.
19
- allowedLevels={[
20
- { level: "read", label: "Level 1" },
21
- { level: "write", label: "Level 2" },
22
- ]}
23
- allowAuthenticated={false}
24
- grantAccess={async () => {
25
- throw new Error("Not used");
26
- }}
27
- updateAccess={() =>
28
- new Promise<void>((resolve) => {
29
- // The test waits for this mark, so it does not depend on how the editor shows a pending change.
30
- document.documentElement.dataset.saving = "true";
31
- document.addEventListener(
32
- "settle-access",
33
- () => {
34
- delete document.documentElement.dataset.saving;
35
- resolve();
36
- },
37
- { once: true },
38
- );
39
- })
40
- }
41
- revokeAccess={async () => {}}
42
- />
31
+ <Show when={data()}>
32
+ {(entries) => (
33
+ <PermissionEditor
34
+ initialEntries={entries()}
35
+ // Equal-width labels isolate pending-state layout from a normal change in label width.
36
+ allowedLevels={[
37
+ { level: "read", label: "Level 1" },
38
+ { level: "write", label: "Level 2" },
39
+ ]}
40
+ allowAuthenticated={false}
41
+ grantAccess={async () => {
42
+ throw new Error("Not used");
43
+ }}
44
+ updateAccess={(id, permission) =>
45
+ new Promise<void>((resolve) => {
46
+ // Existing tests use this mark and event to control the save.
47
+ document.documentElement.dataset.saving = "true";
48
+ document.addEventListener(
49
+ "settle-access",
50
+ () => {
51
+ saved = saved.map((entry) => (entry.id === id ? { ...entry, permission } : entry));
52
+ delete document.documentElement.dataset.saving;
53
+ resolve();
54
+ },
55
+ { once: true },
56
+ );
57
+ })
58
+ }
59
+ revokeAccess={async () => {}}
60
+ />
61
+ )}
62
+ </Show>
43
63
  </LocaleProvider>
44
- ),
45
- host,
46
- );
64
+ );
65
+ }, host);