@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.
Files changed (68) hide show
  1. package/package.json +3 -3
  2. package/src/_internal/app-presentation.ts +40 -11
  3. package/src/_internal/define-app.ts +1 -0
  4. package/src/_internal/registry.ts +10 -1
  5. package/src/_internal/widget-declarations.ts +16 -3
  6. package/src/_internal/widget-settings.ts +93 -0
  7. package/src/ai/capabilities.ts +130 -16
  8. package/src/ai/firecrawl-tools.ts +3 -30
  9. package/src/ai/resource-refs.ts +35 -3
  10. package/src/ai/system-prompt.ts +3 -2
  11. package/src/api/index.ts +2 -0
  12. package/src/api/mcp.ts +48 -17
  13. package/src/api/pwa.ts +7 -2
  14. package/src/api/resource-links.ts +43 -0
  15. package/src/api/widgets.ts +163 -82
  16. package/src/browser/CloudResourceSearch.tsx +6 -6
  17. package/src/browser/command-shortcuts.ts +2 -11
  18. package/src/browser/resource-picker.tsx +1 -0
  19. package/src/browser/resource-search.ts +7 -2
  20. package/src/browser/widgets.ts +23 -5
  21. package/src/capabilities/testing.ts +13 -1
  22. package/src/cli/capabilities.ts +61 -17
  23. package/src/cli/index.ts +2 -0
  24. package/src/cli/resource-links.ts +13 -0
  25. package/src/contracts/app.ts +7 -1
  26. package/src/contracts/capabilities.ts +25 -5
  27. package/src/contracts/registry.ts +3 -1
  28. package/src/contracts/resource-href.ts +7 -0
  29. package/src/contracts/widgets.ts +80 -6
  30. package/src/desktop/index.ts +3 -2
  31. package/src/desktop/solid.tsx +7 -5
  32. package/src/server/widget-request.ts +34 -5
  33. package/src/services/app-approval.ts +4 -2
  34. package/src/services/approvals/index.ts +40 -11
  35. package/src/services/approvals/list.ts +1 -0
  36. package/src/services/approvals/migrate.ts +10 -0
  37. package/src/services/network-security.ts +33 -9
  38. package/src/services/pdf/markdown.ts +11 -7
  39. package/src/services/pwa-devices.ts +68 -15
  40. package/src/shared/app-presentation.ts +49 -1
  41. package/src/shared/index.ts +1 -0
  42. package/src/shared/markdown/callout-sanitizer.ts +37 -0
  43. package/src/shared/markdown/index.ts +8 -2
  44. package/src/shared/network-address.ts +46 -45
  45. package/src/ssr/Layout.tsx +3 -15
  46. package/src/ssr/LayoutHelp.browser-harness.tsx +43 -0
  47. package/src/ssr/LayoutHelp.tsx +320 -183
  48. package/src/ssr/LayoutHelpTrigger.tsx +1 -3
  49. package/src/ssr/LayoutPreferences.tsx +15 -5
  50. package/src/ssr/MinimalLayoutPreferences.island.tsx +3 -9
  51. package/src/ssr/MobileProfileActions.tsx +3 -8
  52. package/src/ssr/ProfilePreferences.tsx +8 -10
  53. package/src/ssr/ShellRuntime.island.tsx +1 -5
  54. package/src/ssr/ThemeCycleButton.tsx +46 -0
  55. package/src/ssr/app-appearance.ts +6 -1
  56. package/src/ssr/layout-help-url.ts +39 -0
  57. package/src/ssr/platform-messages.ts +0 -8
  58. package/src/ssr/preference-controller.ts +53 -19
  59. package/src/ssr/profile-actions.ts +12 -16
  60. package/src/ssr/profile-preferences-messages.ts +4 -2
  61. package/src/ssr/rail-navigation.ts +3 -0
  62. package/src/ssr/shell-data.tsx +2 -2
  63. package/src/styles/effects.css +13 -3
  64. package/src/styles/global.css +47 -12
  65. package/src/styles/input.css +0 -8
  66. package/src/styles/tokens.css +0 -2
  67. package/src/styles/utilities-navigation.css +10 -13
  68. 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.33.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.15.1",
110
- "@k2b/ui": "0.24.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
- const normalized: Record<string, string> = {};
18
- for (const [key, value] of Object.entries(values)) {
19
- if (!allowed.has(key)) throw new Error(`${label} contains unknown key ${JSON.stringify(key)}`);
20
- normalized[key] = text(value, `${label}.${key}`, max);
21
- }
22
- return normalized;
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
- allowed: Set<string>,
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
- if (!allowed.has(id)) throw new Error(`${label} contains unknown key ${JSON.stringify(id)}`);
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, widgetIds, `${canonical}.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 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.
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
+ };
@@ -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
- const reduceSchemaValue = (value: unknown, key?: string): unknown => {
438
- if (key === "const") return structuredClone(value);
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 (key === "properties" || key === "$defs" || key === "definitions") {
448
- output[childKey] = reduceSchemaValue(childValue);
449
- continue;
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
- if (!SCHEMA_KEYS.has(childKey)) continue;
452
- output[childKey] = reduceSchemaValue(childValue, childKey);
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
- /** Keep the provider-useful shape while leaving authoritative validation in the target app. */
458
- export const reduceAiCapabilityInputSchema = (schema: Record<string, unknown>): Record<string, unknown> =>
459
- reduceSchemaValue(schema) as Record<string, unknown>;
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: CloudResourceRefSchema,
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 (!hostname || hostname === "localhost" || hostname.endsWith(".localhost")) {
154
+ if (isUnsafeNetworkHostname(hostname)) {
180
155
  throw new Error("URL host is not allowed.");
181
156
  }
182
157
 
183
- const ipKind = isIP(hostname.replace(/^\[|\]$/g, ""));
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
 
@@ -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 = CloudResourceReferenceSchema.safeParse(value);
81
+ const reference = LinkedResourceReferenceSchema.safeParse(value);
51
82
  if (reference.success) {
52
- const resource: CloudResourceReference = reference.data;
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
  }