@k2b/cloud 0.29.1 → 0.31.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 (136) hide show
  1. package/package.json +4 -2
  2. package/src/_internal/app-presentation.ts +22 -1
  3. package/src/_internal/app-roles.ts +14 -0
  4. package/src/_internal/capabilities.ts +113 -5
  5. package/src/_internal/capability-sentences.ts +413 -0
  6. package/src/_internal/define-app.ts +24 -5
  7. package/src/_internal/registry-validation.ts +5 -0
  8. package/src/_internal/registry.ts +50 -6
  9. package/src/_internal/widget-declarations.ts +53 -0
  10. package/src/access/PermissionEditor.tsx +43 -24
  11. package/src/ai/app-skill-store.ts +298 -0
  12. package/src/ai/app-skills.ts +105 -0
  13. package/src/ai/approval-routes.ts +40 -4
  14. package/src/ai/approvals.ts +68 -10
  15. package/src/ai/browser.ts +10 -0
  16. package/src/ai/capabilities.ts +68 -9
  17. package/src/ai/chart-block.ts +380 -0
  18. package/src/ai/chat/action-sentences.ts +49 -0
  19. package/src/ai/chat/blocks.tsx +97 -40
  20. package/src/ai/chat/chart-block.tsx +79 -0
  21. package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
  22. package/src/ai/chat/message-actions.tsx +3 -1
  23. package/src/ai/chat/message-utils.ts +3 -0
  24. package/src/ai/chat/messages.ts +26 -2
  25. package/src/ai/chat/tool-groups.ts +66 -12
  26. package/src/ai/chat/tool-names.ts +6 -2
  27. package/src/ai/chat/turn-layout.ts +10 -2
  28. package/src/ai/chat/turn-view.tsx +123 -36
  29. package/src/ai/chat/web-tools.tsx +95 -1
  30. package/src/ai/client/controller.ts +6 -3
  31. package/src/ai/code-runtime-tools.ts +109 -61
  32. package/src/ai/code-source-contracts.ts +25 -21
  33. package/src/ai/code-source-tools.ts +3 -1
  34. package/src/ai/default-tool-contracts.ts +198 -0
  35. package/src/ai/default-tools.ts +25 -1
  36. package/src/ai/executor.ts +189 -35
  37. package/src/ai/fetch-file-tool.ts +41 -4
  38. package/src/ai/firecrawl-tools.ts +87 -4
  39. package/src/ai/index.ts +17 -1
  40. package/src/ai/migrate.ts +172 -14
  41. package/src/ai/protocol.ts +22 -1
  42. package/src/ai/routes.ts +11 -0
  43. package/src/ai/runtime-tools.ts +1 -0
  44. package/src/ai/runtime.ts +46 -7
  45. package/src/ai/skill-content.ts +54 -0
  46. package/src/ai/skills-routes.ts +22 -13
  47. package/src/ai/skills.ts +177 -231
  48. package/src/ai/store.ts +66 -1
  49. package/src/ai/tool-audit.ts +11 -1
  50. package/src/ai/tool-sentences.ts +38 -0
  51. package/src/ai/tools.ts +91 -3
  52. package/src/ai/types.ts +47 -1
  53. package/src/ai/web-provenance.ts +103 -0
  54. package/src/api/admin-ai-skills.ts +35 -16
  55. package/src/api/capabilities.ts +3 -2
  56. package/src/api/fanout.ts +128 -0
  57. package/src/api/help.ts +8 -3
  58. package/src/api/mcp.ts +4 -1
  59. package/src/api/search.ts +31 -106
  60. package/src/api/widgets.ts +291 -101
  61. package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
  62. package/src/browser/CloudResourceSearch.tsx +1 -1
  63. package/src/browser/command-shortcuts.ts +21 -6
  64. package/src/browser/ndjson.ts +34 -0
  65. package/src/browser/reload.ts +46 -0
  66. package/src/browser/search-stream.ts +4 -26
  67. package/src/browser/spotlight-position.ts +4 -1
  68. package/src/browser/widgets.ts +30 -0
  69. package/src/capabilities/executions.ts +3 -0
  70. package/src/capabilities/server.ts +20 -2
  71. package/src/cli/access.ts +48 -14
  72. package/src/cli/admin/ai-skills.ts +59 -39
  73. package/src/cli/commands.ts +20 -0
  74. package/src/cli/index.ts +16 -0
  75. package/src/cli/plugin.ts +30 -0
  76. package/src/contracts/announcement-cookie.ts +62 -0
  77. package/src/contracts/announcements.ts +14 -52
  78. package/src/contracts/app.ts +34 -2
  79. package/src/contracts/capabilities.ts +24 -0
  80. package/src/contracts/pwa-paths.ts +7 -2
  81. package/src/contracts/pwa.ts +2 -7
  82. package/src/contracts/registry.ts +10 -1
  83. package/src/contracts/shared.ts +5 -0
  84. package/src/contracts/widgets.ts +67 -2
  85. package/src/desktop/solid.tsx +6 -7
  86. package/src/index.ts +1 -0
  87. package/src/server/help.ts +8 -1
  88. package/src/server/index.ts +1 -0
  89. package/src/server/widget-request.ts +27 -0
  90. package/src/services/accounts/users.ts +15 -0
  91. package/src/services/approvals/index.ts +225 -0
  92. package/src/services/approvals/migrate.ts +33 -0
  93. package/src/services/help/index.ts +18 -5
  94. package/src/services/help/types.ts +3 -2
  95. package/src/services/identity/constants.ts +2 -0
  96. package/src/services/identity/key-ring.ts +123 -42
  97. package/src/services/identity/runtime-config.ts +13 -2
  98. package/src/shared/app-presentation.ts +11 -0
  99. package/src/shared/email-html.ts +43 -8
  100. package/src/ssr/AdminLayout.tsx +4 -7
  101. package/src/ssr/AppLaunchpad.island.tsx +1 -1
  102. package/src/ssr/AppLaunchpadPanel.tsx +12 -3
  103. package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
  104. package/src/ssr/GlobalSearchDialog.tsx +1 -43
  105. package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
  106. package/src/ssr/Layout.tsx +38 -14
  107. package/src/ssr/LayoutFooter.tsx +1 -1
  108. package/src/ssr/LayoutHeader.tsx +17 -20
  109. package/src/ssr/LayoutHelp.tsx +1 -1
  110. package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
  111. package/src/ssr/LayoutRail.tsx +1 -2
  112. package/src/ssr/PageTrail.tsx +23 -0
  113. package/src/ssr/PageTrailRow.island.tsx +20 -0
  114. package/src/ssr/PageTrailRuntime.island.tsx +26 -0
  115. package/src/ssr/ProfilePreferences.island.tsx +7 -3
  116. package/src/ssr/PwaRuntime.island.tsx +1 -1
  117. package/src/ssr/app-navigation.ts +5 -7
  118. package/src/ssr/global-search-host.tsx +55 -0
  119. package/src/ssr/help.ts +3 -3
  120. package/src/ssr/islands/index.ts +1 -0
  121. package/src/ssr/layout-runtime.ts +60 -0
  122. package/src/ssr/mobile-menu-history.ts +1 -1
  123. package/src/ssr/page-trail-runtime.ts +313 -0
  124. package/src/ssr/page-trail.browser-harness.tsx +216 -0
  125. package/src/ssr/page-trail.ts +59 -0
  126. package/src/ssr/platform-messages.ts +4 -0
  127. package/src/ssr/rail-context.ts +4 -3
  128. package/src/ssr/runtime.ts +14 -1
  129. package/src/styles/effects.css +21 -4
  130. package/src/styles/global.css +35 -2
  131. package/src/styles/utilities-layout.css +24 -1
  132. package/src/styles/utilities-navigation.css +6 -18
  133. package/src/ai/code-mode-skill.ts +0 -114
  134. package/src/ai/data-analysis-skill.ts +0 -10
  135. package/src/ai/grids-skill.ts +0 -158
  136. package/src/ai/skill-seeds.ts +0 -560
