@k2b/cloud 0.37.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 (149) hide show
  1. package/package.json +8 -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/help-catalog.ts +123 -4
  9. package/src/_internal/help.ts +27 -6
  10. package/src/_internal/panel-cookie.ts +39 -0
  11. package/src/_internal/registry-validation.ts +21 -26
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  14. package/src/access/PermissionEditor.tsx +30 -5
  15. package/src/access/ResourceApiKeys.tsx +5 -1
  16. package/src/account/Avatar.tsx +20 -2
  17. package/src/account/ui.ts +1 -1
  18. package/src/ai/approval-routes.ts +5 -2
  19. package/src/ai/approvals.ts +139 -28
  20. package/src/ai/browser.ts +11 -0
  21. package/src/ai/chart-block.ts +44 -13
  22. package/src/ai/chat/action-sentences.ts +1 -44
  23. package/src/ai/chat/chart-block.tsx +2 -11
  24. package/src/ai/chat/messages.ts +10 -0
  25. package/src/ai/chat/turn-view.tsx +35 -3
  26. package/src/ai/chat/view-block.tsx +7 -2
  27. package/src/ai/chat/web-tools.tsx +23 -6
  28. package/src/ai/chat-task-contracts.ts +28 -3
  29. package/src/ai/chat-task-reasons-migrate.ts +68 -0
  30. package/src/ai/chat-task-reasons.ts +92 -0
  31. package/src/ai/chat-tasks.ts +460 -66
  32. package/src/ai/default-tool-contracts.ts +30 -13
  33. package/src/ai/fetch-file-tool.ts +37 -3
  34. package/src/ai/index.ts +12 -0
  35. package/src/ai/migrate.ts +46 -3
  36. package/src/ai/readable-html.ts +1057 -0
  37. package/src/ai/request-context.ts +7 -1
  38. package/src/ai/routes.ts +27 -1
  39. package/src/ai/runtime.ts +12 -27
  40. package/src/ai/store.ts +5 -3
  41. package/src/ai/timeline.ts +9 -1
  42. package/src/ai/tool-wording.ts +45 -0
  43. package/src/ai/turn-abort.ts +33 -0
  44. package/src/ai/types.ts +12 -1
  45. package/src/api/admin-core-settings.ts +190 -139
  46. package/src/api/admin-outgoing-mail.ts +4 -2
  47. package/src/api/app-approval.ts +9 -2
  48. package/src/api/approvals.ts +1 -1
  49. package/src/api/help.ts +23 -4
  50. package/src/api/index.ts +2 -1
  51. package/src/api/mcp.ts +8 -2
  52. package/src/browser/CloudResourceSearch.tsx +10 -6
  53. package/src/browser/FileChooser.tsx +11 -3
  54. package/src/browser/app-approval.ts +7 -2
  55. package/src/browser/choose-files.browser-harness.tsx +4 -1
  56. package/src/browser/composer-catalog.ts +103 -0
  57. package/src/browser/composer-insert-messages.ts +31 -0
  58. package/src/browser/composer-insert.ts +150 -0
  59. package/src/browser/file-chooser-messages.ts +4 -0
  60. package/src/browser/file-providers.ts +14 -1
  61. package/src/browser/panel-messages.ts +24 -0
  62. package/src/browser/panel-slot.ts +354 -0
  63. package/src/browser/panel.ts +28 -0
  64. package/src/browser/provider-folder.tsx +58 -5
  65. package/src/browser/resource-picker.tsx +17 -2
  66. package/src/cli/admin/workflows.ts +1 -0
  67. package/src/config/env.ts +14 -0
  68. package/src/contracts/app-approval.ts +49 -9
  69. package/src/contracts/app.ts +14 -0
  70. package/src/contracts/file-provider.ts +48 -1
  71. package/src/contracts/outgoing-mail.ts +8 -1
  72. package/src/contracts/registry.ts +3 -1
  73. package/src/contracts/shared.ts +2 -0
  74. package/src/server/help.ts +136 -10
  75. package/src/server/services/access.ts +5 -1
  76. package/src/server/services/geo.ts +21 -4
  77. package/src/services/accounts/posix.ts +2 -1
  78. package/src/services/allowed-network-fetch.ts +189 -0
  79. package/src/services/app-approval.ts +158 -38
  80. package/src/services/approvals/device.ts +2 -0
  81. package/src/services/help/index.ts +46 -5
  82. package/src/services/help/store.ts +26 -5
  83. package/src/services/help/types.ts +10 -2
  84. package/src/services/index.ts +22 -3
  85. package/src/services/notifications/browser.ts +16 -5
  86. package/src/services/outgoing-mail/dispatcher.ts +7 -2
  87. package/src/services/outgoing-mail/resolve-host.ts +39 -0
  88. package/src/services/outgoing-mail/test-send.ts +18 -3
  89. package/src/services/outgoing-mail/transport.ts +24 -13
  90. package/src/services/pdf/image-protocol.ts +135 -0
  91. package/src/services/pdf/image-variants.ts +124 -0
  92. package/src/services/pdf/image-worker.ts +31 -0
  93. package/src/services/pdf/image.ts +109 -0
  94. package/src/services/pdf/index.ts +9 -0
  95. package/src/services/settings/api.ts +11 -6
  96. package/src/services/settings/app.ts +5 -4
  97. package/src/services/settings/core-settings.ts +4 -3
  98. package/src/services/settings/index.ts +67 -43
  99. package/src/services/settings/namespace.ts +2 -1
  100. package/src/services/settings/store.ts +83 -11
  101. package/src/shared/ai-platform-prompt.ts +1 -1
  102. package/src/shared/app-presentation.ts +1 -0
  103. package/src/shared/help.ts +41 -0
  104. package/src/shared/index.ts +9 -1
  105. package/src/shared/markdown/extensions/guided-help.ts +208 -64
  106. package/src/shared/markdown/formula.ts +8 -8
  107. package/src/shared/markdown/index.ts +72 -11
  108. package/src/shared/redirect.ts +12 -0
  109. package/src/ssr/CloudShell.island.tsx +86 -9
  110. package/src/ssr/Layout.tsx +62 -11
  111. package/src/ssr/LayoutHeader.tsx +15 -9
  112. package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
  113. package/src/ssr/LayoutHelp.tsx +569 -410
  114. package/src/ssr/LayoutRail.tsx +2 -2
  115. package/src/ssr/LayoutTabBar.tsx +29 -0
  116. package/src/ssr/PanelLayout.tsx +27 -0
  117. package/src/ssr/PanelRuntime.island.tsx +87 -0
  118. package/src/ssr/PanelSlot.tsx +108 -0
  119. package/src/ssr/PanelTrigger.tsx +49 -0
  120. package/src/ssr/ShellRuntime.island.tsx +72 -25
  121. package/src/ssr/app-appearance.ts +3 -2
  122. package/src/ssr/app-color.ts +100 -0
  123. package/src/ssr/app-launchpad.tsx +18 -1
  124. package/src/ssr/app-navigation.ts +15 -0
  125. package/src/ssr/compact-shell.baseline.json +5270 -0
  126. package/src/ssr/compact-shell.browser-harness.ts +431 -0
  127. package/src/ssr/help-messages.ts +60 -38
  128. package/src/ssr/help.ts +3 -0
  129. package/src/ssr/index.ts +3 -0
  130. package/src/ssr/layout-help-article.ts +41 -0
  131. package/src/ssr/layout-help-navigation.ts +0 -12
  132. package/src/ssr/layout-help-registry.tsx +4 -11
  133. package/src/ssr/layout-help-search.ts +9 -12
  134. package/src/ssr/layout-help-start.ts +68 -0
  135. package/src/ssr/panel-slot.browser-harness.tsx +110 -0
  136. package/src/ssr/platform-messages.ts +34 -0
  137. package/src/ssr/rail-navigation.ts +44 -0
  138. package/src/ssr/shell-data.tsx +11 -0
  139. package/src/ssr/shell-shortcuts.ts +19 -0
  140. package/src/styles/effects.css +689 -185
  141. package/src/styles/global.css +269 -55
  142. package/src/styles/panel.css +373 -0
  143. package/src/styles/resource-search.css +25 -0
  144. package/src/styles/tokens.css +23 -6
  145. package/src/styles/utilities-navigation.css +5 -21
  146. package/src/workflows/store/events.ts +24 -0
  147. package/src/workflows/store/index.ts +8 -0
  148. package/src/workflows/store/observability.ts +6 -1
  149. package/src/workflows/store/retention.ts +157 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.37.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": {
