@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
@@ -32,22 +32,12 @@ const UNSAFE_IPV4_RANGES: ReadonlyArray<readonly [string, number]> = [
32
32
  ["240.0.0.0", 4],
33
33
  ];
34
34
 
35
- const ipv6ToBigInt = (rawAddress: string): bigint | null => {
36
- let address = rawAddress.toLowerCase().split("%")[0] ?? "";
37
- if (address.includes(".")) {
38
- const separator = address.lastIndexOf(":");
39
- const ipv4 = ipv4ToNumber(address.slice(separator + 1));
40
- if (separator < 0 || ipv4 === null) return null;
41
- address = `${address.slice(0, separator)}:${(ipv4 >>> 16).toString(16)}:${(ipv4 & 0xffff).toString(16)}`;
42
- }
35
+ // Only canonical IPv6 from the WHATWG parser reaches this conversion.
36
+ const ipv6ToBigInt = (address: string): bigint => {
43
37
  const halves = address.split("::");
44
- if (halves.length > 2) return null;
45
38
  const left = halves[0] ? halves[0].split(":") : [];
46
39
  const right = halves[1] ? halves[1].split(":") : [];
47
- const missing = halves.length === 2 ? 8 - left.length - right.length : 0;
48
- if (missing < 0 || (halves.length === 1 && left.length !== 8)) return null;
49
- const groups = [...left, ...Array.from({ length: missing }, () => "0"), ...right];
50
- if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/.test(group))) return null;
40
+ const groups = [...left, ...Array.from({ length: 8 - left.length - right.length }, () => "0"), ...right];
51
41
  return groups.reduce((value, group) => (value << 16n) | BigInt(`0x${group}`), 0n);
52
42
  };
53
43
 
@@ -57,51 +47,62 @@ const ipv6InRange = (address: bigint, base: bigint, bits: number): boolean => {
57
47
  };
58
48
 
59
49
  const UNSAFE_IPV6_RANGES: ReadonlyArray<readonly [bigint, number]> = [
60
- [ipv6ToBigInt("::")!, 96],
61
- [ipv6ToBigInt("64:ff9b::")!, 96],
62
- [ipv6ToBigInt("64:ff9b:1::")!, 48],
63
- [ipv6ToBigInt("100::")!, 64],
64
- [ipv6ToBigInt("2001::")!, 32],
65
- [ipv6ToBigInt("2001:2::")!, 48],
66
- [ipv6ToBigInt("2001:db8::")!, 32],
67
- [ipv6ToBigInt("2002::")!, 16],
68
- [ipv6ToBigInt("3fff::")!, 20],
69
- [ipv6ToBigInt("fc00::")!, 7],
70
- [ipv6ToBigInt("fe80::")!, 10],
71
- [ipv6ToBigInt("fec0::")!, 10],
72
- [ipv6ToBigInt("ff00::")!, 8],
50
+ [ipv6ToBigInt("2001::"), 23],
51
+ [ipv6ToBigInt("2001:db8::"), 32],
52
+ [ipv6ToBigInt("2002::"), 16],
53
+ [ipv6ToBigInt("3fff::"), 20],
73
54
  ];
74
55
 
75
- export const normalizeNetworkHostname = (hostname: string): string =>
76
- hostname
77
- .toLowerCase()
78
- .replace(/^\[|\]$/g, "")
79
- .replace(/\.$/, "");
56
+ export const normalizeNetworkHostname = (hostname: string): string => {
57
+ const lower = hostname.toLowerCase();
58
+ if (lower.startsWith("[") && lower.endsWith("]") && lower.includes(":")) return lower.slice(1, -1);
59
+ return lower.includes(":") ? lower : lower.replace(/\.$/, "");
60
+ };
61
+
62
+ // WHATWG's "ends in a number" rule: these hosts must parse as IPv4,
63
+ // rather than falling back to DNS (including invalid octal and overflow).
64
+ const endsInNumber = (hostname: string): boolean => {
65
+ const last = hostname.split(".").at(-1) ?? "";
66
+ return /^\d+$/.test(last) || /^0x[0-9a-f]*$/i.test(last);
67
+ };
80
68
 
