@k2b/cloud 0.41.0 → 0.42.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 (227) hide show
  1. package/package.json +6 -5
  2. package/src/_internal/bounded-json.ts +14 -2
  3. package/src/_internal/capabilities.ts +87 -8
  4. package/src/_internal/capability-reviews.ts +6 -0
  5. package/src/_internal/capability-timeout.ts +71 -0
  6. package/src/_internal/define-app.ts +145 -89
  7. package/src/_internal/http-drain.ts +56 -0
  8. package/src/_internal/registry-validation.ts +3 -0
  9. package/src/_internal/runtime-context.ts +1 -0
  10. package/src/_internal/status-preserving-ssr.ts +2 -0
  11. package/src/access/PermissionEditor.tsx +16 -17
  12. package/src/access/PrincipalPicker.tsx +45 -10
  13. package/src/access/messages.ts +8 -0
  14. package/src/access/principal-label.ts +18 -0
  15. package/src/access/ui.ts +1 -0
  16. package/src/account/EntitySearch.tsx +5 -4
  17. package/src/ai/action-review.ts +2 -1
  18. package/src/ai/app-skill-store.ts +1 -1
  19. package/src/ai/capabilities.ts +21 -3
  20. package/src/ai/capability-execution.ts +14 -12
  21. package/src/ai/chat/action-card.ts +7 -1
  22. package/src/ai/chat/action-messages.ts +14 -0
  23. package/src/ai/chat/action-question.browser-harness.tsx +12 -0
  24. package/src/ai/chat/action-question.tsx +202 -22
  25. package/src/ai/chat/action-text.ts +1 -1
  26. package/src/ai/chat-tasks.ts +2 -1
  27. package/src/ai/client/controller.ts +150 -32
  28. package/src/ai/client/transport.ts +14 -2
  29. package/src/ai/code-capability-routes.ts +27 -23
  30. package/src/ai/code-runtime-tools.ts +14 -16
  31. package/src/ai/code-source-tools.ts +16 -18
  32. package/src/ai/executor.ts +56 -21
  33. package/src/ai/fetch-file-tool.ts +67 -76
  34. package/src/ai/http.ts +1 -0
  35. package/src/ai/migrate.ts +13 -0
  36. package/src/ai/open-questions.ts +4 -8
  37. package/src/ai/projects.ts +1 -2
  38. package/src/ai/protocol.ts +2 -2
  39. package/src/ai/recent-resources.ts +12 -5
  40. package/src/ai/review-content.ts +77 -0
  41. package/src/ai/routes.ts +69 -31
  42. package/src/ai/runtime.ts +1 -0
  43. package/src/ai/skill-tool.ts +17 -6
  44. package/src/ai/skills-routes.ts +72 -27
  45. package/src/ai/skills.ts +139 -60
  46. package/src/ai/store.ts +168 -33
  47. package/src/ai/types.ts +10 -3
  48. package/src/ai/usage.ts +2 -2
  49. package/src/api/admin-core-settings.ts +15 -2
  50. package/src/api/admin-geo.ts +54 -0
  51. package/src/api/admin-linux-identities.ts +6 -3
  52. package/src/api/admin-outgoing-mail.ts +29 -15
  53. package/src/api/approvals-loader.ts +3 -3
  54. package/src/api/approvals.ts +51 -0
  55. package/src/api/apps.ts +4 -2
  56. package/src/api/capabilities.ts +258 -181
  57. package/src/api/capability-streams.ts +21 -13
  58. package/src/api/cli-plugins.ts +4 -1
  59. package/src/api/geo-glyphs.ts +11 -0
  60. package/src/api/geo.ts +128 -0
  61. package/src/api/help.ts +12 -4
  62. package/src/api/index.ts +5 -0
  63. package/src/api/mcp.ts +46 -38
  64. package/src/api/me-panel-consent.ts +8 -5
  65. package/src/api/search.ts +21 -14
  66. package/src/api/sync-ops.ts +19 -13
  67. package/src/api/widgets.ts +42 -18
  68. package/src/approvals/Approvals.tsx +5 -3
  69. package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
  70. package/src/browser/CloudResourceSearch.tsx +3 -13
  71. package/src/browser/PanelLeaveNotice.tsx +18 -0
  72. package/src/browser/navigation-search.ts +78 -25
  73. package/src/browser/panel-leave.ts +69 -0
  74. package/src/browser/panel-messages.ts +12 -0
  75. package/src/browser/panel-slot.ts +173 -24
  76. package/src/browser/panel.ts +3 -0
  77. package/src/browser/resource-search-messages.ts +2 -0
  78. package/src/capabilities/client.ts +28 -1
  79. package/src/capabilities/executions.ts +22 -3
  80. package/src/capabilities/geo-client.ts +57 -0
  81. package/src/capabilities/response.ts +2 -1
  82. package/src/capabilities/review-storage.ts +24 -0
  83. package/src/capabilities/reviews.ts +17 -3
  84. package/src/capabilities/server.ts +36 -16
  85. package/src/cli/access.ts +15 -23
  86. package/src/cli/admin/linux.ts +13 -2
  87. package/src/cli/admin/outgoing-mail.ts +26 -4
  88. package/src/cli/capabilities.ts +19 -0
  89. package/src/config/env.ts +0 -6
  90. package/src/contracts/app-access.ts +48 -0
  91. package/src/contracts/app.ts +4 -1
  92. package/src/contracts/approvals.ts +2 -2
  93. package/src/contracts/avatar.ts +186 -0
  94. package/src/contracts/capabilities.ts +148 -54
  95. package/src/contracts/geo-admin.ts +127 -0
  96. package/src/contracts/geo-map.ts +19 -0
  97. package/src/contracts/geo-route.ts +168 -0
  98. package/src/contracts/geo.ts +248 -0
  99. package/src/contracts/index.ts +4 -0
  100. package/src/contracts/outgoing-mail.ts +17 -4
  101. package/src/contracts/registry.ts +2 -0
  102. package/src/events/app-access.ts +68 -0
  103. package/src/events/live-delivery.ts +128 -0
  104. package/src/events/live.ts +14 -130
  105. package/src/geo/CloudMap.tsx +95 -0
  106. package/src/geo/map-messages.ts +17 -0
  107. package/src/geo/ui.ts +1 -0
  108. package/src/index.ts +2 -0
  109. package/src/server/client-ip.ts +36 -0
  110. package/src/server/index.ts +3 -4
  111. package/src/server/middleware/app-access.ts +312 -0
  112. package/src/server/middleware/auth.ts +1 -1
  113. package/src/server/middleware/invocation.ts +11 -0
  114. package/src/server/middleware/rate-limit.ts +4 -4
  115. package/src/server/services/access.ts +13 -9
  116. package/src/server/services/index.ts +0 -2
  117. package/src/server/services/services.ts +0 -2
  118. package/src/services/accounts/posix.ts +21 -7
  119. package/src/services/allowed-network-fetch.ts +16 -10
  120. package/src/services/app-access-metrics.ts +24 -0
  121. package/src/services/app-access.ts +123 -0
  122. package/src/services/app-discovery.ts +27 -0
  123. package/src/services/approvals/content.ts +40 -0
  124. package/src/services/approvals/index.ts +14 -6
  125. package/src/services/approvals/list.ts +6 -11
  126. package/src/services/approvals/question.ts +11 -7
  127. package/src/services/downstream-budget.ts +87 -0
  128. package/src/services/geo/admin.ts +262 -0
  129. package/src/services/geo/bucket.ts +67 -0
  130. package/src/services/geo/cache.ts +57 -0
  131. package/src/services/geo/costs.ts +5 -0
  132. package/src/services/geo/errors.ts +41 -0
  133. package/src/services/geo/fixtures/README.md +17 -0
  134. package/src/services/geo/fixtures/along.json +355 -0
  135. package/src/services/geo/fixtures/autocomplete.json +218 -0
  136. package/src/services/geo/fixtures/geocode.json +129 -0
  137. package/src/services/geo/fixtures/places.json +355 -0
  138. package/src/services/geo/fixtures/reverse.json +61 -0
  139. package/src/services/geo/fixtures/routing.json +57 -0
  140. package/src/services/geo/geoapify-route.ts +93 -0
  141. package/src/services/geo/geoapify.ts +281 -0
  142. package/src/services/geo/ledger.ts +122 -0
  143. package/src/services/geo/map.ts +160 -0
  144. package/src/services/geo/migrate.ts +25 -0
  145. package/src/services/geo/polyline.ts +184 -0
  146. package/src/services/geo/route-chain.ts +145 -0
  147. package/src/services/geo/route-costs.ts +9 -0
  148. package/src/services/geo/route-geometry.ts +39 -0
  149. package/src/services/geo/route-plan.ts +172 -0
  150. package/src/services/geo/route-service.ts +433 -0
  151. package/src/services/geo/service.ts +300 -0
  152. package/src/services/geo/settings.ts +49 -0
  153. package/src/services/help/index.ts +16 -7
  154. package/src/services/help/types.ts +6 -1
  155. package/src/services/identity/invocation-actor.ts +20 -2
  156. package/src/services/identity/issue-invocation.ts +67 -0
  157. package/src/services/identity/key-ring.ts +5 -3
  158. package/src/services/identity/runtime-config.ts +23 -0
  159. package/src/services/index.ts +9 -0
  160. package/src/services/mandates/index.ts +40 -3
  161. package/src/services/network-security.ts +6 -3
  162. package/src/services/network-transport.ts +99 -0
  163. package/src/services/notifications/catalog.ts +3 -2
  164. package/src/services/notifications/runtime.ts +1 -0
  165. package/src/services/notifications/web-push-transport.ts +45 -38
  166. package/src/services/outgoing-mail/dispatcher.ts +6 -1
  167. package/src/services/outgoing-mail/index.ts +55 -1
  168. package/src/services/outgoing-mail/messages.ts +82 -3
  169. package/src/services/outgoing-mail/resolve-host.ts +2 -2
  170. package/src/services/outgoing-mail/retention.ts +13 -1
  171. package/src/services/outgoing-mail/store.ts +36 -6
  172. package/src/services/outgoing-mail/test-send.ts +34 -4
  173. package/src/services/outgoing-mail/transport.ts +1 -1
  174. package/src/services/pdf/gotenberg.ts +11 -38
  175. package/src/services/pdf/template-preview.ts +1 -1
  176. package/src/services/public-http.ts +28 -40
  177. package/src/services/settings/app.ts +4 -1
  178. package/src/services/settings/core-settings.ts +88 -0
  179. package/src/services/settings/defaults.ts +8 -2
  180. package/src/services/settings/errors.ts +11 -0
  181. package/src/services/settings/geo-budget.ts +40 -0
  182. package/src/services/settings/index.ts +4 -3
  183. package/src/services/settings/store.ts +25 -17
  184. package/src/services/weather/geo.ts +29 -111
  185. package/src/services/weather/index.ts +4 -3
  186. package/src/services/weather/locations.ts +8 -5
  187. package/src/services/weather/migrate.ts +1 -0
  188. package/src/services/weather/settings.ts +0 -16
  189. package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
  190. package/src/shared/avatar-art.ts +400 -0
  191. package/src/shared/index.ts +2 -0
  192. package/src/shared/network-address.ts +22 -3
  193. package/src/shared/template-rendering.ts +36 -4
  194. package/src/shared-text/documents.ts +1 -1
  195. package/src/shared-text/index.ts +2 -0
  196. package/src/shared-text/log.ts +51 -3
  197. package/src/shared-text/snapshots.ts +340 -0
  198. package/src/ssr/AdminLayout.tsx +4 -2
  199. package/src/ssr/CloudShell.island.tsx +6 -0
  200. package/src/ssr/Layout.tsx +65 -15
  201. package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
  202. package/src/ssr/LayoutHelp.tsx +3 -2
  203. package/src/ssr/PanelRuntime.island.tsx +11 -7
  204. package/src/ssr/PwaLayout.tsx +2 -2
  205. package/src/ssr/RailApps.tsx +6 -0
  206. package/src/ssr/ShellRuntime.island.tsx +23 -7
  207. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
  208. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +6 -6
  209. package/src/ssr/admin-navigation.ts +1 -0
  210. package/src/ssr/app-badges.ts +6 -1
  211. package/src/ssr/compact-shell.browser-harness.ts +44 -5
  212. package/src/ssr/help.ts +3 -2
  213. package/src/ssr/index.ts +1 -1
  214. package/src/ssr/layout-context.ts +2 -0
  215. package/src/ssr/mobile-menu-history.ts +4 -0
  216. package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
  217. package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
  218. package/src/ssr/page-swap.browser-harness.tsx +177 -0
  219. package/src/ssr/page-swap.ts +28 -0
  220. package/src/ssr/page-trail-runtime.ts +32 -13
  221. package/src/ssr/panel-consent.ts +2 -2
  222. package/src/ssr/panel-slot.browser-harness.tsx +3 -0
  223. package/src/ssr/platform-messages.ts +8 -0
  224. package/src/ssr/runtime.ts +17 -0
  225. package/src/ssr/shell-data.tsx +10 -2
  226. package/src/styles/global.css +70 -6
  227. package/src/server/services/geo.ts +0 -215
