@k2b/cloud 0.37.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/package.json +8 -3
  2. package/scripts/build-canvas-workers.ts +109 -29
  3. package/scripts/build.ts +3 -5
  4. package/src/_internal/app-path.ts +25 -0
  5. package/src/_internal/app-presentation.ts +4 -0
  6. package/src/_internal/canvas-worker.ts +5 -3
  7. package/src/_internal/define-app.ts +28 -2
  8. package/src/_internal/help-catalog.ts +123 -4
  9. package/src/_internal/help.ts +27 -6
  10. package/src/_internal/panel-cookie.ts +39 -0
  11. package/src/_internal/registry-validation.ts +21 -26
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  14. package/src/access/PermissionEditor.tsx +30 -5
  15. package/src/access/ResourceApiKeys.tsx +5 -1
  16. package/src/account/Avatar.tsx +20 -2
  17. package/src/account/ui.ts +1 -1
  18. package/src/ai/approval-routes.ts +5 -2
  19. package/src/ai/approvals.ts +139 -28
  20. package/src/ai/browser.ts +11 -0
  21. package/src/ai/chart-block.ts +44 -13
  22. package/src/ai/chat/action-sentences.ts +1 -44
  23. package/src/ai/chat/chart-block.tsx +2 -11
  24. package/src/ai/chat/messages.ts +10 -0
  25. package/src/ai/chat/turn-view.tsx +35 -3
  26. package/src/ai/chat/view-block.tsx +7 -2
  27. package/src/ai/chat/web-tools.tsx +23 -6
  28. package/src/ai/chat-task-contracts.ts +28 -3
  29. package/src/ai/chat-task-reasons-migrate.ts +68 -0
  30. package/src/ai/chat-task-reasons.ts +92 -0
  31. package/src/ai/chat-tasks.ts +460 -66
  32. package/src/ai/default-tool-contracts.ts +30 -13
  33. package/src/ai/fetch-file-tool.ts +37 -3
  34. package/src/ai/index.ts +12 -0
  35. package/src/ai/migrate.ts +46 -3
  36. package/src/ai/readable-html.ts +1057 -0
  37. package/src/ai/request-context.ts +7 -1
  38. package/src/ai/routes.ts +27 -1
  39. package/src/ai/runtime.ts +12 -27
  40. package/src/ai/store.ts +5 -3
  41. package/src/ai/timeline.ts +9 -1
  42. package/src/ai/tool-wording.ts +45 -0
  43. package/src/ai/turn-abort.ts +33 -0
  44. package/src/ai/types.ts +12 -1
  45. package/src/api/admin-core-settings.ts +190 -139
  46. package/src/api/admin-outgoing-mail.ts +4 -2
  47. package/src/api/app-approval.ts +9 -2
  48. package/src/api/approvals.ts +1 -1
  49. package/src/api/help.ts +23 -4
  50. package/src/api/index.ts +2 -1
  51. package/src/api/mcp.ts +8 -2
  52. package/src/browser/CloudResourceSearch.tsx +10 -6
  53. package/src/browser/FileChooser.tsx +11 -3
  54. package/src/browser/app-approval.ts +7 -2
  55. package/src/browser/choose-files.browser-harness.tsx +4 -1
  56. package/src/browser/composer-catalog.ts +103 -0
  57. package/src/browser/composer-insert-messages.ts +31 -0
  58. package/src/browser/composer-insert.ts +150 -0
  59. package/src/browser/file-chooser-messages.ts +4 -0
  60. package/src/browser/file-providers.ts +14 -1
  61. package/src/browser/panel-messages.ts +24 -0
  62. package/src/browser/panel-slot.ts +354 -0
  63. package/src/browser/panel.ts +28 -0
  64. package/src/browser/provider-folder.tsx +58 -5
  65. package/src/browser/resource-picker.tsx +17 -2
  66. package/src/cli/admin/workflows.ts +1 -0
  67. package/src/config/env.ts +14 -0
  68. package/src/contracts/app-approval.ts +49 -9
  69. package/src/contracts/app.ts +14 -0
  70. package/src/contracts/file-provider.ts +48 -1
  71. package/src/contracts/outgoing-mail.ts +8 -1
  72. package/src/contracts/registry.ts +3 -1
  73. package/src/contracts/shared.ts +2 -0
  74. package/src/server/help.ts +136 -10
  75. package/src/server/services/access.ts +5 -1
  76. package/src/server/services/geo.ts +21 -4
  77. package/src/services/accounts/posix.ts +2 -1
  78. package/src/services/allowed-network-fetch.ts +189 -0
  79. package/src/services/app-approval.ts +158 -38
  80. package/src/services/approvals/device.ts +2 -0
  81. package/src/services/help/index.ts +46 -5
  82. package/src/services/help/store.ts +26 -5
  83. package/src/services/help/types.ts +10 -2
  84. package/src/services/index.ts +22 -3
  85. package/src/services/notifications/browser.ts +16 -5
  86. package/src/services/outgoing-mail/dispatcher.ts +7 -2
  87. package/src/services/outgoing-mail/resolve-host.ts +39 -0
  88. package/src/services/outgoing-mail/test-send.ts +18 -3
  89. package/src/services/outgoing-mail/transport.ts +24 -13
  90. package/src/services/pdf/image-protocol.ts +135 -0
  91. package/src/services/pdf/image-variants.ts +124 -0
  92. package/src/services/pdf/image-worker.ts +31 -0
  93. package/src/services/pdf/image.ts +109 -0
  94. package/src/services/pdf/index.ts +9 -0
  95. package/src/services/settings/api.ts +11 -6
  96. package/src/services/settings/app.ts +5 -4
  97. package/src/services/settings/core-settings.ts +4 -3
  98. package/src/services/settings/index.ts +67 -43
  99. package/src/services/settings/namespace.ts +2 -1
  100. package/src/services/settings/store.ts +83 -11
  101. package/src/shared/ai-platform-prompt.ts +1 -1
  102. package/src/shared/app-presentation.ts +1 -0
  103. package/src/shared/help.ts +41 -0
  104. package/src/shared/index.ts +9 -1
  105. package/src/shared/markdown/extensions/guided-help.ts +208 -64
  106. package/src/shared/markdown/formula.ts +8 -8
  107. package/src/shared/markdown/index.ts +72 -11
  108. package/src/shared/redirect.ts +12 -0
  109. package/src/ssr/CloudShell.island.tsx +86 -9
  110. package/src/ssr/Layout.tsx +62 -11
  111. package/src/ssr/LayoutHeader.tsx +15 -9
  112. package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
  113. package/src/ssr/LayoutHelp.tsx +569 -410
  114. package/src/ssr/LayoutRail.tsx +2 -2
  115. package/src/ssr/LayoutTabBar.tsx +29 -0
  116. package/src/ssr/PanelLayout.tsx +27 -0
  117. package/src/ssr/PanelRuntime.island.tsx +87 -0
  118. package/src/ssr/PanelSlot.tsx +108 -0
  119. package/src/ssr/PanelTrigger.tsx +49 -0
  120. package/src/ssr/ShellRuntime.island.tsx +72 -25
  121. package/src/ssr/app-appearance.ts +3 -2
  122. package/src/ssr/app-color.ts +100 -0
  123. package/src/ssr/app-launchpad.tsx +18 -1
  124. package/src/ssr/app-navigation.ts +15 -0
  125. package/src/ssr/compact-shell.baseline.json +5270 -0
  126. package/src/ssr/compact-shell.browser-harness.ts +431 -0
  127. package/src/ssr/help-messages.ts +60 -38
  128. package/src/ssr/help.ts +3 -0
  129. package/src/ssr/index.ts +3 -0
  130. package/src/ssr/layout-help-article.ts +41 -0
  131. package/src/ssr/layout-help-navigation.ts +0 -12
  132. package/src/ssr/layout-help-registry.tsx +4 -11
  133. package/src/ssr/layout-help-search.ts +9 -12
  134. package/src/ssr/layout-help-start.ts +68 -0
  135. package/src/ssr/panel-slot.browser-harness.tsx +110 -0
  136. package/src/ssr/platform-messages.ts +34 -0
  137. package/src/ssr/rail-navigation.ts +44 -0
  138. package/src/ssr/shell-data.tsx +11 -0
  139. package/src/ssr/shell-shortcuts.ts +19 -0
  140. package/src/styles/effects.css +689 -185
  141. package/src/styles/global.css +269 -55
  142. package/src/styles/panel.css +373 -0
  143. package/src/styles/resource-search.css +25 -0
  144. package/src/styles/tokens.css +23 -6
  145. package/src/styles/utilities-navigation.css +5 -21
  146. package/src/workflows/store/events.ts +24 -0
  147. package/src/workflows/store/index.ts +8 -0
  148. package/src/workflows/store/observability.ts +6 -1
  149. package/src/workflows/store/retention.ts +157 -0
