@k2b/cloud 0.30.0 → 0.31.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 +3 -2
- package/src/_internal/app-presentation.ts +22 -1
- package/src/_internal/capabilities.ts +2 -2
- package/src/_internal/define-app.ts +14 -4
- package/src/_internal/registry.ts +50 -6
- package/src/_internal/widget-declarations.ts +53 -0
- package/src/ai/approval-routes.ts +40 -4
- package/src/ai/approvals.ts +68 -10
- package/src/ai/chat/blocks.tsx +53 -32
- package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
- package/src/ai/chat/message-actions.tsx +3 -1
- package/src/ai/chat/messages.ts +18 -0
- package/src/ai/chat/tool-groups.ts +66 -12
- package/src/ai/chat/tool-names.ts +4 -2
- package/src/ai/chat/turn-layout.ts +8 -0
- package/src/ai/chat/turn-view.tsx +94 -25
- package/src/ai/chat/web-tools.tsx +95 -1
- package/src/ai/client/controller.ts +6 -3
- package/src/ai/code-runtime-tools.ts +50 -17
- package/src/ai/code-source-contracts.ts +25 -21
- package/src/ai/code-source-tools.ts +3 -1
- package/src/ai/executor.ts +170 -14
- package/src/ai/fetch-file-tool.ts +41 -4
- package/src/ai/firecrawl-tools.ts +87 -4
- package/src/ai/index.ts +8 -0
- package/src/ai/migrate.ts +113 -2
- package/src/ai/protocol.ts +22 -1
- package/src/ai/routes.ts +11 -0
- package/src/ai/runtime.ts +46 -7
- package/src/ai/store.ts +66 -1
- package/src/ai/tool-audit.ts +11 -1
- package/src/ai/tools.ts +91 -3
- package/src/ai/types.ts +39 -0
- package/src/ai/web-provenance.ts +103 -0
- package/src/api/capabilities.ts +3 -2
- package/src/api/widgets.ts +36 -5
- package/src/browser/CloudResourceSearch.tsx +1 -1
- package/src/browser/command-shortcuts.ts +21 -6
- package/src/browser/spotlight-position.ts +4 -1
- package/src/browser/widgets.ts +7 -5
- package/src/capabilities/executions.ts +3 -0
- package/src/cli/commands.ts +20 -0
- package/src/cli/index.ts +16 -0
- package/src/cli/plugin.ts +30 -0
- package/src/contracts/app.ts +34 -2
- package/src/contracts/registry.ts +9 -1
- package/src/contracts/widgets.ts +47 -2
- package/src/desktop/solid.tsx +6 -7
- package/src/server/index.ts +1 -0
- package/src/server/widget-request.ts +27 -0
- package/src/services/approvals/index.ts +225 -0
- package/src/services/approvals/migrate.ts +33 -0
- package/src/shared/app-presentation.ts +11 -0
- package/src/shared/email-html.ts +43 -8
- package/src/ssr/AdminLayout.tsx +4 -7
- package/src/ssr/AppLaunchpad.island.tsx +1 -1
- package/src/ssr/Layout.tsx +38 -14
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +17 -20
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -2
- package/src/ssr/PageTrail.tsx +23 -0
- package/src/ssr/PageTrailRow.island.tsx +20 -0
- package/src/ssr/PageTrailRuntime.island.tsx +26 -0
- package/src/ssr/ProfilePreferences.island.tsx +7 -3
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/layout-runtime.ts +60 -0
- package/src/ssr/mobile-menu-history.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +313 -0
- package/src/ssr/page-trail.browser-harness.tsx +216 -0
- package/src/ssr/page-trail.ts +59 -0
- package/src/ssr/platform-messages.ts +4 -0
- package/src/styles/effects.css +21 -4
- package/src/styles/global.css +35 -2
- package/src/styles/utilities-layout.css +24 -1
- package/src/styles/utilities-navigation.css +6 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -73,6 +73,7 @@
|
|
|
73
73
|
"./ssr": "./src/ssr/index.ts",
|
|
74
74
|
"./ssr/islands": "./src/ssr/islands/index.ts",
|
|
75
75
|
"./ssr/WorkspaceNavigation.island": "./src/ssr/WorkspaceNavigation.island.tsx",
|
|
76
|
+
"./ssr/layout-runtime": "./src/ssr/layout-runtime.ts",
|
|
76
77
|
"./ssr/*": "./src/ssr/*",
|
|
77
78
|
"./config": "./src/config/index.ts",
|
|
78
79
|
"./config/*": "./src/config/*",
|
|
@@ -104,7 +105,7 @@
|
|
|
104
105
|
"@tailwindcss/typography": "0.5.20",
|
|
105
106
|
"@k2b/nessi": "0.17.0",
|
|
106
107
|
"@k2b/ssr": "0.15.0",
|
|
107
|
-
"@k2b/ui": "0.
|
|
108
|
+
"@k2b/ui": "0.22.0",
|
|
108
109
|
"@k2b/stdlib": "0.28.0",
|
|
109
110
|
"@k2b/sync": "7.0.0",
|
|
110
111
|
"@nats-io/jetstream": "3.4.0",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { AppMeta, AppPresentationCatalog, AppPresentationTranslation } from "../contracts/app";
|
|
1
|
+
import type { AppMeta, AppPresentationCatalog, AppPresentationTranslation, AppWidgetTranslation } from "../contracts/app";
|
|
2
|
+
import { WIDGET_DESCRIPTION_MAX_LENGTH, WIDGET_TITLE_MAX_LENGTH } from "../contracts/widgets";
|
|
2
3
|
import { canonicalLocale } from "../shared/locale";
|
|
3
4
|
|
|
4
5
|
export const APP_PRESENTATION_MAX_BYTES = 64 * 1024;
|
|
@@ -21,6 +22,24 @@ const labels = (values: Readonly<Record<string, string>> | undefined, allowed: S
|
|
|
21
22
|
return normalized;
|
|
22
23
|
};
|
|
23
24
|
|
|
25
|
+
const widgetTexts = (
|
|
26
|
+
values: Readonly<Record<string, AppWidgetTranslation>>,
|
|
27
|
+
allowed: Set<string>,
|
|
28
|
+
label: string,
|
|
29
|
+
): Record<string, AppWidgetTranslation> => {
|
|
30
|
+
const normalized: Record<string, AppWidgetTranslation> = {};
|
|
31
|
+
for (const [id, value] of Object.entries(values)) {
|
|
32
|
+
if (!allowed.has(id)) throw new Error(`${label} contains unknown key ${JSON.stringify(id)}`);
|
|
33
|
+
normalized[id] = {
|
|
34
|
+
...(value.title !== undefined ? { title: text(value.title, `${label}.${id}.title`, WIDGET_TITLE_MAX_LENGTH) } : {}),
|
|
35
|
+
...(value.description !== undefined
|
|
36
|
+
? { description: text(value.description, `${label}.${id}.description`, WIDGET_DESCRIPTION_MAX_LENGTH) }
|
|
37
|
+
: {}),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return normalized;
|
|
41
|
+
};
|
|
42
|
+
|
|
24
43
|
/** Validate and canonicalize one static app-presentation catalog before it enters the registry. */
|
|
25
44
|
export const compileAppPresentation = (app: AppMeta, catalog: AppPresentationCatalog | undefined): AppPresentationCatalog | undefined => {
|
|
26
45
|
if (!catalog) return undefined;
|
|
@@ -36,6 +55,7 @@ export const compileAppPresentation = (app: AppMeta, catalog: AppPresentationCat
|
|
|
36
55
|
const adminHrefs = new Set((app.adminNav ?? []).flatMap((group) => group.links.map((link) => link.href)));
|
|
37
56
|
const searchHrefs = new Set((app.searchLinks ?? []).map((link) => link.href));
|
|
38
57
|
const legalHrefs = new Set((app.legalLinks ?? []).map((link) => link.href));
|
|
58
|
+
const widgetIds = new Set((app.widgets ?? []).map((widget) => widget.id));
|
|
39
59
|
const translations: Record<string, AppPresentationTranslation> = {};
|
|
40
60
|
|
|
41
61
|
for (const [locale, translation] of Object.entries(catalog.translations)) {
|
|
@@ -62,6 +82,7 @@ export const compileAppPresentation = (app: AppMeta, catalog: AppPresentationCat
|
|
|
62
82
|
}
|
|
63
83
|
: {}),
|
|
64
84
|
...(translation.legalLinks ? { legalLinks: labels(translation.legalLinks, legalHrefs, `${canonical}.legalLinks`) } : {}),
|
|
85
|
+
...(translation.widgets ? { widgets: widgetTexts(translation.widgets, widgetIds, `${canonical}.widgets`) } : {}),
|
|
65
86
|
};
|
|
66
87
|
}
|
|
67
88
|
|
|
@@ -1469,9 +1469,9 @@ export const reviewCompiledCapability = async (params: {
|
|
|
1469
1469
|
return normalizeProviderError(reviewed.error, "Capability review returned an invalid error", params.onUnexpectedError);
|
|
1470
1470
|
}
|
|
1471
1471
|
const parsed = CapabilityActionReviewSchema.safeParse(reviewed.data);
|
|
1472
|
+
// A rememberable Action may leave out its scope for arguments that should ask every time; any other Action has none.
|
|
1472
1473
|
const approvalScopeIsValid =
|
|
1473
|
-
parsed.success &&
|
|
1474
|
-
(operation.manifest.approval === "rememberable" ? parsed.data.approvalScope !== undefined : parsed.data.approvalScope === undefined);
|
|
1474
|
+
parsed.success && (operation.manifest.approval === "rememberable" || parsed.data.approvalScope === undefined);
|
|
1475
1475
|
if (!parsed.success || !approvalScopeIsValid) {
|
|
1476
1476
|
try {
|
|
1477
1477
|
params.onUnexpectedError?.(
|
|
@@ -54,6 +54,7 @@ import { matchedRouteTemplate, routeTemplate } from "../server/middleware/route-
|
|
|
54
54
|
import { runtime as runtimeMiddleware } from "../server/middleware/runtime";
|
|
55
55
|
import { preloadLayoutAnnouncements, settings as settingsMiddleware } from "../server/middleware/settings";
|
|
56
56
|
import { validationErrorResponse } from "../server/middleware/validator";
|
|
57
|
+
import { setWidgetRequest } from "../server/widget-request";
|
|
57
58
|
import {
|
|
58
59
|
capabilityInvocationOperation,
|
|
59
60
|
searchInvocationOperation,
|
|
@@ -92,6 +93,7 @@ import { ensureRuntimeWatcher, getCurrentRuntime, stopRuntimeWatcher } from "./r
|
|
|
92
93
|
import { servePublicAsset } from "./static-assets";
|
|
93
94
|
import { createStatusPreservingSsrHandler } from "./status-preserving-ssr";
|
|
94
95
|
import { WEB_VITALS_ASSET_HREF } from "./web-vitals-asset";
|
|
96
|
+
import { compileWidgetDeclarations } from "./widget-declarations";
|
|
95
97
|
|
|
96
98
|
/** Cache-busting version stamp — changes on every server start / rebuild. */
|
|
97
99
|
const v = Date.now();
|
|
@@ -515,7 +517,7 @@ export const defineApp = <
|
|
|
515
517
|
legalLinks: opts.legalLinks ? [...opts.legalLinks] : undefined,
|
|
516
518
|
searchLinks: opts.searchLinks?.map((link) => ({ ...link, keywords: link.keywords ? [...link.keywords] : undefined })),
|
|
517
519
|
pwa: pwaPart,
|
|
518
|
-
widgets: opts.
|
|
520
|
+
widgets: compileWidgetDeclarations(opts.id, opts.widgets),
|
|
519
521
|
settingKeys: opts.settings ? Object.keys(opts.settings) : undefined,
|
|
520
522
|
openapi: opts.openapi,
|
|
521
523
|
cli: opts.cli,
|
|
@@ -614,7 +616,11 @@ export const defineApp = <
|
|
|
614
616
|
pwa: meta.pwa
|
|
615
617
|
? { href: meta.pwa.href, requiresRoles: meta.pwa.requiresRoles ? [...meta.pwa.requiresRoles] : undefined }
|
|
616
618
|
: undefined,
|
|
617
|
-
widgets: meta.widgets
|
|
619
|
+
widgets: meta.widgets?.map((widget) => ({
|
|
620
|
+
...widget,
|
|
621
|
+
sizes: widget.sizes ? [...widget.sizes] : undefined,
|
|
622
|
+
requiresRoles: widget.requiresRoles ? [...widget.requiresRoles] : undefined,
|
|
623
|
+
})),
|
|
618
624
|
platformPermissions: meta.platformPermissions ? [...meta.platformPermissions] : undefined,
|
|
619
625
|
settingKeys: meta.settingKeys ? [...meta.settingKeys] : undefined,
|
|
620
626
|
openapi: advertiseOpenapi ? opts.openapi : undefined,
|
|
@@ -863,8 +869,12 @@ export const defineApp = <
|
|
|
863
869
|
runtimeMiddleware(),
|
|
864
870
|
settingsMiddleware(),
|
|
865
871
|
async (c) => {
|
|
866
|
-
const
|
|
867
|
-
|
|
872
|
+
const widgetId = c.req.param("widgetId") ?? "";
|
|
873
|
+
const handler = startOpts.widgets?.[widgetId];
|
|
874
|
+
const declaration = meta.widgets?.find((widget) => widget.id === widgetId);
|
|
875
|
+
if (!handler || !declaration) return c.json({ message: "Widget not found" }, 404);
|
|
876
|
+
setWidgetRequest(c, declaration);
|
|
877
|
+
return handler(c, async () => {});
|
|
868
878
|
},
|
|
869
879
|
);
|
|
870
880
|
}
|
|
@@ -3,7 +3,14 @@ import type { AppAppearanceColor } from "../contracts/app";
|
|
|
3
3
|
import type { CapabilityManifest, CapabilityPresentationCatalog } from "../contracts/capabilities";
|
|
4
4
|
import { fileProviderIssues } from "../contracts/file-provider";
|
|
5
5
|
import type { AppRegistryEntry, CapabilityRegistryEntry } from "../contracts/registry";
|
|
6
|
-
import type
|
|
6
|
+
import { type Role, RoleSchema } from "../contracts/shared";
|
|
7
|
+
import {
|
|
8
|
+
type DashboardWidgetPresentation,
|
|
9
|
+
type DashboardWidgetSize,
|
|
10
|
+
resolveWidgetSizes,
|
|
11
|
+
WIDGET_DESCRIPTION_MAX_LENGTH,
|
|
12
|
+
WIDGET_TITLE_MAX_LENGTH,
|
|
13
|
+
} from "../contracts/widgets";
|
|
7
14
|
import { resolveAppPresentations } from "../shared/app-presentation";
|
|
8
15
|
import { compileCapabilityPresentation, parseCapabilityManifest } from "./capabilities";
|
|
9
16
|
import { lazySync } from "./process-sync";
|
|
@@ -332,7 +339,9 @@ export const listLegalLinks = async (locale?: string): Promise<Array<{ label: st
|
|
|
332
339
|
* the dashboard app to build the widget grid and for Core to resolve the
|
|
333
340
|
* exact target before proxying a widget request.
|
|
334
341
|
*
|
|
335
|
-
* Order = registration order across apps.
|
|
342
|
+
* Order = registration order across apps. Every optional declaration field is
|
|
343
|
+
* resolved here, so readers never see a registry record from an older app
|
|
344
|
+
* without `title`, `sizes`, or `defaultSize`, nor an unknown size or role.
|
|
336
345
|
*/
|
|
337
346
|
export type DashboardWidget = {
|
|
338
347
|
appId: string;
|
|
@@ -341,23 +350,58 @@ export type DashboardWidget = {
|
|
|
341
350
|
widgetId: string;
|
|
342
351
|
/** Fully-qualified URL — `<baseUrl>/<path>`. */
|
|
343
352
|
url: string;
|
|
353
|
+
/** The declared title in the requested locale, or the app's name. */
|
|
354
|
+
title: string;
|
|
355
|
+
/** The declared description in the requested locale, or the app's description. */
|
|
356
|
+
description: string;
|
|
357
|
+
sizes: DashboardWidgetSize[];
|
|
358
|
+
defaultSize: DashboardWidgetSize;
|
|
359
|
+
suggest: boolean;
|
|
360
|
+
/** Visibility only, like `nav.requiresRoles`; empty means everyone. */
|
|
361
|
+
requiresRoles: Role[];
|
|
362
|
+
/**
|
|
363
|
+
* @deprecated The zone and width the app recommended before widgets had sizes. Only the dashboard reads it, to
|
|
364
|
+
* convert a board saved then into the board the person saw.
|
|
365
|
+
*/
|
|
344
366
|
presentation?: DashboardWidgetPresentation;
|
|
345
367
|
};
|
|
346
368
|
|
|
347
|
-
|
|
348
|
-
|
|
369
|
+
const LEGACY_ZONES = ["focus", "overview", "context"] as const;
|
|
370
|
+
const LEGACY_SPANS = ["standard", "wide"] as const;
|
|
371
|
+
|
|
372
|
+
const legacyPresentation = (value: DashboardWidgetPresentation | undefined): DashboardWidgetPresentation | undefined => {
|
|
373
|
+
const defaultZone = LEGACY_ZONES.find((zone) => zone === value?.defaultZone);
|
|
374
|
+
const defaultSpan = LEGACY_SPANS.find((span) => span === value?.defaultSpan);
|
|
375
|
+
return defaultZone || defaultSpan ? { defaultZone, defaultSpan } : undefined;
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const declaredText = (value: unknown, max: number): string | undefined =>
|
|
379
|
+
typeof value === "string" && value.trim() && value.trim().length <= max ? value.trim() : undefined;
|
|
380
|
+
|
|
381
|
+
/** The widgets `apps` declare, resolved as `listWidgets()` returns them. */
|
|
382
|
+
export const dashboardWidgetsOf = (apps: readonly AppRegistryEntry[], locale?: string): DashboardWidget[] => {
|
|
349
383
|
const out: DashboardWidget[] = [];
|
|
350
|
-
for (const app of apps) {
|
|
384
|
+
for (const app of locale ? resolveAppPresentations(apps, locale) : apps) {
|
|
351
385
|
for (const w of app.widgets ?? []) {
|
|
386
|
+
const { sizes, defaultSize } = resolveWidgetSizes(w);
|
|
352
387
|
out.push({
|
|
353
388
|
appId: app.id,
|
|
354
389
|
appName: app.name,
|
|
355
390
|
appIcon: app.icon,
|
|
356
391
|
widgetId: w.id,
|
|
357
392
|
url: `${app.baseUrl.replace(/\/$/, "")}${w.path.startsWith("/") ? w.path : `/${w.path}`}`,
|
|
358
|
-
|
|
393
|
+
title: declaredText(w.title, WIDGET_TITLE_MAX_LENGTH) ?? app.name,
|
|
394
|
+
description: declaredText(w.description, WIDGET_DESCRIPTION_MAX_LENGTH) ?? app.description,
|
|
395
|
+
sizes,
|
|
396
|
+
defaultSize,
|
|
397
|
+
suggest: w.suggest === true,
|
|
398
|
+
requiresRoles: Array.isArray(w.requiresRoles) ? w.requiresRoles.filter((role) => RoleSchema.safeParse(role).success) : [],
|
|
399
|
+
presentation: legacyPresentation(w.presentation),
|
|
359
400
|
});
|
|
360
401
|
}
|
|
361
402
|
}
|
|
362
403
|
return out;
|
|
363
404
|
};
|
|
405
|
+
|
|
406
|
+
/** Every declared widget, with titles and descriptions in `locale` when given. */
|
|
407
|
+
export const listWidgets = async (locale?: string): Promise<DashboardWidget[]> => dashboardWidgetsOf(await listApps(), locale);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { WidgetEndpoint } from "../contracts/app";
|
|
2
|
+
import { RoleSchema } from "../contracts/shared";
|
|
3
|
+
import {
|
|
4
|
+
DASHBOARD_WIDGET_SIZES,
|
|
5
|
+
isDashboardWidgetSize,
|
|
6
|
+
WIDGET_DESCRIPTION_MAX_LENGTH,
|
|
7
|
+
WIDGET_TITLE_MAX_LENGTH,
|
|
8
|
+
} from "../contracts/widgets";
|
|
9
|
+
|
|
10
|
+
const text = (value: string | undefined, label: string, max: number): string | undefined => {
|
|
11
|
+
if (value === undefined) return undefined;
|
|
12
|
+
const normalized = value.trim();
|
|
13
|
+
if (!normalized || normalized.length > max) throw new Error(`${label} must contain 1 to ${max} characters`);
|
|
14
|
+
return normalized;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validates and copies an app's widget declarations at `defineApp()`, so a typo fails at startup instead of a widget
|
|
19
|
+
* silently missing from the gallery: unique ids without `@`, which separates a widget's key from the size the
|
|
20
|
+
* dashboard asks for, bounded titles and descriptions, known sizes with the default among them, a boolean `suggest`,
|
|
21
|
+
* and known roles.
|
|
22
|
+
*/
|
|
23
|
+
export const compileWidgetDeclarations = (
|
|
24
|
+
appId: string,
|
|
25
|
+
widgets: ReadonlyArray<WidgetEndpoint> | undefined,
|
|
26
|
+
): WidgetEndpoint[] | undefined => {
|
|
27
|
+
if (!widgets) return undefined;
|
|
28
|
+
const ids = new Set<string>();
|
|
29
|
+
return widgets.map((widget) => {
|
|
30
|
+
const label = `Widget ${JSON.stringify(widget.id)} of app ${JSON.stringify(appId)}`;
|
|
31
|
+
if (!widget.id.trim()) throw new Error(`Widgets of app ${JSON.stringify(appId)} need a non-empty id`);
|
|
32
|
+
if (widget.id.includes("@")) throw new Error(`${label} must not contain "@"`);
|
|
33
|
+
if (ids.has(widget.id)) throw new Error(`${label} is declared twice`);
|
|
34
|
+
ids.add(widget.id);
|
|
35
|
+
const sizes = widget.sizes ? [...widget.sizes] : undefined;
|
|
36
|
+
if (sizes && (sizes.length === 0 || sizes.some((size) => !isDashboardWidgetSize(size)) || new Set(sizes).size !== sizes.length)) {
|
|
37
|
+
throw new Error(`${label} must declare sizes as distinct values of ${DASHBOARD_WIDGET_SIZES.join(", ")}`);
|
|
38
|
+
}
|
|
39
|
+
if (widget.defaultSize !== undefined && !(sizes ?? ["large"]).includes(widget.defaultSize)) {
|
|
40
|
+
throw new Error(`${label} must declare a defaultSize it offers in sizes`);
|
|
41
|
+
}
|
|
42
|
+
if (widget.suggest !== undefined && typeof widget.suggest !== "boolean")
|
|
43
|
+
throw new Error(`${label} must declare suggest as true or false`);
|
|
44
|
+
if (widget.requiresRoles?.some((role) => !RoleSchema.safeParse(role).success)) throw new Error(`${label} requires an unknown role`);
|
|
45
|
+
return {
|
|
46
|
+
...widget,
|
|
47
|
+
title: text(widget.title, `${label} title`, WIDGET_TITLE_MAX_LENGTH),
|
|
48
|
+
description: text(widget.description, `${label} description`, WIDGET_DESCRIPTION_MAX_LENGTH),
|
|
49
|
+
sizes: sizes ? DASHBOARD_WIDGET_SIZES.filter((size) => sizes.includes(size)) : undefined,
|
|
50
|
+
requiresRoles: widget.requiresRoles ? [...widget.requiresRoles] : undefined,
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
};
|
|
@@ -5,8 +5,15 @@ import { listCapabilities } from "../_internal/registry";
|
|
|
5
5
|
import { ErrorResponseSchema } from "../contracts";
|
|
6
6
|
import type { CapabilityRegistryEntry } from "../contracts/registry";
|
|
7
7
|
import { type AuthContext, auth, expectUserBackedActor, getLocale, jsonResponse, rateLimit, requiresAuth, v } from "../server";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
AI_WEBSITE_APPROVAL_TOOL,
|
|
10
|
+
type AiToolApprovalPreference,
|
|
11
|
+
listAiToolApprovalPreferences,
|
|
12
|
+
parseAiWebsiteApprovalScope,
|
|
13
|
+
revokeAiToolApprovalPreference,
|
|
14
|
+
} from "./approvals";
|
|
9
15
|
import { buildAiCapabilityCatalog } from "./capabilities";
|
|
16
|
+
import { aiConversations } from "./store";
|
|
10
17
|
|
|
11
18
|
export type AiApprovalPreferenceView = AiToolApprovalPreference & {
|
|
12
19
|
title: string;
|
|
@@ -16,6 +23,8 @@ export type AiApprovalPreferenceView = AiToolApprovalPreference & {
|
|
|
16
23
|
icon: string;
|
|
17
24
|
accent?: string;
|
|
18
25
|
} | null;
|
|
26
|
+
/** For a website approval: the exact origin it lets code read, and the Studio app it is limited to, if any. */
|
|
27
|
+
website: { origin: string; resourceId: string | null } | null;
|
|
19
28
|
};
|
|
20
29
|
|
|
21
30
|
const AiApprovalPreferenceSchema = z.object({
|
|
@@ -24,6 +33,7 @@ const AiApprovalPreferenceSchema = z.object({
|
|
|
24
33
|
approvalScope: z.string(),
|
|
25
34
|
createdAt: z.iso.datetime(),
|
|
26
35
|
lastUsedAt: z.iso.datetime().nullable(),
|
|
36
|
+
conversationId: z.uuid().nullable(),
|
|
27
37
|
expiresAt: z.iso.datetime().nullable(),
|
|
28
38
|
title: z.string(),
|
|
29
39
|
app: z
|
|
@@ -34,20 +44,29 @@ const AiApprovalPreferenceSchema = z.object({
|
|
|
34
44
|
accent: z.string().optional(),
|
|
35
45
|
})
|
|
36
46
|
.nullable(),
|
|
47
|
+
website: z.object({ origin: z.string(), resourceId: z.string().nullable() }).nullable(),
|
|
37
48
|
});
|
|
38
49
|
|
|
39
50
|
const AiApprovalPreferenceListSchema = z.object({ approvals: z.array(AiApprovalPreferenceSchema) });
|
|
40
51
|
const AiApprovalPreferenceParamSchema = z.object({ preferenceId: z.uuid() });
|
|
52
|
+
const AiApprovalPreferenceQuerySchema = z.object({ conversation: z.string().min(1).max(80).optional() });
|
|
41
53
|
const AiApprovalPreferenceDeleteSchema = z.object({ deleted: z.literal(true) });
|
|
42
54
|
|
|
43
55
|
type AiApprovalRouteDependencies = {
|
|
44
56
|
limit?: MiddlewareHandler<AuthContext>;
|
|
45
57
|
authenticate?: MiddlewareHandler<AuthContext>;
|
|
46
|
-
listPreferences?: (actorUserId: string) => Promise<AiToolApprovalPreference[]>;
|
|
58
|
+
listPreferences?: (actorUserId: string, options: { conversationId?: string }) => Promise<AiToolApprovalPreference[]>;
|
|
47
59
|
revokePreference?: (actorUserId: string, preferenceId: string) => Promise<boolean>;
|
|
48
60
|
listCapabilities?: () => Promise<CapabilityRegistryEntry[]>;
|
|
61
|
+
/** Resolves a chat the user owns by its ID or short ID. */
|
|
62
|
+
findConversation?: (ownerUserId: string, conversation: string) => Promise<{ id: string } | null>;
|
|
49
63
|
};
|
|
50
64
|
|
|
65
|
+
const findOwnConversation = async (ownerUserId: string, conversation: string) =>
|
|
66
|
+
z.uuid().safeParse(conversation).success
|
|
67
|
+
? aiConversations.getConversation({ conversationId: conversation, ownerUserId })
|
|
68
|
+
: aiConversations.getConversationByShortId({ shortId: conversation, ownerUserId });
|
|
69
|
+
|
|
51
70
|
const fallbackTitle = (toolName: string): string => {
|
|
52
71
|
const label = toolName
|
|
53
72
|
.replaceAll("__", " ")
|
|
@@ -61,6 +80,7 @@ export const createAiApprovalPreferenceRoutes = (dependencies: AiApprovalRouteDe
|
|
|
61
80
|
const listPreferences = dependencies.listPreferences ?? listAiToolApprovalPreferences;
|
|
62
81
|
const revokePreference = dependencies.revokePreference ?? revokeAiToolApprovalPreference;
|
|
63
82
|
const registry = dependencies.listCapabilities ?? listCapabilities;
|
|
83
|
+
const findConversation = dependencies.findConversation ?? findOwnConversation;
|
|
64
84
|
|
|
65
85
|
return new Hono<AuthContext>()
|
|
66
86
|
.use(dependencies.limit ?? rateLimit())
|
|
@@ -70,25 +90,41 @@ export const createAiApprovalPreferenceRoutes = (dependencies: AiApprovalRouteDe
|
|
|
70
90
|
describeRoute({
|
|
71
91
|
tags: ["AI"],
|
|
72
92
|
summary: "List remembered AI approvals",
|
|
73
|
-
description:
|
|
93
|
+
description:
|
|
94
|
+
"Lists AI tool approvals remembered for the current user, with titles in the request locale. Without `conversation`, it lists the approvals that apply everywhere; with it, those limited to that chat.",
|
|
74
95
|
...requiresAuth,
|
|
75
96
|
responses: {
|
|
76
97
|
200: jsonResponse(AiApprovalPreferenceListSchema, "Remembered approvals"),
|
|
77
98
|
401: jsonResponse(ErrorResponseSchema, "Authentication required"),
|
|
78
99
|
403: jsonResponse(ErrorResponseSchema, "User-backed actor required"),
|
|
100
|
+
404: jsonResponse(ErrorResponseSchema, "Chat not found"),
|
|
79
101
|
},
|
|
80
102
|
}),
|
|
103
|
+
v("query", AiApprovalPreferenceQuerySchema),
|
|
81
104
|
async (c) => {
|
|
82
105
|
const user = expectUserBackedActor(c);
|
|
106
|
+
const requested = c.req.valid("query").conversation;
|
|
107
|
+
const conversation = requested ? await findConversation(user.id, requested) : null;
|
|
108
|
+
if (requested && !conversation) return c.json({ message: "Chat not found", code: "NOT_FOUND" }, 404);
|
|
83
109
|
const [preferences, capabilities] = await Promise.all([
|
|
84
|
-
listPreferences(user.id),
|
|
110
|
+
listPreferences(user.id, conversation ? { conversationId: conversation.id } : {}),
|
|
85
111
|
registry().catch(() => [] as CapabilityRegistryEntry[]),
|
|
86
112
|
]);
|
|
87
113
|
const catalogByName = new Map(buildAiCapabilityCatalog(capabilities, getLocale(c)).map((entry) => [entry.name, entry]));
|
|
88
114
|
const approvals: AiApprovalPreferenceView[] = preferences.map((preference) => {
|
|
115
|
+
if (preference.toolName === AI_WEBSITE_APPROVAL_TOOL) {
|
|
116
|
+
const website = parseAiWebsiteApprovalScope(preference.approvalScope);
|
|
117
|
+
return {
|
|
118
|
+
...preference,
|
|
119
|
+
title: URL.canParse(website.origin) ? new URL(website.origin).host : website.origin,
|
|
120
|
+
app: null,
|
|
121
|
+
website,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
89
124
|
const capability = catalogByName.get(preference.toolName);
|
|
90
125
|
return {
|
|
91
126
|
...preference,
|
|
127
|
+
website: null,
|
|
92
128
|
title: capability?.display.title ?? fallbackTitle(preference.toolName),
|
|
93
129
|
app: capability
|
|
94
130
|
? {
|
package/src/ai/approvals.ts
CHANGED
|
@@ -5,14 +5,41 @@ import type { AiToolApprovalPolicy, AiTurnRunConfig } from "./types";
|
|
|
5
5
|
export const aiTurnAllowsRememberedApprovals = (config: AiTurnRunConfig | null | undefined): boolean =>
|
|
6
6
|
config == null || config.kind === "compact" || config.mandate === undefined;
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Reserved remembered-approval name for reading one website in one chat or one managed app. No capability or tool can
|
|
10
|
+
* collide with it: tool names never contain a colon.
|
|
11
|
+
*/
|
|
12
|
+
export const AI_WEBSITE_APPROVAL_TOOL = "website:read";
|
|
13
|
+
|
|
14
|
+
/** The remembered scope of a website approval: one exact origin, in a chat or for one managed Studio app. */
|
|
15
|
+
export const aiWebsiteApprovalScope = (origin: string, resourceId?: string): string =>
|
|
16
|
+
resourceId ? `${origin} resource:${resourceId}` : origin;
|
|
17
|
+
|
|
18
|
+
export const parseAiWebsiteApprovalScope = (scope: string): { origin: string; resourceId: string | null } => {
|
|
19
|
+
const [origin = scope, resource] = scope.split(" ");
|
|
20
|
+
return { origin, resourceId: resource?.startsWith("resource:") ? resource.slice("resource:".length) : null };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Website approvals apply only to a turn a person started in a signed-in browser session: never to a scheduled task,
|
|
25
|
+
* a mandate, `cld`, an API key, or another delegated credential.
|
|
26
|
+
*/
|
|
27
|
+
export const aiTurnAllowsWebsiteApprovals = (config: AiTurnRunConfig | null | undefined): boolean =>
|
|
28
|
+
config != null && config.kind !== "compact" && config.signedInSession === true && aiTurnAllowsRememberedApprovals(config);
|
|
29
|
+
|
|
8
30
|
export type AiToolApprovalContext = {
|
|
9
31
|
actorUserId: string;
|
|
10
32
|
};
|
|
11
33
|
|
|
34
|
+
/** Where a remembered approval applies: in one chat until it is deleted, or always. */
|
|
35
|
+
export type AiToolApprovalRemember = "chat" | "always";
|
|
36
|
+
|
|
12
37
|
export type AiToolApprovalPreference = {
|
|
13
38
|
id: string;
|
|
14
39
|
toolName: string;
|
|
15
40
|
approvalScope: string;
|
|
41
|
+
/** The chat this approval is limited to; null for an approval that applies everywhere. */
|
|
42
|
+
conversationId: string | null;
|
|
16
43
|
createdAt: string;
|
|
17
44
|
lastUsedAt: string | null;
|
|
18
45
|
expiresAt: string | null;
|
|
@@ -22,6 +49,7 @@ type AiToolApprovalPreferenceRow = {
|
|
|
22
49
|
id: string;
|
|
23
50
|
tool_name: string;
|
|
24
51
|
approval_scope: string;
|
|
52
|
+
conversation_id: string | null;
|
|
25
53
|
created_at: Date | string;
|
|
26
54
|
last_used_at: Date | string | null;
|
|
27
55
|
expires_at: Date | string | null;
|
|
@@ -37,36 +65,52 @@ export const aiToolNeedsApproval = (policy: AiToolApprovalPolicy | undefined): b
|
|
|
37
65
|
export const aiToolAllowsAlways = (policy: AiToolApprovalPolicy | undefined): boolean =>
|
|
38
66
|
policy === "always" || (typeof policy === "object" && policy.kind === "user-configurable");
|
|
39
67
|
|
|
40
|
-
|
|
68
|
+
/**
|
|
69
|
+
* The remembered approval that covers this tool and scope: one that applies everywhere, or one limited to
|
|
70
|
+
* `conversationId`; with `chatOnly`, only the latter. Returns its ID and marks it used.
|
|
71
|
+
*/
|
|
72
|
+
export const findRememberedAiToolApproval = async (
|
|
41
73
|
context: AiToolApprovalContext,
|
|
42
|
-
input: { toolName: string; approvalScope: string },
|
|
43
|
-
): Promise<
|
|
74
|
+
input: { toolName: string; approvalScope: string; conversationId?: string | null; chatOnly?: boolean },
|
|
75
|
+
): Promise<string | null> => {
|
|
76
|
+
const conversationId = input.conversationId ?? null;
|
|
77
|
+
if (input.chatOnly && !conversationId) return null;
|
|
44
78
|
const rows = await sql<{ id: string }[]>`
|
|
45
79
|
SELECT id
|
|
46
80
|
FROM ai.tool_approval_preferences
|
|
47
81
|
WHERE actor_user_id = ${context.actorUserId}
|
|
48
82
|
AND tool_name = ${input.toolName}
|
|
49
83
|
AND approval_scope = ${input.approvalScope}
|
|
84
|
+
AND (conversation_id = ${conversationId}::uuid OR (conversation_id IS NULL AND NOT ${input.chatOnly === true}))
|
|
50
85
|
AND (expires_at IS NULL OR expires_at > now())
|
|
86
|
+
ORDER BY conversation_id NULLS LAST
|
|
51
87
|
LIMIT 1
|
|
52
88
|
`;
|
|
53
89
|
|
|
54
90
|
const id = rows[0]?.id;
|
|
55
|
-
if (!id) return
|
|
91
|
+
if (!id) return null;
|
|
56
92
|
await sql`UPDATE ai.tool_approval_preferences SET last_used_at = now() WHERE id = ${id}`;
|
|
57
|
-
return
|
|
93
|
+
return id;
|
|
58
94
|
};
|
|
59
95
|
|
|
96
|
+
export const hasRememberedAiToolApproval = async (
|
|
97
|
+
context: AiToolApprovalContext,
|
|
98
|
+
input: { toolName: string; approvalScope: string; conversationId?: string | null },
|
|
99
|
+
): Promise<boolean> => (await findRememberedAiToolApproval(context, input)) !== null;
|
|
100
|
+
|
|
101
|
+
/** Remembers an approval everywhere, or in one chat when `conversationId` is set; a chat approval ends with its chat. */
|
|
60
102
|
export const rememberAiToolApproval = async (
|
|
61
103
|
context: AiToolApprovalContext,
|
|
62
|
-
input: { toolName: string; approvalScope: string; expiresAt?: Date | null },
|
|
104
|
+
input: { toolName: string; approvalScope: string; conversationId?: string | null; expiresAt?: Date | null },
|
|
63
105
|
): Promise<void> => {
|
|
106
|
+
const conversationId = input.conversationId ?? null;
|
|
64
107
|
await sql.begin(async () => {
|
|
65
108
|
await sql`
|
|
66
109
|
DELETE FROM ai.tool_approval_preferences
|
|
67
110
|
WHERE actor_user_id = ${context.actorUserId}
|
|
68
111
|
AND tool_name = ${input.toolName}
|
|
69
112
|
AND approval_scope = ${input.approvalScope}
|
|
113
|
+
AND conversation_id IS NOT DISTINCT FROM ${conversationId}::uuid
|
|
70
114
|
`;
|
|
71
115
|
|
|
72
116
|
await sql`
|
|
@@ -74,6 +118,7 @@ export const rememberAiToolApproval = async (
|
|
|
74
118
|
actor_user_id,
|
|
75
119
|
tool_name,
|
|
76
120
|
approval_scope,
|
|
121
|
+
conversation_id,
|
|
77
122
|
last_used_at,
|
|
78
123
|
expires_at
|
|
79
124
|
)
|
|
@@ -81,6 +126,7 @@ export const rememberAiToolApproval = async (
|
|
|
81
126
|
${context.actorUserId},
|
|
82
127
|
${input.toolName},
|
|
83
128
|
${input.approvalScope},
|
|
129
|
+
${conversationId}::uuid,
|
|
84
130
|
now(),
|
|
85
131
|
${input.expiresAt ?? null}
|
|
86
132
|
)
|
|
@@ -88,16 +134,20 @@ export const rememberAiToolApproval = async (
|
|
|
88
134
|
});
|
|
89
135
|
};
|
|
90
136
|
|
|
137
|
+
/** Forgets the approval with exactly this reach: everywhere, or in the one chat `conversationId` names. */
|
|
91
138
|
export const forgetAiToolApproval = async (
|
|
92
139
|
context: AiToolApprovalContext,
|
|
93
|
-
input: { toolName: string; approvalScope: string },
|
|
94
|
-
): Promise<
|
|
95
|
-
await sql
|
|
140
|
+
input: { toolName: string; approvalScope: string; conversationId?: string | null },
|
|
141
|
+
): Promise<boolean> => {
|
|
142
|
+
const rows = await sql<{ id: string }[]>`
|
|
96
143
|
DELETE FROM ai.tool_approval_preferences
|
|
97
144
|
WHERE actor_user_id = ${context.actorUserId}
|
|
98
145
|
AND tool_name = ${input.toolName}
|
|
99
146
|
AND approval_scope = ${input.approvalScope}
|
|
147
|
+
AND conversation_id IS NOT DISTINCT FROM ${input.conversationId ?? null}::uuid
|
|
148
|
+
RETURNING id
|
|
100
149
|
`;
|
|
150
|
+
return rows.length > 0;
|
|
101
151
|
};
|
|
102
152
|
|
|
103
153
|
const toIsoString = (value: Date | string): string => (value instanceof Date ? value : new Date(value)).toISOString();
|
|
@@ -106,22 +156,30 @@ const approvalPreferenceFromRow = (row: AiToolApprovalPreferenceRow): AiToolAppr
|
|
|
106
156
|
id: row.id,
|
|
107
157
|
toolName: row.tool_name,
|
|
108
158
|
approvalScope: row.approval_scope,
|
|
159
|
+
conversationId: row.conversation_id,
|
|
109
160
|
createdAt: toIsoString(row.created_at),
|
|
110
161
|
lastUsedAt: row.last_used_at ? toIsoString(row.last_used_at) : null,
|
|
111
162
|
expiresAt: row.expires_at ? toIsoString(row.expires_at) : null,
|
|
112
163
|
});
|
|
113
164
|
|
|
114
|
-
|
|
165
|
+
/** Lists the approvals that apply everywhere, or only those of one chat when `conversationId` is set. */
|
|
166
|
+
export const listAiToolApprovalPreferences = async (
|
|
167
|
+
actorUserId: string,
|
|
168
|
+
options: { conversationId?: string } = {},
|
|
169
|
+
): Promise<AiToolApprovalPreference[]> => {
|
|
170
|
+
const conversationId = options.conversationId ?? null;
|
|
115
171
|
const rows = await sql<AiToolApprovalPreferenceRow[]>`
|
|
116
172
|
SELECT
|
|
117
173
|
id,
|
|
118
174
|
tool_name,
|
|
119
175
|
approval_scope,
|
|
176
|
+
conversation_id,
|
|
120
177
|
created_at,
|
|
121
178
|
last_used_at,
|
|
122
179
|
expires_at
|
|
123
180
|
FROM ai.tool_approval_preferences
|
|
124
181
|
WHERE actor_user_id = ${actorUserId}
|
|
182
|
+
AND conversation_id IS NOT DISTINCT FROM ${conversationId}::uuid
|
|
125
183
|
AND (expires_at IS NULL OR expires_at > now())
|
|
126
184
|
ORDER BY last_used_at DESC NULLS LAST, created_at DESC, id
|
|
127
185
|
`;
|