@@ -19,6 +19,7 @@
19
19
  "!src/**/*.test.tsx",
20
20
  "!src/**/*.integration.test.ts",
21
21
  "!src/**/*.test-fixture.ts",
22
+ "!src/**/*.test-fixture.tsx",
22
23
  "!scripts/**/*.test.ts",
23
24
  "!scripts/runtime-recovery-*.ts"
24
25
  ],
@@ -37,10 +38,12 @@
37
38
  "./browser/notifications": "./src/browser/notifications.ts",
38
39
  "./browser/resource-clipboard": "./src/browser/resource-clipboard.ts",
39
40
  "./browser/commands": "./src/browser/commands.ts",
41
+ "./browser/panel": "./src/browser/panel.ts",
40
42
  "./browser/search": "./src/browser/search.ts",
41
43
  "./browser/widgets": "./src/browser/widgets.ts",
42
44
  "./browser/testing": "./src/browser/testing.ts",
43
45
  "./browser/resource-picker": "./src/browser/resource-picker.tsx",
46
+ "./browser/composer-insert": "./src/browser/composer-insert.ts",
44
47
  "./browser/files": "./src/browser/files.tsx",
45
48
  "./capabilities": "./src/capabilities/client.ts",
46
49
  "./capabilities/server": "./src/capabilities/server.ts",
