@k2b/cloud 0.33.0 → 0.34.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 -3
- package/src/_internal/app-presentation.ts +40 -11
- package/src/_internal/define-app.ts +1 -0
- package/src/_internal/registry.ts +10 -1
- package/src/_internal/widget-declarations.ts +16 -3
- package/src/_internal/widget-settings.ts +93 -0
- package/src/ai/capabilities.ts +8 -5
- package/src/ai/resource-refs.ts +35 -3
- package/src/ai/system-prompt.ts +3 -2
- package/src/api/index.ts +2 -0
- package/src/api/mcp.ts +48 -17
- package/src/api/pwa.ts +7 -2
- package/src/api/resource-links.ts +43 -0
- package/src/api/widgets.ts +163 -82
- package/src/browser/CloudResourceSearch.tsx +6 -6
- package/src/browser/command-shortcuts.ts +2 -11
- package/src/browser/resource-picker.tsx +1 -0
- package/src/browser/resource-search.ts +7 -2
- package/src/browser/widgets.ts +23 -5
- package/src/capabilities/testing.ts +13 -1
- package/src/cli/capabilities.ts +61 -17
- package/src/cli/index.ts +2 -0
- package/src/cli/resource-links.ts +13 -0
- package/src/contracts/app.ts +7 -1
- package/src/contracts/capabilities.ts +25 -5
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/resource-href.ts +7 -0
- package/src/contracts/widgets.ts +80 -6
- package/src/desktop/index.ts +3 -2
- package/src/desktop/solid.tsx +7 -5
- package/src/server/widget-request.ts +34 -5
- package/src/services/app-approval.ts +4 -2
- package/src/services/approvals/index.ts +40 -11
- package/src/services/approvals/list.ts +1 -0
- package/src/services/approvals/migrate.ts +10 -0
- package/src/services/pdf/markdown.ts +11 -7
- package/src/services/pwa-devices.ts +68 -15
- package/src/shared/app-presentation.ts +49 -1
- package/src/shared/index.ts +1 -0
- package/src/shared/markdown/callout-sanitizer.ts +37 -0
- package/src/shared/markdown/index.ts +8 -2
- package/src/ssr/Layout.tsx +3 -15
- package/src/ssr/LayoutHelp.browser-harness.tsx +43 -0
- package/src/ssr/LayoutHelp.tsx +319 -183
- package/src/ssr/LayoutHelpTrigger.tsx +1 -3
- package/src/ssr/LayoutPreferences.tsx +15 -5
- package/src/ssr/MinimalLayoutPreferences.island.tsx +3 -9
- package/src/ssr/MobileProfileActions.tsx +3 -8
- package/src/ssr/ProfilePreferences.tsx +8 -10
- package/src/ssr/ShellRuntime.island.tsx +1 -5
- package/src/ssr/ThemeCycleButton.tsx +46 -0
- package/src/ssr/app-appearance.ts +6 -1
- package/src/ssr/layout-help-url.ts +39 -0
- package/src/ssr/platform-messages.ts +0 -8
- package/src/ssr/preference-controller.ts +53 -19
- package/src/ssr/profile-actions.ts +12 -16
- package/src/ssr/profile-preferences-messages.ts +4 -2
- package/src/ssr/rail-navigation.ts +3 -0
- package/src/ssr/shell-data.tsx +2 -2
- package/src/styles/effects.css +13 -3
- package/src/styles/global.css +27 -1
- package/src/styles/input.css +0 -8
- package/src/styles/tokens.css +0 -2
- package/src/styles/utilities-navigation.css +10 -13
- package/src/ssr/GlobalSearchHelpDialog.tsx +0 -104
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.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": {
|
|
@@ -106,8 +106,8 @@
|
|
|
106
106
|
"@tabler/icons": "3.48.0",
|
|
107
107
|
"@tailwindcss/typography": "0.5.20",
|
|
108
108
|
"@k2b/nessi": "0.18.0",
|
|
109
|
-
"@k2b/ssr": "0.
|
|
110
|
-
"@k2b/ui": "0.
|
|
109
|
+
"@k2b/ssr": "0.16.0",
|
|
110
|
+
"@k2b/ui": "0.25.0",
|
|
111
111
|
"@k2b/stdlib": "0.29.0",
|
|
112
112
|
"@k2b/sync": "7.0.0",
|
|
113
113
|
"@nats-io/jetstream": "3.4.0",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AppMeta, AppPresentationCatalog, AppPresentationTranslation, AppWidgetTranslation } from "../contracts/app";
|
|
1
|
+
import type { AppMeta, AppPresentationCatalog, AppPresentationTranslation, AppWidgetTranslation, WidgetEndpoint } from "../contracts/app";
|
|
2
2
|
import { WIDGET_DESCRIPTION_MAX_LENGTH, WIDGET_TITLE_MAX_LENGTH } from "../contracts/widgets";
|
|
3
3
|
import { canonicalLocale } from "../shared/locale";
|
|
4
4
|
|
|
@@ -14,23 +14,53 @@ const text = (value: string, label: string, max = MAX_SHORT_TEXT): string => {
|
|
|
14
14
|
|
|
15
15
|
const labels = (values: Readonly<Record<string, string>> | undefined, allowed: Set<string>, label: string, max = MAX_SHORT_TEXT) => {
|
|
16
16
|
if (!values) return undefined;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
return Object.fromEntries(
|
|
18
|
+
Object.entries(values).map(([key, value]) => {
|
|
19
|
+
if (!allowed.has(key)) throw new Error(`${label} contains unknown key ${JSON.stringify(key)}`);
|
|
20
|
+
return [key, text(value, `${label}.${key}`, max)];
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
const widgetTexts = (
|
|
26
26
|
values: Readonly<Record<string, AppWidgetTranslation>>,
|
|
27
|
-
|
|
27
|
+
widgets: readonly WidgetEndpoint[],
|
|
28
28
|
label: string,
|
|
29
29
|
): Record<string, AppWidgetTranslation> => {
|
|
30
30
|
const normalized: Record<string, AppWidgetTranslation> = {};
|
|
31
31
|
for (const [id, value] of Object.entries(values)) {
|
|
32
|
-
|
|
32
|
+
const widget = widgets.find((widget) => widget.id === id);
|
|
33
|
+
if (!widget) throw new Error(`${label} contains unknown key ${JSON.stringify(id)}`);
|
|
34
|
+
const settings: NonNullable<AppWidgetTranslation["settings"]> = Object.fromEntries(
|
|
35
|
+
Object.entries(value.settings ?? {}).map(([name, translation]) => {
|
|
36
|
+
const field = widget.settings?.[name];
|
|
37
|
+
const settingLabel = `${label}.${id}.settings.${name}`;
|
|
38
|
+
if (!field) throw new Error(`${settingLabel} is not declared`);
|
|
39
|
+
return [
|
|
40
|
+
name,
|
|
41
|
+
{
|
|
42
|
+
...(translation.label !== undefined
|
|
43
|
+
? { label: text(translation.label, `${settingLabel}.label`, WIDGET_TITLE_MAX_LENGTH) }
|
|
44
|
+
: {}),
|
|
45
|
+
...(translation.placeholder !== undefined
|
|
46
|
+
? { placeholder: text(translation.placeholder, `${settingLabel}.placeholder`, WIDGET_TITLE_MAX_LENGTH) }
|
|
47
|
+
: {}),
|
|
48
|
+
...(translation.options
|
|
49
|
+
? {
|
|
50
|
+
options: labels(
|
|
51
|
+
translation.options,
|
|
52
|
+
new Set(field.kind === "choice" ? field.options.map((option) => option.value) : []),
|
|
53
|
+
`${settingLabel}.options`,
|
|
54
|
+
WIDGET_TITLE_MAX_LENGTH,
|
|
55
|
+
),
|
|
56
|
+
}
|
|
57
|
+
: {}),
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
33
62
|
normalized[id] = {
|
|
63
|
+
...(value.settings ? { settings } : {}),
|
|
34
64
|
...(value.title !== undefined ? { title: text(value.title, `${label}.${id}.title`, WIDGET_TITLE_MAX_LENGTH) } : {}),
|
|
35
65
|
...(value.description !== undefined
|
|
36
66
|
? { description: text(value.description, `${label}.${id}.description`, WIDGET_DESCRIPTION_MAX_LENGTH) }
|
|
@@ -55,7 +85,6 @@ export const compileAppPresentation = (app: AppMeta, catalog: AppPresentationCat
|
|
|
55
85
|
const adminHrefs = new Set((app.adminNav ?? []).flatMap((group) => group.links.map((link) => link.href)));
|
|
56
86
|
const searchHrefs = new Set((app.searchLinks ?? []).map((link) => link.href));
|
|
57
87
|
const legalHrefs = new Set((app.legalLinks ?? []).map((link) => link.href));
|
|
58
|
-
const widgetIds = new Set((app.widgets ?? []).map((widget) => widget.id));
|
|
59
88
|
const translations: Record<string, AppPresentationTranslation> = {};
|
|
60
89
|
|
|
61
90
|
for (const [locale, translation] of Object.entries(catalog.translations)) {
|
|
@@ -82,7 +111,7 @@ export const compileAppPresentation = (app: AppMeta, catalog: AppPresentationCat
|
|
|
82
111
|
}
|
|
83
112
|
: {}),
|
|
84
113
|
...(translation.legalLinks ? { legalLinks: labels(translation.legalLinks, legalHrefs, `${canonical}.legalLinks`) } : {}),
|
|
85
|
-
...(translation.widgets ? { widgets: widgetTexts(translation.widgets,
|
|
114
|
+
...(translation.widgets ? { widgets: widgetTexts(translation.widgets, app.widgets ?? [], `${canonical}.widgets`) } : {}),
|
|
86
115
|
};
|
|
87
116
|
}
|
|
88
117
|
|
|
@@ -629,6 +629,7 @@ export const defineApp = <
|
|
|
629
629
|
: undefined,
|
|
630
630
|
widgets: meta.widgets?.map((widget) => ({
|
|
631
631
|
...widget,
|
|
632
|
+
...(widget.settings ? { settings: structuredClone(widget.settings) } : {}),
|
|
632
633
|
sizes: widget.sizes ? [...widget.sizes] : undefined,
|
|
633
634
|
requiresRoles: widget.requiresRoles ? [...widget.requiresRoles] : undefined,
|
|
634
635
|
})),
|
|
@@ -10,12 +10,14 @@ import {
|
|
|
10
10
|
resolveWidgetSizes,
|
|
11
11
|
WIDGET_DESCRIPTION_MAX_LENGTH,
|
|
12
12
|
WIDGET_TITLE_MAX_LENGTH,
|
|
13
|
+
type WidgetSettingsDeclaration,
|
|
13
14
|
} from "../contracts/widgets";
|
|
14
15
|
import { resolveAppPresentations } from "../shared/app-presentation";
|
|
15
16
|
import { compileCapabilityPresentation, parseCapabilityManifest } from "./capabilities";
|
|
16
17
|
import { lazySync } from "./process-sync";
|
|
17
18
|
import { validateAppRegistryEntry } from "./registry-validation";
|
|
18
19
|
import { watchRegistryChanges } from "./registry-watch";
|
|
20
|
+
import { WidgetSettingsDeclarationSchema } from "./widget-settings";
|
|
19
21
|
|
|
20
22
|
/**
|
|
21
23
|
* Shared app registry: two @k2b/sync ephemerals on the process Sync
|
|
@@ -356,6 +358,8 @@ export type DashboardWidget = {
|
|
|
356
358
|
description: string;
|
|
357
359
|
sizes: DashboardWidgetSize[];
|
|
358
360
|
defaultSize: DashboardWidgetSize;
|
|
361
|
+
/** Validated setting presentation in the requested locale, absent when none is valid. */
|
|
362
|
+
settings?: WidgetSettingsDeclaration;
|
|
359
363
|
suggest: boolean;
|
|
360
364
|
/** Visibility only, like `nav.requiresRoles`; empty means everyone. */
|
|
361
365
|
requiresRoles: Role[];
|
|
@@ -381,9 +385,13 @@ const declaredText = (value: unknown, max: number): string | undefined =>
|
|
|
381
385
|
/** The widgets `apps` declare, resolved as `listWidgets()` returns them. */
|
|
382
386
|
export const dashboardWidgetsOf = (apps: readonly AppRegistryEntry[], locale?: string): DashboardWidget[] => {
|
|
383
387
|
const out: DashboardWidget[] = [];
|
|
384
|
-
for (const app of locale ? resolveAppPresentations(apps, locale) : apps) {
|
|
388
|
+
for (const [index, app] of (locale ? resolveAppPresentations(apps, locale) : apps).entries()) {
|
|
385
389
|
for (const w of app.widgets ?? []) {
|
|
386
390
|
const { sizes, defaultSize } = resolveWidgetSizes(w);
|
|
391
|
+
const translated = WidgetSettingsDeclarationSchema.safeParse(w.settings);
|
|
392
|
+
const original = translated.success
|
|
393
|
+
? translated
|
|
394
|
+
: WidgetSettingsDeclarationSchema.safeParse(apps[index]?.widgets?.find((widget) => widget.id === w.id)?.settings);
|
|
387
395
|
out.push({
|
|
388
396
|
appId: app.id,
|
|
389
397
|
appName: app.name,
|
|
@@ -394,6 +402,7 @@ export const dashboardWidgetsOf = (apps: readonly AppRegistryEntry[], locale?: s
|
|
|
394
402
|
description: declaredText(w.description, WIDGET_DESCRIPTION_MAX_LENGTH) ?? app.description,
|
|
395
403
|
sizes,
|
|
396
404
|
defaultSize,
|
|
405
|
+
...(original.success ? { settings: original.data } : {}),
|
|
397
406
|
suggest: w.suggest === true,
|
|
398
407
|
requiresRoles: Array.isArray(w.requiresRoles) ? w.requiresRoles.filter((role) => RoleSchema.safeParse(role).success) : [],
|
|
399
408
|
presentation: legacyPresentation(w.presentation),
|
|
@@ -4,8 +4,10 @@ import {
|
|
|
4
4
|
DASHBOARD_WIDGET_SIZES,
|
|
5
5
|
isDashboardWidgetSize,
|
|
6
6
|
WIDGET_DESCRIPTION_MAX_LENGTH,
|
|
7
|
+
WIDGET_ID_MAX_LENGTH,
|
|
7
8
|
WIDGET_TITLE_MAX_LENGTH,
|
|
8
9
|
} from "../contracts/widgets";
|
|
10
|
+
import { WidgetSettingsDeclarationSchema } from "./widget-settings";
|
|
9
11
|
|
|
10
12
|
const text = (value: string | undefined, label: string, max: number): string | undefined => {
|
|
11
13
|
if (value === undefined) return undefined;
|
|
@@ -16,9 +18,9 @@ const text = (value: string | undefined, label: string, max: number): string | u
|
|
|
16
18
|
|
|
17
19
|
/**
|
|
18
20
|
* 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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
21
|
+
* silently missing from the gallery: unique ids that fit Core's `widget.read:<id>` invocation, without `@`, which
|
|
22
|
+
* separates a widget's key from the requested size, bounded titles and descriptions, known sizes with the default
|
|
23
|
+
* among them, a boolean `suggest`, known roles, and a bounded settings declaration.
|
|
22
24
|
*/
|
|
23
25
|
export const compileWidgetDeclarations = (
|
|
24
26
|
appId: string,
|
|
@@ -30,6 +32,11 @@ export const compileWidgetDeclarations = (
|
|
|
30
32
|
const label = `Widget ${JSON.stringify(widget.id)} of app ${JSON.stringify(appId)}`;
|
|
31
33
|
if (!widget.id.trim()) throw new Error(`Widgets of app ${JSON.stringify(appId)} need a non-empty id`);
|
|
32
34
|
if (widget.id.includes("@")) throw new Error(`${label} must not contain "@"`);
|
|
35
|
+
if (!/^[a-z0-9:._-]+$/.test(widget.id) || widget.id.length > WIDGET_ID_MAX_LENGTH) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`${label} id must use 1 to ${WIDGET_ID_MAX_LENGTH} lowercase letters, digits, ".", "_", "-", or ":" because Core invokes it as widget.read:<id>`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
33
40
|
if (ids.has(widget.id)) throw new Error(`${label} is declared twice`);
|
|
34
41
|
ids.add(widget.id);
|
|
35
42
|
const sizes = widget.sizes ? [...widget.sizes] : undefined;
|
|
@@ -42,8 +49,14 @@ export const compileWidgetDeclarations = (
|
|
|
42
49
|
if (widget.suggest !== undefined && typeof widget.suggest !== "boolean")
|
|
43
50
|
throw new Error(`${label} must declare suggest as true or false`);
|
|
44
51
|
if (widget.requiresRoles?.some((role) => !RoleSchema.safeParse(role).success)) throw new Error(`${label} requires an unknown role`);
|
|
52
|
+
const settings = widget.settings === undefined ? undefined : WidgetSettingsDeclarationSchema.safeParse(widget.settings);
|
|
53
|
+
if (settings && !settings.success) {
|
|
54
|
+
const issue = settings.error.issues[0]!;
|
|
55
|
+
throw new Error(`${label} setting ${JSON.stringify(issue.path[0] ?? "settings")}: ${issue.message}`);
|
|
56
|
+
}
|
|
45
57
|
return {
|
|
46
58
|
...widget,
|
|
59
|
+
...(settings?.success ? { settings: settings.data } : {}),
|
|
47
60
|
title: text(widget.title, `${label} title`, WIDGET_TITLE_MAX_LENGTH),
|
|
48
61
|
description: text(widget.description, `${label} description`, WIDGET_DESCRIPTION_MAX_LENGTH),
|
|
49
62
|
sizes: sizes ? DASHBOARD_WIDGET_SIZES.filter((size) => sizes.includes(size)) : undefined,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { CapabilityQualifiedIdSchema, CloudResourceRefSchema } from "../contracts/capabilities";
|
|
3
|
+
import {
|
|
4
|
+
WIDGET_SETTING_MAX_OPTIONS,
|
|
5
|
+
WIDGET_SETTING_NAME_MAX_LENGTH,
|
|
6
|
+
WIDGET_SETTING_TEXT_MAX_LENGTH,
|
|
7
|
+
WIDGET_SETTINGS_MAX_FIELDS,
|
|
8
|
+
WIDGET_TITLE_MAX_LENGTH,
|
|
9
|
+
type WidgetSettings,
|
|
10
|
+
type WidgetSettingsDeclaration,
|
|
11
|
+
type WidgetSettingValue,
|
|
12
|
+
} from "../contracts/widgets";
|
|
13
|
+
|
|
14
|
+
const controlCharacters = /[\u0000-\u001f\u007f-\u009f]/u;
|
|
15
|
+
const settingLabel = z.string().trim().min(1).max(WIDGET_TITLE_MAX_LENGTH);
|
|
16
|
+
/** The single declaration schema used at startup and for tolerant registry reads. */
|
|
17
|
+
export const WidgetSettingsDeclarationSchema = z
|
|
18
|
+
.record(
|
|
19
|
+
z.string().regex(new RegExp(`^[a-z][a-zA-Z0-9]{0,${WIDGET_SETTING_NAME_MAX_LENGTH - 1}}$`)),
|
|
20
|
+
z.discriminatedUnion("kind", [
|
|
21
|
+
z
|
|
22
|
+
.object({
|
|
23
|
+
kind: z.literal("choice"),
|
|
24
|
+
label: settingLabel,
|
|
25
|
+
options: z
|
|
26
|
+
.array(
|
|
27
|
+
z.object({
|
|
28
|
+
label: settingLabel,
|
|
29
|
+
value: z
|
|
30
|
+
.string()
|
|
31
|
+
.min(1)
|
|
32
|
+
.max(100)
|
|
33
|
+
.refine((value) => !controlCharacters.test(value)),
|
|
34
|
+
}),
|
|
35
|
+
)
|
|
36
|
+
.min(1)
|
|
37
|
+
.max(WIDGET_SETTING_MAX_OPTIONS),
|
|
38
|
+
default: z.string().optional(),
|
|
39
|
+
})
|
|
40
|
+
.superRefine((field, ctx) => {
|
|
41
|
+
if (new Set(field.options.map((option) => option.value)).size !== field.options.length)
|
|
42
|
+
ctx.addIssue({ code: "custom", path: ["options"], message: "Choice option values must be distinct" });
|
|
43
|
+
if (field.default !== undefined && !field.options.some((option) => option.value === field.default))
|
|
44
|
+
ctx.addIssue({ code: "custom", path: ["default"], message: "Choice default must be a declared option" });
|
|
45
|
+
}),
|
|
46
|
+
z.object({ kind: z.literal("resource"), label: settingLabel, type: CapabilityQualifiedIdSchema }),
|
|
47
|
+
z.object({ kind: z.literal("toggle"), label: settingLabel, default: z.boolean().optional() }),
|
|
48
|
+
z.object({
|
|
49
|
+
kind: z.literal("text"),
|
|
50
|
+
label: settingLabel,
|
|
51
|
+
placeholder: settingLabel.optional(),
|
|
52
|
+
maxLength: z.number().int().min(1).max(WIDGET_SETTING_TEXT_MAX_LENGTH).optional(),
|
|
53
|
+
}),
|
|
54
|
+
]),
|
|
55
|
+
)
|
|
56
|
+
.superRefine((fields, ctx) => {
|
|
57
|
+
for (const name of Object.keys(fields)) {
|
|
58
|
+
if (name in Object.prototype)
|
|
59
|
+
ctx.addIssue({ code: "custom", path: [name], message: "Setting names must not be Object.prototype members" });
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
.refine((fields) => Object.keys(fields).length <= WIDGET_SETTINGS_MAX_FIELDS, `Declare at most ${WIDGET_SETTINGS_MAX_FIELDS} settings`);
|
|
63
|
+
|
|
64
|
+
/** Drop invalid or undeclared values and fill choice and toggle defaults without reporting input errors. */
|
|
65
|
+
export const parseWidgetSettings = (declaration: WidgetSettingsDeclaration | undefined, raw: unknown): WidgetSettings => {
|
|
66
|
+
const values = z.record(z.string(), z.unknown()).safeParse(raw);
|
|
67
|
+
const settings: Record<string, WidgetSettingValue> = {};
|
|
68
|
+
for (const [name, field] of Object.entries(declaration ?? {})) {
|
|
69
|
+
const value = values.success && Object.hasOwn(values.data, name) ? values.data[name] : undefined;
|
|
70
|
+
switch (field.kind) {
|
|
71
|
+
case "choice":
|
|
72
|
+
if (typeof value === "string" && field.options.some((option) => option.value === value)) settings[name] = value;
|
|
73
|
+
else if (field.default !== undefined) settings[name] = field.default;
|
|
74
|
+
break;
|
|
75
|
+
case "toggle":
|
|
76
|
+
if (typeof value === "boolean") settings[name] = value;
|
|
77
|
+
else if (field.default !== undefined) settings[name] = field.default;
|
|
78
|
+
break;
|
|
79
|
+
case "text": {
|
|
80
|
+
if (typeof value !== "string" || controlCharacters.test(value)) break;
|
|
81
|
+
const text = value.trim();
|
|
82
|
+
if (text && text.length <= (field.maxLength ?? WIDGET_SETTING_TEXT_MAX_LENGTH)) settings[name] = text;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
case "resource": {
|
|
86
|
+
const ref = CloudResourceRefSchema.safeParse(value);
|
|
87
|
+
if (ref.success && ref.data.type === field.type) settings[name] = ref.data;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return settings;
|
|
93
|
+
};
|
package/src/ai/capabilities.ts
CHANGED
|
@@ -15,8 +15,8 @@ import {
|
|
|
15
15
|
type CapabilityActionManifest,
|
|
16
16
|
type CapabilityActionReview,
|
|
17
17
|
type CapabilityQueryManifest,
|
|
18
|
-
CloudResourceRefSchema,
|
|
19
18
|
cloudResourceRefAppId,
|
|
19
|
+
LinkedCloudResourceReferenceSchema,
|
|
20
20
|
resolveCapabilityResourceReader,
|
|
21
21
|
} from "../contracts/capabilities";
|
|
22
22
|
import type { CapabilityRegistryEntry } from "../contracts/registry";
|
|
@@ -29,6 +29,7 @@ import { recordRejectedAiCapability } from "./capability-execution";
|
|
|
29
29
|
import { CODE_SOURCE_TOOLS } from "./code-source-contracts";
|
|
30
30
|
import { createCodeSourceTool } from "./code-source-tools";
|
|
31
31
|
import { CLOUD_AI_DEFERRED_BUILTIN_TOOL_NAMES } from "./default-tools";
|
|
32
|
+
import { addCloudResourceHrefs } from "./resource-refs";
|
|
32
33
|
import { type AiToolPreparationContext, defineAiTool, type PreparedAiTools, prepareAiTools } from "./tools";
|
|
33
34
|
import type { AiConversationService, AiRuntimeTool, AiToolPresentation } from "./types";
|
|
34
35
|
|
|
@@ -638,6 +639,7 @@ export const createAiToolMetaTools = (input: {
|
|
|
638
639
|
};
|
|
639
640
|
|
|
640
641
|
export const createLoadedAiCapabilityTools = (input: {
|
|
642
|
+
apps: readonly CapabilityRegistryEntry[];
|
|
641
643
|
catalog: readonly AiCapabilityCatalogEntry[];
|
|
642
644
|
loadedNames: readonly string[];
|
|
643
645
|
actor: RequestActor;
|
|
@@ -686,7 +688,7 @@ export const createLoadedAiCapabilityTools = (input: {
|
|
|
686
688
|
throw new Error("Capability Action was rejected by the user.");
|
|
687
689
|
}
|
|
688
690
|
}
|
|
689
|
-
return input.execute(entry, args, context);
|
|
691
|
+
return addCloudResourceHrefs(await input.execute(entry, args, context), input.apps);
|
|
690
692
|
}),
|
|
691
693
|
];
|
|
692
694
|
});
|
|
@@ -701,14 +703,14 @@ export const createAiResourceReaderTool = (input: {
|
|
|
701
703
|
name: "read_cloud_resource",
|
|
702
704
|
description:
|
|
703
705
|
"Read a Cloud resource from its structured reference using the resource type's current canonical reader. Pass refs returned by search, Projects, or other capabilities unchanged; never substitute an id from another resource type.",
|
|
704
|
-
inputSchema:
|
|
706
|
+
inputSchema: LinkedCloudResourceReferenceSchema,
|
|
705
707
|
outputSchema: z.unknown(),
|
|
706
708
|
approval: "never",
|
|
707
709
|
}).server(async (ref, context) => {
|
|
708
710
|
if (ref.type === "assistant.artifact") {
|
|
709
711
|
const tool = createCodeSourceTool("code_read");
|
|
710
712
|
if (tool.location !== "server") throw new Error("Code reader must execute on the server.");
|
|
711
|
-
return tool.run(CODE_SOURCE_TOOLS.code_read.input.parse({ id: ref.id }), context);
|
|
713
|
+
return addCloudResourceHrefs(await tool.run(CODE_SOURCE_TOOLS.code_read.input.parse({ id: ref.id }), context), input.apps);
|
|
712
714
|
}
|
|
713
715
|
const appId = cloudResourceRefAppId(ref);
|
|
714
716
|
const app = input.apps.find((candidate) => candidate.appId === appId);
|
|
@@ -718,7 +720,7 @@ export const createAiResourceReaderTool = (input: {
|
|
|
718
720
|
(candidate) => candidate.appId === appId && candidate.kind === "query" && candidate.operation.localId === reader.localId,
|
|
719
721
|
);
|
|
720
722
|
if (!entry) throw new Error(`Cloud resource reader ${appId}.${reader.localId} is unavailable.`);
|
|
721
|
-
return input.execute(entry, { id: ref.id }, context);
|
|
723
|
+
return addCloudResourceHrefs(await input.execute(entry, { id: ref.id }, context), input.apps);
|
|
722
724
|
});
|
|
723
725
|
|
|
724
726
|
/** Nessi resolver: one registry/load-state snapshot drives discovery, loading, schemas, and execution per model turn. */
|
|
@@ -860,6 +862,7 @@ export const createAiToolResolver =
|
|
|
860
862
|
...activeBuiltIns,
|
|
861
863
|
...(input.execute
|
|
862
864
|
? createLoadedAiCapabilityTools({
|
|
865
|
+
apps: registry,
|
|
863
866
|
catalog: capabilityCatalog,
|
|
864
867
|
loadedNames,
|
|
865
868
|
actor: input.actor,
|
package/src/ai/resource-refs.ts
CHANGED
|
@@ -1,12 +1,43 @@
|
|
|
1
1
|
import {
|
|
2
|
+
CloudResourceHrefSchema,
|
|
2
3
|
type CloudResourceRef,
|
|
3
|
-
type CloudResourceReference,
|
|
4
4
|
CloudResourceReferenceSchema,
|
|
5
5
|
type CloudResourceView,
|
|
6
6
|
CloudResourceViewSchema,
|
|
7
|
+
cloudResourceHref,
|
|
8
|
+
cloudResourceRefAppId,
|
|
9
|
+
resolveCapabilityResourceReader,
|
|
7
10
|
} from "../contracts/capabilities";
|
|
11
|
+
import type { CapabilityRegistryEntry } from "../contracts/registry";
|
|
8
12
|
import type { AiConversationResourceObservation } from "./types";
|
|
9
13
|
|
|
14
|
+
const LinkedResourceReferenceSchema = CloudResourceReferenceSchema.extend({
|
|
15
|
+
href: CloudResourceHrefSchema.optional(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const addCloudResourceHrefs = <T>(
|
|
19
|
+
value: T,
|
|
20
|
+
apps: readonly Pick<CapabilityRegistryEntry, "appId" | "manifest">[],
|
|
21
|
+
origin?: string,
|
|
22
|
+
) => {
|
|
23
|
+
if (!value || typeof value !== "object" || !("refs" in value) || !Array.isArray(value.refs)) return value;
|
|
24
|
+
return {
|
|
25
|
+
...value,
|
|
26
|
+
refs: value.refs.map((reference: unknown) => {
|
|
27
|
+
if (reference && typeof reference === "object" && "href" in reference) return reference;
|
|
28
|
+
const parsed = CloudResourceReferenceSchema.safeParse(reference);
|
|
29
|
+
if (!parsed.success) return reference;
|
|
30
|
+
const ref = parsed.data;
|
|
31
|
+
if (!ref.id.isWellFormed()) return reference;
|
|
32
|
+
const app = apps.find((candidate) => candidate.appId === cloudResourceRefAppId(ref));
|
|
33
|
+
if (!app || !resolveCapabilityResourceReader(app.manifest, ref)) return reference;
|
|
34
|
+
const href = CloudResourceHrefSchema.safeParse(cloudResourceHref(ref));
|
|
35
|
+
if (!href.success) return reference;
|
|
36
|
+
return { ...ref, href: origin ? new URL(href.data, origin).href : href.data };
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
10
41
|
const observationKey = (ref: CloudResourceRef): string => `${ref.type}\0${ref.id}`;
|
|
11
42
|
|
|
12
43
|
export const isConversationResourceCursor = (value: string, scope: "conversation" | "user"): boolean => {
|
|
@@ -47,9 +78,9 @@ export const collectConversationResourceObservations = (...values: unknown[]): A
|
|
|
47
78
|
href: resource.links.find((link) => link.rel === "open")?.href,
|
|
48
79
|
});
|
|
49
80
|
} else {
|
|
50
|
-
const reference =
|
|
81
|
+
const reference = LinkedResourceReferenceSchema.safeParse(value);
|
|
51
82
|
if (reference.success) {
|
|
52
|
-
const resource
|
|
83
|
+
const resource = reference.data;
|
|
53
84
|
const ref: CloudResourceRef = { type: resource.type, id: resource.id };
|
|
54
85
|
const previous = observations.get(observationKey(ref));
|
|
55
86
|
observations.set(observationKey(ref), {
|
|
@@ -58,6 +89,7 @@ export const collectConversationResourceObservations = (...values: unknown[]): A
|
|
|
58
89
|
...(resource.title !== undefined ? { title: resource.title } : {}),
|
|
59
90
|
...(resource.preview !== undefined ? { preview: resource.preview } : {}),
|
|
60
91
|
...(resource.icon !== undefined ? { icon: resource.icon } : {}),
|
|
92
|
+
...(resource.href !== undefined ? { href: resource.href } : {}),
|
|
61
93
|
});
|
|
62
94
|
}
|
|
63
95
|
}
|
package/src/ai/system-prompt.ts
CHANGED
|
@@ -171,6 +171,7 @@ export const composeAiSystemPrompt = (input: AiSystemPromptInput): string => {
|
|
|
171
171
|
? [
|
|
172
172
|
"# Local client",
|
|
173
173
|
'This turn comes from a local client. Where this section describes what the user sees, it replaces "What the user sees" above; it cannot override the other platform rules.',
|
|
174
|
+
"Offer the supplied link for every Cloud resource you create, change, or name, because cld and editors show these links as full Cloud addresses.",
|
|
174
175
|
`Client: ${input.localClient.client.kind === "cli" ? "terminal (cld)" : `editor via ACP${input.localClient.client.name ? ` (${input.localClient.client.name})` : ""}`}`,
|
|
175
176
|
input.localClient.cloudUrl.trim() ? `Cloud: ${withoutTrailingSlashes(input.localClient.cloudUrl.trim())}` : undefined,
|
|
176
177
|
`Workspace: ${input.localClient.client.cwd}`,
|
|
@@ -244,8 +245,8 @@ export const composeAiSystemPrompt = (input: AiSystemPromptInput): string => {
|
|
|
244
245
|
].join("\n"),
|
|
245
246
|
[
|
|
246
247
|
"# Cloud resource links",
|
|
247
|
-
"When the answer mentions a Cloud resource
|
|
248
|
-
"Example: [Urgent invoice review 001](/
|
|
248
|
+
"Cloud adds an href to resource refs it can open. When the answer mentions a Cloud resource, use its ref's href or the result's open or edit link to make the resource's human-readable title a Markdown link using that exact href. Use the title exactly as supplied, even when answering in another language. Apply this to every mentioned resource, including list items and headings. Prefer open over edit. Without a supplied href, use plain text. Never construct a Cloud URL.",
|
|
249
|
+
"Example: [Urgent invoice review 001](/open/mail.draft/~AbC123) — payment deadline approaching.",
|
|
249
250
|
].join("\n"),
|
|
250
251
|
];
|
|
251
252
|
|
package/src/api/index.ts
CHANGED
package/src/api/mcp.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { z } from "zod";
|
|
|
17
17
|
import { readBoundedJson } from "../_internal/bounded-json";
|
|
18
18
|
import { HELP_SEARCH_MAX_LIMIT, helpResourceUri, parseHelpResourceUri, readHelpArticle } from "../_internal/help-catalog";
|
|
19
19
|
import { getCapability, listApps } from "../_internal/registry";
|
|
20
|
+
import { addCloudResourceHrefs } from "../ai/resource-refs";
|
|
20
21
|
import { capabilityValueMeta, recordCapabilityExecution } from "../capabilities/executions";
|
|
21
22
|
import {
|
|
22
23
|
CAPABILITY_FRAMEWORK_ERROR_CODES,
|
|
@@ -26,9 +27,11 @@ import {
|
|
|
26
27
|
type CapabilityActionManifest,
|
|
27
28
|
type CapabilityQueryManifest,
|
|
28
29
|
CapabilitySemanticLinkSchema,
|
|
29
|
-
|
|
30
|
+
type CloudResourceRef,
|
|
30
31
|
capabilityResultJsonSchema,
|
|
32
|
+
capabilityResultSchema,
|
|
31
33
|
cloudResourceRefAppId,
|
|
34
|
+
LinkedCloudResourceReferenceSchema,
|
|
32
35
|
resolveCapabilityResourceReader,
|
|
33
36
|
} from "../contracts/capabilities";
|
|
34
37
|
import type { AppRegistryEntry, CapabilityRegistryEntry } from "../contracts/registry";
|
|
@@ -121,7 +124,7 @@ const platformTools: Tool[] = [
|
|
|
121
124
|
title: "Read Cloud resource",
|
|
122
125
|
description:
|
|
123
126
|
"Read one Cloud resource through its current canonical reader. Pass a typed ref returned by search, list, lookup, or another capability unchanged; never guess a reader name or substitute an id from another resource type.",
|
|
124
|
-
inputSchema: structuredClone(z.toJSONSchema(
|
|
127
|
+
inputSchema: structuredClone(z.toJSONSchema(LinkedCloudResourceReferenceSchema)) as Tool["inputSchema"],
|
|
125
128
|
annotations: { title: "Read Cloud resource", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
126
129
|
_meta: { "cloud/kind": "resource-read" },
|
|
127
130
|
},
|
|
@@ -206,7 +209,7 @@ const projectTool = (
|
|
|
206
209
|
title: `${app.appName}: ${operation.title}`,
|
|
207
210
|
description: operation.description,
|
|
208
211
|
inputSchema: action ? actionInputSchema(action) : cloneObjectSchema(operation.inputSchema),
|
|
209
|
-
outputSchema: cloneObjectSchema(capabilityResultJsonSchema(operation.dataSchema)),
|
|
212
|
+
outputSchema: cloneObjectSchema(capabilityResultJsonSchema(operation.dataSchema, { consumer: true })),
|
|
210
213
|
annotations: {
|
|
211
214
|
title: operation.title,
|
|
212
215
|
readOnlyHint: !isAction,
|
|
@@ -300,7 +303,7 @@ const findCapabilityTool = async (
|
|
|
300
303
|
};
|
|
301
304
|
|
|
302
305
|
const findResourceReaderTool = async (
|
|
303
|
-
ref:
|
|
306
|
+
ref: CloudResourceRef,
|
|
304
307
|
lookup: (appId: string) => Promise<CapabilityRegistryEntry | null>,
|
|
305
308
|
registry: () => Promise<AppRegistryEntry[]>,
|
|
306
309
|
): Promise<CapabilityTool | null> => {
|
|
@@ -333,15 +336,20 @@ const responseTooLargeToolResult = (): CallToolResult => {
|
|
|
333
336
|
return { isError: true, structuredContent: error, content: [{ type: "text", text: JSON.stringify(error) }] };
|
|
334
337
|
};
|
|
335
338
|
|
|
339
|
+
const toolResult = (body: Record<string, unknown>, isError: boolean): CallToolResult => ({
|
|
340
|
+
...(isError ? { isError: true } : {}),
|
|
341
|
+
structuredContent: body,
|
|
342
|
+
content: [{ type: "text", text: JSON.stringify(body) }],
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const fitsToolResult = (result: CallToolResult): boolean =>
|
|
346
|
+
new TextEncoder().encode(JSON.stringify(result)).byteLength <= CAPABILITY_MAX_RESULT_BYTES;
|
|
347
|
+
|
|
336
348
|
const ensureBoundedToolResult = (result: CallToolResult): CallToolResult =>
|
|
337
|
-
|
|
349
|
+
fitsToolResult(result) ? result : responseTooLargeToolResult();
|
|
338
350
|
|
|
339
351
|
const boundedToolResult = (body: Record<string, unknown>, isError: boolean): CallToolResult =>
|
|
340
|
-
ensureBoundedToolResult(
|
|
341
|
-
...(isError ? { isError: true } : {}),
|
|
342
|
-
structuredContent: body,
|
|
343
|
-
content: [{ type: "text", text: JSON.stringify(body) }],
|
|
344
|
-
});
|
|
352
|
+
ensureBoundedToolResult(toolResult(body, isError));
|
|
345
353
|
|
|
346
354
|
const semanticResourceLinks = (body: Record<string, unknown>, origin: string): CallToolResult["content"] => {
|
|
347
355
|
const found = new Map<string, z.infer<typeof CapabilitySemanticLinkSchema>>();
|
|
@@ -573,14 +581,14 @@ const createMcpServer = (
|
|
|
573
581
|
let args: Record<string, unknown>;
|
|
574
582
|
try {
|
|
575
583
|
if (message.params.name === RESOURCE_READ_TOOL) {
|
|
576
|
-
const parsed =
|
|
584
|
+
const parsed = LinkedCloudResourceReferenceSchema.safeParse(message.params.arguments);
|
|
577
585
|
if (!parsed.success) {
|
|
578
586
|
const issues = parsed.error.issues.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`).join("; ");
|
|
579
587
|
return validationToolError(
|
|
580
588
|
`cloud__resource__read requires one exact typed ref returned by a current Cloud capability (${issues})`,
|
|
581
589
|
);
|
|
582
590
|
}
|
|
583
|
-
selected = await findResourceReaderTool(parsed.data, capabilityLookup, registry);
|
|
591
|
+
selected = await findResourceReaderTool({ type: parsed.data.type, id: parsed.data.id }, capabilityLookup, registry);
|
|
584
592
|
args = { id: parsed.data.id };
|
|
585
593
|
if (!selected) {
|
|
586
594
|
return boundedToolResult(
|
|
@@ -646,12 +654,35 @@ const createMcpServer = (
|
|
|
646
654
|
authority,
|
|
647
655
|
dependencies,
|
|
648
656
|
});
|
|
649
|
-
const
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
657
|
+
const providerBody = await parseResponse(response);
|
|
658
|
+
let body = providerBody;
|
|
659
|
+
const origin = response.ok ? publicCloudOrigin(await (dependencies.getAppUrl ?? defaultAppUrl)()) : undefined;
|
|
660
|
+
if (origin) {
|
|
661
|
+
const parsed = capabilityResultSchema(z.unknown()).safeParse(body);
|
|
662
|
+
const appIds = new Set(parsed.success ? parsed.data.refs?.map(cloudResourceRefAppId) : []);
|
|
663
|
+
const apps = appIds.size ? await registry().catch(() => []) : [];
|
|
664
|
+
const capabilities = await Promise.all(
|
|
665
|
+
apps
|
|
666
|
+
.filter((app) => appIds.has(app.id) && app.capabilities?.protocolVersion === CAPABILITY_PROTOCOL_VERSION)
|
|
667
|
+
.map(async (app) => {
|
|
668
|
+
const entry = await capabilityLookup(app.id).catch(() => null);
|
|
669
|
+
return entry?.appId === app.id && entry.manifest.manifestHash === app.capabilities?.manifestHash ? entry : null;
|
|
670
|
+
}),
|
|
671
|
+
);
|
|
672
|
+
body = addCloudResourceHrefs(
|
|
673
|
+
body,
|
|
674
|
+
capabilities.filter((entry) => entry !== null),
|
|
675
|
+
origin,
|
|
676
|
+
);
|
|
653
677
|
}
|
|
654
|
-
|
|
678
|
+
const resultFor = (value: Record<string, unknown>): CallToolResult => {
|
|
679
|
+
const result = toolResult(value, !response.ok);
|
|
680
|
+
if (origin) result.content.push(...semanticResourceLinks(value, origin));
|
|
681
|
+
return result;
|
|
682
|
+
};
|
|
683
|
+
// Added hrefs are optional: they never turn a result that fits into RESPONSE_TOO_LARGE.
|
|
684
|
+
const result = resultFor(body);
|
|
685
|
+
return ensureBoundedToolResult(fitsToolResult(result) || body === providerBody ? result : resultFor(providerBody));
|
|
655
686
|
});
|
|
656
687
|
|
|
657
688
|
return server;
|
package/src/api/pwa.ts
CHANGED
|
@@ -134,11 +134,16 @@ export const createPwaRoutes = (options: PwaRouteOptions = {}) => {
|
|
|
134
134
|
describeRoute({
|
|
135
135
|
tags,
|
|
136
136
|
summary: "Read a pairing",
|
|
137
|
-
description:
|
|
137
|
+
description:
|
|
138
|
+
"Only for the web session that started it. Never returns the code. A pending read is held for up to one poll interval and answers as soon as the phone claims the link.",
|
|
138
139
|
responses: { 200: jsonResponse(PwaPairingStatusSchema, "Pairing state") },
|
|
139
140
|
}),
|
|
140
141
|
v("param", IdParamSchema, pwaInvalidRequest),
|
|
141
|
-
async (c) =>
|
|
142
|
+
async (c) => {
|
|
143
|
+
const actor = await webActor(c);
|
|
144
|
+
const hold = { ms: PWA_LIMITS.pollSeconds * 1000, signal: c.req.raw.signal };
|
|
145
|
+
return c.json(await service.inspectPairing(actor, c.req.valid("param").id, hold));
|
|
146
|
+
},
|
|
142
147
|
)
|
|
143
148
|
.post(
|
|
144
149
|
"/pairings/:id/confirm",
|