@@ -1,14 +1,26 @@
1
- import { Hono, type MiddlewareHandler } from "hono";
1
+ import { type Context, Hono, type MiddlewareHandler } from "hono";
2
2
  import { readBoundedJson } from "../_internal/bounded-json";
3
- import { listWidgets } from "../_internal/registry";
4
- import { WIDGET_MAX_RESPONSE_BYTES, WidgetResponseSchema } from "../contracts/widgets";
3
+ import { type DashboardWidget, listWidgets } from "../_internal/registry";
4
+ import {
5
+ type DashboardWidgetSize,
6
+ fitWidgetSize,
7
+ isDashboardWidgetSize,
8
+ WIDGET_MAX_RESPONSE_BYTES,
9
+ type WidgetResponse,
10
+ WidgetResponseSchema,
11
+ type WidgetStreamLine,
12
+ widgetKey,
13
+ } from "../contracts/widgets";
5
14
  import { type AuthContext, auth, preferredLocale, rejectReservedWorkloadCredential } from "../server";
15
+ import { WIDGET_SIZE_PARAM } from "../server/widget-request";
16
+ import { CLOUD_INVOCATION_TOKEN_TTL_SECONDS } from "../services/identity/constants";
6
17
  import { invocationAuthorityFromRequest } from "../services/identity/invocation-authority";
7
18
  import { widgetInvocationOperation } from "../services/identity/invocation-operations";
8
19
  import { normalizeInvocationRequestId, signInvocationToken } from "../services/identity/invocation-token";
9
20
  import { withActiveIdentitySigner } from "../services/identity/key-ring";
10
21
  import { logger } from "../services/logging";
11
22
  import { LOCALE_HEADER } from "../shared/locale";
23
+ import { ndjsonStream, startBounded, waitWithin } from "./fanout";
12
24
 