@@ -102,12 +105,13 @@
102
105
  "@firecrawl/anydoc": "0.2.4",
103
106
  "@napi-rs/canvas": "1.0.9",
104
107
  "pdfjs-dist": "6.3.289",
108
+ "sharp": "0.35.4",
105
109
  "@simplewebauthn/server": "14.0.3",
106
110
  "@tabler/icons": "3.48.0",
107
111
  "@tailwindcss/typography": "0.5.20",
108
112
  "@k2b/nessi": "0.18.0",
109
113
  "@k2b/ssr": "0.17.0",
110
- "@k2b/ui": "0.27.0",
114
+ "@k2b/ui": "0.29.0",
111
115
  "@k2b/stdlib": "0.29.0",
112
116
  "@k2b/sync": "7.0.0",
113
117
  "@nats-io/jetstream": "3.4.0",
@@ -115,6 +119,7 @@
115
119
  "@modelcontextprotocol/sdk": "1.32.1",
116
120
  "bun-plugin-tailwind": "0.1.2",
117
121
  "hono-openapi": "1.3.3",
122
+ "htmlparser2": "12.0.0",
118
123
  "jose": "6.2.12",
119
124
  "katex": "0.18.9",
120
125
  "liquidjs": "10.29.0",
@@ -139,7 +144,7 @@
139
144
  "@types/bun": "1.4.2",
140
145
  "babel-preset-solid": "1.9.15",
141
146
  "hono": "4.13.9",
142
- "playwright": "1.63.0",
147
+ "playwright": "1.64.1",
143
148
  "solid-js": "1.9.15",
144
149
  "typescript": "7.0.2",
145
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) } : {}),
@@ -1,5 +1,124 @@
1
+ import { i18n } from "@k2b/stdlib";
2
+ import { formatShortcut } from "@k2b/ui";
3
+ import type { Token } from "marked";
1
4
  import type { HelpArticle } from "../services/help/types";
