@k2b/cloud 0.29.1 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/package.json +3 -2
  2. package/src/_internal/app-roles.ts +14 -0
  3. package/src/_internal/capabilities.ts +111 -3
  4. package/src/_internal/capability-sentences.ts +413 -0
  5. package/src/_internal/define-app.ts +10 -1
  6. package/src/_internal/registry-validation.ts +5 -0
  7. package/src/access/PermissionEditor.tsx +43 -24
  8. package/src/ai/app-skill-store.ts +298 -0
  9. package/src/ai/app-skills.ts +105 -0
  10. package/src/ai/browser.ts +10 -0
  11. package/src/ai/capabilities.ts +68 -9
  12. package/src/ai/chart-block.ts +380 -0
  13. package/src/ai/chat/action-sentences.ts +49 -0
  14. package/src/ai/chat/blocks.tsx +45 -9
  15. package/src/ai/chat/chart-block.tsx +79 -0
  16. package/src/ai/chat/message-utils.ts +3 -0
  17. package/src/ai/chat/messages.ts +8 -2
  18. package/src/ai/chat/tool-names.ts +2 -0
  19. package/src/ai/chat/turn-layout.ts +2 -2
  20. package/src/ai/chat/turn-view.tsx +29 -11
  21. package/src/ai/code-runtime-tools.ts +59 -44
  22. package/src/ai/default-tool-contracts.ts +198 -0
  23. package/src/ai/default-tools.ts +25 -1
  24. package/src/ai/executor.ts +19 -21
  25. package/src/ai/index.ts +9 -1
  26. package/src/ai/migrate.ts +59 -12
  27. package/src/ai/runtime-tools.ts +1 -0
  28. package/src/ai/skill-content.ts +54 -0
  29. package/src/ai/skills-routes.ts +22 -13
  30. package/src/ai/skills.ts +177 -231
  31. package/src/ai/tool-sentences.ts +38 -0
  32. package/src/ai/types.ts +8 -1
  33. package/src/api/admin-ai-skills.ts +35 -16
  34. package/src/api/fanout.ts +128 -0
  35. package/src/api/help.ts +8 -3
  36. package/src/api/mcp.ts +4 -1
  37. package/src/api/search.ts +31 -106
  38. package/src/api/widgets.ts +259 -100
  39. package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
  40. package/src/browser/ndjson.ts +34 -0
  41. package/src/browser/reload.ts +46 -0
  42. package/src/browser/search-stream.ts +4 -26
  43. package/src/browser/widgets.ts +28 -0
  44. package/src/capabilities/server.ts +20 -2
  45. package/src/cli/access.ts +48 -14
  46. package/src/cli/admin/ai-skills.ts +59 -39
  47. package/src/contracts/announcement-cookie.ts +62 -0
  48. package/src/contracts/announcements.ts +14 -52
  49. package/src/contracts/capabilities.ts +24 -0
  50. package/src/contracts/pwa-paths.ts +7 -2
  51. package/src/contracts/pwa.ts +2 -7
  52. package/src/contracts/registry.ts +1 -0
  53. package/src/contracts/shared.ts +5 -0
  54. package/src/contracts/widgets.ts +20 -0
  55. package/src/index.ts +1 -0
  56. package/src/server/help.ts +8 -1
  57. package/src/services/accounts/users.ts +15 -0
  58. package/src/services/help/index.ts +18 -5
  59. package/src/services/help/types.ts +3 -2
  60. package/src/services/identity/constants.ts +2 -0
  61. package/src/services/identity/key-ring.ts +123 -42
  62. package/src/services/identity/runtime-config.ts +13 -2
  63. package/src/ssr/AppLaunchpadPanel.tsx +12 -3
  64. package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
  65. package/src/ssr/GlobalSearchDialog.tsx +1 -43
  66. package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
  67. package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
  68. package/src/ssr/PwaRuntime.island.tsx +1 -1
  69. package/src/ssr/app-navigation.ts +5 -7
  70. package/src/ssr/global-search-host.tsx +55 -0
  71. package/src/ssr/help.ts +3 -3
  72. package/src/ssr/rail-context.ts +4 -3
  73. package/src/ssr/runtime.ts +14 -1
  74. package/src/ai/code-mode-skill.ts +0 -114
  75. package/src/ai/data-analysis-skill.ts +0 -10
  76. package/src/ai/grids-skill.ts +0 -158
  77. package/src/ai/skill-seeds.ts +0 -560
