@k2b/cloud 0.21.1 → 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.
Files changed (93) hide show
  1. package/package.json +4 -3
  2. package/scripts/README.md +1 -1
  3. package/scripts/build-canvas-workers.ts +58 -0
  4. package/scripts/build.ts +4 -5
  5. package/src/_internal/canvas-worker.ts +24 -0
  6. package/src/_internal/define-app.ts +81 -7
  7. package/src/_internal/page-responses.ts +19 -3
  8. package/src/_internal/registry-validation.ts +6 -0
  9. package/src/_internal/runtime-context.ts +1 -0
  10. package/src/_internal/static-assets.ts +18 -3
  11. package/src/access/ResourceApiKeys.tsx +30 -30
  12. package/src/ai/capability-execution.ts +5 -0
  13. package/src/ai/client/controller.ts +17 -3
  14. package/src/ai/client/transport.ts +47 -3
  15. package/src/ai/live-routes.ts +2 -2
  16. package/src/ai/pdf-render.ts +2 -22
  17. package/src/ai/routes.ts +5 -4
  18. package/src/ai/stream.ts +89 -65
  19. package/src/api/app-approval.ts +5 -1
  20. package/src/api/index.ts +3 -0
  21. package/src/api/me.ts +19 -5
  22. package/src/api/pwa-phone.ts +161 -0
  23. package/src/api/pwa.ts +179 -0
  24. package/src/browser/app-session.ts +69 -0
  25. package/src/browser/live-connection.ts +211 -0
  26. package/src/browser/live-websocket.ts +31 -11
  27. package/src/browser/live.ts +2 -0
  28. package/src/contracts/app.ts +10 -0
  29. package/src/contracts/index.ts +1 -0
  30. package/src/contracts/pwa-paths.ts +6 -0
  31. package/src/contracts/pwa.ts +156 -0
  32. package/src/contracts/registry.ts +7 -0
  33. package/src/contracts/shared.ts +7 -1
  34. package/src/events/index.ts +2 -0
  35. package/src/events/live-engine.ts +826 -0
  36. package/src/events/live-protocol.ts +31 -0
  37. package/src/events/live.ts +298 -0
  38. package/src/server/actor.ts +7 -0
  39. package/src/server/api-client.ts +23 -2
  40. package/src/server/index.ts +3 -1
  41. package/src/server/middleware/auth.ts +80 -8
  42. package/src/server/middleware/openapi.ts +2 -1
  43. package/src/server/services/access.ts +49 -0
  44. package/src/server/services/index.ts +1 -0
  45. package/src/services/app-approval.ts +6 -25
  46. package/src/services/audit/index.ts +2 -0
  47. package/src/services/branding/app-icon-source.ts +40 -0
  48. package/src/services/branding/app-icons.ts +112 -0
  49. package/src/services/branding/icon-render-worker.ts +69 -0
  50. package/src/services/branding/icon-svg.ts +22 -0
  51. package/src/services/identity/invocation-actor.ts +8 -5
  52. package/src/services/identity/invocation-authority.ts +1 -0
  53. package/src/services/identity/invocation-token.ts +6 -0
  54. package/src/services/index.ts +1 -0
  55. package/src/services/outbox.ts +150 -30
  56. package/src/services/pairing-secret.ts +16 -0
  57. package/src/services/pwa-devices.ts +574 -0
  58. package/src/services/session/index.ts +212 -57
  59. package/src/services/session/recent.ts +42 -0
  60. package/src/services/session/user.ts +15 -5
  61. package/src/shared/index.ts +1 -0
  62. package/src/{browser → shared}/locale-preference.ts +1 -1
  63. package/src/shared/markdown/extensions/info-blocks.ts +18 -47
  64. package/src/shared/markdown/index.ts +5 -14
  65. package/src/ssr/AppLaunchpad.island.tsx +8 -1
  66. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  67. package/src/ssr/GlobalAnnouncements.island.tsx +9 -34
  68. package/src/ssr/Layout.tsx +10 -12
  69. package/src/ssr/LayoutHeader.tsx +14 -2
  70. package/src/ssr/LayoutHelp.tsx +2 -4
  71. package/src/ssr/LayoutRail.tsx +13 -4
  72. package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
  73. package/src/ssr/MobileProfileActions.tsx +19 -13
  74. package/src/ssr/PageError.tsx +23 -1
  75. package/src/ssr/ProfilePreferences.island.tsx +9 -1
  76. package/src/ssr/PwaLayout.tsx +69 -0
  77. package/src/ssr/PwaRuntime.island.tsx +105 -0
  78. package/src/ssr/TimezoneCookie.island.tsx +7 -1
  79. package/src/ssr/app-navigation.ts +25 -10
  80. package/src/ssr/index.ts +8 -1
  81. package/src/ssr/layout-context.ts +1 -1
  82. package/src/ssr/preference-controller.ts +2 -2
  83. package/src/ssr/profile-actions.ts +3 -1
  84. package/src/ssr/profile-preferences-messages.ts +2 -0
  85. package/src/ssr/pwa-messages.ts +24 -0
  86. package/src/styles/effects.css +6 -30
  87. package/src/styles/global.css +11 -0
  88. package/src/styles/tokens.css +5 -1
  89. package/src/styles/utilities-feedback.css +15 -5
  90. package/src/styles/utilities-layout.css +4 -0
  91. package/src/styles/utilities-markdown-editor.css +20 -2
  92. package/src/styles/utilities-markdown-table.css +35 -17
  93. 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.21.1",
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-websocket.ts",
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.11.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-pdf-renderer.ts` — packages the PDF decoder and native assets for bundles that import PDF vision.
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 { buildPdfRenderer } from "./build-pdf-renderer";
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-render/worker.js"),
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
- if (Object.keys(server.metafile?.inputs ?? {}).some((path) => path.endsWith("/ai/pdf-render.ts"))) {
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="width=device-width, initial-scale=1.0">
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
- <meta name="theme-color" content="#09090b">
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
- return { access, error };
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-store" : "public, max-age=31536000, immutable",
77
- "Content-Length": String(selected.size),
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 { Button, CopyButton, DateTimePicker, NoticeCard, Placeholder, prompts, Select, Tag, TextInput, useLocale } from "@k2b/ui";
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" icon={false}>
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
- <section class="flex flex-col gap-4">
208
- <div class="flex items-start justify-between gap-3">
209
- <div>
210
- <h3 class="flex items-center gap-1.5 text-sm font-semibold text-primary">
211
- <i class="ti ti-key text-sm" />
212
- {props.title ?? t().apiKeys}
213
- </h3>
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
- </div>
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
- <div class="flex min-w-0 items-center gap-2">
238
- <span class="truncate text-sm font-medium text-primary">{key.name}</span>
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
- </section>
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)) setStreamStatus(status);
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
- setStreamStatus("idle");
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();