@k2b/cloud 0.33.0 → 0.35.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 +130 -16
- package/src/ai/firecrawl-tools.ts +3 -30
- 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/network-security.ts +33 -9
- 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/shared/network-address.ts +46 -45
- package/src/ssr/Layout.tsx +3 -15
- package/src/ssr/LayoutHelp.browser-harness.tsx +43 -0
- package/src/ssr/LayoutHelp.tsx +320 -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 +47 -12
- 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.35.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
|
|
|
@@ -424,6 +425,7 @@ const SCHEMA_KEYS = new Set([
|
|
|
424
425
|
"required",
|
|
425
426
|
"items",
|
|
426
427
|
"prefixItems",
|
|
428
|
+
"additionalItems",
|
|
427
429
|
"additionalProperties",
|
|
428
430
|
"anyOf",
|
|
429
431
|
"oneOf",
|
|
@@ -434,29 +436,139 @@ const SCHEMA_KEYS = new Set([
|
|
|
434
436
|
"definitions",
|
|
435
437
|
]);
|
|
436
438
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
+
type SchemaReductionContext = {
|
|
440
|
+
root: Record<string, unknown>;
|
|
441
|
+
mirrorNames: Map<string, string>;
|
|
442
|
+
mirrors: Record<string, Record<string, unknown>>;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
/** A local reference into the input schema: the root (`#`) or one of its `$defs` or `definitions`. */
|
|
446
|
+
const localReference = (
|
|
447
|
+
reference: unknown,
|
|
448
|
+
root: Record<string, unknown>,
|
|
449
|
+
): { identity: string; keyword: string; name: string; schema: unknown } | null => {
|
|
450
|
+
if (reference === "#") {
|
|
451
|
+
const { $defs: _defs, definitions: _definitions, ...schema } = root;
|
|
452
|
+
return { identity: "#", keyword: "$defs", name: "root", schema };
|
|
453
|
+
}
|
|
454
|
+
const match = typeof reference === "string" ? /^#\/(\$defs|definitions)\/([^/]+)$/.exec(reference) : null;
|
|
455
|
+
if (!match) return null;
|
|
456
|
+
const [, keyword, encodedName] = match;
|
|
457
|
+
const name = encodedName!.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
458
|
+
const definitions = root[keyword!];
|
|
459
|
+
if (!definitions || typeof definitions !== "object" || !Object.hasOwn(definitions, name)) return null;
|
|
460
|
+
return { identity: `${keyword}/${name}`, keyword: keyword!, name, schema: Reflect.get(definitions, name) };
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
/** Whether a raw schema reaches a tuple; `visited` keeps each definition to one visit per question. */
|
|
464
|
+
const containsTuple = (value: unknown, context: SchemaReductionContext, key?: string, visited = new Set<string>()): boolean => {
|
|
465
|
+
if (key === "const" || key === "enum" || key === "default" || key === "required" || key === "type") return false;
|
|
466
|
+
if (Array.isArray(value)) return value.some((item) => containsTuple(item, context, undefined, visited));
|
|
467
|
+
if (!value || typeof value !== "object") return false;
|
|
468
|
+
if (key === "properties" || key === "$defs" || key === "definitions") {
|
|
469
|
+
return Object.values(value).some((schema) => containsTuple(schema, context, undefined, visited));
|
|
470
|
+
}
|
|
471
|
+
if (Object.hasOwn(value, "prefixItems") || Array.isArray(Reflect.get(value, "items"))) return true;
|
|
472
|
+
const reference = localReference(Reflect.get(value, "$ref"), context.root);
|
|
473
|
+
if (reference && !visited.has(reference.identity)) {
|
|
474
|
+
visited.add(reference.identity);
|
|
475
|
+
if (containsTuple(reference.schema, context, undefined, visited)) return true;
|
|
476
|
+
}
|
|
477
|
+
return Object.entries(value).some(([childKey, childValue]) => containsTuple(childValue, context, childKey, visited));
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const reduceSchemaValue = (value: unknown, context: SchemaReductionContext, key?: string, merged = false): unknown => {
|
|
481
|
+
if (key === "const" || key === "default") return structuredClone(value);
|
|
439
482
|
if (Array.isArray(value)) {
|
|
440
483
|
if (key === "required" || key === "enum" || key === "type") return structuredClone(value);
|
|
441
|
-
return value.map((item) => reduceSchemaValue(item));
|
|
484
|
+
return value.map((item) => reduceSchemaValue(item, context, undefined, merged));
|
|
442
485
|
}
|
|
443
486
|
if (!value || typeof value !== "object") return value;
|
|
487
|
+
if (key === "properties" || key === "$defs" || key === "definitions") {
|
|
488
|
+
return Object.fromEntries(Object.entries(value).map(([name, schema]) => [name, reduceSchemaValue(schema, context, undefined, merged)]));
|
|
489
|
+
}
|
|
444
490
|
|
|
445
491
|
const output: Record<string, unknown> = {};
|
|
446
492
|
for (const [childKey, childValue] of Object.entries(value)) {
|
|
447
|
-
if (
|
|
448
|
-
|
|
449
|
-
|
|
493
|
+
if (!SCHEMA_KEYS.has(childKey) || (merged && childKey === "default")) continue;
|
|
494
|
+
// Tuple widening can make surrounding union branches overlap, so their defaults belong to the app.
|
|
495
|
+
const unionMerged =
|
|
496
|
+
merged ||
|
|
497
|
+
((childKey === "anyOf" || childKey === "oneOf") &&
|
|
498
|
+
Array.isArray(childValue) &&
|
|
499
|
+
childValue.some((branch) => containsTuple(branch, context)));
|
|
500
|
+
output[childKey] = reduceSchemaValue(childValue, context, childKey, unionMerged);
|
|
501
|
+
}
|
|
502
|
+
if (output.oneOf !== undefined) {
|
|
503
|
+
if (output.anyOf === undefined) output.anyOf = output.oneOf;
|
|
504
|
+
else {
|
|
505
|
+
output.allOf = [...(Array.isArray(output.allOf) ? output.allOf : []), { anyOf: output.anyOf }, { anyOf: output.oneOf }];
|
|
506
|
+
delete output.anyOf;
|
|
450
507
|
}
|
|
451
|
-
|
|
452
|
-
|
|
508
|
+
delete output.oneOf;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const positions = Array.isArray(output.prefixItems) ? output.prefixItems : Array.isArray(output.items) ? output.items : null;
|
|
512
|
+
if (positions) {
|
|
513
|
+
const rest = Array.isArray(output.prefixItems) ? output.items : output.additionalItems;
|
|
514
|
+
// Extra items of an open tuple may be anything, so the AI side must not narrow them to the positions.
|
|
515
|
+
const schemas = rest === undefined || rest === true ? [] : rest === false ? positions : [...positions, rest];
|
|
516
|
+
// Position-erased branches must leave defaults to the app's authoritative tuple schema.
|
|
517
|
+
const mergedSchemas = schemas.some((schema) => JSON.stringify(schema) !== JSON.stringify(schemas[0]))
|
|
518
|
+
? schemas.map((schema) => reduceSchemaValue(schema, context, undefined, true))
|
|
519
|
+
: schemas;
|
|
520
|
+
const distinct = mergedSchemas.filter(
|
|
521
|
+
(schema, index) => mergedSchemas.findIndex((candidate) => JSON.stringify(candidate) === JSON.stringify(schema)) === index,
|
|
522
|
+
);
|
|
523
|
+
output.items = distinct.length === 0 ? {} : distinct.length === 1 ? distinct[0] : { anyOf: distinct };
|
|
524
|
+
if (rest === false)
|
|
525
|
+
output.maxItems = Math.min(typeof output.maxItems === "number" ? output.maxItems : positions.length, positions.length);
|
|
526
|
+
}
|
|
527
|
+
delete output.prefixItems;
|
|
528
|
+
delete output.additionalItems;
|
|
529
|
+
if (output.items === false) output.maxItems = 0;
|
|
530
|
+
// Zod drops array bounds without an item schema, so "any item" stays an empty schema.
|
|
531
|
+
if (typeof output.items === "boolean") output.items = {};
|
|
532
|
+
const reference = merged ? localReference(output.$ref, context.root) : null;
|
|
533
|
+
if (reference) {
|
|
534
|
+
const { identity, keyword, name } = reference;
|
|
535
|
+
const definitions = context.root[keyword];
|
|
536
|
+
const mirrors = context.mirrors[keyword] ?? {};
|
|
537
|
+
context.mirrors[keyword] = mirrors;
|
|
538
|
+
let mirrorName = context.mirrorNames.get(identity);
|
|
539
|
+
if (mirrorName === undefined) {
|
|
540
|
+
mirrorName = `${name}.ai`;
|
|
541
|
+
for (
|
|
542
|
+
let suffix = 1;
|
|
543
|
+
(definitions && typeof definitions === "object" && Object.hasOwn(definitions, mirrorName)) || Object.hasOwn(mirrors, mirrorName);
|
|
544
|
+
suffix++
|
|
545
|
+
) {
|
|
546
|
+
mirrorName = `${name}.ai${suffix}`;
|
|
547
|
+
}
|
|
548
|
+
// Reserve the mirror before descending so recursive refs never reach default-bearing originals.
|
|
549
|
+
context.mirrorNames.set(identity, mirrorName);
|
|
550
|
+
mirrors[mirrorName] = {};
|
|
551
|
+
mirrors[mirrorName] = reduceSchemaValue(reference.schema, context, undefined, true);
|
|
552
|
+
}
|
|
553
|
+
output.$ref = `#/${keyword}/${mirrorName.replace(/~/g, "~0").replace(/\//g, "~1")}`;
|
|
453
554
|
}
|
|
454
555
|
return output;
|
|
455
556
|
};
|
|
456
557
|
|
|
457
|
-
/**
|
|
458
|
-
|
|
459
|
-
|
|
558
|
+
/**
|
|
559
|
+
* Keep the provider-useful shape while leaving authoritative validation in the target app.
|
|
560
|
+
* Tuples reach the model as bounded arrays of their item schemas and `oneOf` as `anyOf`, because loosened branches may
|
|
561
|
+
* overlap; the app still validates exact tuple positions and exclusive unions.
|
|
562
|
+
*/
|
|
563
|
+
export const reduceAiCapabilityInputSchema = (schema: Record<string, unknown>): Record<string, unknown> => {
|
|
564
|
+
const context: SchemaReductionContext = { root: schema, mirrorNames: new Map(), mirrors: {} };
|
|
565
|
+
const reduced = reduceSchemaValue(schema, context) as Record<string, unknown>;
|
|
566
|
+
for (const [keyword, mirrors] of Object.entries(context.mirrors)) {
|
|
567
|
+
const definitions = reduced[keyword];
|
|
568
|
+
reduced[keyword] = { ...(definitions && typeof definitions === "object" ? definitions : {}), ...mirrors };
|
|
569
|
+
}
|
|
570
|
+
return reduced;
|
|
571
|
+
};
|
|
460
572
|
|
|
461
573
|
export const aiCapabilityInputSchema = (schema: Record<string, unknown>): z.ZodType =>
|
|
462
574
|
z.fromJSONSchema(reduceAiCapabilityInputSchema(schema));
|
|
@@ -638,6 +750,7 @@ export const createAiToolMetaTools = (input: {
|
|
|
638
750
|
};
|
|
639
751
|
|
|
640
752
|
export const createLoadedAiCapabilityTools = (input: {
|
|
753
|
+
apps: readonly CapabilityRegistryEntry[];
|
|
641
754
|
catalog: readonly AiCapabilityCatalogEntry[];
|
|
642
755
|
loadedNames: readonly string[];
|
|
643
756
|
actor: RequestActor;
|
|
@@ -686,7 +799,7 @@ export const createLoadedAiCapabilityTools = (input: {
|
|
|
686
799
|
throw new Error("Capability Action was rejected by the user.");
|
|
687
800
|
}
|
|
688
801
|
}
|
|
689
|
-
return input.execute(entry, args, context);
|
|
802
|
+
return addCloudResourceHrefs(await input.execute(entry, args, context), input.apps);
|
|
690
803
|
}),
|
|
691
804
|
];
|
|
692
805
|
});
|
|
@@ -701,14 +814,14 @@ export const createAiResourceReaderTool = (input: {
|
|
|
701
814
|
name: "read_cloud_resource",
|
|
702
815
|
description:
|
|
703
816
|
"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:
|
|
817
|
+
inputSchema: LinkedCloudResourceReferenceSchema,
|
|
705
818
|
outputSchema: z.unknown(),
|
|
706
819
|
approval: "never",
|
|
707
820
|
}).server(async (ref, context) => {
|
|
708
821
|
if (ref.type === "assistant.artifact") {
|
|
709
822
|
const tool = createCodeSourceTool("code_read");
|
|
710
823
|
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);
|
|
824
|
+
return addCloudResourceHrefs(await tool.run(CODE_SOURCE_TOOLS.code_read.input.parse({ id: ref.id }), context), input.apps);
|
|
712
825
|
}
|
|
713
826
|
const appId = cloudResourceRefAppId(ref);
|
|
714
827
|
const app = input.apps.find((candidate) => candidate.appId === appId);
|
|
@@ -718,7 +831,7 @@ export const createAiResourceReaderTool = (input: {
|
|
|
718
831
|
(candidate) => candidate.appId === appId && candidate.kind === "query" && candidate.operation.localId === reader.localId,
|
|
719
832
|
);
|
|
720
833
|
if (!entry) throw new Error(`Cloud resource reader ${appId}.${reader.localId} is unavailable.`);
|
|
721
|
-
return input.execute(entry, { id: ref.id }, context);
|
|
834
|
+
return addCloudResourceHrefs(await input.execute(entry, { id: ref.id }, context), input.apps);
|
|
722
835
|
});
|
|
723
836
|
|
|
724
837
|
/** Nessi resolver: one registry/load-state snapshot drives discovery, loading, schemas, and execution per model turn. */
|
|
@@ -860,6 +973,7 @@ export const createAiToolResolver =
|
|
|
860
973
|
...activeBuiltIns,
|
|
861
974
|
...(input.execute
|
|
862
975
|
? createLoadedAiCapabilityTools({
|
|
976
|
+
apps: registry,
|
|
863
977
|
catalog: capabilityCatalog,
|
|
864
978
|
loadedNames,
|
|
865
979
|
actor: input.actor,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { isIP } from "node:net";
|
|
2
1
|
import { type ToolContext, truncateMiddle } from "@k2b/nessi";
|
|
3
2
|
import { z } from "zod";
|
|
4
3
|
import type { RequestActor } from "../server";
|
|
5
4
|
import { coreSettings } from "../services";
|
|
5
|
+
import { isUnsafeNetworkAddress, isUnsafeNetworkHostname, parseNetworkAddress } from "../shared/network-address";
|
|
6
6
|
import { AI_WEBSITE_APPROVAL_TOOL, aiTurnAllowsWebsiteApprovals, aiWebsiteApprovalScope, findRememberedAiToolApproval } from "./approvals";
|
|
7
7
|
import { aiConversations } from "./store";
|
|
8
8
|
import { defineAiTool } from "./tools";
|
|
@@ -141,31 +141,6 @@ const truncate = (text: string, maxChars: number): { text: string; truncated: bo
|
|
|
141
141
|
return { text: text.slice(0, Math.max(0, maxChars)).trimEnd(), truncated: true };
|
|
142
142
|
};
|
|
143
143
|
|
|
144
|
-
const isPrivateIpv4 = (hostname: string): boolean => {
|
|
145
|
-
const parts = hostname.split(".").map((part) => Number(part));
|
|
146
|
-
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true;
|
|
147
|
-
const [a, b] = parts as [number, number, number, number];
|
|
148
|
-
if (a === 0 || a === 10 || a === 127) return true;
|
|
149
|
-
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
150
|
-
if (a === 169 && b === 254) return true;
|
|
151
|
-
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
152
|
-
if (a === 192 && (b === 0 || b === 168)) return true;
|
|
153
|
-
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
154
|
-
return a >= 224;
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
const isPrivateIpv6 = (hostname: string): boolean => {
|
|
158
|
-
const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
159
|
-
return (
|
|
160
|
-
normalized === "::" ||
|
|
161
|
-
normalized === "::1" ||
|
|
162
|
-
normalized.startsWith("fc") ||
|
|
163
|
-
normalized.startsWith("fd") ||
|
|
164
|
-
normalized.startsWith("fe80:") ||
|
|
165
|
-
normalized.startsWith("2001:db8:")
|
|
166
|
-
);
|
|
167
|
-
};
|
|
168
|
-
|
|
169
144
|
export const assertPublicHttpUrl = (rawUrl: string): string => {
|
|
170
145
|
let parsed: URL;
|
|
171
146
|
try {
|
|
@@ -176,13 +151,11 @@ export const assertPublicHttpUrl = (rawUrl: string): string => {
|
|
|
176
151
|
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new Error("URL must use http or https.");
|
|
177
152
|
|
|
178
153
|
const hostname = parsed.hostname.toLowerCase();
|
|
179
|
-
if (
|
|
154
|
+
if (isUnsafeNetworkHostname(hostname)) {
|
|
180
155
|
throw new Error("URL host is not allowed.");
|
|
181
156
|
}
|
|
182
157
|
|
|
183
|
-
|
|
184
|
-
if (ipKind === 4 && isPrivateIpv4(hostname)) throw new Error("URL must not point to a private IP address.");
|
|
185
|
-
if (ipKind === 6 && isPrivateIpv6(hostname)) throw new Error("URL must not point to a private IP address.");
|
|
158
|
+
if (parseNetworkAddress(hostname) && isUnsafeNetworkAddress(hostname)) throw new Error("URL must not point to a private IP address.");
|
|
186
159
|
return parsed.toString();
|
|
187
160
|
};
|
|
188
161
|
|
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
|
}
|