@k2b/cloud 0.29.0 → 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 (92) hide show
  1. package/package.json +5 -3
  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 +203 -185
  15. package/src/ai/chat/chart-block.tsx +79 -0
  16. package/src/ai/chat/live-turn.browser-harness.tsx +5 -1
  17. package/src/ai/chat/message-utils.ts +3 -0
  18. package/src/ai/chat/messages.ts +12 -26
  19. package/src/ai/chat/tool-disclosure.tsx +21 -14
  20. package/src/ai/chat/tool-groups.ts +64 -37
  21. package/src/ai/chat/tool-names.ts +2 -0
  22. package/src/ai/chat/turn-layout.ts +4 -4
  23. package/src/ai/chat/turn-view.tsx +141 -69
  24. package/src/ai/code-runtime-tools.ts +59 -44
  25. package/src/ai/default-tool-contracts.ts +198 -0
  26. package/src/ai/default-tools.ts +25 -1
  27. package/src/ai/executor.ts +19 -21
  28. package/src/ai/index.ts +9 -1
  29. package/src/ai/migrate.ts +59 -12
  30. package/src/ai/protocol.ts +9 -4
  31. package/src/ai/runtime-tools.ts +1 -0
  32. package/src/ai/skill-content.ts +54 -0
  33. package/src/ai/skills-routes.ts +22 -13
  34. package/src/ai/skills.ts +177 -231
  35. package/src/ai/store.ts +20 -13
  36. package/src/ai/tool-sentences.ts +38 -0
  37. package/src/ai/types.ts +10 -2
  38. package/src/api/admin-ai-skills.ts +35 -16
  39. package/src/api/admin-outgoing-mail.ts +18 -0
  40. package/src/api/fanout.ts +128 -0
  41. package/src/api/help.ts +8 -3
  42. package/src/api/mcp.ts +4 -1
  43. package/src/api/search.ts +31 -106
  44. package/src/api/widgets.ts +259 -100
  45. package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
  46. package/src/browser/ndjson.ts +34 -0
  47. package/src/browser/reload.ts +46 -0
  48. package/src/browser/search-stream.ts +4 -26
  49. package/src/browser/widgets.ts +28 -0
  50. package/src/capabilities/server.ts +20 -2
  51. package/src/cli/access.ts +48 -14
  52. package/src/cli/admin/ai-skills.ts +59 -39
  53. package/src/cli/admin/outgoing-mail.ts +30 -2
  54. package/src/contracts/announcement-cookie.ts +62 -0
  55. package/src/contracts/announcements.ts +14 -52
  56. package/src/contracts/capabilities.ts +24 -0
  57. package/src/contracts/outgoing-mail.ts +56 -11
  58. package/src/contracts/pwa-paths.ts +7 -2
  59. package/src/contracts/pwa.ts +2 -7
  60. package/src/contracts/registry.ts +1 -0
  61. package/src/contracts/shared.ts +5 -0
  62. package/src/contracts/widgets.ts +20 -0
  63. package/src/index.ts +1 -0
  64. package/src/server/help.ts +8 -1
  65. package/src/services/accounts/users.ts +15 -0
  66. package/src/services/help/index.ts +18 -5
  67. package/src/services/help/types.ts +3 -2
  68. package/src/services/identity/constants.ts +2 -0
  69. package/src/services/identity/key-ring.ts +123 -42
  70. package/src/services/identity/runtime-config.ts +13 -2
  71. package/src/services/outbox.ts +24 -9
  72. package/src/services/outgoing-mail/index.ts +33 -5
  73. package/src/services/outgoing-mail/messages.ts +60 -19
  74. package/src/services/outgoing-mail/store.ts +63 -4
  75. package/src/shared/network-address.ts +1 -0
  76. package/src/ssr/AppLaunchpadPanel.tsx +12 -3
  77. package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
  78. package/src/ssr/GlobalSearchDialog.tsx +1 -43
  79. package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
  80. package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
  81. package/src/ssr/PwaRuntime.island.tsx +1 -1
  82. package/src/ssr/app-navigation.ts +5 -7
  83. package/src/ssr/global-search-host.tsx +55 -0
  84. package/src/ssr/help.ts +3 -3
  85. package/src/ssr/rail-context.ts +4 -3
  86. package/src/ssr/runtime.ts +14 -1
  87. package/src/styles/effects.css +142 -0
  88. package/src/styles/utilities-markdown-table.css +5 -1
  89. package/src/ai/code-mode-skill.ts +0 -114
  90. package/src/ai/data-analysis-skill.ts +0 -10
  91. package/src/ai/grids-skill.ts +0 -158
  92. package/src/ai/skill-seeds.ts +0 -560
