@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.
- package/package.json +6 -3
- package/scripts/build-canvas-workers.ts +109 -29
- package/scripts/build.ts +3 -5
- package/src/_internal/app-path.ts +25 -0
- package/src/_internal/app-presentation.ts +4 -0
- package/src/_internal/canvas-worker.ts +5 -3
- package/src/_internal/define-app.ts +28 -2
- package/src/_internal/panel-cookie.ts +39 -0
- package/src/_internal/registry-validation.ts +21 -26
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.browser-harness.tsx +57 -38
- package/src/access/PermissionEditor.tsx +30 -5
- package/src/access/ResourceApiKeys.tsx +5 -1
- package/src/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/browser.ts +10 -0
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/turn-view.tsx +35 -3
- package/src/ai/chat/view-block.tsx +7 -2
- package/src/ai/chat-task-contracts.ts +28 -3
- package/src/ai/chat-task-reasons-migrate.ts +68 -0
- package/src/ai/chat-task-reasons.ts +92 -0
- package/src/ai/chat-tasks.ts +396 -66
- package/src/ai/index.ts +12 -0
- package/src/ai/migrate.ts +28 -2
- package/src/ai/request-context.ts +7 -1
- package/src/ai/routes.ts +27 -1
- package/src/ai/runtime.ts +12 -27
- package/src/ai/store.ts +5 -3
- package/src/ai/timeline.ts +9 -1
- package/src/ai/tool-wording.ts +45 -0
- package/src/ai/turn-abort.ts +33 -0
- package/src/ai/types.ts +12 -1
- package/src/api/admin-core-settings.ts +190 -139
- package/src/api/admin-outgoing-mail.ts +4 -2
- package/src/api/app-approval.ts +9 -2
- package/src/api/approvals.ts +1 -1
- package/src/api/index.ts +2 -1
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/app-approval.ts +7 -2
- package/src/browser/composer-catalog.ts +103 -0
- package/src/browser/composer-insert-messages.ts +31 -0
- package/src/browser/composer-insert.ts +150 -0
- package/src/browser/panel-messages.ts +24 -0
- package/src/browser/panel-slot.ts +354 -0
- package/src/browser/panel.ts +28 -0
- package/src/browser/resource-picker.tsx +12 -1
- package/src/contracts/app-approval.ts +49 -9
- package/src/contracts/app.ts +14 -0
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/shared.ts +2 -0
- package/src/server/services/access.ts +5 -1
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/app-approval.ts +158 -38
- package/src/services/approvals/device.ts +2 -0
- package/src/services/index.ts +21 -3
- package/src/services/notifications/browser.ts +16 -5
- package/src/services/pdf/image-protocol.ts +135 -0
- package/src/services/pdf/image-variants.ts +124 -0
- package/src/services/pdf/image-worker.ts +31 -0
- package/src/services/pdf/image.ts +109 -0
- package/src/services/pdf/index.ts +9 -0
- package/src/services/settings/api.ts +11 -6
- package/src/services/settings/app.ts +5 -4
- package/src/services/settings/core-settings.ts +4 -3
- package/src/services/settings/index.ts +19 -17
- package/src/services/settings/namespace.ts +2 -1
- package/src/services/settings/store.ts +83 -11
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/app-presentation.ts +1 -0
- package/src/shared/redirect.ts +12 -0
- package/src/ssr/CloudShell.island.tsx +86 -9
- package/src/ssr/Layout.tsx +42 -9
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +2 -2
- package/src/ssr/LayoutTabBar.tsx +29 -0
- package/src/ssr/PanelLayout.tsx +27 -0
- package/src/ssr/PanelRuntime.island.tsx +87 -0
- package/src/ssr/PanelSlot.tsx +108 -0
- package/src/ssr/PanelTrigger.tsx +49 -0
- package/src/ssr/ShellRuntime.island.tsx +60 -5
- package/src/ssr/app-appearance.ts +3 -2
- package/src/ssr/app-color.ts +100 -0
- package/src/ssr/app-launchpad.tsx +18 -1
- package/src/ssr/app-navigation.ts +15 -0
- package/src/ssr/compact-shell.baseline.json +5270 -0
- package/src/ssr/compact-shell.browser-harness.ts +431 -0
- package/src/ssr/index.ts +3 -0
- package/src/ssr/panel-slot.browser-harness.tsx +110 -0
- package/src/ssr/platform-messages.ts +30 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +4 -0
- package/src/ssr/shell-shortcuts.ts +13 -2
- package/src/styles/effects.css +3 -2
- package/src/styles/global.css +269 -55
- package/src/styles/panel.css +373 -0
- package/src/styles/tokens.css +23 -6
- 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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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,
|
|
22
|
-
|
|
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(`
|
|
60
|
+
if (!build.success) throw new Error(`Native worker ${worker.output} build failed: ${build.logs.join("\n")}`);
|
|
37
61
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
//
|
|
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,
|
|
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 && !
|
|
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
|
-
|
|
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) || !
|
|
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
|
-
<
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
)
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
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
|
-
|
|
46
|
-
);
|
|
64
|
+
);
|
|
65
|
+
}, host);
|