@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.
- package/package.json +6 -5
- package/src/_internal/bounded-json.ts +14 -2
- package/src/_internal/capabilities.ts +87 -8
- package/src/_internal/capability-reviews.ts +6 -0
- package/src/_internal/capability-timeout.ts +71 -0
- package/src/_internal/define-app.ts +145 -89
- package/src/_internal/http-drain.ts +56 -0
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.tsx +16 -17
- package/src/access/PrincipalPicker.tsx +45 -10
- package/src/access/messages.ts +8 -0
- package/src/access/principal-label.ts +18 -0
- package/src/access/ui.ts +1 -0
- package/src/account/EntitySearch.tsx +5 -4
- package/src/ai/action-review.ts +2 -1
- package/src/ai/app-skill-store.ts +1 -1
- package/src/ai/capabilities.ts +21 -3
- package/src/ai/capability-execution.ts +14 -12
- package/src/ai/chat/action-card.ts +7 -1
- package/src/ai/chat/action-messages.ts +14 -0
- package/src/ai/chat/action-question.browser-harness.tsx +12 -0
- package/src/ai/chat/action-question.tsx +202 -22
- package/src/ai/chat/action-text.ts +1 -1
- package/src/ai/chat-tasks.ts +2 -1
- package/src/ai/client/controller.ts +150 -32
- package/src/ai/client/transport.ts +14 -2
- package/src/ai/code-capability-routes.ts +27 -23
- package/src/ai/code-runtime-tools.ts +14 -16
- package/src/ai/code-source-tools.ts +16 -18
- package/src/ai/executor.ts +56 -21
- package/src/ai/fetch-file-tool.ts +67 -76
- package/src/ai/http.ts +1 -0
- package/src/ai/migrate.ts +13 -0
- package/src/ai/open-questions.ts +4 -8
- package/src/ai/projects.ts +1 -2
- package/src/ai/protocol.ts +2 -2
- package/src/ai/recent-resources.ts +12 -5
- package/src/ai/review-content.ts +77 -0
- package/src/ai/routes.ts +69 -31
- package/src/ai/runtime.ts +1 -0
- package/src/ai/skill-tool.ts +17 -6
- package/src/ai/skills-routes.ts +72 -27
- package/src/ai/skills.ts +139 -60
- package/src/ai/store.ts +168 -33
- package/src/ai/types.ts +10 -3
- package/src/ai/usage.ts +2 -2
- package/src/api/admin-core-settings.ts +15 -2
- package/src/api/admin-geo.ts +54 -0
- package/src/api/admin-linux-identities.ts +6 -3
- package/src/api/admin-outgoing-mail.ts +29 -15
- package/src/api/approvals-loader.ts +3 -3
- package/src/api/approvals.ts +51 -0
- package/src/api/apps.ts +4 -2
- package/src/api/capabilities.ts +258 -181
- package/src/api/capability-streams.ts +21 -13
- package/src/api/cli-plugins.ts +4 -1
- package/src/api/geo-glyphs.ts +11 -0
- package/src/api/geo.ts +128 -0
- package/src/api/help.ts +12 -4
- package/src/api/index.ts +5 -0
- package/src/api/mcp.ts +46 -38
- package/src/api/me-panel-consent.ts +8 -5
- package/src/api/search.ts +21 -14
- package/src/api/sync-ops.ts +19 -13
- package/src/api/widgets.ts +42 -18
- package/src/approvals/Approvals.tsx +5 -3
- package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
- package/src/browser/CloudResourceSearch.tsx +3 -13
- package/src/browser/PanelLeaveNotice.tsx +18 -0
- package/src/browser/navigation-search.ts +78 -25
- package/src/browser/panel-leave.ts +69 -0
- package/src/browser/panel-messages.ts +12 -0
- package/src/browser/panel-slot.ts +173 -24
- package/src/browser/panel.ts +3 -0
- package/src/browser/resource-search-messages.ts +2 -0
- package/src/capabilities/client.ts +28 -1
- package/src/capabilities/executions.ts +22 -3
- package/src/capabilities/geo-client.ts +57 -0
- package/src/capabilities/response.ts +2 -1
- package/src/capabilities/review-storage.ts +24 -0
- package/src/capabilities/reviews.ts +17 -3
- package/src/capabilities/server.ts +36 -16
- package/src/cli/access.ts +15 -23
- package/src/cli/admin/linux.ts +13 -2
- package/src/cli/admin/outgoing-mail.ts +26 -4
- package/src/cli/capabilities.ts +19 -0
- package/src/config/env.ts +0 -6
- package/src/contracts/app-access.ts +48 -0
- package/src/contracts/app.ts +4 -1
- package/src/contracts/approvals.ts +2 -2
- package/src/contracts/avatar.ts +186 -0
- package/src/contracts/capabilities.ts +148 -54
- package/src/contracts/geo-admin.ts +127 -0
- package/src/contracts/geo-map.ts +19 -0
- package/src/contracts/geo-route.ts +168 -0
- package/src/contracts/geo.ts +248 -0
- package/src/contracts/index.ts +4 -0
- package/src/contracts/outgoing-mail.ts +17 -4
- package/src/contracts/registry.ts +2 -0
- package/src/events/app-access.ts +68 -0
- package/src/events/live-delivery.ts +128 -0
- package/src/events/live.ts +14 -130
- package/src/geo/CloudMap.tsx +95 -0
- package/src/geo/map-messages.ts +17 -0
- package/src/geo/ui.ts +1 -0
- package/src/index.ts +2 -0
- package/src/server/client-ip.ts +36 -0
- package/src/server/index.ts +3 -4
- package/src/server/middleware/app-access.ts +312 -0
- package/src/server/middleware/auth.ts +1 -1
- package/src/server/middleware/invocation.ts +11 -0
- package/src/server/middleware/rate-limit.ts +4 -4
- package/src/server/services/access.ts +13 -9
- package/src/server/services/index.ts +0 -2
- package/src/server/services/services.ts +0 -2
- package/src/services/accounts/posix.ts +21 -7
- package/src/services/allowed-network-fetch.ts +16 -10
- package/src/services/app-access-metrics.ts +24 -0
- package/src/services/app-access.ts +123 -0
- package/src/services/app-discovery.ts +27 -0
- package/src/services/approvals/content.ts +40 -0
- package/src/services/approvals/index.ts +14 -6
- package/src/services/approvals/list.ts +6 -11
- package/src/services/approvals/question.ts +11 -7
- package/src/services/downstream-budget.ts +87 -0
- package/src/services/geo/admin.ts +262 -0
- package/src/services/geo/bucket.ts +67 -0
- package/src/services/geo/cache.ts +57 -0
- package/src/services/geo/costs.ts +5 -0
- package/src/services/geo/errors.ts +41 -0
- package/src/services/geo/fixtures/README.md +17 -0
- package/src/services/geo/fixtures/along.json +355 -0
- package/src/services/geo/fixtures/autocomplete.json +218 -0
- package/src/services/geo/fixtures/geocode.json +129 -0
- package/src/services/geo/fixtures/places.json +355 -0
- package/src/services/geo/fixtures/reverse.json +61 -0
- package/src/services/geo/fixtures/routing.json +57 -0
- package/src/services/geo/geoapify-route.ts +93 -0
- package/src/services/geo/geoapify.ts +281 -0
- package/src/services/geo/ledger.ts +122 -0
- package/src/services/geo/map.ts +160 -0
- package/src/services/geo/migrate.ts +25 -0
- package/src/services/geo/polyline.ts +184 -0
- package/src/services/geo/route-chain.ts +145 -0
- package/src/services/geo/route-costs.ts +9 -0
- package/src/services/geo/route-geometry.ts +39 -0
- package/src/services/geo/route-plan.ts +172 -0
- package/src/services/geo/route-service.ts +433 -0
- package/src/services/geo/service.ts +300 -0
- package/src/services/geo/settings.ts +49 -0
- package/src/services/help/index.ts +16 -7
- package/src/services/help/types.ts +6 -1
- package/src/services/identity/invocation-actor.ts +20 -2
- package/src/services/identity/issue-invocation.ts +67 -0
- package/src/services/identity/key-ring.ts +5 -3
- package/src/services/identity/runtime-config.ts +23 -0
- package/src/services/index.ts +9 -0
- package/src/services/mandates/index.ts +40 -3
- package/src/services/network-security.ts +6 -3
- package/src/services/network-transport.ts +99 -0
- package/src/services/notifications/catalog.ts +3 -2
- package/src/services/notifications/runtime.ts +1 -0
- package/src/services/notifications/web-push-transport.ts +45 -38
- package/src/services/outgoing-mail/dispatcher.ts +6 -1
- package/src/services/outgoing-mail/index.ts +55 -1
- package/src/services/outgoing-mail/messages.ts +82 -3
- package/src/services/outgoing-mail/resolve-host.ts +2 -2
- package/src/services/outgoing-mail/retention.ts +13 -1
- package/src/services/outgoing-mail/store.ts +36 -6
- package/src/services/outgoing-mail/test-send.ts +34 -4
- package/src/services/outgoing-mail/transport.ts +1 -1
- package/src/services/pdf/gotenberg.ts +11 -38
- package/src/services/pdf/template-preview.ts +1 -1
- package/src/services/public-http.ts +28 -40
- package/src/services/settings/app.ts +4 -1
- package/src/services/settings/core-settings.ts +88 -0
- package/src/services/settings/defaults.ts +8 -2
- package/src/services/settings/errors.ts +11 -0
- package/src/services/settings/geo-budget.ts +40 -0
- package/src/services/settings/index.ts +4 -3
- package/src/services/settings/store.ts +25 -17
- package/src/services/weather/geo.ts +29 -111
- package/src/services/weather/index.ts +4 -3
- package/src/services/weather/locations.ts +8 -5
- package/src/services/weather/migrate.ts +1 -0
- package/src/services/weather/settings.ts +0 -16
- package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
- package/src/shared/avatar-art.ts +400 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/network-address.ts +22 -3
- package/src/shared/template-rendering.ts +36 -4
- package/src/shared-text/documents.ts +1 -1
- package/src/shared-text/index.ts +2 -0
- package/src/shared-text/log.ts +51 -3
- package/src/shared-text/snapshots.ts +340 -0
- package/src/ssr/AdminLayout.tsx +4 -2
- package/src/ssr/CloudShell.island.tsx +6 -0
- package/src/ssr/Layout.tsx +65 -15
- package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
- package/src/ssr/LayoutHelp.tsx +3 -2
- package/src/ssr/PanelRuntime.island.tsx +11 -7
- package/src/ssr/PwaLayout.tsx +2 -2
- package/src/ssr/RailApps.tsx +6 -0
- package/src/ssr/ShellRuntime.island.tsx +23 -7
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
- package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +6 -6
- package/src/ssr/admin-navigation.ts +1 -0
- package/src/ssr/app-badges.ts +6 -1
- package/src/ssr/compact-shell.browser-harness.ts +44 -5
- package/src/ssr/help.ts +3 -2
- package/src/ssr/index.ts +1 -1
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/mobile-menu-history.ts +4 -0
- package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
- package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
- package/src/ssr/page-swap.browser-harness.tsx +177 -0
- package/src/ssr/page-swap.ts +28 -0
- package/src/ssr/page-trail-runtime.ts +32 -13
- package/src/ssr/panel-consent.ts +2 -2
- package/src/ssr/panel-slot.browser-harness.tsx +3 -0
- package/src/ssr/platform-messages.ts +8 -0
- package/src/ssr/runtime.ts +17 -0
- package/src/ssr/shell-data.tsx +10 -2
- package/src/styles/global.css +70 -6
- package/src/server/services/geo.ts +0 -215
package/src/api/widgets.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 = (
|
|
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
|
-
<
|
|
183
|
-
{
|
|
184
|
-
|
|
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("
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
84
|
+
.filter(uniqueTarget)
|
|
85
|
+
: scoped.filter(uniqueTarget);
|
|
45
86
|
}
|
|
46
|
-
const words = query.split(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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) &&
|