@@ -4,6 +4,7 @@ import { z } from "zod";
4
4
  import {
5
5
  type AdminMailFilter,
6
6
  type AdminMailRecord,
7
+ type MailFilter,
7
8
  type MailMessage,
8
9
  type MailPage,
9
10
  type MailPageParams,
@@ -86,16 +87,17 @@ type LogRow = Pick<
86
87
  | "sent_at"
87
88
  | "content_purged_at"
88
89
  >;
89
- const logColumns = (db: SQL, includeText: boolean) => db`id, app_id, profile_key, batch_id, ref_scope, ref_id, to_addresses, subject,
90
- ${includeText ? db`text_body` : db`NULL::text AS text_body`}, attachments, status, error_code, error_message, smtp_response,
90
+ const logColumns = (db: SQL, ownApp?: string) => db`id, app_id, profile_key, batch_id, ref_scope, ref_id, to_addresses, subject,
91
+ ${ownApp === undefined ? db`NULL::text AS text_body` : db`CASE WHEN app_id = ${ownApp} THEN text_body END AS text_body`}, attachments, status, error_code, error_message, smtp_response,
91
92
  failures, attempt_count, actor_id, actor_name, created_at, created_at::text AS cursor_created_at, sent_at, content_purged_at`;
92
93
  const metadata = async (id: string): Promise<AdminMailRecord | undefined> => {
93
- const [row] = await sql<LogRow[]>`SELECT ${logColumns(sql, false)} FROM outgoing_mail.messages WHERE id = ${id}::uuid`;
94
+ const [row] = await sql<LogRow[]>`SELECT ${logColumns(sql)} FROM outgoing_mail.messages WHERE id = ${id}::uuid`;
94
95
  return row ? adminMessageRecord(row) : undefined;
95
96
  };
96
97
  const date = (value: Date | string) => new Date(value).toISOString();
97
98
  export const messageRecord = (row: LogRow): MailRecord => ({
98
99
  id: row.id,
100
+ appId: row.app_id,
99
101
  profile: row.profile_key,
100
102
  to: row.to_addresses,
101
103
  subject: row.subject,
@@ -329,28 +331,67 @@ export const decodeMailCursor = (cursor?: string) => {
329
331
  throw new OutgoingMailError("bad_input", "Invalid outgoing mail cursor.");
330
332
  }
331
333
  };
334
+ type LogFilter = AdminMailFilter & Pick<MailFilter, "apps"> & { recipientExact?: string };
335
+ /**
336
+ * The longest one search of the send log may run, the same budget as a Mail search; its statements share
337
+ * it. A broad search on a large log, or one that runs before Core has built the search indexes, could
338
+ * otherwise read every record on every page.
339
+ */
340
+ const SEARCH_BUDGET_MS = 5_000;
341
+ const searchTooBroad = () =>
342
+ new OutgoingMailError("bad_input", "This search took too long. Narrow it with a longer term, fewer apps, or a shorter time range.");
343
+ const isStatementTimeout = (error: unknown) => {
344
+ const candidate = error as { code?: unknown; errno?: unknown } | null;
345
+ return candidate?.errno === "57014" || candidate?.code === "57014";
346
+ };
347
+ const withinSearchBudget = async <T>(read: (tx: SQL, limit: () => Promise<void>) => Promise<T>): Promise<T> => {
348
+ const deadline = Date.now() + SEARCH_BUDGET_MS;
349
+ try {
350
+ return await sql.begin((tx) =>
351
+ read(tx, async () => {
352
+ const remainingMs = Math.floor(deadline - Date.now());
353
+ if (remainingMs < 1) throw searchTooBroad();
354
+ await tx`SELECT set_config('statement_timeout', ${`${remainingMs}ms`}, true)`;
355
+ }),
356
+ );
357
+ } catch (error) {
358
+ throw isStatementTimeout(error) ? searchTooBroad() : error;
359
+ }
360
+ };
332
361
  const list = async (
333
- filter: AdminMailFilter,
362
+ filter: LogFilter,
334
363
  page: MailPageParams = {},
335
364
  metadata = false,
365
+ ownApp?: string,
336
366
  ): Promise<MailPage<MailRecord | AdminMailRecord>> => {
337
367
  const cursor = decodeMailCursor(page.cursor);
338
368
  const perPage = page.perPage ?? 50;
339
369
  const pageNumber = cursor?.page ?? page.page ?? 1;
340
- const where = sql`WHERE TRUE
341
- ${filter.app === undefined ? sql`` : sql`AND app_id = ${filter.app}`}
342
- ${filter.profile === undefined ? sql`` : sql`AND profile_key = ${filter.profile}`}
343
- ${filter.ids === undefined ? sql`` : sql`AND id = ANY(${toPgUuidArray(filter.ids)}::uuid[])`}
344
- ${filter.batchId === undefined ? sql`` : sql`AND batch_id = ${filter.batchId}::uuid`}
345
- ${filter.status === undefined ? sql`` : sql`AND status = ANY(${toPgTextArray(filter.status)}::text[])`}
346
- ${filter.since === undefined ? sql`` : sql`AND created_at >= ${filter.since}::timestamptz`}
347
- ${filter.ref === undefined ? sql`` : sql`AND ref_scope = ${filter.ref.scope}`}
348
- ${filter.ref?.id === undefined ? sql`` : sql`AND ref_id = ${filter.ref.id}`}
349
- ${filter.recipient === undefined ? sql`` : sql`AND EXISTS(SELECT 1 FROM unnest(to_addresses) AS address WHERE address ILIKE ${`%${escapeLikePattern(filter.recipient)}%`})`}`;
350
- const [count] = await sql<{ total: number }[]>`SELECT count(*)::int AS total FROM outgoing_mail.messages ${where}`;
351
- const rows = await sql<LogRow[]>`SELECT ${logColumns(sql, !metadata)} FROM outgoing_mail.messages ${where}
352
- ${cursor ? sql`AND (created_at < ${cursor.createdAt}::timestamptz OR (created_at = ${cursor.createdAt}::timestamptz AND id > ${cursor.id}::uuid))` : sql``}
353
- ORDER BY created_at DESC, id LIMIT ${perPage + 1} OFFSET ${cursor ? 0 : (pageNumber - 1) * perPage}`;
370
+ const where = (db: SQL) => db`WHERE TRUE
371
+ ${filter.apps === undefined ? db`` : db`AND app_id = ANY(${toPgTextArray(filter.apps)}::text[])`}
372
+ ${filter.app === undefined ? db`` : db`AND app_id = ${filter.app}`}
373
+ ${filter.profile === undefined ? db`` : db`AND profile_key = ${filter.profile}`}
374
+ ${filter.ids === undefined ? db`` : db`AND id = ANY(${toPgUuidArray(filter.ids)}::uuid[])`}
375
+ ${filter.batchId === undefined ? db`` : db`AND batch_id = ${filter.batchId}::uuid`}
376
+ ${filter.status === undefined ? db`` : db`AND status = ANY(${toPgTextArray(filter.status)}::text[])`}
377
+ ${filter.since === undefined ? db`` : db`AND created_at >= ${filter.since}::timestamptz`}
378
+ ${filter.until === undefined ? db`` : db`AND created_at < ${filter.until}::timestamptz`}
379
+ ${filter.q === undefined ? db`` : db`AND outgoing_mail.search_text(subject, to_addresses) ILIKE ${`%${escapeLikePattern(filter.q.toLowerCase())}%`}`}
380
+ ${filter.recipientExact === undefined ? db`` : db`AND outgoing_mail.lower_addresses(to_addresses) @> ARRAY[pg_catalog.lower(${filter.recipientExact})]`}
381
+ ${filter.ref === undefined ? db`` : db`AND ref_scope = ${filter.ref.scope}`}
382
+ ${filter.ref?.id === undefined ? db`` : db`AND ref_id = ${filter.ref.id}`}
383
+ ${filter.recipient === undefined ? db`` : db`AND EXISTS(SELECT 1 FROM unnest(to_addresses) AS address WHERE address ILIKE ${`%${escapeLikePattern(filter.recipient)}%`})`}`;
384
+ const read = async (db: SQL, limit: () => Promise<void>) => {
385
+ await limit();
386
+ const [count] = await db<{ total: number }[]>`SELECT count(*)::int AS total FROM outgoing_mail.messages ${where(db)}`;
387
+ await limit();
388
+ const rows = await db<LogRow[]>`SELECT ${logColumns(db, metadata ? undefined : ownApp)} FROM outgoing_mail.messages ${where(db)}
389
+ ${cursor ? db`AND (created_at < ${cursor.createdAt}::timestamptz OR (created_at = ${cursor.createdAt}::timestamptz AND id > ${cursor.id}::uuid))` : db``}
390
+ ORDER BY created_at DESC, id LIMIT ${perPage + 1} OFFSET ${cursor ? 0 : (pageNumber - 1) * perPage}`;
391
+ return { total: count?.total ?? 0, rows };
392
+ };
393
+ const { total, rows } =
394
+ filter.q === undefined && filter.recipientExact === undefined ? await read(sql, async () => {}) : await withinSearchBudget(read);
354
395
  const hasNext = rows.length > perPage;
355
396
  const items = rows.slice(0, perPage);
356
397
  const last = items.at(-1);
@@ -358,7 +399,7 @@ const list = async (
358
399
  items: items.map(metadata ? adminMessageRecord : messageRecord),
359
400
  page: pageNumber,
360
401
  perPage,
361
- total: count?.total ?? 0,
402
+ total,
362
403
  hasNext,
363
404
  ...(hasNext && last
364
405
  ? {
@@ -8,6 +8,9 @@ import {
8
8
  MailAppAccessSchema,
9
9
  type MailBounceErrorCode,
10
10
  type MailErrorCode,
11
+ type MailLogAccess,
12
+ MailLogAccessSchema,
13
+ MailLogAppIdSchema,
11
14
  type MailProfile,
12
15
  type MailProfileInput,
13
16
  MailProfileInputSchema,
@@ -22,7 +25,7 @@ export class OutgoingMailError extends Error {
22
25
  constructor(
23
26
  public readonly code: MailErrorCode,
24
27
  message: string,
25
- public readonly status: 400 | 404 | 409 | 502 = 400,
28
+ public readonly status: 400 | 403 | 404 | 409 | 502 = 400,
26
29
  ) {
27
30
  super(message);
28
31
  }
@@ -75,11 +78,12 @@ const locked = async <T>(run: (tx: SQL) => Promise<T>): Promise<T> =>
75
78
  await tx`SELECT pg_advisory_xact_lock(hashtextextended('outgoing_mail.policy', 0))`;
76
79
  return run(tx);
77
80
  });
78
- const record = (db: SQL, context: MailAuditContext, action: string, target: string) =>
81
+ const record = (db: SQL, context: MailAuditContext, action: string, target: string, metadata?: Record<string, unknown>) =>
79
82
  audit.record(
80
83
  {
81
84
  ...context,
82
85
  action: `outgoing_mail.${action}`,
86
+ ...(metadata ? { metadata } : {}),
83
87
  outcome: "allowed",
84
88
  target: { type: action.startsWith("profile.") ? "outgoing_mail_profile" : "outgoing_mail_app", id: target },
85
89
  },
@@ -241,7 +245,9 @@ const appsState = async (
241
245
  const registered = registeredApps ?? (await listApps());
242
246
  const { access, grants } = await accessRows(db);
243
247
  const [profile] = await db<{ key: string }[]>`SELECT key FROM outgoing_mail.profiles WHERE is_default`;
244
- const ids = new Set([...registered.map((app) => app.id), ...access.map((a) => a.app_id)]);
248
+ const logGrants = await db<{ app_id: string; source_app_id: string }[]>`
249
+ SELECT app_id, source_app_id FROM outgoing_mail.app_log_access ORDER BY source_app_id`;
250
+ const ids = new Set([...registered.map((app) => app.id), ...access.map((a) => a.app_id), ...logGrants.map((g) => g.app_id)]);
245
251
  const items = [...ids]
246
252
  .map((appId): AdminMailApp => {
247
253
  const app = registered.find((a) => a.id === appId);
@@ -251,6 +257,13 @@ const appsState = async (
251
257
  name: app?.name ?? appId,
252
258
  registered: !!app,
253
259
  declared: !!app?.platformPermissions?.includes("mail:send"),
260
+ readDeclared: appId !== "core" && !!app?.platformPermissions?.includes("mail:read"),
261
+ logApps:
262
+ appId === "core"
263
+ ? []
264
+ : logGrants
265
+ .filter((g) => g.app_id === appId && g.source_app_id !== "core" && g.source_app_id !== appId)
266
+ .map((g) => g.source_app_id),
254
267
  mode,
255
268
  profiles: mode === "selected" ? grants.filter((g) => g.app_id === appId).map((g) => g.key) : [],
256
269
  };
@@ -285,6 +298,41 @@ const setAppAccess = async (appId: string, value: MailAppAccess, context: MailAu
285
298
  name: appId,
286
299
  registered: false,
287
300
  declared: false,
301
+ readDeclared: false,
302
+ logApps: [],
303
+ mode: "default",
304
+ profiles: [],
305
+ }
306
+ );
307
+ });
308
+ };
309
+ const logAppsFor = async (appId: string): Promise<string[]> => {
310
+ if (appId === "core") return [];
311
+ const rows = await sql<{ source_app_id: string }[]>`SELECT source_app_id FROM outgoing_mail.app_log_access
312
+ WHERE app_id = ${appId} AND source_app_id <> 'core' AND source_app_id <> app_id ORDER BY source_app_id`;
313
+ return rows.map((row) => row.source_app_id);
314
+ };
315
+ const setAppLogAccess = async (appId: string, value: MailLogAccess, context: MailAuditContext): Promise<AdminMailApp> => {
316
+ const reader = MailLogAppIdSchema.safeParse(appId);
317
+ const parsed = MailLogAccessSchema.safeParse(value);
318
+ if (!reader.success || appId === "core" || !parsed.success || parsed.data.apps.includes(appId))
319
+ throw new OutgoingMailError("bad_input", "Choose a valid reader and distinct source apps other than Core or the reader itself.");
320
+ const apps = [...parsed.data.apps].sort();
321
+ const registered = await listApps();
322
+ return locked(async (tx) => {
323
+ await tx`DELETE FROM outgoing_mail.app_log_access WHERE app_id = ${appId}`;
324
+ await tx`INSERT INTO outgoing_mail.app_log_access(app_id, source_app_id, created_by)
325
+ SELECT ${appId}, source_app_id, ${context.actor.userId ?? null}
326
+ FROM unnest(${toPgTextArray(apps)}::text[]) AS source_app_id`;
327
+ await record(tx, context, "app_log_access.update", appId, { apps });
328
+ return (
329
+ (await appsState(tx, registered)).items.find((app) => app.appId === appId) ?? {
330
+ appId,
331
+ name: appId,
332
+ registered: false,
333
+ declared: false,
334
+ readDeclared: false,
335
+ logApps: [],
288
336
  mode: "default",
289
337
  profiles: [],
290
338
  }
@@ -319,7 +367,18 @@ const profilesForApp = async (appId: string): Promise<MailProfile[]> => {
319
367
  quota: { dailyRecipients: row.daily_recipient_limit, usedLast24h: row.used },
320
368
  }));
321
369
  };
322
- export const outgoingMailStore = { list, get, put, setDefault, delete: remove, apps: appsState, setAppAccess, profilesForApp };
370
+ export const outgoingMailStore = {
371
+ list,
372
+ get,
373
+ put,
374
+ setDefault,
375
+ delete: remove,
376
+ apps: appsState,
377
+ setAppAccess,
378
+ setAppLogAccess,
379
+ logAppsFor,
380
+ profilesForApp,
381
+ };
323
382
 
324
383
  /** Platform email send path only. Never use this helper to shape API or CLI responses. */
325
384
  export const resolveMailCredentials = async (key?: string | { id: string }) => {
@@ -65,6 +65,7 @@ const UNSAFE_IPV6_RANGES: ReadonlyArray<readonly [bigint, number]> = [
65
65
  [ipv6ToBigInt("2001:2::")!, 48],
66
66
  [ipv6ToBigInt("2001:db8::")!, 32],
67
67
  [ipv6ToBigInt("2002::")!, 16],
68
+ [ipv6ToBigInt("3fff::")!, 20],
68
69
  [ipv6ToBigInt("fc00::")!, 7],
69
70
  [ipv6ToBigInt("fe80::")!, 10],
70
71
  [ipv6ToBigInt("fec0::")!, 10],
@@ -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) {
@@ -1,4 +1,4 @@
1
- import { type RailPreferences, type RailSnapshot, RailSnapshotSchema } from "../contracts/rail-preferences";
1
+ import type { RailPreferences, RailSnapshot } from "../contracts/rail-preferences";
2
2
  import type { RailApp } from "./rail-navigation";
3
3
 
4
4
  export type RailContext = { apps: RailApp[]; settings: RailSnapshot };
@@ -8,9 +8,10 @@ export const readRailContext = (): RailContext | undefined => {
8
8
  if (typeof document === "undefined") return undefined;
9
9
  const text = document.getElementById("cloud-rail-data")?.textContent;
10
10
  if (!text) return undefined;
11
- // Framework-generated, escaped SSR data; never loaded from localStorage.
11
+ // Framework-generated, escaped SSR data; never loaded from localStorage. The server validates the snapshot when
12
+ // it reads it, so the shell does not load the schemas on every page.
12
13
  const value: RailContext = JSON.parse(text);
13
- return { apps: value.apps, settings: RailSnapshotSchema.parse(value.settings) };
14
+ return { apps: value.apps, settings: value.settings };
14
15
  };
15
16
 
16
17
  export const publishRailPreferences = (settings: RailPreferences) => {
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import type { CloudRuntime } from "../contracts/app";
6
6
  import { getLocale } from "../server/locale";
7
- import { resolveAppPresentations } from "../shared/app-presentation";
7
+ import { resolveAppPresentation, resolveAppPresentations } from "../shared/app-presentation";
8
8
 
9
9
  export type RuntimeContext = CloudRuntime;
10
10
 
@@ -29,3 +29,16 @@ export const getLocalizedRuntimeContext = (carrier: RuntimeCarrier & { req: { ra
29
29
  const locale = getLocale(carrier as Parameters<typeof getLocale>[0]);
30
30
  return { ...runtime, apps: resolveAppPresentations(runtime.apps, locale) };
31
31
  };
32
+
33
+ /** An app's name in the request locale while the app is registered; `fallback` (its stored base name) otherwise. */
34
+ export const localizedAppName = (
35
+ carrier: RuntimeCarrier & { req: { raw: { headers: Headers } } },
36
+ appId: string,
37
+ fallback: string,
38
+ ): string => {
39
+ const runtime = carrier.get("runtime");
40
+ const apps =
41
+ runtime && typeof runtime === "object" && Array.isArray((runtime as RuntimeContext).apps) ? (runtime as RuntimeContext).apps : [];
42
+ const app = apps.find((entry) => entry.id === appId);
43
+ return app ? resolveAppPresentation(app, getLocale(carrier as Parameters<typeof getLocale>[0])).name : fallback;
44
+ };