@@ -1,8 +1,9 @@
1
+ import type { SQL } from "bun";
1
2
  import { type Context, Hono, type MiddlewareHandler } from "hono";
2
3
  import { bodyLimit } from "hono/body-limit";
3
4
  import { z } from "zod";
4
5
  import { readBoundedJson } from "../_internal/bounded-json";
5
- import { type DashboardWidget, listWidgets } from "../_internal/registry";
6
+ import { type DashboardWidget, type listApps, listWidgets } from "../_internal/registry";
6
7
  import { parseWidgetSettings } from "../_internal/widget-settings";
7
8
  import {
8
9
  type DashboardWidgetSize,
@@ -20,12 +21,16 @@ import {
20
21
  widgetKey,
21
22
  } from "../contracts/widgets";
22
23
  import { type AuthContext, auth, preferredLocale, rejectReservedWorkloadCredential } from "../server";
24
+ import { AppAccessDeniedError, appAccessDeniedBody } from "../server/middleware/app-access";
23
25
  import { noTransform } from "../server/middleware/no-transform";
24
26
  import { WIDGET_SETTINGS_PARAM, WIDGET_SETTINGS_PARAM_MAX_LENGTH, WIDGET_SIZE_PARAM } from "../server/widget-request";
27
+ import { appAccessAuthority } from "../services/app-access";
28
+ import { discoverableAppEntries } from "../services/app-discovery";
25
29
  import { CLOUD_INVOCATION_TOKEN_TTL_SECONDS } from "../services/identity/constants";
26
30
  import { invocationAuthorityFromRequest } from "../services/identity/invocation-authority";
27
31
  import { widgetInvocationOperation } from "../services/identity/invocation-operations";
28
- import { normalizeInvocationRequestId, signInvocationToken } from "../services/identity/invocation-token";
32
+ import { normalizeInvocationRequestId } from "../services/identity/invocation-token";
33
+ import { issueInvocationToken as signInvocationToken } from "../services/identity/issue-invocation";
29
34
  import { withActiveIdentitySigner } from "../services/identity/key-ring";
30
35
  import { logger } from "../services/logging";
31
36
  import { LOCALE_HEADER } from "../shared/locale";
@@ -35,6 +40,7 @@ type WidgetRouteDependencies = {
35
40
  listWidgets?: typeof listWidgets;
36
41
  fetch?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
37
42
  authenticate?: MiddlewareHandler<AuthContext>;
43
+ listApps?: typeof listApps;
38
44
  signInvocation?: typeof signInvocationToken;
39
45
  withActiveSigner?: typeof withActiveIdentitySigner;
40
46
  timeoutMs?: number;
@@ -196,17 +202,21 @@ export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) =
196
202
  widget: DashboardWidget,
197
203
  requestId: string | undefined,
198
204
  signer: Parameters<Parameters<typeof withActiveIdentitySigner>[1]>[0],
205
+ db: SQL,
199
206
  ) =>
