@k2b/cloud 0.38.0 → 0.40.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 (244) hide show
  1. package/README.md +8 -0
  2. package/package.json +12 -5
  3. package/scripts/build-canvas-workers.ts +109 -29
  4. package/scripts/build.ts +3 -5
  5. package/src/_internal/app-favicon.ts +4 -2
  6. package/src/_internal/app-path.ts +25 -0
  7. package/src/_internal/app-presentation.ts +4 -0
  8. package/src/_internal/canvas-worker.ts +5 -3
  9. package/src/_internal/capabilities.ts +72 -48
  10. package/src/_internal/capability-reviews.ts +81 -0
  11. package/src/_internal/capability-schemas.ts +29 -0
  12. package/src/_internal/capability-sentences.ts +216 -1
  13. package/src/_internal/capability-streams.ts +7 -0
  14. package/src/_internal/define-app.ts +63 -16
  15. package/src/_internal/font-preloads.ts +2 -1
  16. package/src/_internal/panel-cookie.ts +39 -0
  17. package/src/_internal/registry-validation.ts +21 -26
  18. package/src/_internal/runtime-context.ts +1 -0
  19. package/src/_internal/runtime-watcher.ts +6 -0
  20. package/src/_internal/static-assets.ts +52 -3
  21. package/src/_internal/status-preserving-ssr.ts +2 -0
  22. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  23. package/src/access/PermissionEditor.tsx +32 -7
  24. package/src/access/ResourceApiKeys.tsx +5 -1
  25. package/src/account/EntitySearch.tsx +1 -1
  26. package/src/ai/action-review.ts +101 -0
  27. package/src/ai/approval-routes.ts +5 -2
  28. package/src/ai/approvals.ts +139 -28
  29. package/src/ai/background-review.ts +72 -0
  30. package/src/ai/browser.ts +10 -0
  31. package/src/ai/capabilities.ts +29 -11
  32. package/src/ai/capability-execution.ts +76 -43
  33. package/src/ai/capability-result.ts +95 -0
  34. package/src/ai/chat/action-card.ts +398 -0
  35. package/src/ai/chat/action-messages.ts +129 -0
  36. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  37. package/src/ai/chat/action-question.fixtures.ts +733 -0
  38. package/src/ai/chat/action-question.tsx +669 -0
  39. package/src/ai/chat/action-sentences.ts +1 -44
  40. package/src/ai/chat/blocks.tsx +19 -25
  41. package/src/ai/chat/message-actions.tsx +6 -0
  42. package/src/ai/chat/tool-action.tsx +481 -0
  43. package/src/ai/chat/turn-view.tsx +45 -163
  44. package/src/ai/chat/view-block.tsx +22 -7
  45. package/src/ai/chat-task-contracts.ts +31 -3
  46. package/src/ai/chat-task-reasons-migrate.ts +75 -0
  47. package/src/ai/chat-task-reasons.ts +92 -0
  48. package/src/ai/chat-tasks.ts +438 -78
  49. package/src/ai/client/controller.ts +59 -0
  50. package/src/ai/default-tools.ts +9 -1
  51. package/src/ai/executor.ts +190 -39
  52. package/src/ai/index.ts +14 -0
  53. package/src/ai/migrate.ts +39 -4
  54. package/src/ai/projects-routes.ts +3 -0
  55. package/src/ai/protocol.ts +14 -2
  56. package/src/ai/recent-resources.ts +240 -0
  57. package/src/ai/recent-sent-refs.ts +38 -0
  58. package/src/ai/request-context.ts +7 -1
  59. package/src/ai/routes.ts +172 -2
  60. package/src/ai/runtime.ts +13 -27
  61. package/src/ai/skills-routes.ts +3 -0
  62. package/src/ai/skills.ts +10 -1
  63. package/src/ai/store.ts +232 -27
  64. package/src/ai/timeline.ts +9 -1
  65. package/src/ai/tool-audit.ts +8 -0
  66. package/src/ai/tool-wording.ts +67 -0
  67. package/src/ai/turn-abort.ts +33 -0
  68. package/src/ai/types.ts +40 -4
  69. package/src/api/accounts-entities.ts +2 -1
  70. package/src/api/admin-ai-projects.ts +2 -0
  71. package/src/api/admin-ai-skills.ts +2 -0
  72. package/src/api/admin-ai-usage.ts +2 -0
  73. package/src/api/admin-core-settings.ts +190 -139
  74. package/src/api/admin-geoip.ts +80 -0
  75. package/src/api/admin-identity.ts +3 -0
  76. package/src/api/admin-lifecycle.ts +3 -0
  77. package/src/api/admin-outgoing-mail.ts +6 -2
  78. package/src/api/app-approval.ts +54 -6
  79. package/src/api/approvals-loader.ts +23 -1
  80. package/src/api/approvals.ts +3 -1
  81. package/src/api/auth.ts +25 -6
  82. package/src/api/capabilities.ts +44 -11
  83. package/src/api/capability-streams.ts +12 -0
  84. package/src/api/help.ts +4 -28
  85. package/src/api/index.ts +7 -1
  86. package/src/api/lasting-access-messages.ts +17 -0
  87. package/src/api/mcp.ts +3 -0
  88. package/src/api/me-panel-consent.ts +71 -0
  89. package/src/api/me.ts +80 -33
  90. package/src/api/search.ts +3 -1
  91. package/src/api/sync-ops.ts +2 -0
  92. package/src/api/widgets.ts +3 -0
  93. package/src/approvals/Approvals.tsx +180 -238
  94. package/src/approvals/messages.ts +12 -24
  95. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  96. package/src/browser/CloudResourceSearch.tsx +10 -6
  97. package/src/browser/app-approval.ts +60 -6
  98. package/src/browser/commands.ts +22 -6
  99. package/src/browser/composer-catalog.ts +145 -0
  100. package/src/browser/composer-insert-messages.ts +31 -0
  101. package/src/browser/composer-insert.ts +160 -0
  102. package/src/browser/composer-open-messages.ts +17 -0
  103. package/src/browser/composer-open.ts +110 -0
  104. package/src/browser/discoverable-command.ts +6 -0
  105. package/src/browser/hints.ts +6 -0
  106. package/src/browser/live-connection.ts +26 -2
  107. package/src/browser/navigation-apps.ts +7 -0
  108. package/src/browser/panel-context-bridge.ts +30 -0
  109. package/src/browser/panel-context-receiver.ts +53 -0
  110. package/src/browser/panel-menu.tsx +56 -0
  111. package/src/browser/panel-messages.ts +35 -0
  112. package/src/browser/panel-shortcuts.ts +31 -0
  113. package/src/browser/panel-slot.ts +756 -0
  114. package/src/browser/panel.ts +65 -0
  115. package/src/browser/resource-picker.tsx +12 -1
  116. package/src/browser/search-commands.ts +6 -3
  117. package/src/capabilities/client.ts +8 -2
  118. package/src/capabilities/reviews.ts +205 -0
  119. package/src/capabilities/server.ts +10 -3
  120. package/src/capabilities/testing.ts +47 -0
  121. package/src/capabilities/types.ts +2 -2
  122. package/src/cli/account.ts +13 -3
  123. package/src/cli/admin/geoip.ts +115 -0
  124. package/src/cli/admin/index.ts +3 -0
  125. package/src/contracts/app-approval.ts +159 -12
  126. package/src/contracts/app.ts +14 -0
  127. package/src/contracts/approvals.ts +2 -2
  128. package/src/contracts/capabilities.ts +410 -18
  129. package/src/contracts/geoip.ts +42 -0
  130. package/src/contracts/index.ts +3 -0
  131. package/src/contracts/panel-context-plain.ts +99 -0
  132. package/src/contracts/panel-context.ts +93 -0
  133. package/src/contracts/registry.ts +3 -1
  134. package/src/contracts/seen-hints.ts +35 -0
  135. package/src/contracts/shared.ts +2 -0
  136. package/src/server/client-ip.ts +5 -0
  137. package/src/server/help-payload.ts +38 -0
  138. package/src/server/middleware/auth.ts +11 -1
  139. package/src/server/middleware/middleware.ts +9 -0
  140. package/src/server/middleware/no-transform.ts +19 -0
  141. package/src/server/middleware/rate-limit.ts +4 -6
  142. package/src/server/request-context/address.ts +45 -0
  143. package/src/server/request-context/geoip.ts +380 -0
  144. package/src/server/request-context/index.ts +33 -0
  145. package/src/server/request-context/user-agent.ts +95 -0
  146. package/src/server/services/access.ts +5 -1
  147. package/src/services/accounts/posix.ts +2 -1
  148. package/src/services/app-approval.ts +533 -90
  149. package/src/services/approvals/device.ts +9 -5
  150. package/src/services/approvals/index.ts +6 -5
  151. package/src/services/approvals/list.ts +26 -5
  152. package/src/services/auth-flows/notification-sender.ts +3 -0
  153. package/src/services/geoip/index.ts +164 -0
  154. package/src/services/geoip/store.ts +30 -0
  155. package/src/services/geoip/upload.ts +150 -0
  156. package/src/services/identity/routes.ts +5 -1
  157. package/src/services/index.ts +32 -3
  158. package/src/services/known-browsers.ts +312 -0
  159. package/src/services/login-address-store.ts +51 -0
  160. package/src/services/login-context.ts +118 -0
  161. package/src/services/mandates/index.ts +3 -0
  162. package/src/services/mandates/policy.ts +15 -3
  163. package/src/services/notifications/browser.ts +16 -5
  164. package/src/services/notifications/email-frame.ts +1 -1
  165. package/src/services/oauth-tokens.ts +16 -2
  166. package/src/services/panel-context-consent.ts +58 -0
  167. package/src/services/pdf/image-protocol.ts +135 -0
  168. package/src/services/pdf/image-variants.ts +124 -0
  169. package/src/services/pdf/image-worker.ts +31 -0
  170. package/src/services/pdf/image.ts +109 -0
  171. package/src/services/pdf/index.ts +9 -0
  172. package/src/services/service-account-credentials.ts +21 -0
  173. package/src/services/session/index.ts +97 -16
  174. package/src/services/session/recent.ts +13 -0
  175. package/src/services/settings/api.ts +11 -6
  176. package/src/services/settings/app.ts +5 -4
  177. package/src/services/settings/core-settings.ts +4 -3
  178. package/src/services/settings/index.ts +19 -17
  179. package/src/services/settings/namespace.ts +2 -1
  180. package/src/services/settings/store.ts +83 -11
  181. package/src/services/webauthn.ts +12 -0
  182. package/src/shared/ai-platform-prompt.ts +1 -1
  183. package/src/shared/app-presentation.ts +1 -0
  184. package/src/shared/email-html.ts +2 -0
  185. package/src/shared/help.ts +3 -0
  186. package/src/shared/redirect.ts +12 -0
  187. package/src/shared-text/cursor.ts +22 -0
  188. package/src/shared-text/documents.ts +142 -0
  189. package/src/shared-text/events.ts +43 -0
  190. package/src/shared-text/index.ts +12 -0
  191. package/src/shared-text/log.ts +48 -0
  192. package/src/shared-text/text-diff.ts +23 -0
  193. package/src/shared-text/types.ts +41 -0
  194. package/src/ssr/AdminLayout.tsx +2 -0
  195. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  196. package/src/ssr/CloudShell.island.tsx +95 -10
  197. package/src/ssr/Layout.tsx +66 -10
  198. package/src/ssr/LayoutHeader.tsx +15 -9
  199. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  200. package/src/ssr/LayoutHelp.tsx +435 -274
  201. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
  202. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  203. package/src/ssr/LayoutRail.tsx +2 -2
  204. package/src/ssr/LayoutTabBar.tsx +29 -0
  205. package/src/ssr/MobileNavigation.tsx +2 -2
  206. package/src/ssr/PanelLayout.tsx +35 -0
  207. package/src/ssr/PanelRuntime.island.tsx +94 -0
  208. package/src/ssr/PanelSlot.tsx +158 -0
  209. package/src/ssr/PanelTrigger.tsx +49 -0
  210. package/src/ssr/ShellRuntime.island.tsx +69 -5
  211. package/src/ssr/app-appearance.ts +3 -2
  212. package/src/ssr/app-color.ts +105 -0
  213. package/src/ssr/app-launchpad.tsx +18 -1
  214. package/src/ssr/app-navigation.ts +28 -0
  215. package/src/ssr/compact-shell.baseline.json +5270 -0
  216. package/src/ssr/compact-shell.browser-harness.ts +439 -0
  217. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  218. package/src/ssr/help-messages.ts +4 -0
  219. package/src/ssr/help.ts +20 -0
  220. package/src/ssr/hints.ts +25 -0
  221. package/src/ssr/index.ts +5 -0
  222. package/src/ssr/islands/index.ts +4 -3
  223. package/src/ssr/layout-context.ts +2 -0
  224. package/src/ssr/layout-help-article.server.ts +50 -0
  225. package/src/ssr/layout-help-start.ts +33 -7
  226. package/src/ssr/panel-consent.ts +29 -0
  227. package/src/ssr/panel-context.ts +10 -0
  228. package/src/ssr/panel-slot.browser-harness.tsx +159 -0
  229. package/src/ssr/platform-messages.ts +48 -0
  230. package/src/ssr/rail-navigation.ts +44 -0
  231. package/src/ssr/shell-data.tsx +5 -0
  232. package/src/ssr/shell-shortcuts.ts +13 -2
  233. package/src/styles/effects.css +201 -11
  234. package/src/styles/file-chooser.css +2 -1
  235. package/src/styles/global.css +314 -63
  236. package/src/styles/panel.css +416 -0
  237. package/src/styles/resource-search.css +6 -2
  238. package/src/styles/tokens.css +36 -13
  239. package/src/styles/utilities-buttons.css +3 -2
  240. package/src/styles/utilities-data.css +2 -1
  241. package/src/styles/utilities-feedback.css +2 -2
  242. package/src/styles/utilities-layout.css +3 -13
  243. package/src/styles/utilities-markdown-editor.css +6 -6
  244. package/src/styles/utilities-navigation.css +22 -43
