@k2b/cloud 0.22.0 → 0.23.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 +4 -3
- package/scripts/README.md +1 -1
- package/scripts/build-canvas-workers.ts +58 -0
- package/scripts/build.ts +4 -5
- package/src/_internal/canvas-worker.ts +24 -0
- package/src/_internal/define-app.ts +81 -7
- package/src/_internal/page-responses.ts +19 -3
- package/src/_internal/registry-validation.ts +6 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/static-assets.ts +18 -3
- package/src/access/ResourceApiKeys.tsx +30 -30
- package/src/ai/capability-execution.ts +5 -0
- package/src/ai/client/controller.ts +17 -3
- package/src/ai/client/transport.ts +47 -3
- package/src/ai/live-routes.ts +2 -2
- package/src/ai/pdf-render.ts +2 -22
- package/src/ai/routes.ts +5 -4
- package/src/ai/stream.ts +89 -65
- package/src/api/app-approval.ts +5 -1
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +19 -5
- package/src/api/pwa-phone.ts +161 -0
- package/src/api/pwa.ts +179 -0
- package/src/browser/app-session.ts +69 -0
- package/src/browser/live-connection.ts +211 -0
- package/src/browser/live-websocket.ts +31 -11
- package/src/browser/live.ts +2 -0
- package/src/contracts/app.ts +10 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/pwa-paths.ts +6 -0
- package/src/contracts/pwa.ts +156 -0
- package/src/contracts/registry.ts +7 -0
- package/src/contracts/shared.ts +7 -1
- package/src/events/index.ts +2 -0
- package/src/events/live-engine.ts +826 -0
- package/src/events/live-protocol.ts +31 -0
- package/src/events/live.ts +298 -0
- package/src/server/actor.ts +7 -0
- package/src/server/api-client.ts +23 -2
- package/src/server/index.ts +3 -1
- package/src/server/middleware/auth.ts +80 -8
- package/src/server/middleware/openapi.ts +2 -1
- package/src/server/services/access.ts +49 -0
- package/src/server/services/index.ts +1 -0
- package/src/services/app-approval.ts +6 -25
- package/src/services/audit/index.ts +2 -0
- package/src/services/branding/app-icon-source.ts +40 -0
- package/src/services/branding/app-icons.ts +112 -0
- package/src/services/branding/icon-render-worker.ts +69 -0
- package/src/services/branding/icon-svg.ts +22 -0
- package/src/services/identity/invocation-actor.ts +8 -5
- package/src/services/identity/invocation-authority.ts +1 -0
- package/src/services/identity/invocation-token.ts +6 -0
- package/src/services/index.ts +1 -0
- package/src/services/outbox.ts +150 -30
- package/src/services/pairing-secret.ts +16 -0
- package/src/services/pwa-devices.ts +574 -0
- package/src/services/session/index.ts +212 -57
- package/src/services/session/recent.ts +42 -0
- package/src/services/session/user.ts +15 -5
- package/src/shared/index.ts +1 -0
- package/src/{browser → shared}/locale-preference.ts +1 -1
- package/src/shared/markdown/extensions/info-blocks.ts +18 -47
- package/src/shared/markdown/index.ts +5 -14
- package/src/ssr/AppLaunchpad.island.tsx +8 -1
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
- package/src/ssr/Layout.tsx +10 -12
- package/src/ssr/LayoutHeader.tsx +14 -2
- package/src/ssr/LayoutHelp.tsx +2 -4
- package/src/ssr/LayoutRail.tsx +13 -4
- package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
- package/src/ssr/MobileProfileActions.tsx +19 -13
- package/src/ssr/PageError.tsx +23 -1
- package/src/ssr/ProfilePreferences.island.tsx +9 -1
- package/src/ssr/PwaLayout.tsx +69 -0
- package/src/ssr/PwaRuntime.island.tsx +105 -0
- package/src/ssr/TimezoneCookie.island.tsx +7 -1
- package/src/ssr/app-navigation.ts +25 -10
- package/src/ssr/index.ts +8 -1
- package/src/ssr/layout-context.ts +1 -1
- package/src/ssr/preference-controller.ts +2 -2
- package/src/ssr/profile-actions.ts +3 -1
- package/src/ssr/profile-preferences-messages.ts +2 -0
- package/src/ssr/pwa-messages.ts +24 -0
- package/src/styles/global.css +6 -0
- package/src/styles/utilities-feedback.css +2 -2
- package/scripts/build-pdf-renderer.ts +0 -39
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.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": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"./server/*": "./src/server/*",
|
|
31
31
|
"./browser": "./src/server/api-client.ts",
|
|
32
32
|
"./browser/app-approval": "./src/browser/app-approval.ts",
|
|
33
|
-
"./browser/live": "./src/browser/live
|
|
33
|
+
"./browser/live": "./src/browser/live.ts",
|
|
34
34
|
"./browser/mermaid": "./src/browser/mermaid.ts",
|
|
35
35
|
"./browser/reload": "./src/browser/reload.ts",
|
|
36
36
|
"./browser/notifications": "./src/browser/notifications.ts",
|
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
"./contracts": "./src/contracts/index.ts",
|
|
75
75
|
"./contracts/notifications": "./src/contracts/notification-types.ts",
|
|
76
76
|
"./contracts/*": "./src/contracts/*",
|
|
77
|
+
"./events": "./src/events/index.ts",
|
|
77
78
|
"./workflows": "./src/workflows/index.ts",
|
|
78
79
|
"./workflows/ai": "./src/workflows/ai/index.ts",
|
|
79
80
|
"./workflows/editor": "./src/workflows/editor.ts",
|
|
@@ -98,7 +99,7 @@
|
|
|
98
99
|
"@tailwindcss/typography": "0.5.20",
|
|
99
100
|
"@k2b/nessi": "0.12.1",
|
|
100
101
|
"@k2b/ssr": "0.14.0",
|
|
101
|
-
"@k2b/ui": "0.
|
|
102
|
+
"@k2b/ui": "0.13.0",
|
|
102
103
|
"@k2b/stdlib": "0.27.0",
|
|
103
104
|
"@k2b/sync": "7.0.0",
|
|
104
105
|
"@nats-io/jetstream": "3.4.0",
|
package/scripts/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
- `build.ts` — production build for one Cloud app (`APP_ID`); used by the Dockerfiles and `bun run build` targets.
|
|
4
4
|
- `preload.ts` — dev-mode preload that registers the SSR plugin and builds CSS before app code loads.
|
|
5
|
-
- `build-
|
|
5
|
+
- `build-canvas-workers.ts` — packages the isolated canvas workers (PDF pages, mobile app icons) and the native canvas for bundles that start them.
|
|
6
6
|
- `app-favicon.ts` — generates app favicons from Tabler icons; imported by `build.ts` and `preload.ts`.
|
|
7
7
|
- `browser-performance.ts` — bundles the web-vitals browser asset for core; imported by `build.ts` and `preload.ts`.
|
|
8
8
|
- `runtime-recovery-acceptance.ts` — shared-runtime recovery acceptance on disposable Docker containers: `bun packages/cloud/scripts/runtime-recovery-acceptance.ts` (also `bun run test:runtime-recovery`).
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { cp, mkdir } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/** Isolated native canvas workers, each packaged only when the server bundle reaches the module that starts it. */
|
|
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
|
+
{
|
|
10
|
+
reachedBy: "/services/branding/app-icons.ts",
|
|
11
|
+
entry: "src/services/branding/icon-render-worker.ts",
|
|
12
|
+
output: "icon-worker.js",
|
|
13
|
+
packages: [],
|
|
14
|
+
},
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
/**
|
|
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`.
|
|
20
|
+
*/
|
|
21
|
+
export async function buildCanvasWorkers(dist: string, bundledInputs: readonly string[]) {
|
|
22
|
+
const workers = CANVAS_WORKERS.filter((worker) => bundledInputs.some((path) => path.endsWith(worker.reachedBy)));
|
|
23
|
+
if (workers.length === 0) return;
|
|
24
|
+
const framework = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
25
|
+
const out = resolve(dist, "canvas");
|
|
26
|
+
await mkdir(out, { recursive: true });
|
|
27
|
+
const external = ["@napi-rs/canvas", ...workers.flatMap((worker) => worker.packages)];
|
|
28
|
+
for (const worker of workers) {
|
|
29
|
+
const build = await Bun.build({
|
|
30
|
+
entrypoints: [resolve(framework, worker.entry)],
|
|
31
|
+
target: "bun",
|
|
32
|
+
outdir: out,
|
|
33
|
+
naming: worker.output,
|
|
34
|
+
external,
|
|
35
|
+
});
|
|
36
|
+
if (!build.success) throw new Error(`Canvas worker ${worker.output} build failed: ${build.logs.join("\n")}`);
|
|
37
|
+
}
|
|
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());
|
|
45
|
+
let nativePackages = 0;
|
|
46
|
+
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++;
|
|
55
|
+
}
|
|
56
|
+
if (!nativePackages) throw new Error("Canvas workers need an installed native canvas binary for the build platform.");
|
|
57
|
+
}
|
|
58
|
+
}
|
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 {
|
|
38
|
+
import { buildCanvasWorkers } 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");
|
|
@@ -132,7 +132,8 @@ try {
|
|
|
132
132
|
__CLOUD_VERSION__: JSON.stringify(version),
|
|
133
133
|
__CLOUD_RELEASE__: JSON.stringify(release),
|
|
134
134
|
__CLOUD_SYNC_VERSION__: JSON.stringify(syncVersion),
|
|
135
|
-
__CLOUD_PDF_RENDER_WORKER__: JSON.stringify("./pdf-
|
|
135
|
+
__CLOUD_PDF_RENDER_WORKER__: JSON.stringify("./canvas/pdf-worker.js"),
|
|
136
|
+
__CLOUD_ICON_RENDER_WORKER__: JSON.stringify("./canvas/icon-worker.js"),
|
|
136
137
|
__CLOUD_APP_ASSETS__: JSON.stringify("./assets/"),
|
|
137
138
|
__CLOUD_CLI_PLUGINS__: JSON.stringify("./cli/"),
|
|
138
139
|
},
|
|
@@ -145,9 +146,7 @@ if (!server.success) {
|
|
|
145
146
|
for (const m of server.logs) console.error(m);
|
|
146
147
|
throw new Error("Server bundle failed");
|
|
147
148
|
}
|
|
148
|
-
|
|
149
|
-
await buildPdfRenderer(dist);
|
|
150
|
-
}
|
|
149
|
+
await buildCanvasWorkers(dist, Object.keys(server.metafile?.inputs ?? {}));
|
|
151
150
|
|
|
152
151
|
// 2. Per-app Tailwind stylesheet.
|
|
153
152
|
const appCss = resolve(appDir, "src/styles/app.css");
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Starts a native canvas worker (PDF pages, app icons) in a process of its own. RLIMIT_DATA bounds the decoder heap
|
|
5
|
+
* and native writable mappings on Linux. Positional arguments keep paths out of shell code; no elevated privileges.
|
|
6
|
+
* The caller owns the deadline and kills the process on abort.
|
|
7
|
+
*/
|
|
8
|
+
export const spawnCanvasWorker = (worker: URL, name: string, input: string) =>
|
|
9
|
+
Bun.spawn(["/bin/sh", "-c", 'ulimit -d 524288 || exit 70; exec "$@"', name, process.execPath, "--no-env-file", fileURLToPath(worker)], {
|
|
10
|
+
stdin: new Blob([input]),
|
|
11
|
+
stdout: "pipe",
|
|
12
|
+
stderr: "ignore",
|
|
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
|
|
15
|
+
// markers otherwise grow with the host's cores, and RLIMIT_DATA also counts
|
|
16
|
+
// each thread's stack and allocator memory.
|
|
17
|
+
env: {
|
|
18
|
+
PATH: process.env.PATH,
|
|
19
|
+
LANG: "C.UTF-8",
|
|
20
|
+
UV_THREADPOOL_SIZE: "2",
|
|
21
|
+
TOKIO_WORKER_THREADS: "2",
|
|
22
|
+
BUN_JSC_numberOfGCMarkers: "2",
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { startLiveOutbox } from "../events/live";
|
|
1
2
|
import { registerHelp } from "../services/help";
|
|
2
3
|
import { preloadLayoutHelp } from "../ssr/help";
|
|
3
4
|
import { invokeCapabilityStream } from "./capability-streams";
|
|
@@ -26,6 +27,7 @@ import type {
|
|
|
26
27
|
AppLifecycle,
|
|
27
28
|
AppMeta,
|
|
28
29
|
AppPresentationCatalog,
|
|
30
|
+
AppPwaPart,
|
|
29
31
|
AppSearchLink,
|
|
30
32
|
CloudLifecycleContext,
|
|
31
33
|
WidgetEndpoint,
|
|
@@ -38,6 +40,7 @@ import {
|
|
|
38
40
|
CapabilityOriginSchema,
|
|
39
41
|
} from "../contracts/capabilities";
|
|
40
42
|
import { type BoundNotificationMap, bindNotificationDefinitions, type NotificationDefinitionMap } from "../contracts/notification-types";
|
|
43
|
+
import { isPwaPartId, PWA_AUTH_PATH, PWA_CANVAS_COLORS, PWA_MANIFEST_PATH, PWA_SHELL_APP_ID } from "../contracts/pwa";
|
|
41
44
|
import type { AppRegistryEntry } from "../contracts/registry";
|
|
42
45
|
import type { AppSettingsMap, KindToType } from "../contracts/settings-types";
|
|
43
46
|
import type { Role } from "../contracts/shared";
|
|
@@ -95,6 +98,8 @@ export type PageOptions = {
|
|
|
95
98
|
theme?: "light" | "dark";
|
|
96
99
|
/** Framework-resolved BCP 47 document language — always the canonical request locale, never a page override. */
|
|
97
100
|
lang?: string;
|
|
101
|
+
/** Renders the document as a page of the installable mobile app; set by `PwaLayout`. */
|
|
102
|
+
pwa?: { name: string };
|
|
98
103
|
};
|
|
99
104
|
|
|
100
105
|
type ResolvedPageOptions = PageOptions & { performanceRoute?: string };
|
|
@@ -162,6 +167,16 @@ export type AppOptions<S extends AppSettingsMap = {}, N extends NotificationDefi
|
|
|
162
167
|
legalLinks?: ReadonlyArray<{ label: string; href: string; icon?: string }>;
|
|
163
168
|
/** Static destinations for the global search UI; no capabilities are created. */
|
|
164
169
|
searchLinks?: readonly AppSearchLink[];
|
|
170
|
+
/**
|
|
171
|
+
* Preview: pages of this app in the installable mobile app. Declaring it adds
|
|
172
|
+
* `/pwa/<id>` to the gateway routes and lists the app in the mobile app,
|
|
173
|
+
* with its name, icon and description. Serve the pages below `/pwa/<id>`
|
|
174
|
+
* with `PwaLayout` and `ssr.pwaAccess`. `routes` never lists `/pwa` paths.
|
|
175
|
+
*/
|
|
176
|
+
pwa?: {
|
|
177
|
+
/** Coarse visibility in the mobile app, like `nav.requiresRoles`. Routes and services still authorize. */
|
|
178
|
+
requiresRoles?: Role[];
|
|
179
|
+
};
|
|
165
180
|
/**
|
|
166
181
|
* Dashboard widget endpoints this app exposes. Each entry references an
|
|
167
182
|
* HTTP path on this app that returns a `WidgetResponse`. The dashboard
|
|
@@ -312,6 +327,32 @@ export type AppDefinition<S extends AppSettingsMap = {}, N extends NotificationD
|
|
|
312
327
|
|
|
313
328
|
// ── Implementation ──────────────────────────────────────────────────────────
|
|
314
329
|
|
|
330
|
+
const isPwaPath = (route: string): boolean => route === "/pwa" || route.startsWith("/pwa/");
|
|
331
|
+
/** A route as the gateway reads it: trimmed and without empty segments, so `//pwa/x/` is `/pwa/x`. */
|
|
332
|
+
const canonicalRoute = (route: string): string => {
|
|
333
|
+
const trimmed = route.trim();
|
|
334
|
+
return trimmed.startsWith("/") ? `/${trimmed.split("/").filter(Boolean).join("/")}` : trimmed;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* `/pwa` belongs to the mobile app shell, `/pwa/_auth` to Core, and `/pwa/<id>`
|
|
339
|
+
* to the app that declares `pwa`. A part never claims a path by hand, so no app
|
|
340
|
+
* can take the shell's pages or another app's part.
|
|
341
|
+
*/
|
|
342
|
+
const validateAppPwaPart = (opts: Pick<AppOptions, "id" | "routes" | "pwa">): AppPwaPart | undefined => {
|
|
343
|
+
for (const route of opts.routes) {
|
|
344
|
+
const path = canonicalRoute(route);
|
|
345
|
+
if (!isPwaPath(path)) continue;
|
|
346
|
+
if ((path === "/pwa" && opts.id === PWA_SHELL_APP_ID) || (path === PWA_AUTH_PATH && opts.id === "core")) continue;
|
|
347
|
+
throw new Error(`App "${opts.id}" lists route "${route}"; /pwa is reserved for the mobile app, declare pwa: {} to get /pwa/${opts.id}`);
|
|
348
|
+
}
|
|
349
|
+
if (!opts.pwa) return undefined;
|
|
350
|
+
if (!isPwaPartId(opts.id)) {
|
|
351
|
+
throw new Error(`App "${opts.id}" cannot declare pwa; a part needs a plain id other than pwa, settings and offline`);
|
|
352
|
+
}
|
|
353
|
+
return { href: `/pwa/${opts.id}`, requiresRoles: opts.pwa.requiresRoles ? [...opts.pwa.requiresRoles] : undefined };
|
|
354
|
+
};
|
|
355
|
+
|
|
315
356
|
export const defineApp = <
|
|
316
357
|
const S extends AppSettingsMap = {},
|
|
317
358
|
const N extends NotificationDefinitionMap = {},
|
|
@@ -323,6 +364,7 @@ export const defineApp = <
|
|
|
323
364
|
const isDevelopment = env.IS_DEVELOPMENT;
|
|
324
365
|
const notifications = bindNotificationDefinitions(opts.id, opts.notifications);
|
|
325
366
|
const cliModules = validateAppCliModules(opts.id, opts.cli);
|
|
367
|
+
const pwaPart = validateAppPwaPart(opts);
|
|
326
368
|
|
|
327
369
|
// ── 0. Register declared settings into the runtime registry ──────────
|
|
328
370
|
// SETTINGS_MAP is the single source of truth for validation in store.ts
|
|
@@ -341,19 +383,43 @@ export const defineApp = <
|
|
|
341
383
|
rootDir,
|
|
342
384
|
componentRoots: [resolve(env.APP_DIR ?? rootDir, "src"), fileURLToPath(new URL("../", import.meta.url))],
|
|
343
385
|
basePath: opts.basePath,
|
|
344
|
-
template: ({ body, scripts, title, description, theme, lang, performanceRoute }) => {
|
|
386
|
+
template: ({ body, scripts, title, description, theme, lang, performanceRoute, pwa }) => {
|
|
345
387
|
const themeFixed = theme !== undefined;
|
|
388
|
+
// App pages paint the canvas before any stylesheet and give it to the
|
|
389
|
+
// status bar. No status-bar style: `black-translucent` lays a blur band
|
|
390
|
+
// over the top edge on iOS 26.
|
|
391
|
+
const canvas = PWA_CANVAS_COLORS[theme ?? "light"];
|
|
392
|
+
const head = pwa
|
|
393
|
+
? {
|
|
394
|
+
html: ` style="background-color:${canvas}"`,
|
|
395
|
+
viewport: "width=device-width, initial-scale=1, viewport-fit=cover",
|
|
396
|
+
app: `<meta name="theme-color" content="${canvas}">
|
|
397
|
+
<link rel="manifest" href="${PWA_MANIFEST_PATH}">
|
|
398
|
+
<link rel="apple-touch-icon" href="/branding/apple-touch-icon.png">
|
|
399
|
+
<meta name="apple-mobile-web-app-title" content="${escapeHtml(pwa.name)}">`,
|
|
400
|
+
// The web cross-fades between pages; an app page swaps at once. The browser ignores taps while a
|
|
401
|
+
// cross-document transition runs, so a tab tap right after the last one would be lost. After
|
|
402
|
+
// global.css, whose `navigation: auto` it overrides.
|
|
403
|
+
styles: `
|
|
404
|
+
<style data-cloud-app-navigation>@view-transition{navigation:none}</style>`,
|
|
405
|
+
}
|
|
406
|
+
: {
|
|
407
|
+
html: "",
|
|
408
|
+
viewport: "width=device-width, initial-scale=1.0",
|
|
409
|
+
app: `<meta name="theme-color" content="#09090b">
|
|
410
|
+
<meta name="mobile-web-app-capable" content="yes">`,
|
|
411
|
+
styles: "",
|
|
412
|
+
};
|
|
346
413
|
// The inline layer statement fixes the cascade order before any
|
|
347
414
|
// stylesheet declares a layer. Tailwind's `properties` layer resets
|
|
348
415
|
// `--tw-*` in browsers without `@property` and must stay lowest.
|
|
349
416
|
return `<!DOCTYPE html>
|
|
350
|
-
<html lang="${normalizeLocale(lang)}" class="${theme ?? "light"}"${themeFixed ? " data-theme-fixed" : ""}>
|
|
351
|
-
<head><meta charset="UTF-8"><meta name="viewport" content="
|
|
417
|
+
<html lang="${normalizeLocale(lang)}" class="${theme ?? "light"}"${themeFixed ? " data-theme-fixed" : ""}${head.html}>
|
|
418
|
+
<head><meta charset="UTF-8"><meta name="viewport" content="${head.viewport}">
|
|
352
419
|
<meta name="view-transition" content="same-origin">
|
|
353
420
|
<title>${escapeHtml(title ?? "Cloud")}</title>
|
|
354
421
|
<meta name="description" content="${escapeHtml(description ?? "Cloud workspace")}">
|
|
355
|
-
|
|
356
|
-
<meta name="mobile-web-app-capable" content="yes">
|
|
422
|
+
${head.app}
|
|
357
423
|
<link rel="icon" href="${appFaviconHref(opts.id, v)}">
|
|
358
424
|
<style data-cloud-css-layers>@layer properties, theme, base, components, utilities;</style>
|
|
359
425
|
<link rel="preload" href="/public/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
|
|
@@ -361,7 +427,7 @@ export const defineApp = <
|
|
|
361
427
|
<link rel="stylesheet" href="/public/fonts.css?v=${v}">
|
|
362
428
|
<link rel="stylesheet" href="/public/tabler-icons.css?v=${v}">
|
|
363
429
|
<link rel="stylesheet" href="/public/${opts.id}/app.css?v=${v}">
|
|
364
|
-
<link rel="stylesheet" href="/public/global.css?v=${v}"
|
|
430
|
+
<link rel="stylesheet" href="/public/global.css?v=${v}">${head.styles}
|
|
365
431
|
<script>${themeBootstrapScript}</script>
|
|
366
432
|
</head>
|
|
367
433
|
<body class="k2b-ui" data-k2b-app-workspace-controller="global">
|
|
@@ -414,10 +480,11 @@ export const defineApp = <
|
|
|
414
480
|
links: group.links.map((link) => ({ ...link })),
|
|
415
481
|
}),
|
|
416
482
|
),
|
|
417
|
-
routes: [...opts.routes, ...cliPluginRoutePrefixes(cliModules)],
|
|
483
|
+
routes: [...opts.routes, ...cliPluginRoutePrefixes(cliModules), ...(pwaPart ? [pwaPart.href] : [])],
|
|
418
484
|
nav: opts.nav,
|
|
419
485
|
legalLinks: opts.legalLinks ? [...opts.legalLinks] : undefined,
|
|
420
486
|
searchLinks: opts.searchLinks?.map((link) => ({ ...link, keywords: link.keywords ? [...link.keywords] : undefined })),
|
|
487
|
+
pwa: pwaPart,
|
|
421
488
|
widgets: opts.widgets ? opts.widgets.map((w) => ({ ...w })) : undefined,
|
|
422
489
|
settingKeys: opts.settings ? Object.keys(opts.settings) : undefined,
|
|
423
490
|
openapi: opts.openapi,
|
|
@@ -512,6 +579,9 @@ export const defineApp = <
|
|
|
512
579
|
help: compiledHelp?.summary,
|
|
513
580
|
legalLinks: meta.legalLinks ? meta.legalLinks.map((l) => ({ ...l })) : undefined,
|
|
514
581
|
searchLinks: meta.searchLinks,
|
|
582
|
+
pwa: meta.pwa
|
|
583
|
+
? { href: meta.pwa.href, requiresRoles: meta.pwa.requiresRoles ? [...meta.pwa.requiresRoles] : undefined }
|
|
584
|
+
: undefined,
|
|
515
585
|
widgets: meta.widgets ? meta.widgets.map((w) => ({ ...w })) : undefined,
|
|
516
586
|
settingKeys: meta.settingKeys ? [...meta.settingKeys] : undefined,
|
|
517
587
|
openapi: advertiseOpenapi ? opts.openapi : undefined,
|
|
@@ -812,6 +882,10 @@ export const defineApp = <
|
|
|
812
882
|
const stopLifecycle = startOpts.lifecycle?.stop;
|
|
813
883
|
if (stopLifecycle) cleanup.push(() => stopLifecycle(cloudCtx));
|
|
814
884
|
|
|
885
|
+
// Fails before any setup when Core has not created the platform outbox yet.
|
|
886
|
+
const stopLiveOutbox = await startLiveOutbox(meta.id);
|
|
887
|
+
if (stopLiveOutbox) cleanup.push(stopLiveOutbox);
|
|
888
|
+
|
|
815
889
|
if (!startOpts.skipSetup && startOpts.lifecycle?.setup) {
|
|
816
890
|
log.info(`Setup: ${meta.id}`);
|
|
817
891
|
await startOpts.lifecycle.setup(cloudCtx);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { HtmlFn } from "@k2b/ssr";
|
|
2
2
|
import type { Context } from "hono";
|
|
3
3
|
import type { ClientErrorStatusCode, ServerErrorStatusCode } from "hono/utils/http-status";
|
|
4
|
+
import { PWA_AUTH_PATH, PWA_SCOPE } from "../contracts/pwa";
|
|
4
5
|
import { getLocale } from "../server/locale";
|
|
5
6
|
import { preloadLayoutAnnouncements } from "../server/middleware/settings";
|
|
6
7
|
import { createLoginRedirectUrl } from "../shared/redirect";
|
|
@@ -10,8 +11,8 @@ export type PageErrorOptions = {
|
|
|
10
11
|
title?: string;
|
|
11
12
|
description?: string;
|
|
12
13
|
action?: { label: string; href: string; icon?: string };
|
|
13
|
-
/** Keep standalone/public pages outside the Cloud navigation shell. */
|
|
14
|
-
layout?: "cloud" | "minimal";
|
|
14
|
+
/** Keep standalone/public pages outside the Cloud navigation shell; `pwa` renders a page of the mobile app. */
|
|
15
|
+
layout?: "cloud" | "minimal" | "pwa";
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
export type PageErrorStatus = ClientErrorStatusCode | ServerErrorStatusCode;
|
|
@@ -38,5 +39,20 @@ export const createPageResponses = (html: HtmlFn<PageOptions>) => {
|
|
|
38
39
|
},
|
|
39
40
|
};
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Route policy for pages of the mobile app (preview), below `/pwa/`. Only an app session reaches them. Without
|
|
44
|
+
* one, Core renews the app session with the phone's device key and returns to the same page; `pwa_launch` marks
|
|
45
|
+
* that return, so a second miss stops at the shell's "unavailable" state instead of bouncing again.
|
|
46
|
+
*/
|
|
47
|
+
const pwaAccess = {
|
|
48
|
+
onReject: (c: Context, reason: "unauthenticated" | "forbidden") => {
|
|
49
|
+
c.header("Cache-Control", "private, no-store");
|
|
50
|
+
if (reason === "forbidden" || c.get("actor")) return error(c, 403, { layout: "pwa" });
|
|
51
|
+
const url = new URL(c.req.url);
|
|
52
|
+
if (url.searchParams.has("pwa_launch")) return `${PWA_SCOPE}?pwa=unavailable`;
|
|
53
|
+
return `${PWA_AUTH_PATH}/session/launch?to=${encodeURIComponent(url.pathname + url.search)}`;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return { access, pwaAccess, error };
|
|
42
58
|
};
|
|
@@ -104,6 +104,12 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
|
|
|
104
104
|
)
|
|
105
105
|
return invalid("searchLinks", "an array of same-origin navigation links");
|
|
106
106
|
}
|
|
107
|
+
if (value.pwa !== undefined) {
|
|
108
|
+
if (!isRecord(value.pwa) || value.pwa.href !== `/pwa/${value.id as string}`) return invalid("pwa", "a mobile app part at /pwa/<id>");
|
|
109
|
+
if (value.pwa.requiresRoles !== undefined && !isStringArray(value.pwa.requiresRoles)) {
|
|
110
|
+
return invalid("pwa.requiresRoles", "an array of strings");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
107
113
|
if (value.widgets !== undefined) {
|
|
108
114
|
if (
|
|
109
115
|
!Array.isArray(value.widgets) ||
|
|
@@ -67,6 +67,7 @@ export const buildRuntimeFromRegistry = (entries: AppRegistryEntry[], capabiliti
|
|
|
67
67
|
),
|
|
68
68
|
legalLinks: e.legalLinks ? e.legalLinks.map((l) => ({ ...l })) : undefined,
|
|
69
69
|
searchLinks: e.searchLinks?.map((link) => ({ ...link, keywords: link.keywords ? [...link.keywords] : undefined })),
|
|
70
|
+
pwa: e.pwa ? { href: e.pwa.href, requiresRoles: e.pwa.requiresRoles ? ([...e.pwa.requiresRoles] as Role[]) : undefined } : undefined,
|
|
70
71
|
openapi: e.openapi,
|
|
71
72
|
};
|
|
72
73
|
}),
|
|
@@ -33,6 +33,15 @@ function acceptsEncoding(header: string | null, encoding: "br" | "gzip"): boolea
|
|
|
33
33
|
});
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** `If-None-Match` names the current file: a list of tags, possibly weak, or `*`. */
|
|
37
|
+
function matchesEtag(header: string | null, etag: string): boolean {
|
|
38
|
+
if (!header) return false;
|
|
39
|
+
return header.split(",").some((part) => {
|
|
40
|
+
const tag = part.trim();
|
|
41
|
+
return tag === "*" || tag === etag || tag === `W/${etag}`;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
36
45
|
async function encodedFile(path: string, encoding: "br" | "gzip"): Promise<Bun.BunFile | null> {
|
|
37
46
|
const suffix = encoding === "br" ? ".br" : ".gz";
|
|
38
47
|
const file = Bun.file(`${path}${suffix}`);
|
|
@@ -72,13 +81,19 @@ export function servePublicAsset(isDevelopment: boolean) {
|
|
|
72
81
|
}
|
|
73
82
|
}
|
|
74
83
|
|
|
84
|
+
// Development rebuilds files in place under the same address, so the browser asks again on every use; an
|
|
85
|
+
// unchanged file then costs one round trip instead of its whole size on every page.
|
|
86
|
+
const etag = `"${selectedEncoding ?? "identity"}-${selected.size.toString(36)}-${selected.lastModified.toString(36)}"`;
|
|
75
87
|
const headers = new Headers({
|
|
76
|
-
"Cache-Control": isDevelopment ? "no-
|
|
77
|
-
|
|
78
|
-
"Content-Type": sourceFile.type || "application/octet-stream",
|
|
88
|
+
"Cache-Control": isDevelopment ? "no-cache" : "public, max-age=31536000, immutable",
|
|
89
|
+
ETag: etag,
|
|
79
90
|
Vary: "Accept-Encoding",
|
|
80
91
|
});
|
|
81
92
|
|
|
93
|
+
if (matchesEtag(c.req.header("If-None-Match") ?? null, etag)) return new Response(null, { status: 304, headers });
|
|
94
|
+
|
|
95
|
+
headers.set("Content-Length", String(selected.size));
|
|
96
|
+
headers.set("Content-Type", sourceFile.type || "application/octet-stream");
|
|
82
97
|
if (selectedEncoding) headers.set("Content-Encoding", selectedEncoding);
|
|
83
98
|
|
|
84
99
|
return new Response(c.req.method === "HEAD" ? null : selected, { headers });
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { dates } from "@k2b/stdlib";
|
|
2
2
|
import { mutation as mutations } from "@k2b/stdlib/solid";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
CopyButton,
|
|
6
|
+
DateTimePicker,
|
|
7
|
+
DetailPanel,
|
|
8
|
+
NoticeCard,
|
|
9
|
+
Placeholder,
|
|
10
|
+
prompts,
|
|
11
|
+
Select,
|
|
12
|
+
Tag,
|
|
13
|
+
TextInput,
|
|
14
|
+
useLocale,
|
|
15
|
+
} from "@k2b/ui";
|
|
4
16
|
import { createEffect, createSignal, For, Show } from "solid-js";
|
|
5
17
|
import type { PermissionLevel, ServiceAccountCredential } from "../contracts/shared";
|
|
6
18
|
import { accessMessages } from "./messages";
|
|
@@ -56,9 +68,7 @@ function TokenDialog(props: { token: string }) {
|
|
|
56
68
|
const t = () => accessMessages.resolve([locale()]).t;
|
|
57
69
|
return (
|
|
58
70
|
<div class="flex flex-col gap-4">
|
|
59
|
-
<NoticeCard tone="warning"
|
|
60
|
-
{t().copyOnce}
|
|
61
|
-
</NoticeCard>
|
|
71
|
+
<NoticeCard tone="warning">{t().copyOnce}</NoticeCard>
|
|
62
72
|
<div class="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-800 dark:bg-zinc-900">
|
|
63
73
|
<code class="block break-all font-mono text-xs text-primary">{props.token}</code>
|
|
64
74
|
</div>
|
|
@@ -203,39 +213,29 @@ export default function ResourceApiKeys(props: ResourceApiKeysProps) {
|
|
|
203
213
|
if (confirmed) await revokeMutation.mutate({ id: key.id, name: key.name });
|
|
204
214
|
};
|
|
205
215
|
|
|
216
|
+
// The heading is a detail panel section heading: in a detail panel the keys line up with the sections around them,
|
|
217
|
+
// and below a settings group title they read as one level down.
|
|
206
218
|
return (
|
|
207
|
-
<
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
<p class="mt-1 text-xs text-dimmed">{props.description ?? t().apiKeysDescription}</p>
|
|
215
|
-
</div>
|
|
216
|
-
<Button type="button" variant="secondary" size="sm" class="shrink-0" onClick={openCreate} disabled={createMutation.loading()}>
|
|
217
|
-
<i class="ti ti-plus" />
|
|
219
|
+
<DetailPanel.Section
|
|
220
|
+
title={props.title ?? t().apiKeys}
|
|
221
|
+
icon="ti ti-key"
|
|
222
|
+
description={props.description ?? t().apiKeysDescription}
|
|
223
|
+
actions={
|
|
224
|
+
<Button type="button" variant="secondary" size="sm" onClick={openCreate} disabled={createMutation.loading()}>
|
|
225
|
+
<i class="ti ti-plus" aria-hidden="true" />
|
|
218
226
|
{t().add}
|
|
219
227
|
</Button>
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
<Show
|
|
223
|
-
when={keys().length > 0}
|
|
224
|
-
fallback={
|
|
225
|
-
<Placeholder
|
|
226
|
-
icon="ti ti-key"
|
|
227
|
-
class="rounded-lg border border-dashed border-zinc-200 dark:border-zinc-800"
|
|
228
|
-
description={t().noApiKeys}
|
|
229
|
-
/>
|
|
230
|
-
}
|
|
231
|
-
>
|
|
228
|
+
}
|
|
229
|
+
>
|
|
230
|
+
<Show when={keys().length > 0} fallback={<Placeholder icon="ti ti-key" align="left" description={t().noApiKeys} />}>
|
|
232
231
|
<div class="flex flex-col gap-2">
|
|
233
232
|
<For each={keys()}>
|
|
234
233
|
{(key) => (
|
|
235
234
|
<div class="group/api-key flex items-center gap-3 rounded-lg bg-zinc-50/70 p-3 dark:bg-zinc-900/35">
|
|
236
235
|
<div class="min-w-0 flex-1">
|
|
237
|
-
|
|
238
|
-
|
|
236
|
+
{/* The name and its tags wrap instead of squeezing the name to a few letters in a narrow section. */}
|
|
237
|
+
<div class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
|
238
|
+
<span class="max-w-full truncate text-sm font-medium text-primary">{key.name}</span>
|
|
239
239
|
<Tag color={key.permission === "none" ? "#dc2626" : "#2563eb"}>
|
|
240
240
|
{permissionLabel(key.permission, options(), t().noAccess)}
|
|
241
241
|
</Tag>
|
|
@@ -266,6 +266,6 @@ export default function ResourceApiKeys(props: ResourceApiKeysProps) {
|
|
|
266
266
|
</For>
|
|
267
267
|
</div>
|
|
268
268
|
</Show>
|
|
269
|
-
</
|
|
269
|
+
</DetailPanel.Section>
|
|
270
270
|
);
|
|
271
271
|
}
|
|
@@ -8,6 +8,7 @@ import type { AccessSubject } from "../server/services/access";
|
|
|
8
8
|
import { isAccountCategoryAllowed } from "../services/account-category-policy";
|
|
9
9
|
import { isAccountExpired } from "../services/account-model";
|
|
10
10
|
import { accounts } from "../services/accounts";
|
|
11
|
+
import { withoutAdminRole } from "../services/session/user";
|
|
11
12
|
import { LOCALE_HEADER } from "../shared/locale";
|
|
12
13
|
import type { AiCapabilityCatalogEntry } from "./capabilities";
|
|
13
14
|
import { aiToolAudit } from "./tool-audit";
|
|
@@ -30,6 +31,10 @@ export const resolveAiCapabilityActor = async (input: {
|
|
|
30
31
|
const user = await (input.getUser ?? accounts.users.get)({ id: conversation.createdByUserId });
|
|
31
32
|
if (!user || isAccountExpired(user.accountExpires)) throw new Error("Cloud capability actor is no longer active.");
|
|
32
33
|
if (!(await isAccountCategoryAllowed(user))) throw new Error("Cloud capability actor category is disabled.");
|
|
34
|
+
// A turn started in the mobile app keeps its limits: no administrator role, and invocations say so.
|
|
35
|
+
if (input.persistedActor.sessionKind === "app") {
|
|
36
|
+
return { actor: { kind: "user", user: withoutAdminRole(user), sessionKind: "app" }, accessSubject: { type: "user", userId: user.id } };
|
|
37
|
+
}
|
|
33
38
|
return { actor: { kind: "user", user }, accessSubject: { type: "user", userId: user.id } };
|
|
34
39
|
};
|
|
35
40
|
|
|
@@ -178,6 +178,8 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
178
178
|
const abortRequests = new Map<string, Promise<boolean>>();
|
|
179
179
|
let stream: AiStreamHandle | null = null;
|
|
180
180
|
let streamSession: AiStreamSession | null = null;
|
|
181
|
+
/** The chat error left by a stream that ended for good, until a stream opens again. */
|
|
182
|
+
let streamError: string | null = null;
|
|
181
183
|
let streamGeneration = 0;
|
|
182
184
|
let conversationOpenGeneration = 0;
|
|
183
185
|
let historyTargetSeq: number | null = null;
|
|
@@ -217,9 +219,12 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
217
219
|
if (activeConversationId()) setGlobalError(message);
|
|
218
220
|
else setGlobalError(message);
|
|
219
221
|
};
|
|
222
|
+
/** Starts the person's next action in the active chat clean, including a stream that ended with an error. */
|
|
220
223
|
const clearErrors = () => {
|
|
221
224
|
setGlobalError(null);
|
|
222
225
|
setRunError(null);
|
|
226
|
+
const conversationId = activeConversationId();
|
|
227
|
+
if (conversationId) openStream(conversationId);
|
|
223
228
|
};
|
|
224
229
|
const error = () => globalError() ?? runError();
|
|
225
230
|
|
|
@@ -314,12 +319,19 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
314
319
|
conversationId,
|
|
315
320
|
url: url(`/conversations/${conversationId}/stream`),
|
|
316
321
|
onStatus: (status) => {
|
|
317
|
-
if (isCurrentStreamSession(streamSession, session))
|
|
322
|
+
if (!isCurrentStreamSession(streamSession, session)) return;
|
|
323
|
+
setStreamStatus(status);
|
|
324
|
+
if (status !== "open" || streamError === null) return;
|
|
325
|
+
if (globalError() === streamError) setGlobalError(null);
|
|
326
|
+
streamError = null;
|
|
318
327
|
},
|
|
319
328
|
onEvent: (event) => applyEvent(session, event),
|
|
329
|
+
// The transport has stopped. Drop its handle so the next visit, refresh,
|
|
330
|
+
// or action in this chat can subscribe again.
|
|
320
331
|
onError: (error) => {
|
|
321
332
|
if (!isCurrentStreamSession(streamSession, session)) return;
|
|
322
|
-
|
|
333
|
+
closeStream();
|
|
334
|
+
streamError = error.message;
|
|
323
335
|
setConversationError(conversationId, error.message);
|
|
324
336
|
},
|
|
325
337
|
});
|
|
@@ -422,6 +434,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
422
434
|
|
|
423
435
|
const openConversation = async (conversationId: string) => {
|
|
424
436
|
if (activeConversationId() === conversationId && state.conversation?.id === conversationId) {
|
|
437
|
+
openStream(conversationId);
|
|
425
438
|
void markConversationViewed(conversationId);
|
|
426
439
|
return "current" as const;
|
|
427
440
|
}
|
|
@@ -485,6 +498,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
485
498
|
if (preservedOlder.length === 0) setHasMore(conversationId, detail.hasMoreMessages ?? false);
|
|
486
499
|
if (detail.timeline) setTimeline(conversationId, detail.timeline);
|
|
487
500
|
setRunError(conversationRunError(detail.conversation));
|
|
501
|
+
openStream(conversationId);
|
|
488
502
|
};
|
|
489
503
|
|
|
490
504
|
const requestMessagesPage = async (conversationId: string, before: number, limit: number): Promise<AiMessagesPage> => {
|
|
@@ -573,8 +587,8 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
573
587
|
if (conversationId) void openConversation(conversationId);
|
|
574
588
|
else {
|
|
575
589
|
conversationOpenGeneration += 1;
|
|
576
|
-
clearErrors();
|
|
577
590
|
setActiveConversationIdSignal(null);
|
|
591
|
+
clearErrors();
|
|
578
592
|
historyTargetSeq = null;
|
|
579
593
|
setLoadingConversationId(null);
|
|
580
594
|
closeStream();
|