@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.
- package/package.json +4 -2
- package/src/_internal/app-presentation.ts +22 -1
- package/src/_internal/app-roles.ts +14 -0
- package/src/_internal/capabilities.ts +113 -5
- package/src/_internal/capability-sentences.ts +413 -0
- package/src/_internal/define-app.ts +24 -5
- package/src/_internal/registry-validation.ts +5 -0
- package/src/_internal/registry.ts +50 -6
- package/src/_internal/widget-declarations.ts +53 -0
- package/src/access/PermissionEditor.tsx +43 -24
- package/src/ai/app-skill-store.ts +298 -0
- package/src/ai/app-skills.ts +105 -0
- package/src/ai/approval-routes.ts +40 -4
- package/src/ai/approvals.ts +68 -10
- package/src/ai/browser.ts +10 -0
- package/src/ai/capabilities.ts +68 -9
- package/src/ai/chart-block.ts +380 -0
- package/src/ai/chat/action-sentences.ts +49 -0
- package/src/ai/chat/blocks.tsx +97 -40
- package/src/ai/chat/chart-block.tsx +79 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
- package/src/ai/chat/message-actions.tsx +3 -1
- package/src/ai/chat/message-utils.ts +3 -0
- package/src/ai/chat/messages.ts +26 -2
- package/src/ai/chat/tool-groups.ts +66 -12
- package/src/ai/chat/tool-names.ts +6 -2
- package/src/ai/chat/turn-layout.ts +10 -2
- package/src/ai/chat/turn-view.tsx +123 -36
- package/src/ai/chat/web-tools.tsx +95 -1
- package/src/ai/client/controller.ts +6 -3
- package/src/ai/code-runtime-tools.ts +109 -61
- package/src/ai/code-source-contracts.ts +25 -21
- package/src/ai/code-source-tools.ts +3 -1
- package/src/ai/default-tool-contracts.ts +198 -0
- package/src/ai/default-tools.ts +25 -1
- package/src/ai/executor.ts +189 -35
- package/src/ai/fetch-file-tool.ts +41 -4
- package/src/ai/firecrawl-tools.ts +87 -4
- package/src/ai/index.ts +17 -1
- package/src/ai/migrate.ts +172 -14
- package/src/ai/protocol.ts +22 -1
- package/src/ai/routes.ts +11 -0
- package/src/ai/runtime-tools.ts +1 -0
- package/src/ai/runtime.ts +46 -7
- package/src/ai/skill-content.ts +54 -0
- package/src/ai/skills-routes.ts +22 -13
- package/src/ai/skills.ts +177 -231
- package/src/ai/store.ts +66 -1
- package/src/ai/tool-audit.ts +11 -1
- package/src/ai/tool-sentences.ts +38 -0
- package/src/ai/tools.ts +91 -3
- package/src/ai/types.ts +47 -1
- package/src/ai/web-provenance.ts +103 -0
- package/src/api/admin-ai-skills.ts +35 -16
- package/src/api/capabilities.ts +3 -2
- package/src/api/fanout.ts +128 -0
- package/src/api/help.ts +8 -3
- package/src/api/mcp.ts +4 -1
- package/src/api/search.ts +31 -106
- package/src/api/widgets.ts +291 -101
- package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
- package/src/browser/CloudResourceSearch.tsx +1 -1
- package/src/browser/command-shortcuts.ts +21 -6
- package/src/browser/ndjson.ts +34 -0
- package/src/browser/reload.ts +46 -0
- package/src/browser/search-stream.ts +4 -26
- package/src/browser/spotlight-position.ts +4 -1
- package/src/browser/widgets.ts +30 -0
- package/src/capabilities/executions.ts +3 -0
- package/src/capabilities/server.ts +20 -2
- package/src/cli/access.ts +48 -14
- package/src/cli/admin/ai-skills.ts +59 -39
- package/src/cli/commands.ts +20 -0
- package/src/cli/index.ts +16 -0
- package/src/cli/plugin.ts +30 -0
- package/src/contracts/announcement-cookie.ts +62 -0
- package/src/contracts/announcements.ts +14 -52
- package/src/contracts/app.ts +34 -2
- package/src/contracts/capabilities.ts +24 -0
- package/src/contracts/pwa-paths.ts +7 -2
- package/src/contracts/pwa.ts +2 -7
- package/src/contracts/registry.ts +10 -1
- package/src/contracts/shared.ts +5 -0
- package/src/contracts/widgets.ts +67 -2
- package/src/desktop/solid.tsx +6 -7
- package/src/index.ts +1 -0
- package/src/server/help.ts +8 -1
- package/src/server/index.ts +1 -0
- package/src/server/widget-request.ts +27 -0
- package/src/services/accounts/users.ts +15 -0
- package/src/services/approvals/index.ts +225 -0
- package/src/services/approvals/migrate.ts +33 -0
- package/src/services/help/index.ts +18 -5
- package/src/services/help/types.ts +3 -2
- package/src/services/identity/constants.ts +2 -0
- package/src/services/identity/key-ring.ts +123 -42
- package/src/services/identity/runtime-config.ts +13 -2
- package/src/shared/app-presentation.ts +11 -0
- package/src/shared/email-html.ts +43 -8
- package/src/ssr/AdminLayout.tsx +4 -7
- package/src/ssr/AppLaunchpad.island.tsx +1 -1
- package/src/ssr/AppLaunchpadPanel.tsx +12 -3
- package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
- package/src/ssr/GlobalSearchDialog.tsx +1 -43
- package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
- package/src/ssr/Layout.tsx +38 -14
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +17 -20
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -2
- package/src/ssr/PageTrail.tsx +23 -0
- package/src/ssr/PageTrailRow.island.tsx +20 -0
- package/src/ssr/PageTrailRuntime.island.tsx +26 -0
- package/src/ssr/ProfilePreferences.island.tsx +7 -3
- package/src/ssr/PwaRuntime.island.tsx +1 -1
- package/src/ssr/app-navigation.ts +5 -7
- package/src/ssr/global-search-host.tsx +55 -0
- package/src/ssr/help.ts +3 -3
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/layout-runtime.ts +60 -0
- package/src/ssr/mobile-menu-history.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +313 -0
- package/src/ssr/page-trail.browser-harness.tsx +216 -0
- package/src/ssr/page-trail.ts +59 -0
- package/src/ssr/platform-messages.ts +4 -0
- package/src/ssr/rail-context.ts +4 -3
- package/src/ssr/runtime.ts +14 -1
- package/src/styles/effects.css +21 -4
- package/src/styles/global.css +35 -2
- package/src/styles/utilities-layout.css +24 -1
- package/src/styles/utilities-navigation.css +6 -18
- package/src/ai/code-mode-skill.ts +0 -114
- package/src/ai/data-analysis-skill.ts +0 -10
- package/src/ai/grids-skill.ts +0 -158
- package/src/ai/skill-seeds.ts +0 -560
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
|
|
3
|
+
export const migrateApprovals = async (): Promise<void> => {
|
|
4
|
+
await sql`CREATE SCHEMA IF NOT EXISTS approvals`.simple();
|
|
5
|
+
await sql`
|
|
6
|
+
CREATE TABLE IF NOT EXISTS approvals.requests (
|
|
7
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
8
|
+
app_id TEXT NOT NULL,
|
|
9
|
+
action TEXT NOT NULL,
|
|
10
|
+
approval_scope TEXT,
|
|
11
|
+
requester_kind TEXT NOT NULL CHECK (requester_kind IN ('assistant_turn','studio_app')),
|
|
12
|
+
requester_ref TEXT NOT NULL,
|
|
13
|
+
requester_app_id TEXT NOT NULL,
|
|
14
|
+
requester_title TEXT,
|
|
15
|
+
subject_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
16
|
+
approver_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
17
|
+
assurance TEXT NOT NULL CHECK (assurance IN ('standard','confirm')),
|
|
18
|
+
sentence TEXT NOT NULL,
|
|
19
|
+
details JSONB,
|
|
20
|
+
args_digest TEXT NOT NULL,
|
|
21
|
+
review_digest TEXT NOT NULL,
|
|
22
|
+
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected','expired','cancelled','consumed')),
|
|
23
|
+
decided_via TEXT CHECK (decided_via IS NULL OR decided_via IN ('web_session','app_session','oauth','api_key','invocation')),
|
|
24
|
+
decided_at TIMESTAMPTZ,
|
|
25
|
+
consumed_at TIMESTAMPTZ,
|
|
26
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
27
|
+
expires_at TIMESTAMPTZ NOT NULL,
|
|
28
|
+
UNIQUE (requester_kind, requester_ref)
|
|
29
|
+
)
|
|
30
|
+
`.simple();
|
|
31
|
+
await sql`CREATE INDEX IF NOT EXISTS approvals_requests_pending ON approvals.requests(approver_user_id, created_at) WHERE status='pending'`.simple();
|
|
32
|
+
await sql`CREATE INDEX IF NOT EXISTS approvals_requests_expiry ON approvals.requests(expires_at)`.simple();
|
|
33
|
+
};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type SQL, sql } from "bun";
|
|
2
|
+
import { appAudienceRoles, hasAnyAppRole } from "../../_internal/app-roles";
|
|
2
3
|
import { getApp, listApps } from "../../_internal/registry";
|
|
3
4
|
import type { AppRegistryEntry } from "../../contracts/registry";
|
|
5
|
+
import type { RequestActor } from "../../contracts/shared";
|
|
4
6
|
import { resolveAppPresentations } from "../../shared/app-presentation";
|
|
5
7
|
import { helpLocaleChain } from "../../shared/help";
|
|
6
8
|
import { logger } from "../logging";
|
|
@@ -12,8 +14,20 @@ export type { HelpArticle, HelpMatch, HelpMetadata, HelpReader, HelpReaderFactor
|
|
|
12
14
|
|
|
13
15
|
const log = logger("help");
|
|
14
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Help follows the app's declared visibility: it needs a user (the person, or the user an API credential acts
|
|
19
|
+
* for), the app's navigation roles limit it, and an app reached only through the admin area is for admins.
|
|
20
|
+
* The app's routes still authorize the app itself.
|
|
21
|
+
*/
|
|
22
|
+
export const canReadAppHelp = (app: Pick<AppRegistryEntry, "nav">, viewer: RequestActor | undefined): boolean => {
|
|
23
|
+
const user = viewer?.kind === "user" ? viewer.user : (viewer?.delegatedUser ?? undefined);
|
|
24
|
+
return !!user && hasAnyAppRole(user, appAudienceRoles(app.nav));
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Reads Help in one language for one viewer; apps the viewer may not see do not exist for this reader. */
|
|
15
28
|
export const createHelpReader = (
|
|
16
29
|
locale: string,
|
|
30
|
+
viewer: RequestActor | undefined,
|
|
17
31
|
dependencies: {
|
|
18
32
|
db?: SQL;
|
|
19
33
|
listApps?: () => Promise<AppRegistryEntry[]>;
|
|
@@ -21,11 +35,10 @@ export const createHelpReader = (
|
|
|
21
35
|
} = {},
|
|
22
36
|
): HelpReader => {
|
|
23
37
|
const db = dependencies.db ?? sql;
|
|
38
|
+
const visible = (app: AppRegistryEntry | null): app is AppRegistryEntry => app !== null && canReadAppHelp(app, viewer);
|
|
24
39
|
const select = async (appId?: string): Promise<HelpSelection[]> => {
|
|
25
|
-
const apps = appId
|
|
26
|
-
|
|
27
|
-
: await (dependencies.listApps ?? listApps)();
|
|
28
|
-
return resolveAppPresentations(apps, locale).flatMap((app) =>
|
|
40
|
+
const apps = appId ? [await (dependencies.getApp ?? getApp)(appId)] : await (dependencies.listApps ?? listApps)();
|
|
41
|
+
return resolveAppPresentations(apps.filter(visible), locale).flatMap((app) =>
|
|
29
42
|
app.help
|
|
30
43
|
? [
|
|
31
44
|
{
|
|
@@ -41,7 +54,7 @@ export const createHelpReader = (
|
|
|
41
54
|
return {
|
|
42
55
|
async manifest(appId) {
|
|
43
56
|
const app = await (dependencies.getApp ?? getApp)(appId);
|
|
44
|
-
if (!app
|
|
57
|
+
if (!visible(app) || !app.help) return null;
|
|
45
58
|
const [corpus] = await db`SELECT 1 FROM help.corpora WHERE app_id=${app.id} AND manifest_hash=${app.help.manifestHash}`;
|
|
46
59
|
if (!corpus) return null;
|
|
47
60
|
const rows = await queryHelp(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AppRegistryHelpSummary } from "../../contracts/registry";
|
|
2
|
+
import type { RequestActor } from "../../contracts/shared";
|
|
2
3
|
import type { HelpDocumentManifest } from "../../shared/help";
|
|
3
4
|
|
|
4
5
|
export type HelpDocument = {
|
|
@@ -36,11 +37,11 @@ export type HelpSearchInput = { query: string; appId?: string; limit?: number };
|
|
|
36
37
|
export type HelpReadInput = { appId: string; documentId: string };
|
|
37
38
|
export type HelpManifestResult = AppRegistryHelpSummary & { locale: string; documents: readonly HelpDocumentManifest[] };
|
|
38
39
|
|
|
39
|
-
/** One request's language; operations resolve the current published
|
|
40
|
+
/** One request's language and viewer; operations resolve the current published versions of the apps the viewer may see. */
|
|
40
41
|
export type HelpReader = {
|
|
41
42
|
manifest(appId: string): Promise<HelpManifestResult | null>;
|
|
42
43
|
list(cursor?: string): Promise<HelpMetadata[]>;
|
|
43
44
|
search(input: HelpSearchInput): Promise<HelpMatch[]>;
|
|
44
45
|
read(input: HelpReadInput): Promise<HelpArticle | null>;
|
|
45
46
|
};
|
|
46
|
-
export type HelpReaderFactory = (locale: string) => HelpReader;
|
|
47
|
+
export type HelpReaderFactory = (locale: string, viewer: RequestActor | undefined) => HelpReader;
|
|
@@ -13,6 +13,8 @@ export const INVOCATION_CLOCK_TOLERANCE_SECONDS = 2;
|
|
|
13
13
|
export const IDENTITY_JWKS_MAX_AGE_SECONDS = 5 * 60;
|
|
14
14
|
export const IDENTITY_ACTIVATION_LEAD_MS = 10 * 60_000;
|
|
15
15
|
export const IDENTITY_SIGNING_CACHE_MS = 60_000;
|
|
16
|
+
/** Bounds a shared signer refresh or identity settings load; matches the default issuance deadline. */
|
|
17
|
+
export const IDENTITY_REFRESH_TIMEOUT_MS = 30_000;
|
|
16
18
|
export const IDENTITY_ROTATION_AGE_MS = 30 * 24 * 60 * 60_000;
|
|
17
19
|
export const IDENTITY_ROLLOUT_MARGIN_MS = 10 * 60_000;
|
|
18
20
|
export const IDENTITY_MAX_COMPACT_TOKEN_BYTES = 4 * 1024;
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
CLOUD_IDENTITY_ALGORITHM,
|
|
7
7
|
IDENTITY_ACTIVATION_LEAD_MS,
|
|
8
8
|
IDENTITY_CLOCK_TOLERANCE_SECONDS,
|
|
9
|
+
IDENTITY_REFRESH_TIMEOUT_MS,
|
|
9
10
|
IDENTITY_ROLLOUT_MARGIN_MS,
|
|
10
11
|
IDENTITY_ROTATION_AGE_MS,
|
|
11
12
|
IDENTITY_SIGNING_CACHE_MS,
|
|
@@ -76,6 +77,38 @@ const date = (value: Date | string): Date => (value instanceof Date ? value : ne
|
|
|
76
77
|
const publicJwk = (value: JWK | string): JWK => (typeof value === "string" ? (JSON.parse(value) as JWK) : value);
|
|
77
78
|
const serializedSize = (value: unknown): number => encoder.encode(JSON.stringify(value)).byteLength;
|
|
78
79
|
|
|
80
|
+
const formatDuration = (ms: number): string => (ms % 1_000 === 0 ? `${ms / 1_000} s` : `${ms} ms`);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Stops waiting for `work` once `signal` aborts. The work itself keeps running
|
|
84
|
+
* (a pooled query or a shared refresh cannot be cancelled from here), but the
|
|
85
|
+
* caller is released at its deadline instead of whenever the stall ends.
|
|
86
|
+
*/
|
|
87
|
+
const untilAborted = <V>(work: Promise<V>, signal: AbortSignal): Promise<V> => {
|
|
88
|
+
if (signal.aborted) {
|
|
89
|
+
work.catch(() => undefined);
|
|
90
|
+
return Promise.reject(signal.reason);
|
|
91
|
+
}
|
|
92
|
+
return new Promise<V>((resolve, reject) => {
|
|
93
|
+
const abort = () => reject(signal.reason);
|
|
94
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
95
|
+
work.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const timeoutSignal = (ms: number, reason: () => DOMException): { signal: AbortSignal; clear: () => void } => {
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
const timer = setTimeout(() => controller.abort(reason()), ms);
|
|
102
|
+
return { signal: controller.signal, clear: () => clearTimeout(timer) };
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A JS deadline alone cannot cancel a blocked PostgreSQL query. Bound each
|
|
107
|
+
* statement (including lock waits) on this connection, transaction-local.
|
|
108
|
+
*/
|
|
109
|
+
const boundStatements = (db: typeof sql, deadline: number) =>
|
|
110
|
+
db`SELECT set_config('statement_timeout', ${`${Math.max(1, Math.floor(deadline - Date.now()))}ms`}, true)`;
|
|
111
|
+
|
|
79
112
|
const assertRsaJwk = (value: JWK, privateRequired: boolean): void => {
|
|
80
113
|
if (value.kty !== "RSA" || typeof value.n !== "string" || typeof value.e !== "string") {
|
|
81
114
|
throw new Error("Cloud identity key is not an RSA JWK");
|
|
@@ -156,8 +189,8 @@ const importRow = async (row: SigningKeyRow, keys: IdentityKeyEncryptionConfig):
|
|
|
156
189
|
return { kid: row.kid, key, signUntil: date(row.sign_until) };
|
|
157
190
|
};
|
|
158
191
|
|
|
159
|
-
const selectRows = async (purpose: SigningKeyPurpose): Promise<SigningKeyRow[]> =>
|
|
160
|
-
|
|
192
|
+
const selectRows = async (db: typeof sql, purpose: SigningKeyPurpose): Promise<SigningKeyRow[]> =>
|
|
193
|
+
db<SigningKeyRow[]>`
|
|
161
194
|
SELECT id, purpose, state, kid, alg, public_jwk, encrypted_private_jwk, encryption_key_id,
|
|
162
195
|
created_at, activate_at, activated_at, sign_until, retired_at, verify_until, revoked_at
|
|
163
196
|
FROM auth.signing_keys
|
|
@@ -188,9 +221,16 @@ const insertKey = async (
|
|
|
188
221
|
`;
|
|
189
222
|
};
|
|
190
223
|
|
|
191
|
-
|
|
224
|
+
/**
|
|
225
|
+
* Every statement ends by `deadline`, so a refresh that its callers already
|
|
226
|
+
* gave up on does not keep holding a pooled connection.
|
|
227
|
+
*/
|
|
228
|
+
const maintainPurpose = async (purpose: SigningKeyPurpose, deadline: number): Promise<SigningKeyRow> => {
|
|
192
229
|
const keys = await readIdentityKeyEncryptionConfig();
|
|
193
|
-
const before = await
|
|
230
|
+
const before = await sql.begin(async (tx) => {
|
|
231
|
+
await boundStatements(tx, deadline);
|
|
232
|
+
return selectRows(tx, purpose);
|
|
233
|
+
});
|
|
194
234
|
const beforeActive = before.find((row) => row.state === "active");
|
|
195
235
|
const beforePending = before.find((row) => row.state === "pending");
|
|
196
236
|
const nowMs = Date.now();
|
|
@@ -205,6 +245,8 @@ const maintainPurpose = async (purpose: SigningKeyPurpose): Promise<SigningKeyRo
|
|
|
205
245
|
? await generateEncryptedKey(keys)
|
|
206
246
|
: null;
|
|
207
247
|
const active = await sql.begin(async (tx) => {
|
|
248
|
+
// Another replica's stuck rotation must not hold this refresh forever.
|
|
249
|
+
await boundStatements(tx, deadline);
|
|
208
250
|
await tx`SELECT pg_advisory_xact_lock(hashtextextended(${`cloud:identity-keyring:${purpose}`}, 0))`;
|
|
209
251
|
const rows = await tx<SigningKeyRow[]>`
|
|
210
252
|
SELECT id, purpose, state, kid, alg, public_jwk, encrypted_private_jwk, encryption_key_id,
|
|
@@ -289,10 +331,24 @@ export const prepareIdentitySigner = async (purpose: SigningKeyPurpose): Promise
|
|
|
289
331
|
return signerRefreshes.run(
|
|
290
332
|
purpose,
|
|
291
333
|
async () => {
|
|
334
|
+
// Every caller joins this refresh. Bound it, so one hung connection
|
|
335
|
+
// cannot hold back every later issuance until TCP gives up.
|
|
336
|
+
const deadline = Date.now() + IDENTITY_REFRESH_TIMEOUT_MS;
|
|
337
|
+
const bound = timeoutSignal(
|
|
338
|
+
IDENTITY_REFRESH_TIMEOUT_MS,
|
|
339
|
+
() =>
|
|
340
|
+
new DOMException(
|
|
341
|
+
`Cloud identity signer refresh did not finish within ${formatDuration(IDENTITY_REFRESH_TIMEOUT_MS)}`,
|
|
342
|
+
"TimeoutError",
|
|
343
|
+
),
|
|
344
|
+
);
|
|
292
345
|
try {
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
346
|
+
const refresh = async () => {
|
|
347
|
+
const keys = await readIdentityKeyEncryptionConfig();
|
|
348
|
+
const row = await maintainPurpose(purpose, deadline);
|
|
349
|
+
return importRow(row, keys);
|
|
350
|
+
};
|
|
351
|
+
const signer = await untilAborted(refresh(), bound.signal);
|
|
296
352
|
if (signer.signUntil.getTime() <= now) throw new Error(`Active ${purpose} signing key is past sign_until`);
|
|
297
353
|
return signer;
|
|
298
354
|
} catch (error) {
|
|
@@ -300,6 +356,8 @@ export const prepareIdentitySigner = async (purpose: SigningKeyPurpose): Promise
|
|
|
300
356
|
log.error("Cloud identity signer refresh failed", { purpose, error: error instanceof Error ? error.message : String(error) });
|
|
301
357
|
if (cached && cached.signer.signUntil.getTime() > now) return cached.signer;
|
|
302
358
|
throw error;
|
|
359
|
+
} finally {
|
|
360
|
+
bound.clear();
|
|
303
361
|
}
|
|
304
362
|
},
|
|
305
363
|
(signer) => {
|
|
@@ -313,6 +371,10 @@ export const prepareIdentitySigner = async (purpose: SigningKeyPurpose): Promise
|
|
|
313
371
|
* The shared row lock makes emergency revocation wait for already-started
|
|
314
372
|
* issuance and prevents replicas from releasing a new result under that key
|
|
315
373
|
* after the revocation commits.
|
|
374
|
+
*
|
|
375
|
+
* `timeoutMs` bounds the whole call: signer refresh, identity settings, the
|
|
376
|
+
* wait for a connection and the key check. The caller is released at the
|
|
377
|
+
* deadline with a `TimeoutError` that names the step that stalled.
|
|
316
378
|
*/
|
|
317
379
|
export const withActiveIdentitySigner = async <T>(
|
|
318
380
|
purpose: SigningKeyPurpose,
|
|
@@ -322,42 +384,61 @@ export const withActiveIdentitySigner = async <T>(
|
|
|
322
384
|
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
323
385
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("Identity issuance timeout must be positive");
|
|
324
386
|
const deadline = Date.now() + timeoutMs;
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
387
|
+
let step = "signer refresh";
|
|
388
|
+
const expired = () => new DOMException(`Identity service did not respond within ${formatDuration(timeoutMs)} (${step})`, "TimeoutError");
|
|
389
|
+
const bound = timeoutSignal(timeoutMs, expired);
|
|
390
|
+
const signal = options.signal ? AbortSignal.any([options.signal, bound.signal]) : bound.signal;
|
|
391
|
+
// The timer releases a waiting caller. The clock also catches a timer that a
|
|
392
|
+
// busy event loop delivers late, and a statement Postgres cancels first.
|
|
393
|
+
const check = () => {
|
|
394
|
+
signal.throwIfAborted();
|
|
395
|
+
if (Date.now() >= deadline) throw expired();
|
|
328
396
|
};
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
397
|
+
const wait = <V>(name: string, work: () => Promise<V>): Promise<V> => {
|
|
398
|
+
check();
|
|
399
|
+
step = name;
|
|
400
|
+
return untilAborted(work(), signal);
|
|
401
|
+
};
|
|
402
|
+
try {
|
|
403
|
+
// Preparation can refresh/rotate keys using the pool. Never do it while
|
|
404
|
+
// holding a caller's transaction: every issuance needs just one connection.
|
|
405
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
406
|
+
const signer = await wait("signer refresh", () => prepareIdentitySigner(purpose));
|
|
407
|
+
const { issuer } = await wait("identity settings", getIdentityRuntimeConfig);
|
|
408
|
+
const run = async (db: typeof sql): Promise<{ active: false } | { active: true; result: T }> => {
|
|
409
|
+
// A caller released at its deadline must not sign once a connection frees up.
|
|
410
|
+
check();
|
|
411
|
+
step = "signing key check";
|
|
412
|
+
await boundStatements(db, deadline);
|
|
413
|
+
const [active] = await db<Array<{ kid: string }>>`
|
|
414
|
+
SELECT kid
|
|
415
|
+
FROM auth.signing_keys
|
|
416
|
+
WHERE kid = ${signer.kid}
|
|
417
|
+
AND purpose = ${purpose}
|
|
418
|
+
AND state = 'active'
|
|
419
|
+
AND sign_until > now()
|
|
420
|
+
FOR SHARE
|
|
421
|
+
`;
|
|
422
|
+
check();
|
|
423
|
+
if (!active) return { active: false };
|
|
424
|
+
step = "signing";
|
|
425
|
+
const result = await callback({ ...signer, issuer }, db);
|
|
426
|
+
// Roll back what the callback wrote instead of committing a result the caller never gets.
|
|
427
|
+
check();
|
|
428
|
+
return { active: true, result };
|
|
429
|
+
};
|
|
430
|
+
const checked = await wait("database connection", () => (options.pool ?? sql).begin(run));
|
|
431
|
+
check();
|
|
432
|
+
if (checked.active) return checked.result;
|
|
433
|
+
invalidateIdentitySignerCache(purpose, signer.kid);
|
|
434
|
+
}
|
|
435
|
+
throw new Error(`No active ${purpose} identity signer is available`);
|
|
436
|
+
} catch (error) {
|
|
437
|
+
check();
|
|
438
|
+
throw error;
|
|
439
|
+
} finally {
|
|
440
|
+
bound.clear();
|
|
359
441
|
}
|
|
360
|
-
throw new Error(`No active ${purpose} identity signer is available`);
|
|
361
442
|
};
|
|
362
443
|
|
|
363
444
|
export const initializeIdentityAuthority = async (): Promise<void> => {
|
|
@@ -474,7 +555,7 @@ const maintainVerificationWindows = async (): Promise<void> => {
|
|
|
474
555
|
};
|
|
475
556
|
|
|
476
557
|
export const runIdentityKeyMaintenance = async (): Promise<void> => {
|
|
477
|
-
for (const purpose of SIGNING_KEY_PURPOSES) await maintainPurpose(purpose);
|
|
558
|
+
for (const purpose of SIGNING_KEY_PURPOSES) await maintainPurpose(purpose, Date.now() + IDENTITY_REFRESH_TIMEOUT_MS);
|
|
478
559
|
await maintainVerificationWindows();
|
|
479
560
|
await sql`
|
|
480
561
|
DELETE FROM auth.session_families
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { env } from "../../config/env";
|
|
2
2
|
import { publicCloudOrigin } from "../../shared/app-url";
|
|
3
3
|
import { get as getSetting } from "../settings";
|
|
4
|
-
import { CLOUD_INVOCATION_JWKS_PATH, CLOUD_OAUTH_JWKS_PATH, CLOUD_SESSION_JWKS_PATH } from "./constants";
|
|
4
|
+
import { CLOUD_INVOCATION_JWKS_PATH, CLOUD_OAUTH_JWKS_PATH, CLOUD_SESSION_JWKS_PATH, IDENTITY_REFRESH_TIMEOUT_MS } from "./constants";
|
|
5
5
|
|
|
6
6
|
const RUNTIME_CONFIG_TTL_MS = 60_000;
|
|
7
7
|
|
|
@@ -73,7 +73,18 @@ export const getIdentityRuntimeConfig = async (): Promise<IdentityRuntimeConfig>
|
|
|
73
73
|
const now = Date.now();
|
|
74
74
|
if (cached && cached.expiresAt > now) return cached.value;
|
|
75
75
|
if (loading) return loading;
|
|
76
|
-
|
|
76
|
+
// Every caller joins this load. Bound it like the signer refresh, so one hung
|
|
77
|
+
// settings read cannot hold back every later caller; the next one starts over.
|
|
78
|
+
loading = new Promise<IdentityRuntimeConfig>((resolve, reject) => {
|
|
79
|
+
const timer = setTimeout(
|
|
80
|
+
() =>
|
|
81
|
+
reject(new DOMException(`Cloud identity settings did not load within ${IDENTITY_REFRESH_TIMEOUT_MS / 1_000} s`, "TimeoutError")),
|
|
82
|
+
IDENTITY_REFRESH_TIMEOUT_MS,
|
|
83
|
+
);
|
|
84
|
+
load()
|
|
85
|
+
.then(resolve, reject)
|
|
86
|
+
.finally(() => clearTimeout(timer));
|
|
87
|
+
})
|
|
77
88
|
.then((value) => {
|
|
78
89
|
cached = { value, expiresAt: Date.now() + RUNTIME_CONFIG_TTL_MS };
|
|
79
90
|
return value;
|
|
@@ -23,6 +23,12 @@ const mergedTranslation = (catalog: AppPresentationCatalog, requestedLocale: str
|
|
|
23
23
|
legalLinks: { ...current.legalLinks, ...next.legalLinks },
|
|
24
24
|
searchLinks: { ...current.searchLinks, ...next.searchLinks },
|
|
25
25
|
searchLinkDescriptions: { ...current.searchLinkDescriptions, ...next.searchLinkDescriptions },
|
|
26
|
+
widgets: Object.fromEntries(
|
|
27
|
+
[...new Set([...Object.keys(current.widgets ?? {}), ...Object.keys(next.widgets ?? {})])].map((id) => [
|
|
28
|
+
id,
|
|
29
|
+
{ ...current.widgets?.[id], ...next.widgets?.[id] },
|
|
30
|
+
]),
|
|
31
|
+
),
|
|
26
32
|
};
|
|
27
33
|
}, {});
|
|
28
34
|
};
|
|
@@ -55,6 +61,11 @@ export const resolveAppPresentation = <T extends AppMeta>(app: T, requestedLocal
|
|
|
55
61
|
description: translation.searchLinkDescriptions?.[link.href] ?? link.description,
|
|
56
62
|
})),
|
|
57
63
|
legalLinks: app.legalLinks?.map((link) => ({ ...link, label: translation.legalLinks?.[link.href] ?? link.label })),
|
|
64
|
+
widgets: app.widgets?.map((widget) => ({
|
|
65
|
+
...widget,
|
|
66
|
+
title: translation.widgets?.[widget.id]?.title ?? widget.title,
|
|
67
|
+
description: translation.widgets?.[widget.id]?.description ?? widget.description,
|
|
68
|
+
})),
|
|
58
69
|
};
|
|
59
70
|
};
|
|
60
71
|
|
package/src/shared/email-html.ts
CHANGED
|
@@ -44,11 +44,46 @@ export const EMAIL_HTML_ALLOWED_ATTRIBUTES: Record<string, string[]> = {
|
|
|
44
44
|
ul: ["style"],
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
-
export const EMAIL_HTML_ALLOWED_SCHEMES = ["http", "https", "mailto"] as const;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
47
|
+
export const EMAIL_HTML_ALLOWED_SCHEMES = ["http", "https", "mailto", "tel"] as const;
|
|
48
|
+
|
|
49
|
+
// An optional leading `+`, at least one digit, the visual separators of RFC 3966 and spaces, and an optional extension.
|
|
50
|
+
const PHONE_NUMBER_HREF = /^tel:\+?(?=[^;]*\d)[\d\-.() ]+(?:;ext=\d+)?$/i;
|
|
51
|
+
|
|
52
|
+
/** Whether a `tel:` link holds a phone number, such as `tel:+49 731 000000` or `tel:+49-731-000000;ext=12`. */
|
|
53
|
+
const isPhoneNumberHref = (href: string): boolean => {
|
|
54
|
+
try {
|
|
55
|
+
return PHONE_NUMBER_HREF.test(decodeURI(href.trim()));
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Like sanitize-html, ignore control characters and whitespace when reading the scheme.
|
|
62
|
+
const hrefScheme = (href: string): string | undefined =>
|
|
63
|
+
href
|
|
64
|
+
.replace(/[\u0000-\u0020]+/g, "")
|
|
65
|
+
.match(/^([a-z][a-z\d+.-]*):/i)?.[1]
|
|
66
|
+
?.toLowerCase();
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Drops a `tel:` link that holds no phone number; its text stays. sanitize-html removes HTML comments before
|
|
70
|
+
* it reads a scheme, so a link address with a comment, such as `te<!---->l:`, is dropped too.
|
|
71
|
+
*/
|
|
72
|
+
const keepPhoneNumberHref: sanitizeHtml.Transformer = (tagName, attribs) => {
|
|
73
|
+
const href = attribs.href;
|
|
74
|
+
if (href === undefined || (!href.includes("<!--") && (hrefScheme(href) !== "tel" || isPhoneNumberHref(href)))) {
|
|
75
|
+
return { tagName, attribs };
|
|
76
|
+
}
|
|
77
|
+
const { href: _dropped, ...kept } = attribs;
|
|
78
|
+
return { tagName, attribs: kept };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** The sanitize-html options of the email sanitizer. Extend them instead of repeating the allowlists. */
|
|
82
|
+
export const EMAIL_HTML_SANITIZE_OPTIONS = {
|
|
83
|
+
allowedTags: [...EMAIL_HTML_TAGS],
|
|
84
|
+
allowedAttributes: EMAIL_HTML_ALLOWED_ATTRIBUTES,
|
|
85
|
+
allowedSchemes: [...EMAIL_HTML_ALLOWED_SCHEMES],
|
|
86
|
+
transformTags: { a: keepPhoneNumberHref },
|
|
87
|
+
} satisfies sanitizeHtml.IOptions;
|
|
88
|
+
|
|
89
|
+
export const sanitizeEmailHtml = (html: string): string => sanitizeHtml(html, EMAIL_HTML_SANITIZE_OPTIONS);
|
package/src/ssr/AdminLayout.tsx
CHANGED
|
@@ -33,13 +33,10 @@ export default function AdminLayout(props: Props) {
|
|
|
33
33
|
const url = new URL(c.req.raw.url);
|
|
34
34
|
const currentPath = `${url.pathname}${url.search}`;
|
|
35
35
|
const runtime = getLocalizedRuntimeContext(c);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
];
|
|
40
|
-
if (title !== "Overview" && title !== t.overview) {
|
|
41
|
-
breadcrumbs.push({ title });
|
|
42
|
-
}
|
|
36
|
+
// Admin is the area's root, as an app's root is: the rail and the admin sidebar lead there. So it is no level
|
|
37
|
+
// above an admin page, and every admin page shows its title alone, whichever app owns it.
|
|
38
|
+
const overview = title === "Overview" || title === t.overview;
|
|
39
|
+
const breadcrumbs: Breadcrumb[] = [{ title: t.start, href: "/" }, { title: overview ? t.admin : title }];
|
|
43
40
|
return (
|
|
44
41
|
<Layout c={c} fullWidth fullPage title={breadcrumbs}>
|
|
45
42
|
<AppWorkspace mobileSurface="flush" class="min-h-0 flex-1" resizable={false}>
|
|
@@ -60,7 +60,7 @@ export function openAppLaunchpad(apps?: AppLaunchpadApp[], legalLinks?: AppLaunc
|
|
|
60
60
|
const context = apps ? { apps, legalLinks: legalLinks ?? [], profile } : (window.__cloudAppLaunchpad ?? readEmbeddedContext());
|
|
61
61
|
if (!context) return;
|
|
62
62
|
window.__cloudAppLaunchpad = context;
|
|
63
|
-
if (window.matchMedia("(max-width:
|
|
63
|
+
if (window.matchMedia("(max-width: 767px)").matches) {
|
|
64
64
|
void openCloudMobileMenu(context);
|
|
65
65
|
return;
|
|
66
66
|
}
|
|
@@ -2,11 +2,11 @@ import { documentNavigate } from "@k2b/ssr/nav";
|
|
|
2
2
|
import { IconButton, ScrollArea, TextInput, useLocale } from "@k2b/ui";
|
|
3
3
|
import { createSignal, For, type JSX, Show } from "solid-js";
|
|
4
4
|
import { Dynamic } from "solid-js/web";
|
|
5
|
+
import { importOnDemand } from "../browser/reload";
|
|
5
6
|
import type { CloudTheme } from "../shared/theme";
|
|
6
7
|
import { AppBadge, useAppBadgeDescription } from "./AppBadge";
|
|
7
8
|
import { MobileProfileActions } from "./MobileProfileActions";
|
|
8
9
|
import { platformMessages } from "./platform-messages";
|
|
9
|
-
import { openRailEditor } from "./RailEditor";
|
|
10
10
|
import { readRailContext } from "./rail-context";
|
|
11
11
|
import { railMessages } from "./rail-messages";
|
|
12
12
|
import { projectRailNavigation } from "./rail-navigation";
|
|
@@ -64,6 +64,7 @@ export const AppLaunchpadPanel = (
|
|
|
64
64
|
const t = () => railMessages.resolve([locale()]).t;
|
|
65
65
|
const rail = readRailContext();
|
|
66
66
|
const [query, setQuery] = createSignal("");
|
|
67
|
+
const [editorLoading, setEditorLoading] = createSignal(false);
|
|
67
68
|
const messages = () => platformMessages.resolve([locale()]).t;
|
|
68
69
|
const matches = (label: string, description = "") =>
|
|
69
70
|
`${label} ${description}`.toLocaleLowerCase(locale()).includes(query().trim().toLocaleLowerCase(locale()));
|
|
@@ -106,14 +107,22 @@ export const AppLaunchpadPanel = (
|
|
|
106
107
|
label={t().customize}
|
|
107
108
|
tooltip={t().customize}
|
|
108
109
|
tooltipDelay={0}
|
|
110
|
+
aria-busy={editorLoading() ? "true" : undefined}
|
|
109
111
|
onClick={async () => {
|
|
112
|
+
if (editorLoading()) return;
|
|
113
|
+
// The editor and its validation load only when someone customizes the app bar, while this panel
|
|
114
|
+
// stays open and shows that it is busy. The button stays enabled so it keeps focus.
|
|
115
|
+
setEditorLoading(true);
|
|
116
|
+
const editor = await importOnDemand(() => import("./RailEditor"));
|
|
117
|
+
setEditorLoading(false);
|
|
118
|
+
if (!editor) return;
|
|
110
119
|
if (props.beforeSelect) {
|
|
111
120
|
if (!(await props.beforeSelect())) return;
|
|
112
121
|
} else props.close();
|
|
113
|
-
openRailEditor(locale());
|
|
122
|
+
editor.openRailEditor(locale());
|
|
114
123
|
}}
|
|
115
124
|
>
|
|
116
|
-
<i class="ti ti-adjustments-horizontal" aria-hidden="true" />
|
|
125
|
+
<i class={editorLoading() ? "ti ti-loader-2 animate-spin" : "ti ti-adjustments-horizontal"} aria-hidden="true" />
|
|
117
126
|
</IconButton>
|
|
118
127
|
</div>
|
|
119
128
|
</Show>
|
|
@@ -4,10 +4,10 @@ import {
|
|
|
4
4
|
ANNOUNCEMENTS_COOKIE,
|
|
5
5
|
ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS,
|
|
6
6
|
type AnnouncementCookieState,
|
|
7
|
-
type AnnouncementDisplayEntry,
|
|
8
7
|
mergeAnnouncementCookieState,
|
|
9
8
|
serializeAnnouncementCookieState,
|
|
10
|
-
} from "../contracts/
|
|
9
|
+
} from "../contracts/announcement-cookie";
|
|
10
|
+
import type { AnnouncementDisplayEntry } from "../contracts/announcements";
|
|
11
11
|
import { platformMessages } from "./platform-messages";
|
|
12
12
|
|
|
13
13
|
type Props = {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { query } from "@k2b/stdlib/solid";
|
|
2
|
-
import { type DialogRender,
|
|
2
|
+
import { type DialogRender, toast, useLocale } from "@k2b/ui";
|
|
3
3
|
import { createEffect, createSignal, For, onCleanup, onMount } from "solid-js";
|
|
4
4
|
import type { SearchItem } from "../api/search/schemas";
|
|
5
5
|
import CloudResourceSearch from "../browser/CloudResourceSearch";
|
|
@@ -7,7 +7,6 @@ import { COMMANDS_CHANGED, collectContextAwareCommands } from "../browser/comman
|
|
|
7
7
|
import { contextCommandsWithShortcuts } from "../browser/command-shortcuts";
|
|
8
8
|
import { openCommand, runContextAwareCommand } from "../browser/commands";
|
|
9
9
|
import type { NavigationSearchItem } from "../browser/navigation-search";
|
|
10
|
-
import { resourceSearchDialogOptions } from "../browser/resource-search-dialog";
|
|
11
10
|
import { resourceSearchMessages } from "../browser/resource-search-messages";
|
|
12
11
|
import { type GlobalSearchOptions, requestSearchNavigation } from "../browser/search-bridge";
|
|
13
12
|
import { loadSearchCommands, type PaletteCommand } from "../browser/search-commands";
|
|
@@ -139,44 +138,3 @@ export default function GlobalSearchDialog(props: GlobalSearchDialogProps) {
|
|
|
139
138
|
</div>
|
|
140
139
|
);
|
|
141
140
|
}
|
|
142
|
-
|
|
143
|
-
/** Owned by the layout island; neither dialog state nor callbacks cross SSR props. */
|
|
144
|
-
export const createGlobalSearchHost = (searchLinks: () => NavigationSearchItem[], searchResources = true) => {
|
|
145
|
-
const [request, setRequest] = createSignal<GlobalSearchOptions>({});
|
|
146
|
-
let controller: AbortController | undefined;
|
|
147
|
-
return {
|
|
148
|
-
open: (options: GlobalSearchOptions) => {
|
|
149
|
-
setRequest({ ...options });
|
|
150
|
-
if (controller) {
|
|
151
|
-
document.querySelector<HTMLInputElement>(".cloud-global-search input[role=combobox]")?.focus();
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
const current = new AbortController();
|
|
155
|
-
controller = current;
|
|
156
|
-
const locale = document.documentElement.lang || "en";
|
|
157
|
-
void dialogCore
|
|
158
|
-
.open<void>(
|
|
159
|
-
(close, context) => (
|
|
160
|
-
<GlobalSearchDialog
|
|
161
|
-
request={request()}
|
|
162
|
-
searchLinks={searchLinks()}
|
|
163
|
-
searchResources={searchResources}
|
|
164
|
-
close={close}
|
|
165
|
-
context={context}
|
|
166
|
-
/>
|
|
167
|
-
),
|
|
168
|
-
{ ...resourceSearchDialogOptions(resourceSearchMessages.resolve([locale]).t.searchCloudResources), signal: current.signal },
|
|
169
|
-
)
|
|
170
|
-
.catch(() => {
|
|
171
|
-
toast.error(resourceSearchMessages.resolve([locale]).t.searchFailed);
|
|
172
|
-
})
|
|
173
|
-
.finally(() => {
|
|
174
|
-
if (controller === current) controller = undefined;
|
|
175
|
-
});
|
|
176
|
-
},
|
|
177
|
-
dispose: () => {
|
|
178
|
-
controller?.abort();
|
|
179
|
-
controller = undefined;
|
|
180
|
-
},
|
|
181
|
-
};
|
|
182
|
-
};
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { IconButton, Tooltip, useLocale } from "@k2b/ui";
|
|
2
2
|
import { createEffect, onCleanup, onMount } from "solid-js";
|
|
3
|
+
import { registerContextAwareCommand } from "../browser/command-bridge";
|
|
3
4
|
import { attachCommandShortcuts } from "../browser/command-shortcuts";
|
|
4
|
-
import { registerContextAwareCommand, runContextAwareCommand } from "../browser/commands";
|
|
5
5
|
import type { NavigationSearchItem } from "../browser/navigation-search";
|
|
6
|
+
import { importOnDemand } from "../browser/reload";
|
|
6
7
|
import { openGlobalSearch } from "../browser/search";
|
|
7
8
|
import { registerGlobalSearchHost } from "../browser/search-bridge";
|
|
8
|
-
import { createGlobalSearchHost } from "./
|
|
9
|
+
import { createGlobalSearchHost } from "./global-search-host";
|
|
9
10
|
import { platformMessages } from "./platform-messages";
|
|
10
11
|
|
|
11
12
|
type GlobalSearchTriggerProps = {
|
|
@@ -26,7 +27,15 @@ export default function GlobalSearchTrigger(props: GlobalSearchTriggerProps) {
|
|
|
26
27
|
onMount(() => {
|
|
27
28
|
onCleanup(registerGlobalSearchHost(host.open, host.dispose));
|
|
28
29
|
});
|
|
29
|
-
|
|
30
|
+
// Commands, their schemas and the capability client load on the first shortcut, not with every page.
|
|
31
|
+
onMount(() =>
|
|
32
|
+
onCleanup(
|
|
33
|
+
attachCommandShortcuts(
|
|
34
|
+
(command) =>
|
|
35
|
+
void importOnDemand(() => import("../browser/commands")).then((commands) => commands?.runContextAwareCommand(command)),
|
|
36
|
+
),
|
|
37
|
+
),
|
|
38
|
+
);
|
|
30
39
|
createEffect(() =>
|
|
31
40
|
onCleanup(
|
|
32
41
|
registerContextAwareCommand({
|