@@ -1,37 +1,14 @@
1
1
  import { CLOUD_CLI_MODULE_NAME } from "../cli/plugin";
2
2
  import { PlatformPermissionSchema } from "../contracts/outgoing-mail";
3
3
  import type { AppRegistryEntry } from "../contracts/registry";
4
+ import { isOwnAppPath, SAME_ORIGIN_PATH } from "./app-path";
4
5
 
5
6
  const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
6
7
  const isString = (value: unknown): value is string => typeof value === "string";
7
8
  const isStringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every(isString);
8
9
  const isStringRecord = (value: unknown): value is Record<string, string> => isRecord(value) && Object.values(value).every(isString);
9
10
 
10
- /** An absolute path on the current origin: one leading slash, no backslash or whitespace. */
11
- const SAME_ORIGIN_PATH = /^\/(?![\/\\])[^\\\s]*$/;
12
-
13
- /** A route as the gateway reads it: trimmed and without empty segments, so `//pwa/x/` is `/pwa/x`. */
14
- export const canonicalRoute = (route: string): string => {
15
- const trimmed = route.trim();
16
- return trimmed.startsWith("/") ? `/${trimmed.split("/").filter(Boolean).join("/")}` : trimmed;
17
- };
18
-
19
- /**
20
- * Whether a `nav.badge` path stays below one of the app's own `routes`. It is
21
- * checked on the path the browser actually requests, after dot segments, and
22
- * never starts with `//`, which a URL resolver reads as another host.
23
- */
24
- export const isOwnNavBadge = (badge: string, routes: readonly string[]): boolean => {
25
- if (!SAME_ORIGIN_PATH.test(badge)) return false;
26
- const path = new URL(badge, "https://cloud.invalid").pathname;
27
- return (
28
- !path.startsWith("//") &&
29
- routes.some((route) => {
30
- const prefix = canonicalRoute(route);
31
- return prefix === "/" || path === prefix || path.startsWith(`${prefix}/`);
32
- })
33
- );
34
- };
11
+ export { canonicalRoute, isOwnAppPath } from "./app-path";
35
12
 