2
- import { markdownToPlainText } from "../shared/markdown";
5
+ import { parseHelpReference } from "../shared/help";
6
+ import { lexHelpMarkdown, markdownToPlainText } from "../shared/markdown";
7
+
8
+ const helpAgentMessages = i18n.define({
9
+ baseLocale: "en",
10
+ messages: {
11
+ en: { apple: ({ shortcut }: { shortcut: string }) => `${shortcut} on Mac` },
12
+ de: { apple: ({ shortcut }) => `${shortcut} auf dem Mac` },
13
+ },
14
+ });
15
+
16
+ const helpLinkLabel = (source: string, fallback: string): string => {
17
+ // Index backtick runs once so unmatched runs cannot repeatedly scan the remaining source.
18
+ const backticks = new Map<number, { positions: number[]; next: number }>();
19
+ for (const run of source.matchAll(/`+/g)) {
20
+ const length = run[0].length;
21
+ if (!backticks.has(length)) backticks.set(length, { positions: [], next: 0 });
22
+ backticks.get(length)!.positions.push(run.index);
23
+ }
24
+ let depth = 0;
25
+ for (let index = 1; index < source.length; index++) {
26
+ const char = source[index];
27
+ if (char === "\\") index++;
28
+ else if (char === "`") {
29
+ let end = index + 1;
30
+ while (source[end] === "`") end++;
31
+ const length = end - index;
32
+ const runs = backticks.get(length);
33
+ if (runs) {
34
+ while (runs.next < runs.positions.length && runs.positions[runs.next]! <= index) runs.next++;
35
+ }
36
+ const closing = runs?.positions[runs.next];
37
+ index = closing === undefined ? end - 1 : closing + length - 1;
38
+ } else if (char === "[") depth++;
39
+ else if (char === "]") {
40
+ if (depth === 0) return source.slice(1, index);
41
+ depth--;
42
+ }
43
+ }
44
+ return fallback;
45
+ };
46
+
47
+ /** Portable source for agents and Copy as Markdown; code is kept verbatim. */
48
+ export const helpAgentMarkdown = (
49
+ article: Pick<HelpArticle, "markdown" | "locale" | "appId" | "links">,
50
+ tokens: readonly Token[] = lexHelpMarkdown(article.markdown, article.locale),
51
+ ): string => {
52
+ const { t } = helpAgentMessages.resolve([article.locale]);
53
+ const rewriteChildren = (raw: string, children: readonly Token[]): string => {
54
+ let offset = 0;
55
+ let result = "";
56
+ for (const child of children) {
57
+ let start = raw.indexOf(child.raw, offset);
58
+ let source = child.raw;
59
+ if (start < 0 && child.raw.includes("|")) {
60
+ source = child.raw.replaceAll("|", "\\|");
61
+ start = raw.indexOf(source, offset);
62
+ }
63
+ // Marked removes quote markers and list indentation from child block sources.
64
+ if (start < 0 && child.raw.includes("\n")) {
65
+ const pattern = child.raw
66
+ .split("\n")
67
+ .map((line, index) => (index > 0 ? line.replace(/^[ \t]+/, "") : line).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
68
+ .join("\\r?\\n[ \\t>]*");
69
+ const match = new RegExp(pattern).exec(raw.slice(offset));
70
+ if (match) {
71
+ start = offset + match.index;
72
+ source = match[0];
73
+ }
74
+ }
75
+ if (start < 0) break;
76
+ result += raw.slice(offset, start) + rewrite(child, source);
77
+ offset = start + source.length;
78
+ }
79
+ return result + raw.slice(offset);
80
+ };
81
+ const rewrite = (token: Token, source: string): string => {
82
+ if (token.type === "code" || token.type === "codespan") return source;
83
+ if (token.type === "helpKbd") {
84
+ const keys = String(token.keys);
85
+ const other = formatShortcut(keys, { platform: "other", locale: article.locale });
86
+ const apple = formatShortcut(keys, { platform: "apple", locale: article.locale }).label;
87
+ // Key labels such as | or ` stay text in a table cell or a sentence.
88
+ const escape = (text: string) => text.replace(/[\\`*_[\]|<>~]/g, "\\$&");
89
+ // A key without a platform modifier reads by its name: "Enter", not "↵".
90
+ return other.label === apple ? escape(other.name) : `${escape(other.label)} (${t.apple({ shortcut: escape(apple) })})`;
91
+ }
92
+ if (token.type === "helpDetails") {
93
+ const newline = source.includes("\r\n") ? "\r\n" : "\n";
94
+ const bodyStart = source.indexOf("\n") + 1;
95
+ const closing = [...source.matchAll(/^[ \t>]*:::[ \t]*(?:\r?\n|$)/gm)].at(-1)!;
96
+ const body = source.slice(bodyStart, closing.index);
97
+ const prefix = body.match(/^[ \t>]+/)?.[0] ?? "";
98
+ const trailing = source.slice(closing.index + closing[0].length);
99
+ return `**${rewriteChildren(String(token.title), token.titleTokens ?? [])}**${newline}${prefix}${newline}${rewriteChildren(body, token.tokens ?? [])}${trailing}`;
100
+ }
101
+ if (token.type === "helpLabel") return rewriteChildren(source.replace(/\{icon="[a-z0-9-]+"\}$/, ""), token.tokens ?? []);
102
+ if (token.type === "link" && token.href.startsWith("help:")) {
103
+ const ref = parseHelpReference(token.href.slice(5));
104
+ const target = ref && article.links[`${ref.appId ?? article.appId}/${ref.documentId}`];
105
+ const label = rewriteChildren(helpLinkLabel(source, token.text), token.tokens ?? []);
106
+ return target ? `[${label}](${target.url}${ref.fragment})` : label;
107
+ }
108
+ const children =
109
+ token.type === "list"
110
+ ? token.items
111
+ : token.type === "table"
112
+ ? [...token.header, ...token.rows.flat()].flatMap((cell) => cell.tokens)
113
+ : "tokens" in token
114
+ ? (token.tokens ?? [])
115
+ : [];
116
+ const raw = rewriteChildren(source, children);
117
+ return token.type === "heading" ? raw.replace(/[ \t]+\{icon="[a-z0-9-]+"\}(?=[ \t]*\r?\n?$)/i, "") : raw;
118
+ };
119
+ return rewriteChildren(article.markdown, tokens);
120
+ };
121
+
3
122
  export const HELP_SEARCH_MAX_LIMIT = 25;
4
123
  export const HELP_READ_MAX_CHARS = 7_000;
5
124
  const normalizeSearchText = (value: string): string =>
@@ -33,7 +152,7 @@ const boundedExcerpt = (markdown: string, terms: readonly string[]): string => {
33
152
  return `${start > 0 ? `${marker}\n\n` : ""}${body}${start + budget < markdown.length ? `\n\n${marker}` : ""}`;
34
153
  };
35
154
 
36
- export const selectHelpMarkdown = (markdown: string, query?: string): { markdown: string; truncated: boolean } => {
155
+ export const selectHelpMarkdown = (markdown: string, query?: string, locale?: string): { markdown: string; truncated: boolean } => {
37
156
  // An exact heading identifies one complete reference section, even in short articles.
38
157
  const headingQuery = query?.trim().toLocaleLowerCase();
39
158
  const exact =
@@ -53,7 +172,7 @@ export const selectHelpMarkdown = (markdown: string, query?: string): { markdown
53
172
  const phrase = normalizeSearchText(query ?? "");
54
173
  const ranked = splitSections(markdown)
55
174
  .map((section, index) => {
56
- const text = normalizeSearchText(markdownToPlainText(section));
175
+ const text = normalizeSearchText(markdownToPlainText(section, locale));
57
176
  const score = (phrase && text.includes(phrase) ? 10 : 0) + terms.filter((term) => text.includes(term)).length;
58
177
  return { section, index, score };
59
178
  })
@@ -79,7 +198,7 @@ export const readHelpArticle = (document: HelpArticle, query?: string) => ({
79
198
  documentId: document.documentId,
80
199
  title: document.title,
81
200
  description: document.description,
82
- ...selectHelpMarkdown(document.markdown, query),
201
+ ...selectHelpMarkdown(helpAgentMarkdown(document), query, document.locale),
83
202
  });
84
203
 
85
204
  export const helpResourceUri = (appId: string, documentId: string): string =>
@@ -2,28 +2,49 @@ import { createHash } from "node:crypto";
2
2
  import type { AppRegistryHelpSummary } from "../contracts/registry";
3
3
  import type { HelpDefinition } from "../server/help";
4
4
  import type { HelpCorpus, HelpDocument } from "../services/help/types";
5
+ import { parseHelpReference } from "../shared/help";
6
+ import { lexHelpMarkdown, walkHelpTokens } from "../shared/markdown";
5
7
 
6
8
  export const HELP_DOCUMENT_MAX_BYTES = 128 * 1024;
9
+ // defineHelp documents are frozen; retain only their validated targets, not the token trees.
10
+ export const helpLinkTargets = new WeakMap<object, readonly string[]>();
7
11
  export type CompiledHelp = { summary: AppRegistryHelpSummary; corpus: HelpCorpus };
8
12
 
9
13
  export const compileHelp = (input: { appId: string; basePath?: string; definition: HelpDefinition }): CompiledHelp => {
10
- const compileDocuments = (source: HelpDefinition["documents"]): HelpDocument[] =>
11
- source.map(({ id, title, icon, description, order, markdown, searchText }) => {
14
+ const baseIds = new Set(input.definition.documents.map((document) => document.id));
15
+ const compileDocuments = (source: HelpDefinition["documents"], locale: string): HelpDocument[] =>
16
+ source.map((document) => {
17
+ const { id, title, icon, description, order, kind, needs, next, related, markdown, searchText } = document;
12
18
  if (Buffer.byteLength(markdown) > HELP_DOCUMENT_MAX_BYTES) {
13
19
  throw new Error(`Help document "${id}" exceeds the ${HELP_DOCUMENT_MAX_BYTES}-byte limit`);
14
20
  }
15
- return { id, title, icon, description, order, markdown, searchText };
21
+ const validate = (reference: string) => {
22
+ const ref = parseHelpReference(reference);
23
+ if (ref?.appId === input.appId && !baseIds.has(ref.documentId))
24
+ throw new Error(`Help locale "${locale}" document "${id}" has unknown reference "${reference}"`);
25
+ };
26
+ for (const reference of related) validate(reference);
27
+ const targets = helpLinkTargets.get(document);
28
+ if (targets) {
29
+ for (const target of targets) validate(target);
30
+ } else {
31
+ walkHelpTokens(lexHelpMarkdown(markdown, locale), (token) => {
32
+ if (token.type === "link" && token.href.startsWith("help:")) validate(token.href.slice(5));
33
+ });
34
+ }
35
+ return { id, title, icon, description, order, kind, needs, next, related, markdown, searchText };
16
36
  });
17
37
  const baseLocale = input.definition.baseLocale ?? "en";
18
- const documents = compileDocuments(input.definition.documents);
38
+ const documents = compileDocuments(input.definition.documents, baseLocale);
19
39
  const documentsByLocale = Object.fromEntries(
20
40
  Object.entries(input.definition.documentsByLocale ?? {})
21
41
  .filter(([locale]) => locale !== baseLocale)
22
42
  .sort(([a], [b]) => a.localeCompare(b))
23
- .map(([locale, docs]) => [locale, compileDocuments(docs)]),
43
+ .map(([locale, docs]) => [locale, compileDocuments(docs, locale)]),
24
44
  );
45
+ // A revision bump republishes every corpus, here to fill the new metadata columns.
25
46
  const manifestHash = createHash("sha256")
26
- .update(JSON.stringify({ revision: 1, appId: input.appId, baseLocale, documents, documentsByLocale }))
47
+ .update(JSON.stringify({ revision: 2, appId: input.appId, baseLocale, documents, documentsByLocale }))
27
48
  .digest("hex");
28
49
  return {
29
50
  summary: { manifestHash, pageBase: `${input.basePath?.replace(/\/$/, "") ?? ""}/help`, baseLocale },