@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.
- package/package.json +8 -3
- package/scripts/build-canvas-workers.ts +109 -29
- package/scripts/build.ts +3 -5
- package/src/_internal/app-path.ts +25 -0
- package/src/_internal/app-presentation.ts +4 -0
- package/src/_internal/canvas-worker.ts +5 -3
- package/src/_internal/define-app.ts +28 -2
- package/src/_internal/help-catalog.ts +123 -4
- package/src/_internal/help.ts +27 -6
- package/src/_internal/panel-cookie.ts +39 -0
- package/src/_internal/registry-validation.ts +21 -26
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.browser-harness.tsx +57 -38
- package/src/access/PermissionEditor.tsx +30 -5
- package/src/access/ResourceApiKeys.tsx +5 -1
- package/src/account/Avatar.tsx +20 -2
- package/src/account/ui.ts +1 -1
- package/src/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/browser.ts +11 -0
- package/src/ai/chart-block.ts +44 -13
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/chart-block.tsx +2 -11
- package/src/ai/chat/messages.ts +10 -0
- package/src/ai/chat/turn-view.tsx +35 -3
- package/src/ai/chat/view-block.tsx +7 -2
- package/src/ai/chat/web-tools.tsx +23 -6
- package/src/ai/chat-task-contracts.ts +28 -3
- package/src/ai/chat-task-reasons-migrate.ts +68 -0
- package/src/ai/chat-task-reasons.ts +92 -0
- package/src/ai/chat-tasks.ts +460 -66
- package/src/ai/default-tool-contracts.ts +30 -13
- package/src/ai/fetch-file-tool.ts +37 -3
- package/src/ai/index.ts +12 -0
- package/src/ai/migrate.ts +46 -3
- package/src/ai/readable-html.ts +1057 -0
- package/src/ai/request-context.ts +7 -1
- package/src/ai/routes.ts +27 -1
- package/src/ai/runtime.ts +12 -27
- package/src/ai/store.ts +5 -3
- package/src/ai/timeline.ts +9 -1
- package/src/ai/tool-wording.ts +45 -0
- package/src/ai/turn-abort.ts +33 -0
- package/src/ai/types.ts +12 -1
- package/src/api/admin-core-settings.ts +190 -139
- package/src/api/admin-outgoing-mail.ts +4 -2
- package/src/api/app-approval.ts +9 -2
- package/src/api/approvals.ts +1 -1
- package/src/api/help.ts +23 -4
- package/src/api/index.ts +2 -1
- package/src/api/mcp.ts +8 -2
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/FileChooser.tsx +11 -3
- package/src/browser/app-approval.ts +7 -2
- package/src/browser/choose-files.browser-harness.tsx +4 -1
- package/src/browser/composer-catalog.ts +103 -0
- package/src/browser/composer-insert-messages.ts +31 -0
- package/src/browser/composer-insert.ts +150 -0
- package/src/browser/file-chooser-messages.ts +4 -0
- package/src/browser/file-providers.ts +14 -1
- package/src/browser/panel-messages.ts +24 -0
- package/src/browser/panel-slot.ts +354 -0
- package/src/browser/panel.ts +28 -0
- package/src/browser/provider-folder.tsx +58 -5
- package/src/browser/resource-picker.tsx +17 -2
- package/src/cli/admin/workflows.ts +1 -0
- package/src/config/env.ts +14 -0
- package/src/contracts/app-approval.ts +49 -9
- package/src/contracts/app.ts +14 -0
- package/src/contracts/file-provider.ts +48 -1
- package/src/contracts/outgoing-mail.ts +8 -1
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/shared.ts +2 -0
- package/src/server/help.ts +136 -10
- package/src/server/services/access.ts +5 -1
- package/src/server/services/geo.ts +21 -4
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/allowed-network-fetch.ts +189 -0
- package/src/services/app-approval.ts +158 -38
- package/src/services/approvals/device.ts +2 -0
- package/src/services/help/index.ts +46 -5
- package/src/services/help/store.ts +26 -5
- package/src/services/help/types.ts +10 -2
- package/src/services/index.ts +22 -3
- package/src/services/notifications/browser.ts +16 -5
- package/src/services/outgoing-mail/dispatcher.ts +7 -2
- package/src/services/outgoing-mail/resolve-host.ts +39 -0
- package/src/services/outgoing-mail/test-send.ts +18 -3
- package/src/services/outgoing-mail/transport.ts +24 -13
- package/src/services/pdf/image-protocol.ts +135 -0
- package/src/services/pdf/image-variants.ts +124 -0
- package/src/services/pdf/image-worker.ts +31 -0
- package/src/services/pdf/image.ts +109 -0
- package/src/services/pdf/index.ts +9 -0
- package/src/services/settings/api.ts +11 -6
- package/src/services/settings/app.ts +5 -4
- package/src/services/settings/core-settings.ts +4 -3
- package/src/services/settings/index.ts +67 -43
- package/src/services/settings/namespace.ts +2 -1
- package/src/services/settings/store.ts +83 -11
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/app-presentation.ts +1 -0
- package/src/shared/help.ts +41 -0
- package/src/shared/index.ts +9 -1
- package/src/shared/markdown/extensions/guided-help.ts +208 -64
- package/src/shared/markdown/formula.ts +8 -8
- package/src/shared/markdown/index.ts +72 -11
- package/src/shared/redirect.ts +12 -0
- package/src/ssr/CloudShell.island.tsx +86 -9
- package/src/ssr/Layout.tsx +62 -11
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
- package/src/ssr/LayoutHelp.tsx +569 -410
- package/src/ssr/LayoutRail.tsx +2 -2
- package/src/ssr/LayoutTabBar.tsx +29 -0
- package/src/ssr/PanelLayout.tsx +27 -0
- package/src/ssr/PanelRuntime.island.tsx +87 -0
- package/src/ssr/PanelSlot.tsx +108 -0
- package/src/ssr/PanelTrigger.tsx +49 -0
- package/src/ssr/ShellRuntime.island.tsx +72 -25
- package/src/ssr/app-appearance.ts +3 -2
- package/src/ssr/app-color.ts +100 -0
- package/src/ssr/app-launchpad.tsx +18 -1
- package/src/ssr/app-navigation.ts +15 -0
- package/src/ssr/compact-shell.baseline.json +5270 -0
- package/src/ssr/compact-shell.browser-harness.ts +431 -0
- package/src/ssr/help-messages.ts +60 -38
- package/src/ssr/help.ts +3 -0
- package/src/ssr/index.ts +3 -0
- package/src/ssr/layout-help-article.ts +41 -0
- package/src/ssr/layout-help-navigation.ts +0 -12
- package/src/ssr/layout-help-registry.tsx +4 -11
- package/src/ssr/layout-help-search.ts +9 -12
- package/src/ssr/layout-help-start.ts +68 -0
- package/src/ssr/panel-slot.browser-harness.tsx +110 -0
- package/src/ssr/platform-messages.ts +34 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +11 -0
- package/src/ssr/shell-shortcuts.ts +19 -0
- package/src/styles/effects.css +689 -185
- package/src/styles/global.css +269 -55
- package/src/styles/panel.css +373 -0
- package/src/styles/resource-search.css +25 -0
- package/src/styles/tokens.css +23 -6
- package/src/styles/utilities-navigation.css +5 -21
- package/src/workflows/store/events.ts +24 -0
- package/src/workflows/store/index.ts +8 -0
- package/src/workflows/store/observability.ts +6 -1
- 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
|
-
|
|
11
|
-
const SAME_ORIGIN_PATH = /^\/(?![\/\\])[^\\\s]*$/;
|
|
12
|
-
|
|
13
|
-
/** A route as the gateway reads it: trimmed and without empty segments, so `//pwa/x/` is `/pwa/x`. */
|
|
14
|
-
export const canonicalRoute = (route: string): string => {
|
|
15
|
-
const trimmed = route.trim();
|
|
16
|
-
return trimmed.startsWith("/") ? `/${trimmed.split("/").filter(Boolean).join("/")}` : trimmed;
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Whether a `nav.badge` path stays below one of the app's own `routes`. It is
|
|
21
|
-
* checked on the path the browser actually requests, after dot segments, and
|
|
22
|
-
* never starts with `//`, which a URL resolver reads as another host.
|
|
23
|
-
*/
|
|
24
|
-
export const isOwnNavBadge = (badge: string, routes: readonly string[]): boolean => {
|
|
25
|
-
if (!SAME_ORIGIN_PATH.test(badge)) return false;
|
|
26
|
-
const path = new URL(badge, "https://cloud.invalid").pathname;
|
|
27
|
-
return (
|
|
28
|
-
!path.startsWith("//") &&
|
|
29
|
-
routes.some((route) => {
|
|
30
|
-
const prefix = canonicalRoute(route);
|
|
31
|
-
return prefix === "/" || path === prefix || path.startsWith(`${prefix}/`);
|
|
32
|
-
})
|
|
33
|
-
);
|
|
34
|
-
};
|
|
11
|
+
export { canonicalRoute, isOwnAppPath } from "./app-path";
|
|
35
12
|
|
|
36
13
|
const invalid = (path: string, expected: string): string => `${path} must be ${expected}`;
|
|
37
14
|
|
|
@@ -69,6 +46,12 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
|
|
|
69
46
|
if (!isRecord(translation)) return invalid(`presentation.translations.${locale}`, "an object");
|
|
70
47
|
if (translation.name !== undefined && !isString(translation.name))
|
|
71
48
|
return invalid(`presentation.translations.${locale}.name`, "a string");
|
|
49
|
+
if (
|
|
50
|
+
translation.panelLabel !== undefined &&
|
|
51
|
+
(!isString(translation.panelLabel) || !translation.panelLabel.trim() || value.panel === undefined)
|
|
52
|
+
) {
|
|
53
|
+
return invalid(`presentation.translations.${locale}.panelLabel`, "a non-empty string for a declared panel");
|
|
54
|
+
}
|
|
72
55
|
if (translation.description !== undefined && !isString(translation.description)) {
|
|
73
56
|
return invalid(`presentation.translations.${locale}.description`, "a string");
|
|
74
57
|
}
|
|
@@ -87,10 +70,22 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
|
|
|
87
70
|
if (value.nav.requiresRoles !== undefined && !isStringArray(value.nav.requiresRoles)) {
|
|
88
71
|
return invalid("nav.requiresRoles", "an array of strings");
|
|
89
72
|
}
|
|
90
|
-
if (value.nav.badge !== undefined && (!isString(value.nav.badge) || !
|
|
73
|
+
if (value.nav.badge !== undefined && (!isString(value.nav.badge) || !isOwnAppPath(value.nav.badge, value.routes))) {
|
|
91
74
|
return invalid("nav.badge", "a same-origin path below one of the entry's routes");
|
|
92
75
|
}
|
|
93
76
|
}
|
|
77
|
+
if (value.panel !== undefined) {
|
|
78
|
+
if (!isRecord(value.panel)) return invalid("panel", "an object");
|
|
79
|
+
if (!isString(value.panel.href) || !isOwnAppPath(value.panel.href, value.routes)) {
|
|
80
|
+
return invalid("panel.href", "a same-origin path below one of the entry's routes");
|
|
81
|
+
}
|
|
82
|
+
for (const field of ["label", "icon"] as const) {
|
|
83
|
+
if (!isString(value.panel[field]) || !value.panel[field].trim()) return invalid(`panel.${field}`, "a non-empty string");
|
|
84
|
+
}
|
|
85
|
+
if (value.panel.requiresRoles !== undefined && !isStringArray(value.panel.requiresRoles)) {
|
|
86
|
+
return invalid("panel.requiresRoles", "an array of strings");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
94
89
|
if (value.adminNav !== undefined) {
|
|
95
90
|
if (
|
|
96
91
|
!Array.isArray(value.adminNav) ||
|
|
@@ -58,6 +58,7 @@ export const buildRuntimeFromRegistry = (entries: AppRegistryEntry[], capabiliti
|
|
|
58
58
|
badge: e.nav.badge,
|
|
59
59
|
}
|
|
60
60
|
: undefined,
|
|
61
|
+
panel: e.panel ? { ...e.panel, requiresRoles: e.panel.requiresRoles ? [...e.panel.requiresRoles] : undefined } : undefined,
|
|
61
62
|
help: e.help ? { ...e.help } : undefined,
|
|
62
63
|
searchTags,
|
|
63
64
|
searchHelp: searchQueries?.map((query) => query.description).join(" "),
|
|
@@ -1,46 +1,65 @@
|
|
|
1
1
|
import { LocaleProvider } from "@k2b/ui";
|
|
2
|
+
import { createSignal, onCleanup, Show } from "solid-js";
|
|
2
3
|
import { render } from "solid-js/web";
|
|
4
|
+
import type { AccessEntry } from "../contracts/shared";
|
|
3
5
|
import PermissionEditor from "./PermissionEditor";
|
|
4
6
|
|
|
5
7
|
const host = document.getElementById("root");
|
|
6
8
|
if (!host) throw new Error("Missing harness root");
|
|
7
|
-
render(
|
|
8
|
-
() => (
|
|
9
|
+
render(() => {
|
|
10
|
+
let saved: AccessEntry[] = ["Mara Lind", "Lea Sommer", "Jonas Weber"].map((displayName, index) => ({
|
|
11
|
+
id: `access-${index}`,
|
|
12
|
+
principal: { type: "user", userId: `user-${index}` },
|
|
13
|
+
displayName,
|
|
14
|
+
permission: "read",
|
|
15
|
+
createdAt: "2026-10-15T07:00:00.000Z",
|
|
16
|
+
}));
|
|
17
|
+
const [data, setData] = createSignal(saved);
|
|
18
|
+
const refetch = () => {
|
|
19
|
+
// Like a consumer's query: fresh objects contain the saved level and another person's change.
|
|
20
|
+
saved = saved.map((entry, index) => ({
|
|
21
|
+
...entry,
|
|
22
|
+
principal: { ...entry.principal },
|
|
23
|
+
...(index === 2 ? { permission: "write" as const } : {}),
|
|
24
|
+
}));
|
|
25
|
+
setData(saved);
|
|
26
|
+
};
|
|
27
|
+
document.addEventListener("refetch-access", refetch);
|
|
28
|
+
onCleanup(() => document.removeEventListener("refetch-access", refetch));
|
|
29
|
+
return (
|
|
9
30
|
<LocaleProvider locale="en">
|
|
10
|
-
<
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
)
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/>
|
|
31
|
+
<Show when={data()}>
|
|
32
|
+
{(entries) => (
|
|
33
|
+
<PermissionEditor
|
|
34
|
+
initialEntries={entries()}
|
|
35
|
+
// Equal-width labels isolate pending-state layout from a normal change in label width.
|
|
36
|
+
allowedLevels={[
|
|
37
|
+
{ level: "read", label: "Level 1" },
|
|
38
|
+
{ level: "write", label: "Level 2" },
|
|
39
|
+
]}
|
|
40
|
+
allowAuthenticated={false}
|
|
41
|
+
grantAccess={async () => {
|
|
42
|
+
throw new Error("Not used");
|
|
43
|
+
}}
|
|
44
|
+
updateAccess={(id, permission) =>
|
|
45
|
+
new Promise<void>((resolve) => {
|
|
46
|
+
// Existing tests use this mark and event to control the save.
|
|
47
|
+
document.documentElement.dataset.saving = "true";
|
|
48
|
+
document.addEventListener(
|
|
49
|
+
"settle-access",
|
|
50
|
+
() => {
|
|
51
|
+
saved = saved.map((entry) => (entry.id === id ? { ...entry, permission } : entry));
|
|
52
|
+
delete document.documentElement.dataset.saving;
|
|
53
|
+
resolve();
|
|
54
|
+
},
|
|
55
|
+
{ once: true },
|
|
56
|
+
);
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
revokeAccess={async () => {}}
|
|
60
|
+
/>
|
|
61
|
+
)}
|
|
62
|
+
</Show>
|
|
43
63
|
</LocaleProvider>
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
);
|
|
64
|
+
);
|
|
65
|
+
}, host);
|
|
@@ -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
|
-
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
|
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(
|
|
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",
|
package/src/account/Avatar.tsx
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { Avatar, type AvatarProps } from "@k2b/ui";
|
|
2
2
|
import type { JSX } from "solid-js";
|
|
3
3
|
|
|
4
|
-
|
|
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?:
|
|
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
|
);
|
package/src/ai/approvals.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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:
|
|
217
|
+
context: AiToolApprovalChangeContext,
|
|
140
218
|
input: { toolName: string; approvalScope: string; conversationId?: string | null },
|
|
141
219
|
): Promise<boolean> => {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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 (
|
|
190
|
-
|
|
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 = ${
|
|
193
|
-
|
|
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
|
-
|
|
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,
|