36
13
  const invalid = (path: string, expected: string): string => `${path} must be ${expected}`;
37
14
 
@@ -69,6 +46,12 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
69
46
  if (!isRecord(translation)) return invalid(`presentation.translations.${locale}`, "an object");
70
47
  if (translation.name !== undefined && !isString(translation.name))
71
48
  return invalid(`presentation.translations.${locale}.name`, "a string");
49
+ if (
50
+ translation.panelLabel !== undefined &&
51
+ (!isString(translation.panelLabel) || !translation.panelLabel.trim() || value.panel === undefined)
52
+ ) {
53
+ return invalid(`presentation.translations.${locale}.panelLabel`, "a non-empty string for a declared panel");
54
+ }
72
55
  if (translation.description !== undefined && !isString(translation.description)) {
73
56
  return invalid(`presentation.translations.${locale}.description`, "a string");
74
57
  }
@@ -87,10 +70,22 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
87
70
  if (value.nav.requiresRoles !== undefined && !isStringArray(value.nav.requiresRoles)) {
88
71
  return invalid("nav.requiresRoles", "an array of strings");
89
72
  }
90
- if (value.nav.badge !== undefined && (!isString(value.nav.badge) || !isOwnNavBadge(value.nav.badge, value.routes))) {
73
+ if (value.nav.badge !== undefined && (!isString(value.nav.badge) || !isOwnAppPath(value.nav.badge, value.routes))) {
91
74
  return invalid("nav.badge", "a same-origin path below one of the entry's routes");
92
75
  }
93
76
  }
