@k2b/cloud 0.33.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/_internal/app-presentation.ts +40 -11
- package/src/_internal/define-app.ts +1 -0
- package/src/_internal/registry.ts +10 -1
- package/src/_internal/widget-declarations.ts +16 -3
- package/src/_internal/widget-settings.ts +93 -0
- package/src/ai/capabilities.ts +8 -5
- package/src/ai/resource-refs.ts +35 -3
- package/src/ai/system-prompt.ts +3 -2
- package/src/api/index.ts +2 -0
- package/src/api/mcp.ts +48 -17
- package/src/api/pwa.ts +7 -2
- package/src/api/resource-links.ts +43 -0
- package/src/api/widgets.ts +163 -82
- package/src/browser/CloudResourceSearch.tsx +6 -6
- package/src/browser/command-shortcuts.ts +2 -11
- package/src/browser/resource-picker.tsx +1 -0
- package/src/browser/resource-search.ts +7 -2
- package/src/browser/widgets.ts +23 -5
- package/src/capabilities/testing.ts +13 -1
- package/src/cli/capabilities.ts +61 -17
- package/src/cli/index.ts +2 -0
- package/src/cli/resource-links.ts +13 -0
- package/src/contracts/app.ts +7 -1
- package/src/contracts/capabilities.ts +25 -5
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/resource-href.ts +7 -0
- package/src/contracts/widgets.ts +80 -6
- package/src/desktop/index.ts +3 -2
- package/src/desktop/solid.tsx +7 -5
- package/src/server/widget-request.ts +34 -5
- package/src/services/app-approval.ts +4 -2
- package/src/services/approvals/index.ts +40 -11
- package/src/services/approvals/list.ts +1 -0
- package/src/services/approvals/migrate.ts +10 -0
- package/src/services/pdf/markdown.ts +11 -7
- package/src/services/pwa-devices.ts +68 -15
- package/src/shared/app-presentation.ts +49 -1
- package/src/shared/index.ts +1 -0
- package/src/shared/markdown/callout-sanitizer.ts +37 -0
- package/src/shared/markdown/index.ts +8 -2
- package/src/ssr/Layout.tsx +3 -15
- package/src/ssr/LayoutHelp.browser-harness.tsx +43 -0
- package/src/ssr/LayoutHelp.tsx +319 -183
- package/src/ssr/LayoutHelpTrigger.tsx +1 -3
- package/src/ssr/LayoutPreferences.tsx +15 -5
- package/src/ssr/MinimalLayoutPreferences.island.tsx +3 -9
- package/src/ssr/MobileProfileActions.tsx +3 -8
- package/src/ssr/ProfilePreferences.tsx +8 -10
- package/src/ssr/ShellRuntime.island.tsx +1 -5
- package/src/ssr/ThemeCycleButton.tsx +46 -0
- package/src/ssr/app-appearance.ts +6 -1
- package/src/ssr/layout-help-url.ts +39 -0
- package/src/ssr/platform-messages.ts +0 -8
- package/src/ssr/preference-controller.ts +53 -19
- package/src/ssr/profile-actions.ts +12 -16
- package/src/ssr/profile-preferences-messages.ts +4 -2
- package/src/ssr/rail-navigation.ts +3 -0
- package/src/ssr/shell-data.tsx +2 -2
- package/src/styles/effects.css +13 -3
- package/src/styles/global.css +27 -1
- package/src/styles/input.css +0 -8
- package/src/styles/tokens.css +0 -2
- package/src/styles/utilities-navigation.css +10 -13
- package/src/ssr/GlobalSearchHelpDialog.tsx +0 -104
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getCapability } from "../_internal/registry";
|
|
3
|
+
import {
|
|
4
|
+
CapabilitySemanticLinkSchema,
|
|
5
|
+
type CloudResourceRef,
|
|
6
|
+
CloudResourceRefSchema,
|
|
7
|
+
capabilityResultSchema,
|
|
8
|
+
cloudResourceRefAppId,
|
|
9
|
+
resolveCapabilityResourceReader,
|
|
10
|
+
} from "../contracts/capabilities";
|
|
11
|
+
import type { RequestAuthority } from "../server";
|
|
12
|
+
import { dispatchCapability } from "./capabilities";
|
|
13
|
+
|
|
14
|
+
export const resolveCloudResourceHref = async (
|
|
15
|
+
params: { ref: CloudResourceRef; request: Request; authority: RequestAuthority; locale?: string },
|
|
16
|
+
dependencies: { getCapability?: typeof getCapability; dispatch?: typeof dispatchCapability } = {},
|
|
17
|
+
): Promise<string | null> => {
|
|
18
|
+
const parsed = CloudResourceRefSchema.safeParse(params.ref);
|
|
19
|
+
if (!parsed.success) return null;
|
|
20
|
+
const ref = parsed.data;
|
|
21
|
+
const appId = cloudResourceRefAppId(ref);
|
|
22
|
+
const app = await (dependencies.getCapability ?? getCapability)(appId);
|
|
23
|
+
if (!app || app.appId !== appId) return null;
|
|
24
|
+
const reader = resolveCapabilityResourceReader(app.manifest, ref);
|
|
25
|
+
if (!reader) return null;
|
|
26
|
+
const response = await (dependencies.dispatch ?? dispatchCapability)({
|
|
27
|
+
request: params.request,
|
|
28
|
+
authority: params.authority,
|
|
29
|
+
locale: params.locale,
|
|
30
|
+
origin: "http",
|
|
31
|
+
kind: "queries",
|
|
32
|
+
appId,
|
|
33
|
+
capabilityId: reader.localId,
|
|
34
|
+
input: { id: ref.id },
|
|
35
|
+
});
|
|
36
|
+
if (!response.ok) return null;
|
|
37
|
+
const result = capabilityResultSchema(z.unknown(), { consumer: true }).safeParse(await response.json());
|
|
38
|
+
if (!result.success) return null;
|
|
39
|
+
const link = CapabilitySemanticLinkSchema.safeParse(
|
|
40
|
+
result.data.links?.find((link) => link.rel === "open") ?? result.data.links?.find((link) => link.rel === "edit"),
|
|
41
|
+
);
|
|
42
|
+
return link.success ? link.data.href : null;
|
|
43
|
+
};
|
package/src/api/widgets.ts
CHANGED
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
import { type Context, Hono, type MiddlewareHandler } from "hono";
|
|
2
|
+
import { bodyLimit } from "hono/body-limit";
|
|
3
|
+
import { z } from "zod";
|
|
2
4
|
import { readBoundedJson } from "../_internal/bounded-json";
|
|
3
5
|
import { type DashboardWidget, listWidgets } from "../_internal/registry";
|
|
6
|
+
import { parseWidgetSettings } from "../_internal/widget-settings";
|
|
4
7
|
import {
|
|
5
8
|
type DashboardWidgetSize,
|
|
6
9
|
fitWidgetSize,
|
|
7
10
|
isDashboardWidgetSize,
|
|
11
|
+
WIDGET_KEY_MAX_LENGTH,
|
|
8
12
|
WIDGET_MAX_RESPONSE_BYTES,
|
|
13
|
+
WIDGET_REQUEST_MAX_BYTES,
|
|
14
|
+
WIDGET_REQUEST_MAX_ENTRIES,
|
|
15
|
+
WIDGET_REQUEST_MAX_ID_LENGTH,
|
|
16
|
+
WIDGET_SETTINGS_MAX_FIELDS,
|
|
9
17
|
type WidgetResponse,
|
|
10
18
|
WidgetResponseSchema,
|
|
11
19
|
type WidgetStreamLine,
|
|
12
20
|
widgetKey,
|
|
13
21
|
} from "../contracts/widgets";
|
|
14
22
|
import { type AuthContext, auth, preferredLocale, rejectReservedWorkloadCredential } from "../server";
|
|
15
|
-
import { WIDGET_SIZE_PARAM } from "../server/widget-request";
|
|
23
|
+
import { WIDGET_SETTINGS_PARAM, WIDGET_SETTINGS_PARAM_MAX_LENGTH, WIDGET_SIZE_PARAM } from "../server/widget-request";
|
|
16
24
|
import { CLOUD_INVOCATION_TOKEN_TTL_SECONDS } from "../services/identity/constants";
|
|
17
25
|
import { invocationAuthorityFromRequest } from "../services/identity/invocation-authority";
|
|
18
26
|
import { widgetInvocationOperation } from "../services/identity/invocation-operations";
|
|
@@ -49,6 +57,7 @@ type WidgetRejectionReason =
|
|
|
49
57
|
| "upstream_timeout"
|
|
50
58
|
| "upstream_status"
|
|
51
59
|
| "too_large"
|
|
60
|
+
| "settings_too_large"
|
|
52
61
|
| "invalid_json"
|
|
53
62
|
| "invalid_schema"
|
|
54
63
|
| "deadline_exceeded"
|
|
@@ -84,6 +93,7 @@ const callProvider = async (options: {
|
|
|
84
93
|
widget: DashboardWidget;
|
|
85
94
|
/** What the browser asked for, fitted to the widget's declared sizes before it reaches the app. */
|
|
86
95
|
size: unknown;
|
|
96
|
+
settings?: unknown;
|
|
87
97
|
token: string;
|
|
88
98
|
requestId: string | undefined;
|
|
89
99
|
signal: AbortSignal;
|
|
@@ -105,6 +115,12 @@ const callProvider = async (options: {
|
|
|
105
115
|
|
|
106
116
|
const targetUrl = new URL(`/api/_internal/widgets/v1/${encodeURIComponent(widget.widgetId)}`, widget.url);
|
|
107
117
|
targetUrl.searchParams.set(WIDGET_SIZE_PARAM, fitWidgetSize(options.size, widget));
|
|
118
|
+
if (widget.settings) {
|
|
119
|
+
const json = JSON.stringify(parseWidgetSettings(widget.settings, options.settings));
|
|
120
|
+
const encodedLength = new URLSearchParams([[WIDGET_SETTINGS_PARAM, json]]).toString().length - WIDGET_SETTINGS_PARAM.length - 1;
|
|
121
|
+
if (encodedLength > WIDGET_SETTINGS_PARAM_MAX_LENGTH) return { status: "error", phase: "provider", reason: "settings_too_large" };
|
|
122
|
+
targetUrl.searchParams.set(WIDGET_SETTINGS_PARAM, json);
|
|
123
|
+
}
|
|
108
124
|
signal.throwIfAborted();
|
|
109
125
|
const response = await options.fetch(targetUrl, { headers, signal });
|
|
110
126
|
if (response.status === 204 || response.status === 403) {
|
|
@@ -143,6 +159,30 @@ const parseRequestedWidget = (value: string, declared: ReadonlySet<string>): [st
|
|
|
143
159
|
return isDashboardWidgetSize(size) && declared.has(value.slice(0, at)) ? [value.slice(0, at), size] : [value, undefined];
|
|
144
160
|
};
|
|
145
161
|
|
|
162
|
+
const WidgetEntriesSchema = z
|
|
163
|
+
.object({
|
|
164
|
+
widgets: z
|
|
165
|
+
.array(
|
|
166
|
+
z
|
|
167
|
+
.object({
|
|
168
|
+
id: z.string().min(1).max(WIDGET_REQUEST_MAX_ID_LENGTH),
|
|
169
|
+
key: z.string().min(1).max(WIDGET_KEY_MAX_LENGTH),
|
|
170
|
+
size: z.string().optional(),
|
|
171
|
+
settings: z
|
|
172
|
+
.record(z.string(), z.unknown())
|
|
173
|
+
.refine((values) => Object.keys(values).length <= WIDGET_SETTINGS_MAX_FIELDS)
|
|
174
|
+
.optional(),
|
|
175
|
+
})
|
|
176
|
+
.strict(),
|
|
177
|
+
)
|
|
178
|
+
.min(1)
|
|
179
|
+
.max(WIDGET_REQUEST_MAX_ENTRIES)
|
|
180
|
+
.refine((entries) => new Set(entries.map((entry) => entry.id)).size === entries.length, "Widget entry ids must be unique"),
|
|
181
|
+
})
|
|
182
|
+
.strict();
|
|
183
|
+
|
|
184
|
+
type WidgetEntry = { widget: DashboardWidget; id?: string; size?: string; settings?: unknown };
|
|
185
|
+
|
|
146
186
|
export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) => {
|
|
147
187
|
const registry = dependencies.listWidgets ?? listWidgets;
|
|
148
188
|
const fetchWidget = dependencies.fetch ?? globalThis.fetch;
|
|
@@ -167,6 +207,96 @@ export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) =
|
|
|
167
207
|
issuer: signer.issuer,
|
|
168
208
|
});
|
|
169
209
|
|
|
210
|
+
const stream = async (c: Context<AuthContext>, entries: WidgetEntry[], requestId: string | undefined) => {
|
|
211
|
+
const streamDeadline = AbortSignal.timeout(WIDGET_STREAM_DEADLINE_MS);
|
|
212
|
+
// A client that stops reading, such as a browser leaving the dashboard, ends every widget it still waits for.
|
|
213
|
+
const streamCancelled = new AbortController();
|
|
214
|
+
const fanoutSignal = AbortSignal.any([c.req.raw.signal, streamDeadline, streamCancelled.signal]);
|
|
215
|
+
// One signing guard for the whole dashboard, not one Postgres check per widget.
|
|
216
|
+
let tokens: PromiseSettledResult<Awaited<ReturnType<typeof signInvocationToken>>>[] = [];
|
|
217
|
+
try {
|
|
218
|
+
if (entries.length > 0) {
|
|
219
|
+
const signing = AbortSignal.any([fanoutSignal, AbortSignal.timeout(timeoutMs)]);
|
|
220
|
+
tokens = await waitWithin(
|
|
221
|
+
withActiveSigner(
|
|
222
|
+
"invocation",
|
|
223
|
+
(signer) =>
|
|
224
|
+
Promise.all(startBounded(entries, WIDGET_CONCURRENCY, async (entry) => sign(c, entry.widget, requestId, signer), signing)),
|
|
225
|
+
{ signal: signing, timeoutMs },
|
|
226
|
+
),
|
|
227
|
+
signing,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
log.warn("Widget invocation authority unavailable", { reason: fanoutSignal.aborted ? "deadline_exceeded" : "operation_failed" });
|
|
232
|
+
return c.json({ message: "Widget invocation authority is currently unavailable" }, 503);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const fanoutStarted = performance.now();
|
|
236
|
+
const settled = startBounded(
|
|
237
|
+
entries,
|
|
238
|
+
WIDGET_CONCURRENCY,
|
|
239
|
+
async (entry, index): Promise<WidgetStreamLine> => {
|
|
240
|
+
const { widget } = entry;
|
|
241
|
+
const identity = entry.id === undefined ? {} : { id: entry.id };
|
|
242
|
+
const key = widgetKey(widget.appId, widget.widgetId);
|
|
243
|
+
const started = performance.now();
|
|
244
|
+
const elapsed = () => Math.round(performance.now() - started);
|
|
245
|
+
const token = tokens[index];
|
|
246
|
+
if (token?.status !== "fulfilled") {
|
|
247
|
+
logRejection(widget, "signing", "operation_failed");
|
|
248
|
+
return { type: "widget", ...identity, key, status: "error", ms: elapsed() };
|
|
249
|
+
}
|
|
250
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
251
|
+
const outcome = await callProvider({
|
|
252
|
+
c,
|
|
253
|
+
widget,
|
|
254
|
+
token: token.value.token,
|
|
255
|
+
requestId,
|
|
256
|
+
signal: AbortSignal.any([fanoutSignal, timeout]),
|
|
257
|
+
timeout,
|
|
258
|
+
fetch: fetchWidget,
|
|
259
|
+
size: entry.size,
|
|
260
|
+
settings: entry.settings,
|
|
261
|
+
});
|
|
262
|
+
if (outcome.status === "ok") return { type: "widget", ...identity, key, status: "ok", widget: outcome.widget, ms: elapsed() };
|
|
263
|
+
// A widget the stream itself stopped has already been settled, and is logged once, below.
|
|
264
|
+
if ("reason" in outcome && !fanoutSignal.aborted) logRejection(widget, outcome.phase, outcome.reason, outcome.upstreamStatus);
|
|
265
|
+
return { type: "widget", ...identity, key, status: outcome.status, ms: elapsed() };
|
|
266
|
+
},
|
|
267
|
+
fanoutSignal,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
return ndjsonStream<WidgetStreamLine>({
|
|
271
|
+
first: { type: "start", widgets: entries.map((entry) => entry.id ?? widgetKey(entry.widget.appId, entry.widget.widgetId)) },
|
|
272
|
+
pending: settled.map((pending, index) =>
|
|
273
|
+
pending.then((result): WidgetStreamLine => {
|
|
274
|
+
if (result.status === "fulfilled") return result.value;
|
|
275
|
+
// Only the stream's own deadline or the browser leaving settles a widget this way. Leaving is not a
|
|
276
|
+
// failure of the widget, so only the deadline is logged.
|
|
277
|
+
const entry = entries[index]!;
|
|
278
|
+
const { widget } = entry;
|
|
279
|
+
const status = streamDeadline.aborted ? "timeout" : "error";
|
|
280
|
+
if (streamDeadline.aborted) logRejection(widget, "provider", "deadline_exceeded");
|
|
281
|
+
return {
|
|
282
|
+
type: "widget",
|
|
283
|
+
...(entry.id === undefined ? {} : { id: entry.id }),
|
|
284
|
+
key: widgetKey(widget.appId, widget.widgetId),
|
|
285
|
+
status,
|
|
286
|
+
ms: Math.round(performance.now() - fanoutStarted),
|
|
287
|
+
};
|
|
288
|
+
}),
|
|
289
|
+
),
|
|
290
|
+
last: (lines) => ({
|
|
291
|
+
type: "done",
|
|
292
|
+
status: lines.some((line) => line.type === "widget" && (line.status === "timeout" || line.status === "error"))
|
|
293
|
+
? "partial"
|
|
294
|
+
: "complete",
|
|
295
|
+
}),
|
|
296
|
+
onCancel: () => streamCancelled.abort(),
|
|
297
|
+
});
|
|
298
|
+
};
|
|
299
|
+
|
|
170
300
|
return (
|
|
171
301
|
new Hono<AuthContext>()
|
|
172
302
|
.use(dependencies.authenticate ?? auth.requireRole("authenticated"))
|
|
@@ -197,89 +327,40 @@ export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) =
|
|
|
197
327
|
return c.json({ message: "Widget registry is currently unavailable" }, 503);
|
|
198
328
|
}
|
|
199
329
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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,
|
|
330
|
+
return stream(
|
|
331
|
+
c,
|
|
332
|
+
widgets.map((widget) => ({ widget, size: requested.get(widgetKey(widget.appId, widget.widgetId)) })),
|
|
333
|
+
requestId,
|
|
254
334
|
);
|
|
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(),
|
|
281
|
-
});
|
|
282
335
|
})
|
|
336
|
+
.post(
|
|
337
|
+
"/widgets/v1",
|
|
338
|
+
bodyLimit({ maxSize: WIDGET_REQUEST_MAX_BYTES, onError: (c) => c.json({ message: "Widget request is too large" }, 400) }),
|
|
339
|
+
async (c) => {
|
|
340
|
+
let raw: unknown;
|
|
341
|
+
try {
|
|
342
|
+
raw = await c.req.json();
|
|
343
|
+
} catch {
|
|
344
|
+
return c.json({ message: "Invalid widget request JSON" }, 400);
|
|
345
|
+
}
|
|
346
|
+
const parsed = WidgetEntriesSchema.safeParse(raw);
|
|
347
|
+
if (!parsed.success) return c.json({ message: "Invalid widget request" }, 400);
|
|
348
|
+
const setup = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(timeoutMs)]);
|
|
349
|
+
let declared: DashboardWidget[];
|
|
350
|
+
try {
|
|
351
|
+
declared = await waitWithin(registry(), setup);
|
|
352
|
+
} catch {
|
|
353
|
+
log.warn("Widget registry unavailable", { reason: setup.aborted ? "deadline_exceeded" : "operation_failed" });
|
|
354
|
+
return c.json({ message: "Widget registry is currently unavailable" }, 503);
|
|
355
|
+
}
|
|
356
|
+
const byKey = new Map(declared.map((widget) => [widgetKey(widget.appId, widget.widgetId), widget]));
|
|
357
|
+
const entries = parsed.data.widgets.flatMap((entry) => {
|
|
358
|
+
const widget = byKey.get(entry.key);
|
|
359
|
+
return widget ? [{ ...entry, widget }] : [];
|
|
360
|
+
});
|
|
361
|
+
return stream(c, entries, normalizeInvocationRequestId(c.req.header("x-request-id")));
|
|
362
|
+
},
|
|
363
|
+
)
|
|
283
364
|
.get("/widgets/v1/:appId/:widgetId", async (c) => {
|
|
284
365
|
const timeout = AbortSignal.timeout(timeoutMs);
|
|
285
366
|
const signal = AbortSignal.any([c.req.raw.signal, timeout]);
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { timed } from "@k2b/stdlib/solid";
|
|
2
|
-
import { announce, Button, IconButton, ScrollArea, useLocale } from "@k2b/ui";
|
|
2
|
+
import { announce, Button, IconButton, Kbd, ScrollArea, useLocale } from "@k2b/ui";
|
|
3
3
|
import { createEffect, createMemo, createSignal, createUniqueId, For, on, onCleanup, onMount, Show } from "solid-js";
|
|
4
4
|
import type { SearchApp, SearchItem } from "../api/search/schemas";
|
|
5
5
|
import type { CloudResourceRef } from "../contracts";
|
|
6
|
-
import { shortcutLabel } from "./command-shortcuts";
|
|
7
6
|
import { matchNavigationSearchItems, type NavigationSearchItem } from "./navigation-search";
|
|
8
7
|
import { cloudResourceSearchUrl, filterCloudResourceSearchItems } from "./resource-search";
|
|
9
8
|
import { commitTypedTags, matchingSearchTags, searchTags, tagAtCursor } from "./resource-search-input";
|
|
@@ -39,6 +38,7 @@ export type CloudResourceSearchProps = {
|
|
|
39
38
|
navigationError?: string;
|
|
40
39
|
placeholder?: string;
|
|
41
40
|
requireReader?: boolean;
|
|
41
|
+
resourceType?: string;
|
|
42
42
|
navigationItems?: readonly NavigationSearchItem[];
|
|
43
43
|
searchResources?: boolean;
|
|
44
44
|
excludeRefs?: readonly CloudResourceRef[];
|
|
@@ -668,9 +668,7 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
|
|
|
668
668
|
<span>{item.title}</span>
|
|
669
669
|
<small>{item.preview ?? item.appName}</small>
|
|
670
670
|
</span>
|
|
671
|
-
<Show when={commandFor(item)?.shortcut}>
|
|
672
|
-
{(shortcut) => <kbd class="shrink-0 text-xs text-dimmed">{shortcutLabel(shortcut())}</kbd>}
|
|
673
|
-
</Show>
|
|
671
|
+
<Show when={commandFor(item)?.shortcut}>{(shortcut) => <Kbd keys={shortcut()} />}</Show>
|
|
674
672
|
<Show when={props.selectionMode && selectedKey() === itemKey(item)}>
|
|
675
673
|
<i class="ti ti-check" aria-hidden="true" />
|
|
676
674
|
</Show>
|
|
@@ -792,7 +790,9 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
|
|
|
792
790
|
(!activeItem() || !commandFor(activeItem()!) || isLinkableCommand(commandFor(activeItem()!)!))
|
|
793
791
|
}
|
|
794
792
|
>
|
|
795
|
-
<span
|
|
793
|
+
<span>
|
|
794
|
+
<Kbd keys="mod+enter" /> {t().newTab}
|
|
795
|
+
</span>
|
|
796
796
|
</Show>
|
|
797
797
|
</div>
|
|
798
798
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { keyboardPlatform } from "@k2b/ui";
|
|
1
2
|
import { COMMANDS_CHANGED, type ContextAwareCommand, collectContextAwareCommands } from "./command-bridge";
|
|
2
3
|
|
|
3
|
-
const isMac = () =>
|
|
4
|
+
const isMac = () => keyboardPlatform() === "apple";
|
|
4
5
|
const modifiers = ["ctrl", "alt", "shift", "meta"] as const;
|
|
5
6
|
type Combination = { key: string; ctrl: boolean; alt: boolean; shift: boolean; meta: boolean };
|
|
6
7
|
|
|
@@ -26,16 +27,6 @@ export const contextCommandsWithShortcuts = (): ContextAwareCommand[] => {
|
|
|
26
27
|
}));
|
|
27
28
|
};
|
|
28
29
|
|
|
29
|
-
export const shortcutLabel = (shortcut: string): string => {
|
|
30
|
-
const combination = parse(shortcut);
|
|
31
|
-
if (!combination) return "";
|
|
32
|
-
const mac = isMac();
|
|
33
|
-
const names = { ctrl: mac ? "⌃" : "Ctrl", alt: mac ? "⌥" : "Alt", shift: mac ? "⇧" : "Shift", meta: mac ? "⌘" : "Meta" };
|
|
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 ? "" : "+");
|
|
37
|
-
};
|
|
38
|
-
|
|
39
30
|
const ARROWS = new Set(["arrowup", "arrowdown", "arrowleft", "arrowright"]);
|
|
40
31
|
|
|
41
32
|
/**
|
|
@@ -12,6 +12,7 @@ export type CloudResourcePickerOptions = {
|
|
|
12
12
|
initialAppId?: string;
|
|
13
13
|
excludeRefs?: readonly CloudResourceRef[];
|
|
14
14
|
requireReader?: boolean;
|
|
15
|
+
resourceType?: string;
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
export const openCloudResourcePicker = (options: CloudResourcePickerOptions = {}): Promise<CloudResourcePickerItem | undefined> => {
|
|
@@ -27,8 +27,13 @@ export const cloudResourceSearchUrl = (request: {
|
|
|
27
27
|
|
|
28
28
|
export const filterCloudResourceSearchItems = (
|
|
29
29
|
items: readonly SearchItem[],
|
|
30
|
-
options: { excludeRefs?: readonly CloudResourceRef[]; requireReader?: boolean },
|
|
30
|
+
options: { excludeRefs?: readonly CloudResourceRef[]; requireReader?: boolean; resourceType?: string },
|
|
31
31
|
): SearchItem[] => {
|
|
32
32
|
const excluded = new Set((options.excludeRefs ?? []).map(refKey));
|
|
33
|
-
return items.filter(
|
|
33
|
+
return items.filter(
|
|
34
|
+
(item) =>
|
|
35
|
+
(options.resourceType === undefined || item.ref.type === options.resourceType) &&
|
|
36
|
+
(!options.requireReader || item.readable) &&
|
|
37
|
+
!excluded.has(refKey(item.ref)),
|
|
38
|
+
);
|
|
34
39
|
};
|
package/src/browser/widgets.ts
CHANGED
|
@@ -1,17 +1,29 @@
|
|
|
1
|
-
import type { DashboardWidgetSize, WidgetStreamLine } from "../contracts/widgets";
|
|
1
|
+
import type { DashboardWidgetSize, WidgetSettings, WidgetStreamLine } from "../contracts/widgets";
|
|
2
2
|
import { LOCALE_HEADER } from "../shared/locale";
|
|
3
3
|
import { NDJSON_CONTENT_TYPE, readNdjsonLines } from "./ndjson";
|
|
4
4
|
|
|
5
|
-
export type {
|
|
5
|
+
export type {
|
|
6
|
+
DashboardWidgetSize,
|
|
7
|
+
WidgetResponse,
|
|
8
|
+
WidgetSettingField,
|
|
9
|
+
WidgetSettings,
|
|
10
|
+
WidgetSettingsDeclaration,
|
|
11
|
+
WidgetSettingValue,
|
|
12
|
+
WidgetStreamLine,
|
|
13
|
+
WidgetStreamStatus,
|
|
14
|
+
} from "../contracts/widgets";
|
|
6
15
|
|
|
7
16
|
/**
|
|
8
17
|
* Asks Core for dashboard widgets and hands over each one as soon as it answers: `start` names the widgets, one
|
|
9
18
|
* `widget` line follows per widget in the order they finish, then `done`. `keys` (`<appId>/<widgetId>`) limits the
|
|
10
19
|
* request, for example to retry one widget; without it Core asks every declared widget. `sizes` names the size to ask
|
|
11
20
|
* a widget for; a widget without one, or with a size it does not offer, answers in its default size. Aborting `signal`
|
|
12
|
-
* stops every widget Core still waits for.
|
|
21
|
+
* stops every widget Core still waits for. When `requests` is given, POST sends one invocation per entry with its
|
|
22
|
+
* settings, ignoring `keys` and `sizes`; start and widget lines identify entries by id. Rejects when the request fails
|
|
23
|
+
* or the stream breaks off.
|
|
13
24
|
*/
|
|
14
25
|
export const streamWidgets = async (options: {
|
|
26
|
+
requests?: readonly { id: string; key: string; size?: DashboardWidgetSize; settings?: WidgetSettings }[];
|
|
15
27
|
keys?: readonly string[];
|
|
16
28
|
sizes?: Readonly<Record<string, DashboardWidgetSize>>;
|
|
17
29
|
signal: AbortSignal;
|
|
@@ -19,11 +31,17 @@ export const streamWidgets = async (options: {
|
|
|
19
31
|
onLine: (line: WidgetStreamLine) => void;
|
|
20
32
|
}): Promise<void> => {
|
|
21
33
|
const query = new URLSearchParams();
|
|
22
|
-
for (const key of options.
|
|
34
|
+
for (const key of options.requests ? [] : (options.keys ?? []))
|
|
35
|
+
query.append("widget", options.sizes?.[key] ? `${key}@${options.sizes[key]}` : key);
|
|
23
36
|
const search = query.toString();
|
|
24
37
|
const response = await fetch(`/api/widgets/v1${search ? `?${search}` : ""}`, {
|
|
38
|
+
...(options.requests ? { method: "POST", body: JSON.stringify({ widgets: options.requests }) } : {}),
|
|
25
39
|
signal: options.signal,
|
|
26
|
-
headers: {
|
|
40
|
+
headers: {
|
|
41
|
+
accept: NDJSON_CONTENT_TYPE,
|
|
42
|
+
[LOCALE_HEADER]: options.locale,
|
|
43
|
+
...(options.requests ? { "content-type": "application/json" } : {}),
|
|
44
|
+
},
|
|
27
45
|
});
|
|
28
46
|
if (!response.ok) throw new Error(`Widgets failed with ${response.status}`);
|
|
29
47
|
await readNdjsonLines(response, options.onLine, "Widgets");
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { capabilityManifestEvolutionIssues, compileCapabilities } from "../_internal/capabilities";
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
type CapabilityDefinitions,
|
|
4
|
+
type CapabilityManifest,
|
|
5
|
+
type CapabilityResult,
|
|
6
|
+
CapabilitySemanticLinkSchema,
|
|
7
|
+
} from "../contracts/capabilities";
|
|
3
8
|
|
|
4
9
|
/** Compile and validate the public manifest an app would register at startup. */
|
|
5
10
|
export const compileCapabilityManifest = (appId: string, definitions: CapabilityDefinitions): CapabilityManifest =>
|
|
@@ -10,3 +15,10 @@ export const assertCapabilityManifestEvolution = (previous: CapabilityManifest,
|
|
|
10
15
|
const issues = capabilityManifestEvolutionIssues(previous, next);
|
|
11
16
|
if (issues.length > 0) throw new Error(`Breaking Capability manifest evolution:\n- ${issues.join("\n- ")}`);
|
|
12
17
|
};
|
|
18
|
+
|
|
19
|
+
/** Assert that a reader result exposes a valid destination for its resource permalink. */
|
|
20
|
+
export const assertResourceOpenLink = (result: Pick<CapabilityResult<unknown>, "links">) => {
|
|
21
|
+
const link = result.links?.find((link) => link.rel === "open");
|
|
22
|
+
if (!link) throw new Error("Resource reader must return a top-level open link");
|
|
23
|
+
return CapabilitySemanticLinkSchema.parse(link);
|
|
24
|
+
};
|
package/src/cli/capabilities.ts
CHANGED
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
CAPABILITY_MAX_RESULT_BYTES,
|
|
7
7
|
CapabilityCatalogSchema,
|
|
8
8
|
CapabilityErrorSchema,
|
|
9
|
+
type CapabilityManifest,
|
|
10
|
+
CapabilitySemanticLinkSchema,
|
|
9
11
|
CapabilityStreamSchema,
|
|
10
12
|
CloudResourceRefSchema,
|
|
11
13
|
capabilityResultSchema,
|
|
@@ -14,6 +16,7 @@ import {
|
|
|
14
16
|
} from "../contracts/capabilities";
|
|
15
17
|
import { saveCapabilityStream } from "./capability-streams";
|
|
16
18
|
import { arg, type CliInputFlagValue, type CloudCliContext, command, defineCliCommands, flag, readCliInput } from "./index";
|
|
19
|
+
import { cloudResourceUrl } from "./resource-links";
|
|
17
20
|
|
|
18
21
|
const parseInput = async (input: CliInputFlagValue): Promise<unknown> => {
|
|
19
22
|
const raw = await readCliInput(input, {
|
|
@@ -65,6 +68,26 @@ const readCapabilityJson = async (response: Response, maxBytes: number): Promise
|
|
|
65
68
|
return parsed.data;
|
|
66
69
|
};
|
|
67
70
|
|
|
71
|
+
const findCatalogApps = async (ctx: CloudCliContext, appIds: ReadonlySet<string>) => {
|
|
72
|
+
const wantedApps = new Set(appIds);
|
|
73
|
+
const apps: z.infer<typeof CapabilityCatalogSchema>["apps"] = [];
|
|
74
|
+
let cursor: string | undefined;
|
|
75
|
+
const seenCursors = new Set<string>();
|
|
76
|
+
while (wantedApps.size) {
|
|
77
|
+
const query = new URLSearchParams({ limit: "25" });
|
|
78
|
+
if (cursor) query.set("cursor", cursor);
|
|
79
|
+
const page = CapabilityCatalogSchema.parse(
|
|
80
|
+
await readCapabilityJson(await ctx.fetch(`/api/capabilities/v1/catalog?${query}`), CAPABILITY_MAX_CATALOG_BYTES),
|
|
81
|
+
);
|
|
82
|
+
for (const app of page.apps) if (wantedApps.delete(app.appId)) apps.push(app);
|
|
83
|
+
if (!wantedApps.size || !page.page.hasMore) break;
|
|
84
|
+
cursor = page.page.nextCursor;
|
|
85
|
+
if (seenCursors.has(cursor)) throw new Error("Capability catalog returned a repeated cursor.");
|
|
86
|
+
seenCursors.add(cursor);
|
|
87
|
+
}
|
|
88
|
+
return apps;
|
|
89
|
+
};
|
|
90
|
+
|
|
68
91
|
const invoke = async (config: {
|
|
69
92
|
ctx: CloudCliContext;
|
|
70
93
|
kind: "queries" | "actions";
|
|
@@ -72,6 +95,7 @@ const invoke = async (config: {
|
|
|
72
95
|
capabilityId: string;
|
|
73
96
|
input?: CliInputFlagValue;
|
|
74
97
|
inputValue?: unknown;
|
|
98
|
+
manifest?: CapabilityManifest;
|
|
75
99
|
idempotencyKey?: string;
|
|
76
100
|
}) => {
|
|
77
101
|
const headers = new Headers({ "content-type": "application/json" });
|
|
@@ -113,7 +137,40 @@ const invoke = async (config: {
|
|
|
113
137
|
}
|
|
114
138
|
throw error;
|
|
115
139
|
}
|
|
116
|
-
|
|
140
|
+
const result = capabilityResultSchema(z.unknown()).parse(body);
|
|
141
|
+
const manifests = config.manifest ? [config.manifest] : [];
|
|
142
|
+
const wantedApps = new Set((result.refs ?? []).map(cloudResourceRefAppId));
|
|
143
|
+
for (const manifest of manifests) wantedApps.delete(manifest.appId);
|
|
144
|
+
if (wantedApps.size) {
|
|
145
|
+
try {
|
|
146
|
+
const apps = await findCatalogApps(config.ctx, wantedApps);
|
|
147
|
+
manifests.push(...apps.map((app) => app.manifest));
|
|
148
|
+
} catch {
|
|
149
|
+
config.ctx.error("The capability catalog is unavailable; some resource permalinks could not be added.");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const absoluteLinks = (value: unknown): unknown => {
|
|
153
|
+
if (Array.isArray(value)) return value.map(absoluteLinks);
|
|
154
|
+
if (!value || typeof value !== "object") return value;
|
|
155
|
+
const link = CapabilitySemanticLinkSchema.safeParse(value);
|
|
156
|
+
if (link.success) return { ...link.data, href: new URL(link.data.href, config.ctx.options.server).href };
|
|
157
|
+
return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, absoluteLinks(nested)]));
|
|
158
|
+
};
|
|
159
|
+
printGenericResult(
|
|
160
|
+
config.ctx,
|
|
161
|
+
absoluteLinks({
|
|
162
|
+
...result,
|
|
163
|
+
...(result.refs
|
|
164
|
+
? {
|
|
165
|
+
refs: result.refs.map((ref) =>
|
|
166
|
+
ref.id.isWellFormed() && manifests.some((manifest) => resolveCapabilityResourceReader(manifest, ref))
|
|
167
|
+
? { ...ref, permalink: cloudResourceUrl(config.ctx, ref) }
|
|
168
|
+
: ref,
|
|
169
|
+
),
|
|
170
|
+
}
|
|
171
|
+
: {}),
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
117
174
|
};
|
|
118
175
|
|
|
119
176
|
export default defineCliCommands({
|
|
@@ -230,25 +287,12 @@ export default defineCliCommands({
|
|
|
230
287
|
run: async ({ ctx, args }) => {
|
|
231
288
|
const ref = CloudResourceRefSchema.parse({ type: args.type, id: args.id });
|
|
232
289
|
const appId = cloudResourceRefAppId(ref);
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
let app: z.infer<typeof CapabilityCatalogSchema>["apps"][number] | undefined;
|
|
236
|
-
do {
|
|
237
|
-
const query = new URLSearchParams({ limit: "25" });
|
|
238
|
-
if (cursor) query.set("cursor", cursor);
|
|
239
|
-
const page = CapabilityCatalogSchema.parse(
|
|
240
|
-
await readCapabilityJson(await ctx.fetch(`/api/capabilities/v1/catalog?${query}`), CAPABILITY_MAX_CATALOG_BYTES),
|
|
241
|
-
);
|
|
242
|
-
app = page.apps.find((candidate) => candidate.appId === appId);
|
|
243
|
-
const nextCursor = page.page.hasMore ? page.page.nextCursor : undefined;
|
|
244
|
-
if (nextCursor && seenCursors.has(nextCursor)) throw new Error("Capability catalog returned a repeated cursor.");
|
|
245
|
-
if (nextCursor) seenCursors.add(nextCursor);
|
|
246
|
-
cursor = nextCursor;
|
|
247
|
-
} while (!app && cursor);
|
|
290
|
+
const apps = await findCatalogApps(ctx, new Set([appId]));
|
|
291
|
+
const app = apps.find((candidate) => candidate.appId === appId);
|
|
248
292
|
if (!app) throw new Error(`Capability app ${appId} is unavailable.`);
|
|
249
293
|
const reader = resolveCapabilityResourceReader(app.manifest, ref);
|
|
250
294
|
if (!reader) throw new Error(`Cloud resource type ${ref.type} is unknown or has no reader.`);
|
|
251
|
-
await invoke({ ctx, kind: "queries", appId, capabilityId: reader.localId, inputValue: { id: ref.id } });
|
|
295
|
+
await invoke({ ctx, kind: "queries", appId, capabilityId: reader.localId, inputValue: { id: ref.id }, manifest: app.manifest });
|
|
252
296
|
},
|
|
253
297
|
}),
|
|
254
298
|
command("action", {
|
package/src/cli/index.ts
CHANGED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CloudResourceRef } from "../contracts/capabilities";
|
|
2
|
+
import { cloudResourceHref } from "../contracts/resource-href";
|
|
3
|
+
import type { CloudCliContext } from "./index";
|
|
4
|
+
|
|
5
|
+
export const cloudResourceUrl = (ctx: Pick<CloudCliContext, "options">, ref: CloudResourceRef): string =>
|
|
6
|
+
new URL(cloudResourceHref(ref), ctx.options.server).href;
|
|
7
|
+
|
|
8
|
+
/** The resource id must be the id its Type's reader accepts. */
|
|
9
|
+
export const withCloudResourcePermalink = <T extends { id: string }>(
|
|
10
|
+
ctx: Pick<CloudCliContext, "options">,
|
|
11
|
+
type: CloudResourceRef["type"],
|
|
12
|
+
resource: T,
|
|
13
|
+
) => ({ ...resource, permalink: cloudResourceUrl(ctx, { type, id: resource.id }) });
|