81
- export const networkAddressFamily = (rawAddress: string): 4 | 6 | null => {
69
+ /** Parse all URL IP literal forms without DNS or server-only dependencies. */
70
+ export const parseNetworkAddress = (rawAddress: string): { address: string; family: 4 | 6 } | null => {
82
71
  const address = normalizeNetworkHostname(rawAddress);
83
- if (ipv4ToNumber(address) !== null) return 4;
84
- return ipv6ToBigInt(address) !== null ? 6 : null;
72
+ const ipv6 = address.includes(":");
73
+ if (!address || /[\s/@\\?#%\[\]]/.test(address) || (!ipv6 && !endsInNumber(address))) return null;
74
+ try {
75
+ // URL implements legacy IPv4 (decimal, octal, hex and shortened forms),
76
+ // as well as IPv6 compression and dotted tails. Reject zone IDs above.
77
+ const parsed = new URL(`http://${ipv6 ? `[${address}]` : address}/`).hostname;
78
+ if (ipv6) return { address: normalizeNetworkHostname(parsed), family: 6 };
79
+ return ipv4ToNumber(parsed) !== null ? { address: parsed, family: 4 } : null;
80
+ } catch {
81
+ return null;
82
+ }
85
83
  };
86
84
 
85
+ export const networkAddressFamily = (rawAddress: string): 4 | 6 | null => parseNetworkAddress(rawAddress)?.family ?? null;
86
+
87
87
  export const isUnsafeNetworkAddress = (rawAddress: string): boolean => {
88
- const address = normalizeNetworkHostname(rawAddress);
89
- const family = networkAddressFamily(address);
90
- if (family === 4) return UNSAFE_IPV4_RANGES.some(([base, bits]) => ipv4InRange(address, base, bits));
91
- if (family !== 6) return true;
92
- const value = ipv6ToBigInt(address);
93
- if (value === null) return true;
94
- if (value >> 32n === 0xffffn) {
88
+ const literal = parseNetworkAddress(rawAddress);
89
+ if (!literal) return true;
90
+ if (literal.family === 4) return UNSAFE_IPV4_RANGES.some(([base, bits]) => ipv4InRange(literal.address, base, bits));
91
+ const value = ipv6ToBigInt(literal.address);
92
+ if (value >> 32n === 0xffffn || ipv6InRange(value, ipv6ToBigInt("64:ff9b::"), 96)) {
95
93
  const mapped = Number(value & 0xffffffffn);
96
- const ipv4 = [mapped >>> 24, (mapped >>> 16) & 255, (mapped >>> 8) & 255, mapped & 255].join(".");
97
- return isUnsafeNetworkAddress(ipv4);
94
+ return isUnsafeNetworkAddress([mapped >>> 24, (mapped >>> 16) & 255, (mapped >>> 8) & 255, mapped & 255].join("."));
98
95
  }
99
- return UNSAFE_IPV6_RANGES.some(([base, bits]) => ipv6InRange(value, base, bits));
96
+ return !ipv6InRange(value, ipv6ToBigInt("2000::"), 3) || UNSAFE_IPV6_RANGES.some(([base, bits]) => ipv6InRange(value, base, bits));
100
97
  };
101
98
 
102
99
  export const isUnsafeNetworkHostname = (rawHostname: string): boolean => {
103
100
  const hostname = normalizeNetworkHostname(rawHostname);
104
101
  return (
105
- hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "metadata.google.internal" || hostname.endsWith(".internal")
102
+ !hostname ||
103
+ ["localhost", "localhost.localdomain", "metadata", "metadata.google.internal", "instance-data"].includes(hostname) ||
104
+ [".localhost", ".internal", ".local", ".home.arpa"].some((suffix) => hostname.endsWith(suffix)) ||
105
+ /[%\[\]]/.test(hostname) ||
106
+ ((endsInNumber(hostname) || hostname.includes(":")) && !parseNetworkAddress(hostname))
106
107
  );
107
108
  };
@@ -11,9 +11,8 @@ import { getDateConfig } from "../server/time";
11
11
  import { dates } from "../shared";
12
12
  import { readThemePreferenceFromCookieHeader } from "../shared/theme";
13
13
  import AnnouncementBanners from "./AnnouncementBanners";
14
- import { appAppearanceStyle } from "./app-appearance";
14
+ import { appAppearanceStyle, resolveCurrentApp } from "./app-appearance";
15
15
  import { resolveRuntimeRoute, visibleNavigationApps } from "./app-navigation";
16
- import type { GlobalSearchHelpApp } from "./GlobalSearchHelpDialog";
17
16
  import { getLayoutHelp } from "./help";
18
17
  import LayoutFooter from "./LayoutFooter";
19
18
  import LayoutHeader from "./LayoutHeader";
@@ -151,7 +150,7 @@ export default function Layout(props: LayoutProps) {
151
150
  const user = c.get("user");
152
151
  const pathname = new URL(c.req.raw.url).pathname;
153
152
  const currentRoute = resolveRuntimeRoute(runtime.apps, pathname);
154
- const currentApp = currentRoute?.app;
153
+ const currentApp = resolveCurrentApp(runtime.apps, pathname);
155
154
  const registeredHelp = getLayoutHelp(c);
156
155
  const runtimeHelp = registeredHelp ? { documents: registeredHelp.documents, pageBase: registeredHelp.pageBase } : undefined;
157
156
  const workspaceLayout = resolveAppWorkspaceLayoutForSidebar(
@@ -164,17 +163,6 @@ export default function Layout(props: LayoutProps) {
164
163
  const searchLinks = Object.fromEntries(
165
164
  runtime.apps.flatMap((app) => (visibleAppIds.has(app.id) && app.searchLinks?.length ? [[app.id, app.searchLinks] as const] : [])),
166
165
  );
167
- const searchHelpApps: GlobalSearchHelpApp[] = runtime.apps
168
- .filter((app) => (app.searchTags?.length ?? 0) > 0)
169
- .map((app) => ({
170
- appId: app.id,
171
- appName: app.name,
172
- appIcon: app.icon,
173
- help: app.searchHelp,
174
- tags: [...new Set((app.searchTags ?? []).map((tag) => tag.toLowerCase()))],
175
- tagHelp: [...(app.searchTagHelp ?? [])],
176
- }))
177
- .sort((a, b) => a.appName.localeCompare(b.appName));
178
166
  const settings = c.get("settings");
179
167
  const announcements = c.get("announcements");
180
168
  const appName = settings?.app?.name || "Cloud";
@@ -223,7 +211,7 @@ export default function Layout(props: LayoutProps) {
223
211
  theme,
224
212
  accent: currentApp?.appearance?.accent,
225
213
  searchLinks,
226
- searchHelpApps,
214
+ appId: currentApp?.id,
227
215
  };
228
216
  if (focusMode) {
229
217
  return (
@@ -0,0 +1,43 @@
1
+ import { LocaleProvider } from "@k2b/ui";
2
+ import { render } from "solid-js/web";
3
+ import type { HelpDocumentManifest, HelpDocumentPayload } from "../shared/help";
4
+ import { LayoutHelpPage } from "./LayoutHelp";
5
+
6
+ const documents: HelpDocumentManifest[] = Array.from({ length: 8 }, (_, index) => ({
7
+ id: `topic-${index}`,
8
+ title: `Topic ${index}`,
9
+ description: "Help browser fixture",
10
+ order: index,
11
+ url: `/api/help/topic-${index}`,
12
+ searchUrl: "/api/help/search",
13
+ }));
14
+ const html = Array.from(
15
+ { length: 8 },
16
+ (_, index) =>
17
+ `<h2 id="section-${index}" class="help-section-title" data-help-icon="ti ti-point">Section ${index}</h2>${Array.from({ length: 18 }, () => "<p>Read this guidance to finish the task and understand how the app works.</p>").join("")}`,
18
+ ).join("");
19
+ window.fetch = Object.assign(
20
+ async (input: URL | RequestInfo) => {
21
+ await new Promise((resolve) => setTimeout(resolve, 80));
22
+ const path = String(input);
23
+ if (path.includes("/search")) return Response.json({ locale: "en", ids: ["topic-0", "topic-1"] });
24
+ const id = path.split("/").at(-1) ?? "topic-0";
25
+ const payload: HelpDocumentPayload = { id, locale: "en", title: id, markdown: "Help", html };
26
+ return Response.json(payload);
27
+ },
28
+ { preconnect: () => {} },
29
+ );
30
+ const main = document.querySelector<HTMLElement>(".layout-content-main");
31
+ if (!main) throw new Error("Missing Layout main");
32
+ const embedded = new URL(location.href).searchParams.has("embedded");
33
+ const host = document.createElement("div");
34
+ if (embedded) host.style.cssText = "width:360px;height:700px";
35
+ main.append(host);
36
+ render(
37
+ () => (
38
+ <LocaleProvider locale="en">
39
+ <LayoutHelpPage documents={documents} embedded={embedded} pageBase="/app/spaces/help" />
40
+ </LocaleProvider>
41
+ ),
42
+ host,
43
+ );