@@ -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
- ? [await (dependencies.getApp ?? getApp)(appId)].filter((app): app is AppRegistryEntry => app !== null)
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?.help) return null;
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 app versions. */
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
- sql<SigningKeyRow[]>`
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
- const maintainPurpose = async (purpose: SigningKeyPurpose): Promise<SigningKeyRow> => {
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 selectRows(purpose);
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 keys = await readIdentityKeyEncryptionConfig();
294
- const row = await maintainPurpose(purpose);
295
- const signer = await importRow(row, keys);
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
- const checkDeadline = () => {
326
- options.signal?.throwIfAborted();
327
- if (Date.now() >= deadline) throw new DOMException("Identity issuance deadline exceeded", "TimeoutError");
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
- // Preparation can refresh/rotate keys using the pool. Never do it while
330
- // holding a caller's transaction: every issuance needs just one connection.
331
- for (let attempt = 0; attempt < 2; attempt += 1) {
332
- checkDeadline();
333
- const signer = await prepareIdentitySigner(purpose);
334
- const { issuer } = await getIdentityRuntimeConfig();
335
- checkDeadline();
336
- const run = async (db: typeof sql): Promise<{ active: false } | { active: true; result: T }> => {
337
- checkDeadline();
338
- // A JS deadline alone cannot cancel a blocked PostgreSQL query. Bound
339
- // each statement (including lock waits) on this connection, transaction-local.
340
- await db`SELECT set_config('statement_timeout', ${`${Math.max(1, Math.floor(deadline - Date.now()))}ms`}, true)`;
341
- const [active] = await db<Array<{ kid: string }>>`
342
- SELECT kid
343
- FROM auth.signing_keys
344
- WHERE kid = ${signer.kid}
345
- AND purpose = ${purpose}
346
- AND state = 'active'
347
- AND sign_until > now()
348
- FOR SHARE
349
- `;
350
- checkDeadline();
351
- if (!active) return { active: false };
352
- const result = await callback({ ...signer, issuer }, db);
353
- return { active: true, result };
354
- };
355
- const checked = await (options.pool ?? sql).begin(run);
356
- checkDeadline();
357
- if (checked.active) return checked.result;
358
- invalidateIdentitySignerCache(purpose, signer.kid);
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
- loading = load()
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;
@@ -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/announcements";
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, dialogCore, toast, useLocale } from "@k2b/ui";
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 "./GlobalSearchDialog";
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
- onMount(() => onCleanup(attachCommandShortcuts((command) => void runContextAwareCommand(command))));
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({
@@ -1,6 +1,6 @@
1
1
  import { IconButton, Tooltip, useLocale } from "@k2b/ui";
2
2
  import { createEffect, onCleanup } from "solid-js";
3
- import { registerContextAwareCommand } from "../browser/commands";
3
+ import { registerContextAwareCommand } from "../browser/command-bridge";
4
4
  import type { GlobalSearchHelpApp } from "./GlobalSearchHelpDialog";
5
5
  import { openLayoutHelpDialog } from "./LayoutHelp";
6
6
  import { platformMessages } from "./platform-messages";
@@ -1,7 +1,7 @@
1
1
  import { observeMobileShell, syncThemeColor, type ToastHandle, toast, useLocale } from "@k2b/ui";
2
2
  import { onCleanup, onMount } from "solid-js";
3
3
  import { renewAppSession } from "../browser/app-session";
4
- import { PWA_SCOPE, PWA_SERVICE_WORKER_PATH } from "../contracts/pwa";
4
+ import { PWA_SCOPE, PWA_SERVICE_WORKER_PATH } from "../contracts/pwa-paths";
5
5
  import { pwaMessages } from "./pwa-messages";
6
6
 
7
7
  /** Set once per browsing session, so the first page renews and later navigations do not. */
@@ -1,6 +1,7 @@
1
+ import { hasAnyAppRole } from "../_internal/app-roles";
1
2
  import type { RuntimeAppMeta } from "../contracts/app";
2
- import { isPwaPartId } from "../contracts/pwa";
3
- import { hasRole, type Role, type User } from "../contracts/shared";
3
+ import { isPwaPartId } from "../contracts/pwa-paths";
4
+ import type { User } from "../contracts/shared";
4
5
  import { resolveAppPresentation } from "../shared/app-presentation";
5
6
 
6
7
  export type VisibleNavigationApp = RuntimeAppMeta & {
@@ -27,14 +28,11 @@ export const hasDedicatedRuntimeRoute = (apps: readonly RuntimeAppMeta[], pathna
27
28
  return !!match && match.app.id !== currentAppId;
28
29
  };
29
30
 
30
- const hasAnyRole = (user: User | undefined, roles: readonly Role[] | undefined): boolean =>
31
- !roles || (!!user && roles.some((role) => (role === "guest" ? user.profile === "guest" : hasRole(user, role))));
32
-
33
31
  /** Apps rendered in the navigation for a given authenticated user. */
34
32
  export const visibleNavigationApps = (apps: readonly RuntimeAppMeta[], user: User | undefined): VisibleNavigationApp[] =>
35
33
  apps.filter(
36
34
  (app): app is VisibleNavigationApp =>
37
- !!app.nav && app.nav.section !== "hidden" && (!app.nav.requiresAuth || !!user) && hasAnyRole(user, app.nav.requiresRoles),
35
+ !!app.nav && app.nav.section !== "hidden" && (!app.nav.requiresAuth || !!user) && hasAnyAppRole(user, app.nav.requiresRoles),
38
36
  );
39
37
 
40
38
  export type VisiblePwaPart = RuntimeAppMeta & { pwa: NonNullable<RuntimeAppMeta["pwa"]> };
@@ -49,7 +47,7 @@ export const visiblePwaParts = (apps: readonly RuntimeAppMeta[], user: User | un
49
47
  return apps
50
48
  .filter(
51
49
  (app): app is VisiblePwaPart =>
52
- !!app.pwa && isPwaPartId(app.id) && app.routes.includes(app.pwa.href) && hasAnyRole(user, app.pwa.requiresRoles),
50
+ !!app.pwa && isPwaPartId(app.id) && app.routes.includes(app.pwa.href) && hasAnyAppRole(user, app.pwa.requiresRoles),
53
51
  )
54
52
  .map((app) => resolveAppPresentation(app, locale))
55
53
  .sort((a, b) => a.name.localeCompare(b.name, locale) || a.id.localeCompare(b.id));
@@ -0,0 +1,55 @@
1
+ import { dialogCore, toast } from "@k2b/ui";
2
+ import { createSignal } from "solid-js";
3
+ import type { NavigationSearchItem } from "../browser/navigation-search";
4
+ import { importOnDemand } from "../browser/reload";
5
+ import { resourceSearchDialogOptions } from "../browser/resource-search-dialog";
6
+ import { resourceSearchMessages } from "../browser/resource-search-messages";
7
+ import type { GlobalSearchOptions } from "../browser/search-bridge";
8
+
9
+ /**
10
+ * Owned by the layout island; neither dialog state nor callbacks cross SSR props. The dialog with search, commands
11
+ * and their schemas loads on first open, so no page pays for it before someone searches.
12
+ */
13
+ export const createGlobalSearchHost = (searchLinks: () => NavigationSearchItem[], searchResources = true) => {
14
+ const [request, setRequest] = createSignal<GlobalSearchOptions>({});
15
+ let controller: AbortController | undefined;
16
+ return {
17
+ open: (options: GlobalSearchOptions) => {
18
+ setRequest({ ...options });
19
+ if (controller) {
20
+ document.querySelector<HTMLInputElement>(".cloud-global-search input[role=combobox]")?.focus();
21
+ return;
22
+ }
23
+ const current = new AbortController();
24
+ controller = current;
25
+ const locale = document.documentElement.lang || "en";
26
+ void importOnDemand(() => import("./GlobalSearchDialog"))
27
+ .then((loaded) => {
28
+ if (!loaded || current.signal.aborted) return;
29
+ const GlobalSearchDialog = loaded.default;
30
+ return dialogCore.open<void>(
31
+ (close, context) => (
32
+ <GlobalSearchDialog
33
+ request={request()}
34
+ searchLinks={searchLinks()}
35
+ searchResources={searchResources}
36
+ close={close}
37
+ context={context}
38
+ />
39
+ ),
40
+ { ...resourceSearchDialogOptions(resourceSearchMessages.resolve([locale]).t.searchCloudResources), signal: current.signal },
41
+ );
42
+ })
43
+ .catch(() => {
44
+ toast.error(resourceSearchMessages.resolve([locale]).t.searchFailed);
45
+ })
46
+ .finally(() => {
47
+ if (controller === current) controller = undefined;
48
+ });
49
+ },
50
+ dispose: () => {
51
+ controller?.abort();
52
+ controller = undefined;
53
+ },
54
+ };
55
+ };
package/src/ssr/help.ts CHANGED
@@ -10,14 +10,14 @@ const manifests = new WeakMap<object, HelpManifestResult | null>();
10
10
  const log = logger("help:ssr");
11
11
  export const getLayoutHelp = (c: object): HelpManifestResult | null => manifests.get(c) ?? null;
12
12
 
13
- /** Explicit Help pages also work anonymously; automatic layout loading requires a user. */
13
+ /** Help metadata for the current or an explicit app; `null` without a user or when the user may not see the app. */
14
14
  export const preloadLayoutHelp = async (c: Context, appId?: string): Promise<HelpManifestResult | null> => {
15
15
  if (manifests.has(c)) return getLayoutHelp(c);
16
- if (!c.get("runtime") || (!appId && !c.get("user"))) return null;
16
+ if (!c.get("runtime") || !c.get("user")) return null;
17
17
  const id = appId ?? resolveCurrentApp(getRuntimeContext(c).apps, c.req.path)?.id;
18
18
  if (!id) return null;
19
19
  try {
20
- const manifest = await createHelpReader(getLocale(c)).manifest(id);
20
+ const manifest = await createHelpReader(getLocale(c), c.get("actor")).manifest(id);
21
21
  manifests.set(c, manifest);
22
22
  return manifest;
23
23
  } catch (error) {