200
- signInvocation({
201
- targetAppId: widget.appId,
202
- callingAppId: "core",
203
- operation: widgetInvocationOperation(widget.widgetId),
204
- schemaHash: null,
205
- authority: invocationAuthorityFromRequest(auth.getAuthority(c)),
206
- requestId,
207
- signer,
208
- issuer: signer.issuer,
209
- });
207
+ signInvocation(
208
+ {
209
+ targetAppId: widget.appId,
210
+ callingAppId: "core",
211
+ operation: widgetInvocationOperation(widget.widgetId),
212
+ schemaHash: null,
213
+ authority: invocationAuthorityFromRequest(auth.getAuthority(c)),
214
+ requestId,
215
+ signer,
216
+ issuer: signer.issuer,
217
+ },
218
+ { db, groupsAdmin: signer.groupsAdmin },
219
+ );
210
220
 
211
221
  const stream = async (c: Context<AuthContext>, entries: WidgetEntry[], requestId: string | undefined) => {
212
222
  const streamDeadline = AbortSignal.timeout(WIDGET_STREAM_DEADLINE_MS);
@@ -221,8 +231,10 @@ export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) =
221
231
  tokens = await waitWithin(
222
232
  withActiveSigner(
223
233
  "invocation",
224
- (signer) =>
225
- Promise.all(startBounded(entries, WIDGET_CONCURRENCY, async (entry) => sign(c, entry.widget, requestId, signer), signing)),
234
+ (signer, db) =>
235
+ Promise.all(
236
+ startBounded(entries, WIDGET_CONCURRENCY, async (entry) => sign(c, entry.widget, requestId, signer, db), signing),
237
+ ),
226
238
  { signal: signing, timeoutMs },
227
239
  ),
228
240
  signing,
@@ -316,7 +328,10 @@ export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) =
316
328
  let requested = new Map<string, DashboardWidgetSize | undefined>();