@@ -0,0 +1,39 @@
1
+ export const PANEL_COOKIE = "cloud_panel";
2
+ export const PANEL_MIN_WIDTH_REM = 20;
3
+ export const PANEL_MAX_WIDTH_REM = 36;
4
+
5
+ export type PanelCookieState = { open: boolean; provider?: string; widthRem?: number };
6
+ const clampWidth = (value: number) => Math.min(PANEL_MAX_WIDTH_REM, Math.max(PANEL_MIN_WIDTH_REM, value));
7
+
8
+ export function readPanelCookie(cookieHeader: string | null | undefined, providers: readonly string[]): PanelCookieState {
9
+ const value = cookieHeader
10
+ ?.split(";")
11
+ .map((part) => part.trim())
12
+ .find((part) => part.startsWith(`${PANEL_COOKIE}=`))
13
+ ?.slice(PANEL_COOKIE.length + 1);
14
+ if (value === undefined) return { open: false };
15
+ const parts = value.split(":");
16
+ const [open, encodedProvider, width] = parts;
17
+ if (
18
+ parts.length !== 3 ||
19
+ (open !== "0" && open !== "1") ||
20
+ encodedProvider === undefined ||
21
+ width === undefined ||
22
+ (width !== "" && !/^\d{1,2}(\.\d{1,2})?$/.test(width))
23
+ )
24
+ return { open: false };
25
+ let provider: string;
26
+ try {
27
+ provider = decodeURIComponent(encodedProvider);
28
+ if (encodeURIComponent(provider) !== encodedProvider) return { open: false };
29
+ } catch {
30
+ return { open: false };
31
+ }
32
+ if ((provider && !providers.includes(provider)) || (open === "1" && !provider)) return { open: false };
33
+ return { open: open === "1", ...(provider ? { provider } : {}), ...(width ? { widthRem: clampWidth(Number(width)) } : {}) };
34
+ }
35
+
36
+ export function serializePanelCookie(state: PanelCookieState, secure: boolean): string {
37
+ const width = state.widthRem !== undefined && Number.isFinite(state.widthRem) ? Math.round(clampWidth(state.widthRem) * 100) / 100 : "";
38
+ return `${PANEL_COOKIE}=${state.open && state.provider ? "1" : "0"}:${encodeURIComponent(state.provider ?? "")}:${width}; Path=/; Max-Age=31536000; SameSite=Lax${secure ? "; Secure" : ""}`;
39
+ }
@@ -1,37 +1,14 @@
1
1
  import { CLOUD_CLI_MODULE_NAME } from "../cli/plugin";