13
25
  type WidgetRouteDependencies = {
14
26
  listWidgets?: typeof listWidgets;
@@ -19,7 +31,19 @@ type WidgetRouteDependencies = {
19
31
  timeoutMs?: number;
20
32
  };
21
33
 
22
- export const WIDGET_PROXY_TIMEOUT_MS = 500;
34
+ /**
35
+ * Each widget's own budget, counted from the moment Core starts it: the same interactive budget Universal Search
36
+ * gives its providers. Widgets stream in as they finish, so a slow one delays only itself.
37
+ */
38
+ export const WIDGET_TIMEOUT_MS = 8_000;
39
+ /** Widgets Core asks at the same time for one dashboard, as many as Universal Search asks providers. */
40
+ export const WIDGET_CONCURRENCY = 8;
41
+ /**
42
+ * A stream signs every invocation up front; no widget may still start after those invocations expire. Widgets that
43
+ * have not answered by then end as `timeout`.
44
+ */
45
+ const WIDGET_STREAM_DEADLINE_MS = CLOUD_INVOCATION_TOKEN_TTL_SECONDS * 1_000;
46
+
23
47
  const log = logger("widgets");
24
48
  type WidgetRejectionReason =
25
49
  | "upstream_timeout"
@@ -30,114 +54,280 @@ type WidgetRejectionReason =
30
54
  | "deadline_exceeded"
31
55
  | "request_cancelled"
32
56
  | "operation_failed";
57
+ type WidgetPhase = "registry" | "signing" | "provider" | "response";
33
58
 
34
- const jsonError = (message: string, status: 502 | 504): Response =>
35
- Response.json({ message }, { status, headers: { "content-type": "application/json" } });
36
-
37
- const waitWithin = <T>(value: Promise<T>, signal: AbortSignal): Promise<T> =>
38
- new Promise<T>((resolve, reject) => {
39
- const aborted = () => reject(signal.reason);
40
- if (signal.aborted) return aborted();
41
- signal.addEventListener("abort", aborted, { once: true });
42
- value.then(
43
- (result) => {
44
- signal.removeEventListener("abort", aborted);
45
- resolve(result);
46
- },
47
- (error) => {
48
- signal.removeEventListener("abort", aborted);
49
- reject(error);
50
- },
51
- );
59
+ const logRejection = (
60
+ widget: { appId: string; widgetId: string },
61
+ phase: WidgetPhase,
62
+ reason: WidgetRejectionReason,
63
+ upstreamStatus?: number,
64
+ ) =>
65
+ log.warn("Widget proxy rejected response", {
66
+ appId: widget.appId.slice(0, 80),
67
+ widgetId: widget.widgetId.slice(0, 100),
68
+ phase,
69
+ reason,
70
+ ...(upstreamStatus === undefined ? {} : { upstreamStatus }),
52
71
  });
53
72
 
73
+ type ProviderOutcome =
74
+ | { status: "ok"; widget: WidgetResponse }
75
+ | { status: "empty" | "forbidden" }
76
+ | { status: "timeout" | "error"; phase: WidgetPhase; reason: WidgetRejectionReason; upstreamStatus?: number };
77
+
78
+ /**
79
+ * Calls one provider with its exact invocation and validates the answer. Never throws: every failure becomes a
80
+ * bounded outcome that the caller logs and reports.
81
+ */
82
+ const callProvider = async (options: {
83
+ c: Context<AuthContext>;
84
+ widget: DashboardWidget;
85
+ /** What the browser asked for, fitted to the widget's declared sizes before it reaches the app. */
86
+ size: unknown;
87
+ token: string;
88
+ requestId: string | undefined;
89
+ signal: AbortSignal;
90
+ timeout: AbortSignal;
91
+ fetch: NonNullable<WidgetRouteDependencies["fetch"]>;
92
+ }): Promise<ProviderOutcome> => {
93
+ const { c, widget, signal, timeout } = options;
94
+ let phase: WidgetPhase = "provider";
95
+ try {
96
+ const headers = new Headers({ authorization: `Bearer ${options.token}` });
97
+ if (options.requestId) headers.set("x-request-id", options.requestId);
98
+ for (const name of ["traceparent", "tracestate"] as const) {
99
+ const value = c.req.header(name);
100
+ if (value) headers.set(name, value);
101
+ }
102
+ const locale = preferredLocale(c.req.raw.headers);
103
+ if (locale) headers.set(LOCALE_HEADER, locale);
104
+ headers.set("x-cloud-invocation-operation", widgetInvocationOperation(widget.widgetId));
105
+
106
+ const targetUrl = new URL(`/api/_internal/widgets/v1/${encodeURIComponent(widget.widgetId)}`, widget.url);
107
+ targetUrl.searchParams.set(WIDGET_SIZE_PARAM, fitWidgetSize(options.size, widget));
108
+ signal.throwIfAborted();
109
+ const response = await options.fetch(targetUrl, { headers, signal });
110
+ if (response.status === 204 || response.status === 403) {
111
+ await response.body?.cancel();
112
+ return { status: response.status === 204 ? "empty" : "forbidden" };
113
+ }
114
+ if (!response.ok) {
115
+ await response.body?.cancel();
116
+ return response.status === 504
117
+ ? { status: "timeout", phase, reason: "upstream_timeout", upstreamStatus: response.status }
118
+ : { status: "error", phase, reason: "upstream_status", upstreamStatus: response.status };
119
+ }
120
+ phase = "response";
121
+ const body = await readBoundedJson(response, WIDGET_MAX_RESPONSE_BYTES);
122
+ signal.throwIfAborted();
123
+ if (!body.ok) return { status: "error", phase, reason: body.reason };
124
+ const parsed = WidgetResponseSchema.safeParse(body.data);
125
+ if (!parsed.success) return { status: "error", phase, reason: "invalid_schema" };
126
+ return { status: "ok", widget: parsed.data };
127
+ } catch (error) {
128
+ const isTimeout = timeout.aborted || (error instanceof Error && error.name === "TimeoutError");
129
+ return isTimeout
130
+ ? { status: "timeout", phase, reason: "deadline_exceeded" }
131
+ : { status: "error", phase, reason: signal.aborted ? "request_cancelled" : "operation_failed" };
132
+ }
133
+ };
134
+
135
+ /**
136
+ * `<appId>/<widgetId>` with an optional `@small`, `@medium`, or `@large`, as the key and the size it asks for. The
137
+ * suffix is a size whenever the key before it is declared; `defineApp()` rejects `@` in widget IDs, so only an app
138
+ * built before sizes existed can declare an ID that ends in such a suffix, and it is still asked by that whole ID.
139
+ */
140
+ const parseRequestedWidget = (value: string, declared: ReadonlySet<string>): [string, DashboardWidgetSize | undefined] => {
141
+ const at = value.lastIndexOf("@");
142
+ const size = at < 0 ? undefined : value.slice(at + 1);
143
+ return isDashboardWidgetSize(size) && declared.has(value.slice(0, at)) ? [value.slice(0, at), size] : [value, undefined];
144
+ };
145
+
54
146
  export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) => {
55
147
  const registry = dependencies.listWidgets ?? listWidgets;
56
148
  const fetchWidget = dependencies.fetch ?? globalThis.fetch;
149
+ const timeoutMs = dependencies.timeoutMs ?? WIDGET_TIMEOUT_MS;
150
+ const withActiveSigner = dependencies.withActiveSigner ?? withActiveIdentitySigner;
151
+ const signInvocation = dependencies.signInvocation ?? signInvocationToken;
57
152
 
58
- return new Hono<AuthContext>()
59
- .use(dependencies.authenticate ?? auth.requireRole("authenticated"))
60
- .use(rejectReservedWorkloadCredential)
61
- .use(auth.requireOAuthScope("read", "admin"))
62
- .get("/widgets/v1/:appId/:widgetId", async (c) => {
63
- const timeout = AbortSignal.timeout(dependencies.timeoutMs ?? WIDGET_PROXY_TIMEOUT_MS);
64
- const signal = AbortSignal.any([c.req.raw.signal, timeout]);
65
- const appId = c.req.param("appId");
66
- const widgetId = c.req.param("widgetId");
67
- const requestId = normalizeInvocationRequestId(c.req.header("x-request-id"));
68
- let phase: "registry" | "signing" | "provider" | "response" = "registry";
69
- const reject = (reason: WidgetRejectionReason, message: string, status: 502 | 504, upstreamStatus?: number): Response => {
70
- log.warn("Widget proxy rejected response", {
71
- appId: appId.slice(0, 80),
72
- widgetId: widgetId.slice(0, 100),
73
- phase,
74
- reason,
75
- ...(upstreamStatus === undefined ? {} : { upstreamStatus }),
153
+ const sign = (
154
+ c: Context<AuthContext>,
155
+ widget: DashboardWidget,
156
+ requestId: string | undefined,
157
+ signer: Parameters<Parameters<typeof withActiveIdentitySigner>[1]>[0],
158
+ ) =>
159
+ signInvocation({
160
+ targetAppId: widget.appId,
161
+ callingAppId: "core",
162
+ operation: widgetInvocationOperation(widget.widgetId),
163
+ schemaHash: null,
164
+ authority: invocationAuthorityFromRequest(auth.getAuthority(c)),
165
+ requestId,
166
+ signer,
167
+ issuer: signer.issuer,
168
+ });
169
+
170
+ return (
171
+ new Hono<AuthContext>()
172
+ .use(dependencies.authenticate ?? auth.requireRole("authenticated"))
173
+ .use(rejectReservedWorkloadCredential)
174
+ .use(auth.requireOAuthScope("read", "admin"))
175
+ /**
176
+ * Every requested widget (`?widget=<appId>/<widgetId>`, repeatable, optionally with `@<size>`; all declared
177
+ * widgets without one) as an NDJSON stream: one line per widget as soon as it answers, each within its own budget.
178
+ */
179
+ .get("/widgets/v1", async (c) => {
180
+ const requestId = normalizeInvocationRequestId(c.req.header("x-request-id"));
181
+ const setup = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(timeoutMs)]);
182
+ let widgets: DashboardWidget[];
183
+ let requested = new Map<string, DashboardWidgetSize | undefined>();
184
+ try {
185
+ const keys = new Set<string>();
186
+ const declared = await waitWithin(registry(), setup);
187
+ const declaredKeys = new Set(declared.map((widget) => widgetKey(widget.appId, widget.widgetId)));
188
+ requested = new Map((c.req.queries("widget") ?? []).map((value) => parseRequestedWidget(value, declaredKeys)));
189
+ widgets = declared.filter((widget) => {
190
+ const key = widgetKey(widget.appId, widget.widgetId);
191
+ if (keys.has(key) || (requested.size > 0 && !requested.has(key))) return false;
192
+ keys.add(key);
193
+ return true;
194
+ });
195
+ } catch {
196
+ log.warn("Widget registry unavailable", { reason: setup.aborted ? "deadline_exceeded" : "operation_failed" });
197
+ return c.json({ message: "Widget registry is currently unavailable" }, 503);
198
+ }
199
+
200
+ const streamDeadline = AbortSignal.timeout(WIDGET_STREAM_DEADLINE_MS);
201
+ // A client that stops reading, such as a browser leaving the dashboard, ends every widget it still waits for.
202
+ const streamCancelled = new AbortController();
203
+ const fanoutSignal = AbortSignal.any([c.req.raw.signal, streamDeadline, streamCancelled.signal]);
204
+ // One signing guard for the whole dashboard, not one Postgres check per widget.
205
+ let tokens: PromiseSettledResult<Awaited<ReturnType<typeof signInvocationToken>>>[] = [];
206
+ try {
207
+ if (widgets.length > 0) {
208
+ const signing = AbortSignal.any([fanoutSignal, AbortSignal.timeout(timeoutMs)]);
209
+ tokens = await waitWithin(
210
+ withActiveSigner(
211
+ "invocation",
212
+ (signer) =>
213
+ Promise.all(startBounded(widgets, WIDGET_CONCURRENCY, async (widget) => sign(c, widget, requestId, signer), signing)),
214
+ { signal: signing, timeoutMs },
215
+ ),
216
+ signing,
217
+ );
218
+ }
219
+ } catch {
220
+ log.warn("Widget invocation authority unavailable", { reason: fanoutSignal.aborted ? "deadline_exceeded" : "operation_failed" });
221
+ return c.json({ message: "Widget invocation authority is currently unavailable" }, 503);
222
+ }
223
+
224
+ const fanoutStarted = performance.now();
225
+ const settled = startBounded(
226
+ widgets,
227
+ WIDGET_CONCURRENCY,
228
+ async (widget, index): Promise<WidgetStreamLine> => {
229
+ const key = widgetKey(widget.appId, widget.widgetId);
230
+ const started = performance.now();
231
+ const elapsed = () => Math.round(performance.now() - started);
232
+ const token = tokens[index];
233
+ if (token?.status !== "fulfilled") {
234
+ logRejection(widget, "signing", "operation_failed");
235
+ return { type: "widget", key, status: "error", ms: elapsed() };
236
+ }
237
+ const timeout = AbortSignal.timeout(timeoutMs);
238
+ const outcome = await callProvider({
239
+ c,
240
+ widget,
241
+ token: token.value.token,
242
+ requestId,
243
+ signal: AbortSignal.any([fanoutSignal, timeout]),
244
+ timeout,
245
+ fetch: fetchWidget,
246
+ size: requested.get(key),
247
+ });
248
+ if (outcome.status === "ok") return { type: "widget", key, status: "ok", widget: outcome.widget, ms: elapsed() };
249
+ // A widget the stream itself stopped has already been settled, and is logged once, below.
250
+ if ("reason" in outcome && !fanoutSignal.aborted) logRejection(widget, outcome.phase, outcome.reason, outcome.upstreamStatus);
251
+ return { type: "widget", key, status: outcome.status, ms: elapsed() };
252
+ },
253
+ fanoutSignal,
254
+ );
255
+
256
+ return ndjsonStream<WidgetStreamLine>({
257
+ first: { type: "start", widgets: widgets.map((widget) => widgetKey(widget.appId, widget.widgetId)) },
258
+ pending: settled.map((pending, index) =>
259
+ pending.then((result): WidgetStreamLine => {
260
+ if (result.status === "fulfilled") return result.value;
261
+ // Only the stream's own deadline or the browser leaving settles a widget this way. Leaving is not a
262
+ // failure of the widget, so only the deadline is logged.
263
+ const widget = widgets[index]!;
264
+ const status = streamDeadline.aborted ? "timeout" : "error";
265
+ if (streamDeadline.aborted) logRejection(widget, "provider", "deadline_exceeded");
266
+ return {
267
+ type: "widget",
268
+ key: widgetKey(widget.appId, widget.widgetId),
269
+ status,
270
+ ms: Math.round(performance.now() - fanoutStarted),
271
+ };
272
+ }),
273
+ ),
274
+ last: (lines) => ({
275
+ type: "done",
276
+ status: lines.some((line) => line.type === "widget" && (line.status === "timeout" || line.status === "error"))
277
+ ? "partial"
278
+ : "complete",
279
+ }),
280
+ onCancel: () => streamCancelled.abort(),
76
281
  });
77
- return jsonError(message, status);
78
- };
79
- try {
80
- const widget = (await waitWithin(registry(), signal)).find((entry) => entry.appId === appId && entry.widgetId === widgetId);
81
- if (!widget) return c.json({ message: "Widget not found" }, 404);
82
-
83
- phase = "signing";
84
- const headers = await (async () => {
282
+ })
283
+ .get("/widgets/v1/:appId/:widgetId", async (c) => {
284
+ const timeout = AbortSignal.timeout(timeoutMs);
285
+ const signal = AbortSignal.any([c.req.raw.signal, timeout]);
286
+ const appId = c.req.param("appId");
287
+ const widgetId = c.req.param("widgetId");
288
+ const requestId = normalizeInvocationRequestId(c.req.header("x-request-id"));
289
+ let phase: WidgetPhase = "registry";
290
+ try {
291
+ const widget = (await waitWithin(registry(), signal)).find((entry) => entry.appId === appId && entry.widgetId === widgetId);
292
+ if (!widget) return c.json({ message: "Widget not found" }, 404);
293
+
294
+ phase = "signing";
85
295
  const signed = await waitWithin(
86
- (dependencies.withActiveSigner ?? withActiveIdentitySigner)(
87
- "invocation",
88
- (signer) =>
89
- (dependencies.signInvocation ?? signInvocationToken)({
90
- targetAppId: widget.appId,
91
- callingAppId: "core",
92
- operation: widgetInvocationOperation(widget.widgetId),
93
- schemaHash: null,
94
- authority: invocationAuthorityFromRequest(auth.getAuthority(c)),
95
- requestId,
96
- signer,
97
- issuer: signer.issuer,
98
- }),
99
- { signal, timeoutMs: dependencies.timeoutMs ?? WIDGET_PROXY_TIMEOUT_MS },
100
- ),
296
+ withActiveSigner("invocation", (signer) => sign(c, widget, requestId, signer), { signal, timeoutMs }),
101
297
  signal,
102
298
  );
103
- return new Headers({ authorization: `Bearer ${signed.token}` });
104
- })();
105
299
 
106
- if (requestId) headers.set("x-request-id", requestId);
107
- for (const name of ["traceparent", "tracestate"] as const) {
108
- const value = c.req.header(name);
109
- if (value) headers.set(name, value);
110
- }
111
- const locale = preferredLocale(c.req.raw.headers);
112
- if (locale) headers.set(LOCALE_HEADER, locale);
113
- headers.set("x-cloud-invocation-operation", widgetInvocationOperation(widget.widgetId));
114
-
115
- const targetUrl = new URL(`/api/_internal/widgets/v1/${encodeURIComponent(widget.widgetId)}`, widget.url);
116
- phase = "provider";
117
- signal.throwIfAborted();
118
- const response = await fetchWidget(targetUrl, { headers, signal });
119
- if (response.status === 204 || response.status === 403) {
120
- await response.body?.cancel();
121
- return new Response(null, { status: response.status, headers: { "content-type": "application/json" } });
122
- }
123
- if (!response.ok) {
124
- await response.body?.cancel();
125
- return response.status === 504
126
- ? reject("upstream_timeout", "Widget deadline exceeded", 504, response.status)
127
- : reject("upstream_status", "Widget is unavailable", 502, response.status);
300
+ phase = "provider";
301
+ const outcome = await callProvider({
302
+ c,
303
+ widget,
304
+ size: c.req.query(WIDGET_SIZE_PARAM),
305
+ token: signed.token,
306
+ requestId,
307
+ signal,
308
+ timeout,
309
+ fetch: fetchWidget,
310
+ });
311
+ if (outcome.status === "ok") return Response.json(outcome.widget, { headers: { "content-type": "application/json" } });
312
+ if (!("reason" in outcome))
313
+ return new Response(null, { status: outcome.status === "empty" ? 204 : 403, headers: { "content-type": "application/json" } });
314
+ logRejection(widget, outcome.phase, outcome.reason, outcome.upstreamStatus);
315
+ if (outcome.status === "timeout") return Response.json({ message: "Widget deadline exceeded" }, { status: 504 });
316
+ const message =
317
+ outcome.reason === "invalid_schema"
318
+ ? "Widget returned an invalid response"
319
+ : outcome.reason === "too_large" || outcome.reason === "invalid_json"
320
+ ? "Widget returned invalid or oversized JSON"
321
+ : "Widget is unavailable";
322
+ return Response.json({ message }, { status: 502 });
323
+ } catch (error) {
324
+ const isTimeout = timeout.aborted || (error instanceof Error && error.name === "TimeoutError");
325
+ const reason: WidgetRejectionReason = isTimeout ? "deadline_exceeded" : signal.aborted ? "request_cancelled" : "operation_failed";
326
+ logRejection({ appId, widgetId }, phase, reason);
327
+ return isTimeout
328
+ ? Response.json({ message: "Widget deadline exceeded" }, { status: 504 })
329
+ : Response.json({ message: "Widget is unavailable" }, { status: 502 });
128
330
  }
129
- phase = "response";
130
- const body = await readBoundedJson(response, WIDGET_MAX_RESPONSE_BYTES);
131
- signal.throwIfAborted();
132
- if (!body.ok) return reject(body.reason, "Widget returned invalid or oversized JSON", 502);
133
- const parsed = WidgetResponseSchema.safeParse(body.data);
134
- if (!parsed.success) return reject("invalid_schema", "Widget returned an invalid response", 502);
135
- return Response.json(parsed.data, { headers: { "content-type": "application/json" } });
136
- } catch (error) {
137
- const isTimeout = timeout.aborted || (error instanceof Error && error.name === "TimeoutError");
138
- return isTimeout
139
- ? reject("deadline_exceeded", "Widget deadline exceeded", 504)
140
- : reject(signal.aborted ? "request_cancelled" : "operation_failed", "Widget is unavailable", 502);
141
- }
142
- });
331
+ })
332
+ );
143
333
  };
@@ -1,4 +1,4 @@
1
- import { createGlobalSearchHost } from "../ssr/GlobalSearchDialog";
1
+ import { createGlobalSearchHost } from "../ssr/global-search-host";
2
2
  import { openCloudResourcePicker } from "./resource-picker";
3
3
 
4
4
  /**
@@ -824,7 +824,7 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
824
824
  focusInput();
825
825
  }}
826
826
  >
827
- ← {t().back}
827
+ <i class="ti ti-arrow-left" aria-hidden="true" /> {t().back}
828
828
  </Button>
829
829
  <div class="cloud-resource-search__preview-app">
830
830
  <i class={item().icon ?? item().appIcon} aria-hidden="true" />
@@ -31,12 +31,21 @@ export const shortcutLabel = (shortcut: string): string => {
31
31
  if (!combination) return "";
32
32
  const mac = isMac();
33
33
  const names = { ctrl: mac ? "⌃" : "Ctrl", alt: mac ? "⌥" : "Alt", shift: mac ? "⇧" : "Shift", meta: mac ? "⌘" : "Meta" };
34
- return [...modifiers.filter((key) => combination[key]).map((key) => names[key]), combination.key.toUpperCase()].join(mac ? "" : "+");
34
+ const arrows: Record<string, string> = { arrowup: "↑", arrowdown: "↓", arrowleft: "←", arrowright: "→" };
35
+ const key = arrows[combination.key] ?? combination.key.toUpperCase();
36
+ return [...modifiers.filter((name) => combination[name]).map((name) => names[name]), key].join(mac ? "" : "+");
35
37
  };
36
38
 
37
- /** One owner in the Cloud layout; app islands only publish commands. */
39
+ const ARROWS = new Set(["arrowup", "arrowdown", "arrowleft", "arrowright"]);
40
+
41
+ /**
42
+ * One owner in the Cloud layout; app islands only publish commands. A command
43
+ * on an arrow key comes last: the focused element sees the key first, and a
44
+ * widget that moves with arrow keys, such as a tree that reorders on Alt+↑ or
45
+ * an open list, keeps it by handling it.
46
+ */
38
47
  export const attachCommandShortcuts = (run: (command: ContextAwareCommand) => void): (() => void) => {
39
- const listener = (event: KeyboardEvent) => {
48
+ const listener = (event: KeyboardEvent, arrows: boolean) => {
40
49
  if (event.defaultPrevented || event.repeat || event.isComposing || (!isMac() && event.getModifierState("AltGraph"))) return;
41
50
  const target = event.target;
42
51
  const editing =
@@ -47,7 +56,7 @@ export const attachCommandShortcuts = (run: (command: ContextAwareCommand) => vo
47
56
  if (!command.shortcut) return false;
48
57
  if (command.id !== "cloud.search" && dialogOpen) return false;
49
58
  const combo = parse(command.shortcut);
50
- if (!combo || (editing && !combo.ctrl && !combo.meta)) return false;
59
+ if (!combo || ARROWS.has(combo.key) !== arrows || (editing && !combo.ctrl && !combo.meta)) return false;
51
60
  // Shift+/ reports '?' on common keyboard layouts.
52
61
  const key = event.key === "?" && combo.key === "/" ? "/" : event.key.toLowerCase();
53
62
  const physicalLetter =
@@ -69,8 +78,14 @@ export const attachCommandShortcuts = (run: (command: ContextAwareCommand) => vo
69
78
  run(matches[0]!);
70
79
  };
71
80
  // Registered app combinations must win over editor bindings such as Mod+Shift+K.
72
- window.addEventListener("keydown", listener, true);
73
- return () => window.removeEventListener("keydown", listener, true);
81
+ const first = (event: KeyboardEvent) => listener(event, false);
82
+ const last = (event: KeyboardEvent) => listener(event, true);
83
+ window.addEventListener("keydown", first, true);
84
+ window.addEventListener("keydown", last);
85
+ return () => {
86
+ window.removeEventListener("keydown", first, true);
87
+ window.removeEventListener("keydown", last);
88
+ };
74
89
  };
75
90
 
76
91
  export { COMMANDS_CHANGED };
@@ -0,0 +1,34 @@
1
+ /** Media type Core streams per-app results in; kept local so browser bundles do not load server schemas. */
2
+ export const NDJSON_CONTENT_TYPE = "application/x-ndjson";
3
+
4
+ /**
5
+ * Hands over each line of an NDJSON response as it arrives, until a line of type `done`. Rejects when the stream ends
6
+ * before that line, so the caller can tell a finished stream from one that broke off.
7
+ */
8
+ export const readNdjsonLines = async <Line extends { type: string }>(
9
+ response: Response,
10
+ onLine: (line: Line) => void,
11
+ name: string,
12
+ ): Promise<void> => {
13
+ if (!response.body) throw new Error(`${name} returned no body`);
14
+ const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
15
+ let buffer = "";
16
+ let closed = false;
17
+ while (!closed) {
18
+ const chunk = await reader.read();
19
+ if (chunk.done) break;
20
+ buffer += chunk.value;
21
+ let end = buffer.indexOf("\n");
22
+ while (end >= 0) {
23
+ const text = buffer.slice(0, end).trim();
24
+ buffer = buffer.slice(end + 1);
25
+ if (text) {
26
+ const line: Line = JSON.parse(text);
27
+ onLine(line);
28
+ if (line.type === "done") closed = true;
29
+ }
30
+ end = buffer.indexOf("\n");
31
+ }
32
+ }
33
+ if (!closed) throw new Error(`${name} stream ended early`);
34
+ };
@@ -1,3 +1,6 @@
1
+ import { i18n } from "@k2b/stdlib";
2
+ import { type ToastHandle, toast } from "@k2b/ui";
3
+
1
4
  /**
2
5
  * Default window in which a second automatic reload for the same key is
3
6
  * suppressed. A page load, hydration, and the first live subscription finish
@@ -32,3 +35,46 @@ export const reloadOnce = (key: string, options: { windowMs?: number } = {}): bo
32
35
  window.location.reload();
33
36
  return true;
34
37
  };
38
+
39
+ const reloadMessages = i18n.define({
40
+ baseLocale: "en",
41
+ messages: {
42
+ en: {
43
+ codeUnavailable: "Part of this page could not load. Reload the page and try again.",
44
+ reload: "Reload",
45
+ },
46
+ de: {
47
+ codeUnavailable: "Ein Teil dieser Seite konnte nicht geladen werden. Lade die Seite neu und versuche es erneut.",
48
+ reload: "Neu laden",
49
+ },
50
+ },
51
+ });
52
+
53
+ /** One notice at a time: every failed load in the tab has the same remedy. */
54
+ let codeUnavailable: ToastHandle | undefined;
55
+
56
+ /**
57
+ * Loads code that one action needs when the action runs, for example
58
+ * `importOnDemand(() => import("./SettingsDialog"))`, so the page does not
59
+ * download it up front.
60
+ *
61
+ * Resolves `undefined` when the code cannot load. A release replaces the
62
+ * page's code files, so a page that was open before it cannot load code it
63
+ * has not loaded yet; a dropped connection fails the same way. The helper
64
+ * then shows a toast that offers a reload, and the caller only ends its
65
+ * action.
66
+ */
67
+ export const importOnDemand = async <T>(load: () => Promise<T>): Promise<T | undefined> => {
68
+ try {
69
+ return await load();
70
+ } catch (error) {
71
+ console.warn("[cloud] Could not load code on demand", error);
72
+ const t = reloadMessages.resolve([document.documentElement.lang || "en"]).t;
73
+ codeUnavailable?.dismiss();
74
+ codeUnavailable = toast.error(t.codeUnavailable, {
75
+ duration: 0,
76
+ action: { label: t.reload, onClick: () => window.location.reload() },
77
+ });
78
+ return undefined;
79
+ }
80
+ };
@@ -1,8 +1,6 @@
1
1
  import type { SearchApp, SearchItem, SearchProviderStatus, SearchResponse, SearchStreamLine } from "../api/search/schemas";
2
2
  import { LOCALE_HEADER } from "../shared/locale";
3
-
4
- /** Kept local so the browser bundle does not load the server-side search schemas. */
5
- const STREAM_CONTENT_TYPE = "application/x-ndjson";
3
+ import { NDJSON_CONTENT_TYPE, readNdjsonLines } from "./ndjson";
6
4
 
7
5
  export type SearchAppStatus = SearchProviderStatus | "searching";
8
6
 
@@ -119,33 +117,13 @@ export const streamCloudResourceSearch = async (
119
117
  ): Promise<void> => {
120
118
  const response = await fetch(url, {
121
119
  signal: options.signal,
122
- headers: { accept: STREAM_CONTENT_TYPE, [LOCALE_HEADER]: options.locale },
120
+ headers: { accept: NDJSON_CONTENT_TYPE, [LOCALE_HEADER]: options.locale },
123
121
  });
124
122
  if (!response.ok) throw new Error(`Search failed with ${response.status}`);
125
- if (!response.headers.get("content-type")?.startsWith(STREAM_CONTENT_TYPE)) {
123
+ if (!response.headers.get("content-type")?.startsWith(NDJSON_CONTENT_TYPE)) {
126
124
  const body: SearchResponse = await response.json();
127
125
  for (const line of linesFromResponse(body)) options.onLine(line);
128
126
  return;
129
127
  }
130
- if (!response.body) throw new Error("Search returned no body");
131
- const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
132
- let buffer = "";
133
- let closed = false;
134
- while (!closed) {
135
- const chunk = await reader.read();
136
- if (chunk.done) break;
137
- buffer += chunk.value;
138
- let end = buffer.indexOf("\n");
139
- while (end >= 0) {
140
- const text = buffer.slice(0, end).trim();
141
- buffer = buffer.slice(end + 1);
142
- if (text) {
143
- const line: SearchStreamLine = JSON.parse(text);
144
- options.onLine(line);
145
- if (line.type === "done") closed = true;
146
- }
147
- end = buffer.indexOf("\n");
148
- }
149
- }
150
- if (!closed) throw new Error("Search stream ended early");
128
+ await readNdjsonLines(response, options.onLine, "Search");
151
129
  };