317
329
  try {
318
330
  const keys = new Set<string>();
319
- const declared = await waitWithin(registry(), setup);
331
+ const declared = await waitWithin(
332
+ registry().then((entries) => discoverableAppEntries(appAccessAuthority(c), entries, dependencies.listApps)),
333
+ setup,
334
+ );
320
335
  const declaredKeys = new Set(declared.map((widget) => widgetKey(widget.appId, widget.widgetId)));
321
336
  requested = new Map((c.req.queries("widget") ?? []).map((value) => parseRequestedWidget(value, declaredKeys)));
322
337
  widgets = declared.filter((widget) => {
@@ -351,7 +366,10 @@ export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) =
351
366
  const setup = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(timeoutMs)]);
352
367
  let declared: DashboardWidget[];
353
368
  try {
354
- declared = await waitWithin(registry(), setup);
369
+ declared = await waitWithin(
370
+ registry().then((entries) => discoverableAppEntries(appAccessAuthority(c), entries, dependencies.listApps)),
371
+ setup,
372
+ );
355
373
  } catch {
356
374
  log.warn("Widget registry unavailable", { reason: setup.aborted ? "deadline_exceeded" : "operation_failed" });
357
375
  return c.json({ message: "Widget registry is currently unavailable" }, 503);
@@ -372,12 +390,17 @@ export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) =
372
390
  const requestId = normalizeInvocationRequestId(c.req.header("x-request-id"));