77
+ if (value.panel !== undefined) {
78
+ if (!isRecord(value.panel)) return invalid("panel", "an object");
79
+ if (!isString(value.panel.href) || !isOwnAppPath(value.panel.href, value.routes)) {
80
+ return invalid("panel.href", "a same-origin path below one of the entry's routes");
81
+ }
82
+ for (const field of ["label", "icon"] as const) {
83
+ if (!isString(value.panel[field]) || !value.panel[field].trim()) return invalid(`panel.${field}`, "a non-empty string");
84
+ }
85
+ if (value.panel.requiresRoles !== undefined && !isStringArray(value.panel.requiresRoles)) {
86
+ return invalid("panel.requiresRoles", "an array of strings");
87
+ }
88
+ }
94
89
  if (value.adminNav !== undefined) {
95
90
  if (
96
91
  !Array.isArray(value.adminNav) ||
@@ -58,6 +58,7 @@ export const buildRuntimeFromRegistry = (entries: AppRegistryEntry[], capabiliti
58
58
  badge: e.nav.badge,
59
59
  }
60
60
  : undefined,
61
+ panel: e.panel ? { ...e.panel, requiresRoles: e.panel.requiresRoles ? [...e.panel.requiresRoles] : undefined } : undefined,
61
62
  help: e.help ? { ...e.help } : undefined,
62
63
  searchTags,
63
64
  searchHelp: searchQueries?.map((query) => query.description).join(" "),
@@ -19,6 +19,7 @@ import { buildRuntimeFromRegistry } from "./runtime-context";
19
19
  const log = logger("runtime-watcher");
20
20
 
21
21
  let current: CloudRuntime | undefined;
22
+ let coreRelease: string | undefined;
22
23
  let initPromise: Promise<void> | undefined;
23
24
  let watcherTask: Promise<void> | undefined;
24
25
  let abort: AbortController | undefined;
@@ -27,8 +28,12 @@ const refresh = async () => {
27
28
  const apps = await listApps();
28
29
  const capabilities = await listCapabilities(apps);
29
30
  current = buildRuntimeFromRegistry(apps, capabilities);
31
+ coreRelease = apps.find((app) => app.id === "core")?.runtime?.release;
30
32
  };
31
33
 
34
+ /** Core's release from the live registry, absent before initialization and after shutdown. */
35
+ export const registeredCoreRelease = (): string | undefined => coreRelease;
36
+
32
37
  export const ensureRuntimeWatcher = (): Promise<void> => {
33
38
  // Concurrent callers (start() + first request) wait on the same init —
34
39
  // returning before `current` is populated would race getCurrentRuntime().
@@ -68,6 +73,7 @@ export const stopRuntimeWatcher = async (): Promise<void> => {
68
73
  watcherTask = undefined;
69
74
  initPromise = undefined;
70
75
  current = undefined;
76
+ coreRelease = undefined;
71
77
  };
72
78
 
73
79
  export const getCurrentRuntime = (): CloudRuntime => {
@@ -1,7 +1,43 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync, statSync } from "node:fs";
1
3
  import { resolve, sep } from "node:path";
2
4
  import type { Context } from "hono";
3
5
 
4
6
  const publicRoot = resolve(process.cwd(), "public");
7
+ const versions = new Map<string, { size: number; mtimeMs: number; version: string }>();
8
+
9
+ /**
10
+ * Version of Core's shared stylesheets: the release, which names one build. `local` and `development` are the
11
+ * defaults of unnamed builds and name many, so those sheets get no version and are revalidated instead.
12
+ */
13
+ export function sharedStylesheetVersion(release: string | undefined): string | undefined {
14
+ return !release || release === "local" || release === "development" ? undefined : release;
15
+ }
16
+
17
+ /**
18
+ * Content version of a file below `public/`: hashed on first use and re-hashed only when the file's size or
19
+ * modification time changes; a missing or unreadable file has no version.
20
+ */
21
+ export function publicFileVersion(pathname: string): string | undefined {
22
+ const path = resolvePublicAsset(pathname);
23
+ if (!path) return undefined;
24
+ try {
25
+ const stat = statSync(path, { throwIfNoEntry: false });
26
+ if (!stat?.isFile()) return undefined;
27
+ const cached = versions.get(path);
28
+ if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return cached.version;
29
+ const version = createHash("sha256").update(readFileSync(path)).digest("hex").slice(0, 16);
30
+ versions.set(path, { size: stat.size, mtimeMs: stat.mtimeMs, version });
31
+ return version;
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+
37
+ /** Append a URL-encoded version only when the asset has one. */
38
+ export function versionedPublicHref(pathname: string, version: string | undefined): string {
39
+ return version === undefined ? pathname : `${pathname}?v=${encodeURIComponent(version)}`;
40
+ }
5
41
 
6
42
  function decodePathname(pathname: string): string | null {
7
43
  try {
@@ -48,14 +84,15 @@ async function encodedFile(path: string, encoding: "br" | "gzip"): Promise<Bun.B
48
84
  return (await file.exists()) ? file : null;
49
85
  }
50
86
 
51
- export function servePublicAsset(isDevelopment: boolean) {
87
+ export function servePublicAsset(isDevelopment: boolean, sharedVersion?: string) {
52
88
  return async (c: Context) => {
53
89
  if (c.req.method !== "GET" && c.req.method !== "HEAD") {
54
90
  c.header("Allow", "GET, HEAD");
55
91
  return c.text("Method Not Allowed", 405);
56
92
  }
57
93
 
58
- const path = resolvePublicAsset(new URL(c.req.url).pathname);
94
+ const url = new URL(c.req.url);
95
+ const path = resolvePublicAsset(url.pathname);
59
96
  if (!path) return c.notFound();
60
97
 
61
98
  const sourceFile = Bun.file(path);
@@ -84,8 +121,20 @@ export function servePublicAsset(isDevelopment: boolean) {
84
121
  // Development rebuilds files in place under the same address, so the browser asks again on every use; an
85
122
  // unchanged file then costs one round trip instead of its whole size on every page.
86
123
  const etag = `"${selectedEncoding ?? "identity"}-${selected.size.toString(36)}-${selected.lastModified.toString(36)}"`;
124
+ // The page head links these files with the version this process names for them. Any other version may name
125
+ // other content (a rolling deploy, an app started before a Core redeploy), so it is revalidated, never kept.
126
+ let cacheControl = isDevelopment ? "no-cache" : "public, max-age=31536000, immutable";
127
+ if (!isDevelopment) {
128
+ const pathname = decodePathname(url.pathname)!;
129
+ const shared = /^\/public\/(fonts|tabler-icons|global)\.css$/.test(pathname);
130
+ const own = /^\/public\/[^/]+\/(app\.css|favicon\.svg)$/.test(pathname);
131
+ if (shared || own) {
132
+ const expected = shared ? sharedVersion : publicFileVersion(url.pathname);
133
+ if (expected === undefined || url.searchParams.get("v") !== expected) cacheControl = "no-cache";
134
+ }
135
+ }
87
136
  const headers = new Headers({
88
- "Cache-Control": isDevelopment ? "no-cache" : "public, max-age=31536000, immutable",
137
+ "Cache-Control": cacheControl,
89
138
  ETag: etag,
90
139
  Vary: "Accept-Encoding",
91
140
  });
@@ -2,6 +2,7 @@ import type { HtmlFn, RenderFn } from "@k2b/ssr";
2
2
  import { createSSRHandler } from "@k2b/ssr/hono";
3
3
  import type { Context, Env, MiddlewareHandler, TypedResponse } from "hono";
4
4
  import type { StatusCode } from "hono/utils/http-status";
5
+ import type { PanelConsentSnapshot } from "../contracts/panel-context";
5
6
  import type { RailSnapshot } from "../contracts/rail-preferences";
6
7
  import type { SettingsFlat, Unflatten } from "../contracts/settings-types";
7
8
  import type { User } from "../contracts/shared";
@@ -13,6 +14,7 @@ type PageEnv<T extends object> = {
13
14
  page: Partial<T>;
14
15
  user?: User;
15
16
  railPreferences?: RailSnapshot;
17
+ panelConsent?: PanelConsentSnapshot;
16
18
  settings?: Unflatten<SettingsFlat<typeof CORE_SETTINGS>>;
17
19
  };
18
20
  };
@@ -1,46 +1,65 @@
1
1
  import { LocaleProvider } from "@k2b/ui";
2
+ import { createSignal, onCleanup, Show } from "solid-js";
2
3
  import { render } from "solid-js/web";
4
+ import type { AccessEntry } from "../contracts/shared";
3
5
  import PermissionEditor from "./PermissionEditor";
4
6
 
5
7
  const host = document.getElementById("root");
6
8
  if (!host) throw new Error("Missing harness root");
7
- render(
8
- () => (
9
+ render(() => {
10
+ let saved: AccessEntry[] = ["Mara Lind", "Lea Sommer", "Jonas Weber"].map((displayName, index) => ({
11
+ id: `access-${index}`,
12
+ principal: { type: "user", userId: `user-${index}` },
13
+ displayName,
14
+ permission: "read",
15
+ createdAt: "2026-10-15T07:00:00.000Z",
16
+ }));
17
+ const [data, setData] = createSignal(saved);
18
+ const refetch = () => {
19
+ // Like a consumer's query: fresh objects contain the saved level and another person's change.
20
+ saved = saved.map((entry, index) => ({
21
+ ...entry,
22
+ principal: { ...entry.principal },
23
+ ...(index === 2 ? { permission: "write" as const } : {}),
24
+ }));
25
+ setData(saved);
26
+ };
27
+ document.addEventListener("refetch-access", refetch);
28
+ onCleanup(() => document.removeEventListener("refetch-access", refetch));
29
+ return (
9
30
  <LocaleProvider locale="en">
10
- <PermissionEditor
11
- initialEntries={["Mara Lind", "Lea Sommer", "Jonas Weber"].map((displayName, index) => ({
12
- id: `access-${index}`,
13
- principal: { type: "user", userId: `user-${index}` },
14
- displayName,
15
- permission: "read",
16
- createdAt: "2026-10-15T07:00:00.000Z",
17
- }))}
18
- // Equal-width labels isolate pending-state layout from a normal change in label width.
19
- allowedLevels={[
20
- { level: "read", label: "Level 1" },
21
- { level: "write", label: "Level 2" },
22
- ]}
23
- allowAuthenticated={false}
24
- grantAccess={async () => {
25
- throw new Error("Not used");
26
- }}
27
- updateAccess={() =>
28
- new Promise<void>((resolve) => {
29
- // The test waits for this mark, so it does not depend on how the editor shows a pending change.
30
- document.documentElement.dataset.saving = "true";
31
- document.addEventListener(
32
- "settle-access",
33
- () => {
34
- delete document.documentElement.dataset.saving;
35
- resolve();
36
- },
37
- { once: true },
38
- );
39
- })
40
- }
41
- revokeAccess={async () => {}}
42
- />
31
+ <Show when={data()}>
32
+ {(entries) => (
33
+ <PermissionEditor
34
+ initialEntries={entries()}
35
+ // Equal-width labels isolate pending-state layout from a normal change in label width.
36
+ allowedLevels={[
37
+ { level: "read", label: "Level 1" },
38
+ { level: "write", label: "Level 2" },
39
+ ]}
40
+ allowAuthenticated={false}
41
+ grantAccess={async () => {
42
+ throw new Error("Not used");
43
+ }}
44
+ updateAccess={(id, permission) =>
45
+ new Promise<void>((resolve) => {
46
+ // Existing tests use this mark and event to control the save.
47
+ document.documentElement.dataset.saving = "true";
48
+ document.addEventListener(
49
+ "settle-access",
50
+ () => {
51
+ saved = saved.map((entry) => (entry.id === id ? { ...entry, permission } : entry));
52
+ delete document.documentElement.dataset.saving;
53
+ resolve();
54
+ },
55
+ { once: true },
56
+ );
57
+ })
58
+ }
59
+ revokeAccess={async () => {}}
60
+ />
61
+ )}
62
+ </Show>
43
63
  </LocaleProvider>
44
- ),
45
- host,
46
- );
64
+ );
65
+ }, host);
@@ -1,6 +1,6 @@
1
1
  import { mutation } from "@k2b/stdlib/solid";
2
2
  import { announce, IconButton, Placeholder, prompts, SelectChip, Tooltip, useLocale } from "@k2b/ui";
3
- import { createMemo, createSignal, For, onCleanup, Show } from "solid-js";
3
+ import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";
4
4
  import { createStore, reconcile } from "solid-js/store";
5
5
  import { CloudAvatar } from "../account/Avatar";
6
6
  import type { AccessEntry, PermissionLevel, Principal, ServiceAccountKind } from "../contracts/shared";
@@ -27,9 +27,9 @@ export type GrantableLevel = Exclude<PermissionLevel, "none">;
27
27
  export type AllowedLevel = GrantableLevel | { level: GrantableLevel; scope?: string; label?: string; icon?: string; description?: string };
28
28
 
29
29
  type PermissionEditorProps = {
30
- /** Initial access entries — caller stays the source of truth for
31
- * what's stored on the resource; the editor updates its local copy
32
- * after successful mutations. */
30
+ /** The editor mounts from this list and merges later lists by entry id once no change is saving.
31
+ * Rows the caller changed since its last list win; unchanged rows keep the editor's confirmed result.
32
+ * Keep the editor mounted (no keyed Show or unmount during refresh) and pass refetched data. */
33
33
  initialEntries: AccessEntry[];
34
34
 
35
35
  /** Whether the current user can edit permissions. When `false`, the
@@ -179,7 +179,9 @@ export default function PermissionEditor(props: PermissionEditorProps) {
179
179
  const t = () => accessMessages.resolve([locale()]).t;
180
180
  // A store keeps each row's identity across level changes, so an expanded member list stays open. It holds
181
181
  // copies: store writes go into the objects themselves, and the caller's entries are not the editor's to change.
182
- const [entries, setEntries] = createStore<AccessEntry[]>(props.initialEntries.map((entry) => ({ ...entry })));
182
+ const initialEntries = props.initialEntries;
183
+ let appliedCallerEntries = new Map(initialEntries.map((entry) => [entry.id, JSON.stringify(entry)]));
184
+ const [entries, setEntries] = createStore<AccessEntry[]>(initialEntries.map((entry) => ({ ...entry })));
183
185
  const [pending, setPending] = createSignal<string | null>(null);
184
186
  const grantPending = () => pending() !== null;
185
187
  let disposed = false;
@@ -281,6 +283,29 @@ export default function PermissionEditor(props: PermissionEditorProps) {
281
283
  },
282
284
  });
283
285
  const busy = () => grantPending() || updateMut.loading() || revokeMut.loading();
286
+ createEffect(() => {
287
+ const next = props.initialEntries;
288
+ const nextCallerEntries = new Map(next.map((entry) => [entry.id, JSON.stringify(entry)]));
289
+ // Mutation loading clears before onSuccess; wait for its local writes and onFinally too.
290
+ if (busy() || updating() !== null || revoking() !== null) return;
291
+ if (
292
+ nextCallerEntries.size === appliedCallerEntries.size &&
293
+ next.every((entry) => nextCallerEntries.get(entry.id) === appliedCallerEntries.get(entry.id))
294
+ )
295
+ return;
296
+ // Rows the caller changed since its last list win; unchanged rows keep the editor's confirmed result.
297
+ const localEntries = new Map(entries.map((entry) => [entry.id, entry]));
298
+ const merged: AccessEntry[] = [];
299
+ for (const entry of next) {
300
+ const row = nextCallerEntries.get(entry.id) !== appliedCallerEntries.get(entry.id) ? entry : localEntries.get(entry.id);
301
+ if (row) merged.push({ ...row });
302
+ }
303
+ for (const entry of entries) {
304
+ if (!nextCallerEntries.has(entry.id) && !appliedCallerEntries.has(entry.id)) merged.push({ ...entry });
305
+ }
306
+ setEntries(reconcile(merged, { key: "id" }));
307
+ appliedCallerEntries = nextCallerEntries;
308
+ });
284
309
  // The service refuses to remove the last manager; the row says so up front.
285
310
  const lastManagerId = createMemo(() => {
286
311
  // A grant on part of the resource never manages it.
@@ -400,7 +425,7 @@ function AccessEntryRow(props: {
400
425
  <Show
401
426
  when={props.entry.principal.type === "user"}
402
427
  fallback={
403
- <div class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
428
+ <div class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-secondary dark:bg-zinc-700">
404
429
  <i class={`ti ${getPrincipalIcon(props.entry, t())} text-sm`} />
405
430
  </div>
406
431
  }
@@ -484,7 +509,7 @@ function AccessEntryRow(props: {
484
509
  aria-label={t().remove({ name: displayName() })}
485
510
  // A disabled button takes no focus and touch has no hover, so the reason also reaches assistive technology.
486
511
  aria-description={props.lastManager ? removeLabel() : undefined}
487
- class="focus-ui flex h-7 w-7 items-center justify-center rounded text-zinc-400 transition-colors focus:opacity-100 enabled:hover:bg-red-500/[0.08] enabled:hover:text-red-600 dark:enabled:hover:text-red-400"
512
+ class="focus-ui flex h-7 w-7 items-center justify-center rounded text-dimmed transition-colors focus:opacity-100 enabled:hover:bg-red-500/[0.08] enabled:hover:text-red-600 dark:enabled:hover:text-red-400"
488
513
  >
489
514
  <i class="ti ti-x text-sm" />
490
515
  </IconButton>
@@ -176,7 +176,11 @@ export default function ResourceApiKeys(props: ResourceApiKeysProps) {
176
176
  const createMutation = mutations.create<{ credential: ResourceApiKey; token: string }, CreateResourceApiKeyInput>({
177
177
  mutation: props.createKey,
178
178
  onSuccess: async (data) => {
179
- setKeys([data.credential, ...keys()]);
179
+ setKeys((current) =>
180
+ current.some((key) => key.id === data.credential.id)
181
+ ? current.map((key) => (key.id === data.credential.id ? data.credential : key))
182
+ : [data.credential, ...current],
183
+ );
180
184
  await prompts.dialog<void>(() => <TokenDialog token={data.token} />, {
181
185
  title: t().keyCreated,
182
186
  icon: "ti ti-key",
@@ -350,7 +350,7 @@ const ResultRow = (props: {
350
350
  <Show
351
351
  when={props.avatar}
352
352
  fallback={
353
- <div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-xs text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
353
+ <div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-xs text-secondary dark:bg-zinc-700">
354
354
  <i class={`ti ${props.icon} text-sm`} />
355
355
  </div>
356
356
  }
@@ -0,0 +1,101 @@
1
+ import type {
2
+ CapabilityActionCallReview,
3
+ CapabilityActionPolicy,
4
+ CapabilityActionReceipt,
5
+ CapabilityActionReview,
6
+ CapabilityActionReviewData,
7
+ CapabilitySemanticLink,
8
+ } from "../contracts/capabilities";
9
+
10
+ /**
11
+ * What the chat stores about one Action call so that a reload shows the same question and receipt even after the app
12
+ * changed: the app's per-call review as it was when Cloud asked, or, for an app that still returns the old review shape,
13
+ * that review as literal text. Pure and browser-safe: the server stores it, the chat renders it.
14
+ */
15
+
16
+ /** One old review detail: a label and its value, kept complete. */
17
+ export type AiLegacyReviewRow = { label: string; value: string; format?: "date" | "date-time" };
18
+
19
+ /**
20
+ * An old review (`message`, `details`, `links`) kept as literal text, never as a template: the message is the question,
21
+ * every inline or plain-text detail is a row without the 12-row bound, Markdown details are one Markdown block, and
22
+ * `summary` is the old success summary used as the receipt text.
23
+ */
24
+ export type AiLegacyActionReview = {
25
+ legacy: {
26
+ question: string;
27
+ rows: AiLegacyReviewRow[];
28
+ markdown?: string;
29
+ summary?: string;
30
+ links?: CapabilitySemanticLink[];
31
+ };
32
+ };
33
+
34
+ /** A new review, with `ask` filled from the declared policy when the call itself names no reason. */
35
+ export type AiStoredActionReview = CapabilityActionCallReview | AiLegacyActionReview;
36
+
37
+ export const isLegacyActionReview = (review: AiStoredActionReview): review is AiLegacyActionReview => "legacy" in review;
38
+
39
+ /** The old review shape as stored literal text. */
40
+ export const legacyActionReview = (review: CapabilityActionReview): AiLegacyActionReview => {
41
+ const details = review.details ?? [];
42
+ // One Markdown block, each detail under its own label, so a reader keeps knowing which text belongs where.
43
+ const markdown = details
44
+ .filter((detail) => detail.format === "markdown" && detail.value.trim())
45
+ .map((detail) => `**${detail.label.replace(/([\\*_`[\]])/g, "\\$1")}**\n\n${detail.value}`)
46
+ .join("\n\n");
47
+ return {
48
+ legacy: {
49
+ question: review.message,
50
+ rows: details
51
+ .filter((detail) => detail.format !== "markdown")
52
+ .map((detail) => ({
53
+ label: detail.label,
54
+ value: detail.value,
55
+ ...(detail.format === "date" || detail.format === "date-time" ? { format: detail.format } : {}),
56
+ })),
57
+ ...(markdown ? { markdown } : {}),
58
+ ...(review.links?.length ? { links: review.links } : {}),
59
+ },
60
+ };
61
+ };
62
+
63
+ /** The review Cloud stores and shows for one call. */
64
+ export const storedActionReview = (review: CapabilityActionReviewData, policy?: CapabilityActionPolicy | null): AiStoredActionReview => {
65
+ if (!("sentence" in review)) return legacyActionReview(review);
66
+ if (review.ask || !policy || !("ask" in policy)) return review;
67
+ return { ...review, ask: policy.ask };
68
+ };
69
+
70
+ /**
71
+ * The app's `check` Query after an outcome Cloud could not see. `checking` while it runs; `done` and `not_done` settle
72
+ * the call; `pending` means the app does not know yet; `failed` means the check itself gave no usable answer.
73
+ */
74
+ export type AiActionCheckState = "checking" | "done" | "not_done" | "pending" | "failed";
75
+ export type AiActionCheck = { state: AiActionCheckState; receipt?: CapabilityActionReceipt; at?: string };
76
+
77
+ /** A settled outcome survives an open response; otherwise prefer the later timestamp, or incoming without both. */
78
+ export const preferredActionCheck = (current: AiActionCheck | undefined, incoming: AiActionCheck): AiActionCheck => {
79
+ if (!current) return incoming;
80
+ const currentSettled = current.state === "done" || current.state === "not_done";
81
+ const incomingSettled = incoming.state === "done" || incoming.state === "not_done";
82
+ if (currentSettled !== incomingSettled) return currentSettled ? current : incoming;
83
+ return current.at && incoming.at && Date.parse(current.at) > Date.parse(incoming.at) ? current : incoming;
84
+ };
85
+
86
+ /** The framework's unknown-outcome codes, in persisted `CODE: message` or structured error results. */
87
+ export const isUnknownActionOutcome = (result: unknown): boolean => {
88
+ const code =
89
+ typeof result === "string"
90
+ ? /^([A-Z][A-Z0-9_]{2,}):/.exec(result.trim())?.[1]
91
+ : result !== null && typeof result === "object" && "code" in result && typeof result.code === "string"
92
+ ? result.code
93
+ : undefined;
94
+ return code === "ACTION_OUTCOME_UNKNOWN" || code === "IDEMPOTENCY_UNCERTAIN" || code === "IDEMPOTENCY_IN_PROGRESS";
95
+ };
96
+
97
+ /**
98
+ * A `checking` claim older than this belonged to a worker or request that stopped and is open again: twice a Query's
99
+ * 15-second budget, plus a margin. Core takes such a claim over, and the card offers to check again.
100
+ */
101
+ export const AI_TOOL_CHECK_STALE_MS = 2 * 15_000 + 30_000;
@@ -56,7 +56,7 @@ type AiApprovalRouteDependencies = {
56
56
  limit?: MiddlewareHandler<AuthContext>;
57
57
  authenticate?: MiddlewareHandler<AuthContext>;
58
58
  listPreferences?: (actorUserId: string, options: { conversationId?: string }) => Promise<AiToolApprovalPreference[]>;
59
- revokePreference?: (actorUserId: string, preferenceId: string) => Promise<boolean>;
59
+ revokePreference?: typeof revokeAiToolApprovalPreference;
60
60
  listCapabilities?: () => Promise<CapabilityRegistryEntry[]>;
61
61
  /** Resolves a chat the user owns by its ID or short ID. */
62
62
  findConversation?: (ownerUserId: string, conversation: string) => Promise<{ id: string } | null>;
@@ -157,7 +157,10 @@ export const createAiApprovalPreferenceRoutes = (dependencies: AiApprovalRouteDe
157
157
  v("param", AiApprovalPreferenceParamSchema),
158
158
  async (c) => {
159
159
  const user = expectUserBackedActor(c);
160
- const deleted = await revokePreference(user.id, c.req.valid("param").preferenceId);
160
+ const deleted = await revokePreference(user.id, c.req.valid("param").preferenceId, {
161
+ actor: c.get("actor"),
162
+ requestId: c.req.header("x-request-id") ?? null,
163
+ });
161
164
  return deleted ? c.json({ deleted: true as const }) : c.json({ message: "Approval not found", code: "NOT_FOUND" }, 404);
162
165
  },
163
166
  );