2
2
  import { PlatformPermissionSchema } from "../contracts/outgoing-mail";
3
3
  import type { AppRegistryEntry } from "../contracts/registry";
4
+ import { isOwnAppPath, SAME_ORIGIN_PATH } from "./app-path";
4
5
 
5
6
  const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
6
7
  const isString = (value: unknown): value is string => typeof value === "string";
7
8
  const isStringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every(isString);
8
9
  const isStringRecord = (value: unknown): value is Record<string, string> => isRecord(value) && Object.values(value).every(isString);
9
10
 
10
- /** 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(" "),
@@ -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.
@@ -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",
@@ -1,10 +1,14 @@
1
1
  import { Avatar, type AvatarProps } from "@k2b/ui";
2
2
  import type { JSX } from "solid-js";
3
3
 
4
- export type CloudAvatarProps = Omit<AvatarProps, "name" | "src"> & {
4
+ /** Who an avatar stands for; agents and service accounts are square, people round. */
5
+ export type CloudAvatarKind = "user" | "service_account" | "system";
6
+
7
+ export type CloudAvatarProps = Omit<AvatarProps, "name" | "src" | "shape" | "icon"> & {
5
8
  username: string;
6
9
  userId?: string | null;
7
10
  avatarHash?: string | null;
11
+ kind?: CloudAvatarKind;
8
12
  };
9
13
 
10
14
  const avatarSource = (userId?: string | null, avatarHash?: string | null): string | undefined => {
@@ -12,14 +16,28 @@ const avatarSource = (userId?: string | null, avatarHash?: string | null): strin
12
16
  return `/api/accounts/users/${encodeURIComponent(userId)}/avatar?rev=${encodeURIComponent(avatarHash)}`;
13
17
  };
14
18
 