373
391
  let phase: WidgetPhase = "registry";
374
392
  try {
375
- const widget = (await waitWithin(registry(), signal)).find((entry) => entry.appId === appId && entry.widgetId === widgetId);
393
+ const widget = (
394
+ await waitWithin(
395
+ registry().then((entries) => discoverableAppEntries(appAccessAuthority(c), entries, dependencies.listApps)),
396
+ signal,
397
+ )
398
+ ).find((entry) => entry.appId === appId && entry.widgetId === widgetId);
376
399
  if (!widget) return c.json({ message: "Widget not found" }, 404);
377
400
 
378
401
  phase = "signing";
379
402
  const signed = await waitWithin(
380
- withActiveSigner("invocation", (signer) => sign(c, widget, requestId, signer), { signal, timeoutMs }),
403
+ withActiveSigner("invocation", (signer, db) => sign(c, widget, requestId, signer, db), { signal, timeoutMs }),
381
404
  signal,
382
405
  );
383
406
 
@@ -405,6 +428,7 @@ export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) =
405
428
  : "Widget is unavailable";
406
429
  return Response.json({ message }, { status: 502 });
407
430
  } catch (error) {
431
+ if (error instanceof AppAccessDeniedError) return Response.json(appAccessDeniedBody(error.app), { status: 403 });
408
432
  const isTimeout = timeout.aborted || (error instanceof Error && error.name === "TimeoutError");
409
433
  const reason: WidgetRejectionReason = isTimeout ? "deadline_exceeded" : signal.aborted ? "request_cancelled" : "operation_failed";
410
434
  logRejection({ appId, widgetId }, phase, reason);
@@ -179,9 +179,11 @@ function BandDecision(props: {
179
179
  <Button size="sm" variant="ghost" disabled={props.busy} onClick={() => props.decide({ approved: false })}>
180
180
  {t().reject}
181
181
  </Button>
182
- <Button size="sm" variant="primary" loading={props.busy} onClick={() => props.decide({ approved: true })}>
183
- {t().approve}
184
- </Button>
182
+ <Show when={!(props.item.review && "contentRef" in props.item.review && props.item.review.contentRef)}>
183
+ <Button size="sm" variant="primary" loading={props.busy} onClick={() => props.decide({ approved: true })}>
184
+ {t().approve}
185
+ </Button>
186
+ </Show>
185
187
  </div>
186
188
  );
187
189
  }
@@ -1,6 +1,12 @@
1
+ import { LocaleProvider } from "@k2b/ui";
2
+ import { render } from "solid-js/web";
1
3
  import { appAccentStyle } from "../ssr/app-appearance";
2
4
  import { createGlobalSearchHost } from "../ssr/global-search-host";
5
+ import { platformMessages } from "../ssr/platform-messages";
6
+ import ShellRuntime from "../ssr/ShellRuntime.island";
7
+ import type { ShellData } from "../ssr/shell-data";
3
8
  import { openCloudResourcePicker } from "./resource-picker";
9
+ import { openGlobalSearch } from "./search";
4
10
 
5
11
  /**
6
12
  * Opens the real global search, or with `?picker` the resource picker, for a browser test that answers
@@ -8,7 +14,53 @@ import { openCloudResourcePicker } from "./resource-picker";
8
14
  */
9
15
  const params = new URLSearchParams(location.search);
10
16
  const app = params.get("app") ?? undefined;
11
- if (params.has("picker")) {
17
+ if (params.has("launcher")) {
18
+ // Invented authorized shell data, rendered by the real ShellRuntime and global-search host.
19
+ // Role filtering itself is covered at Layout's SSR seam; this harness does not simulate authentication.
20
+ const locale = document.documentElement.lang || "en";
21
+ const de = locale === "de";
22
+ const data: ShellData = {
23
+ apps: [
24
+ { id: "tools", label: de ? "Werkzeuge" : "Tools", iconClass: "ti ti-tools", href: "/tools", match: "/tools", defaultVisible: false },
25
+ ...(params.has("admin")
26
+ ? [
27
+ {
28
+ id: "admin",
29
+ label: platformMessages.resolve([locale]).t.admin,
30
+ iconClass: "ti ti-settings",
31
+ href: "/admin",
32
+ match: "/admin",
33
+ defaultVisible: false,
34
+ },
35
+ ]
36
+ : []),
37
+ ],
38
+ searchLinks: {
39
+ tools: [
40
+ { label: "Tools", href: "/tools", keywords: ["utilities", "werkzeuge"] },
41
+ { label: "QR Code", href: "/tools/qr", keywords: ["qr"] },
42
+ ],
43
+ },
44
+ legalLinks: [],
45
+ theme: "light",
46
+ };
47
+ const script = document.createElement("script");
48
+ script.id = "cloud-shell-data";
49
+ script.type = "application/json";
50
+ script.textContent = JSON.stringify(data);
51
+ document.body.append(script);
52
+ const host = document.createElement("div");
53
+ document.body.append(host);
54
+ render(
55
+ () => (
56
+ <LocaleProvider locale={locale}>
57
+ <ShellRuntime commands={{ searchResources: !params.has("public") }} />
58
+ </LocaleProvider>
59
+ ),
60
+ host,
61
+ );
62
+ openGlobalSearch(app ? { scope: { appId: app, label: app } } : { query: params.get("q") ?? "" });
63
+ } else if (params.has("picker")) {
12
64
  // The page shell renders `<body class="k2b-ui app-accent-scope" style="…">` for an app page.
13
65
  const style = appAccentStyle(params.get("accent") ?? undefined);
14
66
  if (style) {
@@ -51,15 +51,6 @@ const isLinkableCommand = (command: PaletteCommand) => typeof command.action !==
51
51
 
52
52
  const itemKey = (item: SearchItem) => `${item.ref.type}:${item.ref.id}`;
53
53
  const URL_BASE = "https://cloud.invalid";
54
- const groupByApp = (items: SearchItem[]) => {
55
- const groups = new Map<string, SearchItem[]>();
56
- for (const item of items) {
57
- const group = groups.get(item.appId) ?? [];
58
- group.push(item);
59
- groups.set(item.appId, group);
60
- }
61
- return [...groups.values()].flat();
62
- };
63
54
 
64
55
  export default function CloudResourceSearch(props: CloudResourceSearchProps) {
65
56
  const locale = useLocale();
@@ -236,9 +227,7 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
236
227
  /** Rows of the run for the current input; anything older is visible but cannot be chosen. */
237
228
  const freshResults = () => current() && shownRun();
238
229
  const results = createMemo(() =>
239
- canSearch()
240
- ? [...groupByApp(navigation()), ...shownBlocks().flatMap((block) => filterCloudResourceSearchItems(block.items, props))]
241
- : [],
230
+ canSearch() ? [...navigation(), ...shownBlocks().flatMap((block) => filterCloudResourceSearchItems(block.items, props))] : [],
242
231
  );
243
232
  const searchContext = () => JSON.stringify([desiredUrl(), commandMode(), commandQuery()]);
244
233
  /**
@@ -460,6 +449,7 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
460
449
  }
461
450
  };
462
451
  const updateCaret = () => setCaret(inputRef.selectionStart ?? input().length);
452
+ const isApp = (item: SearchItem) => navigation().some((link) => link === item && link.kind === "app");
463
453
  const previewMetadata = () => (previewItem()?.metadata ?? []).filter((entry) => entry.label.trim() && entry.value.trim()).slice(0, 5);
464
454
 
465
455
  return (
@@ -670,7 +660,7 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
670
660
  <i class={item.icon ?? item.appIcon} aria-hidden="true" />
671
661
  <span class="cloud-resource-search__result-copy">
672
662
  <span>{item.title}</span>
673
- <small>{item.preview ?? item.appName}</small>
663
+ <small>{isApp(item) ? t().app : (item.preview ?? item.appName)}</small>
674
664
  </span>
675
665
  <Show when={commandFor(item)?.shortcut}>{(shortcut) => <Kbd keys={shortcut()} />}</Show>
676
666
  <Show when={props.selectionMode && selectedKey() === itemKey(item)}>
@@ -0,0 +1,18 @@
1
+ import { Button, NoticeCard } from "@k2b/ui";
2
+ import { Show } from "solid-js";
3
+ import { platformMessages } from "../ssr/platform-messages";
4
+
5
+ export default function PanelLeaveNotice(props: { blocked: boolean; locale: string; resume: () => void }) {
6
+ const t = () => platformMessages.resolve([props.locale]).t;
7
+ return (
8
+ <Show when={props.blocked}>
9
+ <div role="status" data-cloud-panel-leave="blocked">
10
+ <NoticeCard tone="warning" title={t().panelLeaveBlocked} detail={t().panelLeaveDetail}>
11
+ <Button variant="ghost" onClick={props.resume}>
12
+ {t().panelLeaveResume}
13
+ </Button>
14
+ </NoticeCard>
15
+ </div>
16
+ </Show>
17
+ );
18
+ }
@@ -3,49 +3,102 @@ import type { SearchItem } from "../api/search/schemas";
3
3
  import type { AppMeta } from "../contracts/app";
4
4
 
5
5
  /** UI-only rows. The synthetic ref is a row key, never a readable resource. */
6
- export type NavigationSearchItem = SearchItem & { keywords?: readonly string[] };
6
+ export type NavigationSearchItem = SearchItem & { keywords?: readonly string[]; kind?: "app" };
7
7
 
8
8
  export const navigationSearchItems = (
9
- apps: readonly Pick<AppMeta, "id" | "name" | "icon" | "searchLinks">[],
9
+ apps: readonly (Pick<AppMeta, "id" | "name" | "icon" | "searchLinks"> & { nav?: Pick<NonNullable<AppMeta["nav"]>, "href"> })[],
10
10
  visibleAppIds: readonly string[],
11
11
  ): NavigationSearchItem[] => {
12
12
  const visible = new Set(visibleAppIds);
13
13
  return apps
14
14
  .filter((app) => visible.has(app.id))
15
- .flatMap((app) =>
16
- (app.searchLinks ?? []).map((link) => ({
17
- appId: app.id,
18
- appName: app.name,
19
- appIcon: app.icon,
20
- ref: { type: "cloud.navigation", id: `${app.id}:${link.href}` },
21
- readable: false,
22
- title: link.label,
23
- preview: link.description,
24
- href: link.href,
25
- icon: link.icon ?? app.icon,
26
- priority: 0,
27
- keywords: link.keywords,
28
- })),
29
- );
15
+ .flatMap((app) => {
16
+ const entryLinks = app.searchLinks?.filter((link) => link.href === app.nav?.href) ?? [];
17
+ const links = [
18
+ ...(app.nav
19
+ ? [
20
+ {
21
+ label: app.name,
22
+ href: app.nav.href,
23
+ icon: app.icon,
24
+ kind: "app" as const,
25
+ keywords: entryLinks.flatMap((link) => [link.label, ...(link.keywords ?? [])]),
26
+ },
27
+ ]
28
+ : []),
29
+ ...(app.searchLinks ?? []),
30
+ ];
31
+ return links.map(
32
+ (link): NavigationSearchItem => ({
33
+ appId: app.id,
34
+ appName: app.name,
35
+ appIcon: app.icon,
36
+ ref: { type: "cloud.navigation", id: `${app.id}:${link.href}` },
37
+ readable: false,
38
+ title: link.label,
39
+ preview: "description" in link ? link.description : undefined,
40
+ href: link.href,
41
+ icon: link.icon ?? app.icon,
42
+ priority: 0,
43
+ keywords: link.keywords,
44
+ kind: "kind" in link ? link.kind : undefined,
45
+ }),
46
+ );
47
+ });
48
+ };
49
+
50
+ const normalize = (text: string) => text.trim().toLowerCase().replace(/\s+/g, " ");
51
+
52
+ /** Compare individual names/aliases, never their combined length or number. */
53
+ const appMatchRank = (item: NavigationSearchItem, query: string): number => {
54
+ const names = [item.title, ...(item.keywords ?? [])].map(normalize);
55
+ if (names.includes(query)) return 0;
56
+ const words = query.split(" ");
57
+ if (names.some((name) => words.every((word) => name.split(/[^\p{L}\p{N}]+/u).some((part) => part.startsWith(word))))) return 1;
58
+ if (names.some((name) => words.every((word) => name.includes(word)))) return 2;
59
+ // Whole-name typos only, at least 80% similarity. No short abbreviations or description fuzzing globally.
60
+ if (query.length < 4) return Infinity;
61
+ const closest = fuzzy.closest(query, names, { maxDistance: query.length >= 8 ? 2 : 1 });
62
+ return closest && closest.similarity >= 0.8 ? 3 + closest.distance / 10 : Infinity;
30
63
  };
31
64
 
32
65
  export const matchNavigationSearchItems = (
33
66
  items: readonly NavigationSearchItem[],
34
67
  input: { query: string; tags: readonly string[]; appId?: string | null; requireReader?: boolean },
35
68
  ): NavigationSearchItem[] => {
36
- const query = input.query.trim().toLowerCase();
69
+ const query = normalize(input.query);
37
70
  if (input.requireReader || input.tags.length > 0 || (!input.appId && query.length < 2)) return [];
71
+ const seen = new Set<string>();
72
+ const uniqueTarget = (item: NavigationSearchItem) => {
73
+ if (seen.has(item.href)) return false;
74
+ seen.add(item.href);
75
+ return true;
76
+ };
38
77
  if (input.appId) {
39
- const scoped = items.filter((item) => item.appId === input.appId);
78
+ // App contexts keep browsing their static pages; app launchers belong to global navigation only.
79
+ const scoped = items.filter((item) => item.appId === input.appId && item.kind !== "app");
40
80
  return query
41
81
  ? fuzzy
42
82
  .filter(query, scoped, { key: (item) => [item.title, item.preview ?? "", ...(item.keywords ?? [])].join(" ") })
43
83
  .map((hit) => hit.item)
44
- : scoped;
84
+ .filter(uniqueTarget)
85
+ : scoped.filter(uniqueTarget);
45
86
  }
46
- const words = query.split(/\s+/);
47
- return items.filter((item) => {
48
- const text = [item.title, item.preview ?? "", ...(item.keywords ?? [])].join(" ").toLowerCase();
49
- return words.every((word) => text.includes(word));
50
- });
87
+ const words = query.split(" ");
88
+ // Deduplicate only this global view. A cross-app page still belongs to its declaring app's own scope.
89
+ const appTargets = new Set(items.filter((item) => item.kind === "app").map((item) => item.href));
90
+ return items
91
+ .filter((item) => item.kind === "app" || !appTargets.has(item.href))
92
+ .flatMap((item) => {
93
+ const rank =
94
+ item.kind === "app"
95
+ ? appMatchRank(item, query)
96
+ : words.every((word) => [item.title, item.preview ?? "", ...(item.keywords ?? [])].join(" ").toLowerCase().includes(word))
97
+ ? 2
98
+ : Infinity;
99
+ return Number.isFinite(rank) ? [{ item, rank }] : [];
100
+ })
101
+ .sort((a, b) => a.rank - b.rank)
102
+ .map(({ item }) => item)
103
+ .filter(uniqueTarget);
51
104
  };
@@ -0,0 +1,69 @@
1
+ /** Internal same-origin collection; no composer content crosses the provider boundary. */
2
+ const LEAVE = "cloud.panel.before-leave";
3
+ const CHECK = "cloud.panel.has-leave-guard";
4
+ type LeaveDecision = { allowed: boolean };
5
+
6
+ /** Ordinary same-origin links handled by the panel host; explicit browser actions stay native. */
7
+ export function panelLinkDestination(event: MouseEvent): URL | undefined {
8
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return;
9
+ const anchor = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>("a[href]") : null;
10
+ if (!anchor || (anchor.target && anchor.target !== "_self") || anchor.hasAttribute("download")) return;
11
+ const url = new URL(anchor.getAttribute("href")!, document.baseURI);
12
+ if (url.origin !== location.origin) return;
13
+ if (url.pathname === location.pathname && url.search === location.search && url.hash) return;
14
+ return url;
15
+ }
16
+
17
+ /**
18
+ * Register before enabling editing. Return true only when all provider work is recoverable.
19
+ * A veto keeps the current frame. Saving and retry belong to the provider; no transition is queued.
20
+ */
21
+ export function onPanelBeforeLeave(guard: () => boolean): () => void {
22
+ const collect = (event: Event) => {
23
+ const decision = (event as CustomEvent<LeaveDecision>).detail;
24
+ try {
25
+ // Only explicit synchronous true permits leaving; throws and accidental Promises fail closed.
26
+ if (guard() !== true) decision.allowed = false;
27
+ } catch {
28
+ decision.allowed = false;
29
+ }
30
+ };
31
+ const registered = (event: Event) => event.preventDefault();
32
+ // Islands start independently: editing and its links must be protected before PanelRuntime loads too.
33
+ const click = (event: MouseEvent) => {
34
+ if (window.parent === window || !document.querySelector("[data-cloud-panel-page]")) return;
35
+ const url = panelLinkDestination(event);
36
+ if (!url) return;
37
+ event.preventDefault();
38
+ window.parent.postMessage({ type: "cloud.panel.navigate", v: 1, href: url.href }, location.origin);
39
+ };
40
+ window.addEventListener(LEAVE, collect);
41
+ window.addEventListener(CHECK, registered);
42
+ document.addEventListener("click", click);
43
+ return () => {
44
+ window.removeEventListener(LEAVE, collect);
45
+ window.removeEventListener(CHECK, registered);
46
+ document.removeEventListener("click", click);
47
+ };
48
+ }
49
+
50
+ export function hasPanelLeaveGuard(): boolean {
51
+ return !window.dispatchEvent(new Event(CHECK, { cancelable: true }));
52
+ }
53
+
54
+ export function panelCanLeave(target: Window): boolean {
55
+ // A cross-origin navigation already destroyed the provider document and its guards.
56
+ // It cannot participate in this same-origin contract or keep the host's slot trapped.
57
+ try {
58
+ void target.document;
59
+ } catch {
60
+ return true;
61
+ }
62
+ const decision: LeaveDecision = { allowed: true };
63
+ try {
64
+ target.dispatchEvent(new CustomEvent<LeaveDecision>(LEAVE, { detail: decision }));
65
+ return decision.allowed;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
@@ -1,5 +1,17 @@
1
1
  const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
2
2
 
3
+ export function isPanelNavigateMessage(value: unknown): value is { type: "cloud.panel.navigate"; v: 1; href: string } {
4
+ return (
5
+ isRecord(value) &&
6
+ Reflect.ownKeys(value).length === 3 &&
7
+ value.type === "cloud.panel.navigate" &&
8
+ value.v === 1 &&
9
+ typeof value.href === "string" &&
10
+ value.href.length <= 8192 &&
11
+ /^https?:\/\//.test(value.href)
12
+ );
13
+ }
14
+
3
15
  export function isPanelReadyMessage(value: unknown, provider: string): value is { type: "cloud.panel.ready"; v: 1; provider: string } {
4
16
  return (
5
17
  isRecord(value) &&