19
+ /** The URL of a person's uploaded picture, or undefined when there is none. */
20
+ export const cloudAvatarSource = avatarSource;
21
+
22
+ const KIND_ICON: Record<CloudAvatarKind, string | undefined> = {
23
+ user: undefined,
24
+ service_account: "ti ti-robot",
25
+ system: "ti ti-settings-automation",
26
+ };
27
+
28
+ /** One avatar for every Cloud surface: the uploaded picture when there is one, otherwise initials. */
15
29
  export function CloudAvatar(props: CloudAvatarProps): JSX.Element {
30
+ const kind = () => props.kind ?? "user";
16
31
  return (
17
32
  <Avatar
18
33
  name={props.username}
19
- src={avatarSource(props.userId, props.avatarHash)}
34
+ src={kind() === "user" ? avatarSource(props.userId, props.avatarHash) : undefined}
35
+ icon={KIND_ICON[kind()]}
36
+ shape={kind() === "user" ? "circle" : "square"}
20
37
  alt={props.alt}
21
38
  fallback={props.fallback}
22
39
  size={props.size}
40
+ ring={props.ring}
23
41
  loading={props.loading}
24
42
  class={props.class}
25
43
  style={props.style}
package/src/account/ui.ts CHANGED
@@ -1,3 +1,3 @@
1
- export { CloudAvatar, type CloudAvatarProps } from "./Avatar";
1
+ export { CloudAvatar, type CloudAvatarKind, type CloudAvatarProps, cloudAvatarSource } from "./Avatar";
2
2
  export { type AvatarUploadDialogOptions, openAvatarUploadDialog } from "./avatar-upload-dialog";
3
3
  export { default as EntitySearch, type EntitySearchPrincipal } from "./EntitySearch";
@@ -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
  );
@@ -1,4 +1,8 @@
1
- import { sql } from "bun";
1
+ import { type SQL, sql } from "bun";
2
+ import type { AccessSubject } from "../contracts/shared";
3
+ import type { RequestActor } from "../server/middleware/auth";
4
+ import { type AuditActor, type AuditRecordParams, audit } from "../services/audit";
5
+ import { toPgTextArray } from "../services/postgres";
2
6
  import type { AiToolApprovalPolicy, AiTurnRunConfig } from "./types";
3
7
 
4
8
  /** Background authority never inherits a user's interactive Always Allow preference. */
@@ -31,6 +35,16 @@ export type AiToolApprovalContext = {
31
35
  actorUserId: string;
32
36
  };
33
37
 
38
+ /**
39
+ * Request identity for approval changes; never persist this context in a turn run config.
40
+ * When actor is absent, audit uses { userId: actorUserId } for the approval's owner.
41
+ * The access subject is derived from actor, or falls back to { type: "user", userId: actorUserId }.
42
+ */
43
+ export type AiToolApprovalChangeContext = AiToolApprovalContext & {
44
+ actor?: RequestActor;
45
+ requestId?: string | null;
46
+ };
47
+
34
48
  /** Where a remembered approval applies: in one chat until it is deleted, or always. */
35
49
  export type AiToolApprovalRemember = "chat" | "always";
36
50
 
@@ -55,6 +69,59 @@ type AiToolApprovalPreferenceRow = {
55
69
  expires_at: Date | string | null;
56
70
  };
57
71
 
72
+ type ApprovalAuditRow = Pick<AiToolApprovalPreferenceRow, "id" | "tool_name" | "approval_scope" | "conversation_id">;
73
+ type ApprovalAuditIdentity = {
74
+ actor: AuditActor | null;
75
+ accessSubject: AccessSubject | null;
76
+ serviceAccountId?: string;
77
+ requestId?: string | null;
78
+ reason?: string;
79
+ };
80
+
81
+ const approvalAuditIdentity = (context: AiToolApprovalChangeContext): ApprovalAuditIdentity => {
82
+ const user = context.actor?.kind === "user" ? context.actor.user : context.actor?.delegatedUser;
83
+ return {
84
+ actor: context.actor
85
+ ? user
86
+ ? { userId: user.id, uid: user.uid, provider: user.provider, roles: user.roles }
87
+ : null
88
+ : { userId: context.actorUserId },
89
+ // Match aiChatAccessSubject locally: importing assistant-models creates a cycle through firecrawl-tools.
90
+ accessSubject: user
91
+ ? {
92
+ type: "user",
93
+ userId: user.id,
94
+ ...(context.actor?.kind === "service_account" ? { delegatedByServiceAccountId: context.actor.serviceAccount.id } : {}),
95
+ }
96
+ : context.actor?.kind === "service_account"
97
+ ? { type: "service_account", serviceAccountId: context.actor.serviceAccount.id }
98
+ : { type: "user", userId: context.actorUserId },
99
+ ...(context.actor?.kind === "service_account" ? { serviceAccountId: context.actor.serviceAccount.id } : {}),
100
+ requestId: context.requestId,
101
+ };
102
+ };
103
+
104
+ const approvalAuditEvent = (
105
+ action: "ai.approval.remember" | "ai.approval.revoke",
106
+ row: ApprovalAuditRow,
107
+ identity: ApprovalAuditIdentity,
108
+ ): AuditRecordParams => ({
109
+ action,
110
+ outcome: "allowed",
111
+ actor: identity.actor,
112
+ target: { type: "ai_approval", id: row.id, label: row.tool_name },
113
+ requestId: identity.requestId,
114
+ reason: identity.reason,
115
+ metadata: {
116
+ toolName: row.tool_name,
117
+ approvalScope: row.approval_scope,
118
+ remember: row.conversation_id !== null ? "chat" : "always",
119
+ ...(row.conversation_id !== null ? { conversationId: row.conversation_id } : {}),
120
+ accessSubject: identity.accessSubject,
121
+ ...(identity.serviceAccountId ? { serviceAccountId: identity.serviceAccountId } : {}),
122
+ },
123
+ });
124
+
58
125
  export const aiToolApprovalScope = (toolName: string, policy: AiToolApprovalPolicy | undefined): string => {
59
126
  if (policy && typeof policy === "object") return policy.scope ?? toolName;
60
127
  return toolName;
@@ -100,20 +167,12 @@ export const hasRememberedAiToolApproval = async (
100
167
 
101
168
  /** Remembers an approval everywhere, or in one chat when `conversationId` is set; a chat approval ends with its chat. */
102
169
  export const rememberAiToolApproval = async (
103
- context: AiToolApprovalContext,
170
+ context: AiToolApprovalChangeContext,
104
171
  input: { toolName: string; approvalScope: string; conversationId?: string | null; expiresAt?: Date | null },
105
172
  ): Promise<void> => {
106
173
  const conversationId = input.conversationId ?? null;
107
- await sql.begin(async () => {
108
- await sql`
109
- DELETE FROM ai.tool_approval_preferences
110
- WHERE actor_user_id = ${context.actorUserId}
111
- AND tool_name = ${input.toolName}
112
- AND approval_scope = ${input.approvalScope}
113
- AND conversation_id IS NOT DISTINCT FROM ${conversationId}::uuid
114
- `;
115
-
116
- await sql`
174
+ await sql.begin(async (tx) => {
175
+ const [row] = await tx<{ id: string }[]>`
117
176
  INSERT INTO ai.tool_approval_preferences (
118
177
  actor_user_id,
119
178
  tool_name,
@@ -130,24 +189,46 @@ export const rememberAiToolApproval = async (
130
189
  now(),
131
190
  ${input.expiresAt ?? null}
132
191
  )
192
+ ON CONFLICT (actor_user_id, tool_name, approval_scope, conversation_id)
193
+ DO UPDATE SET
194
+ created_at = EXCLUDED.created_at,
195
+ last_used_at = EXCLUDED.last_used_at,
196
+ expires_at = EXCLUDED.expires_at
197
+ RETURNING id
133
198
  `;
199
+ await audit.record(
200
+ approvalAuditEvent(
201
+ "ai.approval.remember",
202
+ {
203
+ id: row!.id,
204
+ tool_name: input.toolName,
205
+ approval_scope: input.approvalScope,
206
+ conversation_id: conversationId,
207
+ },
208
+ approvalAuditIdentity(context),
209
+ ),
210
+ tx,
211
+ );
134
212
  });
135
213
  };
136
214
 
137
215
  /** Forgets the approval with exactly this reach: everywhere, or in the one chat `conversationId` names. */
138
216
  export const forgetAiToolApproval = async (
139
- context: AiToolApprovalContext,
217
+ context: AiToolApprovalChangeContext,
140
218
  input: { toolName: string; approvalScope: string; conversationId?: string | null },
141
219
  ): Promise<boolean> => {
142
- const rows = await sql<{ id: string }[]>`
143
- DELETE FROM ai.tool_approval_preferences
144
- WHERE actor_user_id = ${context.actorUserId}
145
- AND tool_name = ${input.toolName}
146
- AND approval_scope = ${input.approvalScope}
147
- AND conversation_id IS NOT DISTINCT FROM ${input.conversationId ?? null}::uuid
148
- RETURNING id
149
- `;
150
- return rows.length > 0;
220
+ return sql.begin(async (tx) => {
221
+ const rows = await tx<ApprovalAuditRow[]>`
222
+ DELETE FROM ai.tool_approval_preferences
223
+ WHERE actor_user_id = ${context.actorUserId}
224
+ AND tool_name = ${input.toolName}
225
+ AND approval_scope = ${input.approvalScope}
226
+ AND conversation_id IS NOT DISTINCT FROM ${input.conversationId ?? null}::uuid
227
+ RETURNING id, tool_name, approval_scope, conversation_id
228
+ `;
229
+ for (const row of rows) await audit.record(approvalAuditEvent("ai.approval.revoke", row, approvalAuditIdentity(context)), tx);
230
+ return rows.length > 0;
231
+ });
151
232
  };
152
233
 
153
234
  const toIsoString = (value: Date | string): string => (value instanceof Date ? value : new Date(value)).toISOString();
@@ -186,12 +267,42 @@ export const listAiToolApprovalPreferences = async (
186
267
  return rows.map(approvalPreferenceFromRow);
187
268
  };
188
269
 
189
- export const revokeAiToolApprovalPreference = async (actorUserId: string, preferenceId: string): Promise<boolean> => {
190
- const rows = await sql<{ id: string }[]>`
270
+ export const revokeAiToolApprovalPreference = async (
271
+ actorUserId: string,
272
+ preferenceId: string,
273
+ change?: { actor?: RequestActor; requestId?: string | null },
274
+ ): Promise<boolean> =>
275
+ sql.begin(async (tx) => {
276
+ const rows = await tx<ApprovalAuditRow[]>`
277
+ DELETE FROM ai.tool_approval_preferences
278
+ WHERE id = ${preferenceId}
279
+ AND actor_user_id = ${actorUserId}
280
+ RETURNING id, tool_name, approval_scope, conversation_id
281
+ `;
282
+ for (const row of rows)
283
+ await audit.record(approvalAuditEvent("ai.approval.revoke", row, approvalAuditIdentity({ ...change, actorUserId })), tx);
284
+ return rows.length > 0;
285
+ });
286
+
287
+ /** Core migrations supply their transaction so deletion and system audit events commit together. */
288
+ export const revokeAiToolApprovalPreferencesAsSystem = async (
289
+ db: SQL,
290
+ input: { ids: readonly string[]; reason: string },
291
+ ): Promise<number> => {
292
+ if (input.ids.length === 0) return 0;
293
+ const rows = await db<(ApprovalAuditRow & { actor_user_id: string | null })[]>`
191
294
  DELETE FROM ai.tool_approval_preferences
192
- WHERE id = ${preferenceId}
193
- AND actor_user_id = ${actorUserId}
194
- RETURNING id
295
+ WHERE id = ANY(${toPgTextArray([...input.ids])}::uuid[])
296
+ RETURNING id, tool_name, approval_scope, conversation_id, actor_user_id
195
297
  `;
196
- return rows.length > 0;
298
+ for (const row of rows)
299
+ await audit.record(
300
+ approvalAuditEvent("ai.approval.revoke", row, {
301
+ actor: null,
302
+ accessSubject: row.actor_user_id === null ? null : { type: "user", userId: row.actor_user_id },
303
+ reason: input.reason,
304
+ }),
305
+ db,
306
+ );
307
+ return rows.length;
197
308
  };
package/src/ai/browser.ts CHANGED
@@ -25,6 +25,7 @@ export {
25
25
  cloudAiChartRenderOptions,
26
26
  cloudAiChartRows,
27
27
  cloudAiChartTable,
28
+ cloudAiChartTooltip,
28
29
  cloudAiChartXValueFormat,
29
30
  cloudAiNumberFormatters,
30
31
  } from "./chart-block";
@@ -188,6 +189,16 @@ export const launchAssistant = async (input: LaunchAssistantInput): Promise<Assi
188
189
  };
189
190
  };
190
191
 
192
+ export {
193
+ CHAT_TASK_REASON_CODES,
194
+ type ChatTaskReason,
195
+ type ChatTaskReasonCode,
196
+ chatTaskLocale,
197
+ chatTaskNeedsNewTime,
198
+ chatTaskReasonSentence,
199
+ chatTaskReasonState,
200
+ chatTaskStopSentence,
201
+ } from "./chat-task-reasons";
191
202
  export {
192
203
  CLOUD_AI_STATS_MAX_ITEMS,
193
204
  CLOUD_AI_TABLE_MAX_COLUMNS,