@k2b/cloud 0.39.0 → 0.41.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 (237) hide show
  1. package/README.md +8 -0
  2. package/package.json +11 -4
  3. package/src/_internal/app-favicon.ts +4 -2
  4. package/src/_internal/capabilities.ts +72 -48
  5. package/src/_internal/capability-reviews.ts +81 -0
  6. package/src/_internal/capability-schemas.ts +29 -0
  7. package/src/_internal/capability-sentences.ts +217 -1
  8. package/src/_internal/capability-streams.ts +7 -0
  9. package/src/_internal/define-app.ts +49 -18
  10. package/src/_internal/font-preloads.ts +2 -1
  11. package/src/_internal/heartbeat.ts +203 -14
  12. package/src/_internal/registry.ts +28 -1
  13. package/src/_internal/runtime-watcher.ts +6 -0
  14. package/src/_internal/setup-lock.ts +103 -0
  15. package/src/_internal/static-assets.ts +52 -3
  16. package/src/_internal/status-preserving-ssr.ts +2 -0
  17. package/src/access/PermissionEditor.tsx +2 -2
  18. package/src/account/EntitySearch.tsx +1 -1
  19. package/src/ai/action-review.ts +101 -0
  20. package/src/ai/background-review.ts +72 -0
  21. package/src/ai/capabilities.ts +29 -11
  22. package/src/ai/capability-execution.ts +76 -43
  23. package/src/ai/capability-result.ts +95 -0
  24. package/src/ai/chat/action-card.ts +464 -0
  25. package/src/ai/chat/action-messages.ts +129 -0
  26. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  27. package/src/ai/chat/action-question.fixtures.ts +733 -0
  28. package/src/ai/chat/action-question.tsx +680 -0
  29. package/src/ai/chat/action-text.ts +288 -0
  30. package/src/ai/chat/blocks.tsx +19 -25
  31. package/src/ai/chat/message-actions.tsx +6 -0
  32. package/src/ai/chat/tool-action-words.ts +31 -0
  33. package/src/ai/chat/tool-action.tsx +456 -0
  34. package/src/ai/chat/turn-view.tsx +10 -160
  35. package/src/ai/chat/view-block.tsx +15 -5
  36. package/src/ai/chat-task-contracts.ts +4 -1
  37. package/src/ai/chat-task-reasons-migrate.ts +7 -0
  38. package/src/ai/chat-task-reasons.ts +1 -1
  39. package/src/ai/chat-tasks.ts +55 -25
  40. package/src/ai/client/controller.ts +59 -0
  41. package/src/ai/default-tools.ts +9 -1
  42. package/src/ai/executor.ts +198 -39
  43. package/src/ai/firecrawl-tools.ts +15 -2
  44. package/src/ai/index.ts +3 -0
  45. package/src/ai/migrate.ts +26 -2
  46. package/src/ai/open-questions.ts +172 -0
  47. package/src/ai/projects-routes.ts +3 -0
  48. package/src/ai/protocol.ts +14 -2
  49. package/src/ai/question-text.ts +8 -0
  50. package/src/ai/recent-resources.ts +240 -0
  51. package/src/ai/recent-sent-refs.ts +38 -0
  52. package/src/ai/routes.ts +195 -5
  53. package/src/ai/runtime.ts +1 -0
  54. package/src/ai/skills-routes.ts +3 -0
  55. package/src/ai/skills.ts +10 -1
  56. package/src/ai/store.ts +245 -24
  57. package/src/ai/testing.ts +36 -0
  58. package/src/ai/tool-audit.ts +8 -0
  59. package/src/ai/tool-sentences.ts +152 -34
  60. package/src/ai/tool-wording.ts +36 -8
  61. package/src/ai/tools.ts +8 -3
  62. package/src/ai/types.ts +43 -4
  63. package/src/api/accounts-entities.ts +2 -1
  64. package/src/api/admin-ai-projects.ts +2 -0
  65. package/src/api/admin-ai-skills.ts +2 -0
  66. package/src/api/admin-ai-usage.ts +2 -0
  67. package/src/api/admin-geoip.ts +80 -0
  68. package/src/api/admin-identity.ts +3 -0
  69. package/src/api/admin-lifecycle.ts +3 -0
  70. package/src/api/admin-outgoing-mail.ts +2 -0
  71. package/src/api/app-approval-actions.ts +31 -7
  72. package/src/api/app-approval.ts +46 -5
  73. package/src/api/approvals-loader.ts +23 -1
  74. package/src/api/approvals.ts +2 -0
  75. package/src/api/auth/schemas.ts +3 -0
  76. package/src/api/auth.ts +67 -12
  77. package/src/api/capabilities.ts +44 -11
  78. package/src/api/capability-streams.ts +12 -0
  79. package/src/api/help.ts +4 -28
  80. package/src/api/index.ts +6 -1
  81. package/src/api/lasting-access-messages.ts +17 -0
  82. package/src/api/mcp.ts +30 -3
  83. package/src/api/me-panel-consent.ts +71 -0
  84. package/src/api/me.ts +159 -36
  85. package/src/api/search.ts +3 -1
  86. package/src/api/sync-ops.ts +2 -0
  87. package/src/api/widgets.ts +3 -0
  88. package/src/approvals/Approvals.tsx +166 -262
  89. package/src/approvals/approval-card.ts +68 -0
  90. package/src/approvals/messages.ts +12 -24
  91. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  92. package/src/browser/app-approval.ts +54 -5
  93. package/src/browser/commands.ts +22 -6
  94. package/src/browser/composer-catalog.ts +45 -3
  95. package/src/browser/composer-insert.ts +10 -0
  96. package/src/browser/composer-open-messages.ts +17 -0
  97. package/src/browser/composer-open.ts +110 -0
  98. package/src/browser/discoverable-command.ts +6 -0
  99. package/src/browser/hints.ts +6 -0
  100. package/src/browser/live-connection.ts +26 -2
  101. package/src/browser/navigation-apps.ts +7 -0
  102. package/src/browser/panel-context-bridge.ts +30 -0
  103. package/src/browser/panel-context-receiver.ts +53 -0
  104. package/src/browser/panel-menu.tsx +56 -0
  105. package/src/browser/panel-messages.ts +11 -0
  106. package/src/browser/panel-shortcuts.ts +31 -0
  107. package/src/browser/panel-slot.ts +412 -10
  108. package/src/browser/panel.ts +37 -0
  109. package/src/browser/search-commands.ts +6 -3
  110. package/src/capabilities/client.ts +8 -2
  111. package/src/capabilities/reviews.ts +205 -0
  112. package/src/capabilities/server.ts +10 -3
  113. package/src/capabilities/testing.ts +47 -0
  114. package/src/capabilities/types.ts +2 -2
  115. package/src/cli/account.ts +13 -3
  116. package/src/cli/admin/geoip.ts +115 -0
  117. package/src/cli/admin/index.ts +3 -0
  118. package/src/contracts/app-approval.ts +123 -16
  119. package/src/contracts/approvals.ts +2 -2
  120. package/src/contracts/capabilities.ts +410 -18
  121. package/src/contracts/geoip.ts +42 -0
  122. package/src/contracts/index.ts +3 -0
  123. package/src/contracts/panel-context-plain.ts +99 -0
  124. package/src/contracts/panel-context.ts +93 -0
  125. package/src/contracts/seen-hints.ts +35 -0
  126. package/src/contracts/shared.ts +3 -0
  127. package/src/server/client-ip.ts +5 -0
  128. package/src/server/help-payload.ts +38 -0
  129. package/src/server/middleware/auth.ts +13 -1
  130. package/src/server/middleware/middleware.ts +9 -0
  131. package/src/server/middleware/no-transform.ts +19 -0
  132. package/src/server/middleware/rate-limit.ts +4 -6
  133. package/src/server/request-context/address.ts +45 -0
  134. package/src/server/request-context/geoip.ts +380 -0
  135. package/src/server/request-context/index.ts +33 -0
  136. package/src/server/request-context/user-agent.ts +95 -0
  137. package/src/services/accounts/app.ts +23 -3
  138. package/src/services/accounts/users.ts +12 -1
  139. package/src/services/app-approval.ts +382 -59
  140. package/src/services/approvals/device.ts +49 -31
  141. package/src/services/approvals/index.ts +7 -5
  142. package/src/services/approvals/list.ts +27 -6
  143. package/src/services/approvals/question.ts +68 -0
  144. package/src/services/audit/index.ts +1 -0
  145. package/src/services/auth-flows/ipa.ts +39 -2
  146. package/src/services/auth-flows/notification-sender.ts +3 -0
  147. package/src/services/auth-flows/password-reset.ts +32 -6
  148. package/src/services/geoip/index.ts +164 -0
  149. package/src/services/geoip/store.ts +30 -0
  150. package/src/services/geoip/upload.ts +150 -0
  151. package/src/services/identity/routes.ts +5 -1
  152. package/src/services/index.ts +13 -0
  153. package/src/services/known-browsers.ts +312 -0
  154. package/src/services/login-address-store.ts +51 -0
  155. package/src/services/login-context.ts +118 -0
  156. package/src/services/mandates/index.ts +3 -0
  157. package/src/services/mandates/policy.ts +15 -3
  158. package/src/services/notifications/email-frame.ts +1 -1
  159. package/src/services/oauth-tokens.ts +16 -2
  160. package/src/services/panel-context-consent.ts +58 -0
  161. package/src/services/pdf/markdown.ts +160 -2
  162. package/src/services/service-account-credentials.ts +21 -0
  163. package/src/services/session/index.ts +296 -41
  164. package/src/services/session/recent.ts +13 -0
  165. package/src/services/webauthn.ts +12 -0
  166. package/src/shared/email-html.ts +2 -0
  167. package/src/shared/help.ts +3 -0
  168. package/src/shared-text/cursor.ts +22 -0
  169. package/src/shared-text/documents.ts +142 -0
  170. package/src/shared-text/events.ts +43 -0
  171. package/src/shared-text/index.ts +12 -0
  172. package/src/shared-text/log.ts +48 -0
  173. package/src/shared-text/text-diff.ts +23 -0
  174. package/src/shared-text/types.ts +41 -0
  175. package/src/ssr/AdminLayout.tsx +2 -0
  176. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  177. package/src/ssr/CloudShell.island.tsx +10 -2
  178. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  179. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  180. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  181. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  182. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  183. package/src/ssr/HelpPanelView.island.tsx +27 -0
  184. package/src/ssr/Layout.tsx +25 -2
  185. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  186. package/src/ssr/LayoutHelp.tsx +222 -419
  187. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +135 -0
  188. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  189. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  190. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  191. package/src/ssr/MobileNavigation.tsx +2 -2
  192. package/src/ssr/PanelLayout.tsx +9 -1
  193. package/src/ssr/PanelRuntime.island.tsx +7 -0
  194. package/src/ssr/PanelSlot.tsx +53 -3
  195. package/src/ssr/ShellRuntime.island.tsx +11 -2
  196. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  197. package/src/ssr/app-color.ts +14 -9
  198. package/src/ssr/app-navigation.ts +16 -3
  199. package/src/ssr/compact-shell.browser-harness.ts +30 -22
  200. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  201. package/src/ssr/help-messages.ts +4 -0
  202. package/src/ssr/help.ts +21 -0
  203. package/src/ssr/hints.ts +25 -0
  204. package/src/ssr/index.ts +3 -0
  205. package/src/ssr/layout-context.ts +2 -0
  206. package/src/ssr/layout-help-article.server.ts +50 -0
  207. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  208. package/src/ssr/layout-help-panel-document.ts +67 -0
  209. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  210. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  211. package/src/ssr/layout-help-panel-modules.ts +7 -0
  212. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  213. package/src/ssr/layout-help-panel-search.tsx +69 -0
  214. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  215. package/src/ssr/layout-help-panel-state.ts +141 -0
  216. package/src/ssr/layout-help-panel-types.ts +19 -0
  217. package/src/ssr/layout-help-start.ts +33 -7
  218. package/src/ssr/layout-help-views.tsx +484 -0
  219. package/src/ssr/panel-consent.ts +29 -0
  220. package/src/ssr/panel-context.ts +10 -0
  221. package/src/ssr/panel-idle.ts +9 -0
  222. package/src/ssr/panel-slot.browser-harness.tsx +53 -4
  223. package/src/ssr/platform-messages.ts +18 -0
  224. package/src/ssr/shell-data.tsx +2 -1
  225. package/src/styles/approvals.css +6 -0
  226. package/src/styles/effects.css +213 -9
  227. package/src/styles/file-chooser.css +2 -1
  228. package/src/styles/global.css +49 -12
  229. package/src/styles/panel.css +47 -4
  230. package/src/styles/resource-search.css +6 -2
  231. package/src/styles/tokens.css +17 -11
  232. package/src/styles/utilities-buttons.css +3 -2
  233. package/src/styles/utilities-data.css +2 -1
  234. package/src/styles/utilities-feedback.css +2 -2
  235. package/src/styles/utilities-layout.css +3 -13
  236. package/src/styles/utilities-markdown-editor.css +6 -6
  237. package/src/styles/utilities-navigation.css +17 -22
@@ -21,27 +21,21 @@ export const approvalMessages = i18n.define({
21
21
  allApprovals: "All approvals",
22
22
  fromChat: ({ title }: { title: string }) => `from chat “${title}”`,
23
23
  fromUntitledChat: "from a chat",
24
- asksEveryTime: "Asks every time",
25
24
  expiresIn: ({ time }: { time: string }) => `Expires ${time}`,
26
25
  details: "Details",
27
26
  reject: "Reject",
28
27
  approve: "Approve",
29
- approveForChat: "Approve for this chat",
30
- allowWebsiteForChat: "Allow this website in this chat",
31
- alwaysApprove: "Always approve",
32
- moreOptions: "More approval options",
33
- decidedHere: "Just now, decided here",
34
- notApplied: "Your decision was not applied",
28
+ notApplied: "Your decision was not applied.",
29
+ sending: "Sending your answer …",
30
+ approvedHere: "Approved.",
31
+ continuesInChat: "The chat it came from carries it out and shows how it ends.",
32
+ declinedHere: "You declined.",
33
+ answerAgain: "Answer again",
35
34
  approved: "Approved",
36
35
  rejected: "Rejected",
37
36
  decidedElsewhere: "Already decided elsewhere",
38
37
  gone: "Expired or withdrawn",
39
38
  decisionFailed: "The decision was not saved. Try again.",
40
- openIn: ({ app }: { app: string }) => `Open in ${app}`,
41
- editIn: ({ app }: { app: string }) => `Edit in ${app}`,
42
- statusIn: ({ app }: { app: string }) => `Status in ${app}`,
43
- preview: "Preview",
44
- download: "Download",
45
39
  exactlyThis: "This approval applies to exactly this Action. If it changes before it runs, Cloud asks again.",
46
40
  approvalFor: ({ title }: { title: string }) => `Approval: ${title}`,
47
41
  close: "Close",
@@ -65,27 +59,21 @@ export const approvalMessages = i18n.define({
65
59
  allApprovals: "Alle Freigaben",
66
60
  fromChat: ({ title }) => `aus Chat „${title}“`,
67
61
  fromUntitledChat: "aus einem Chat",
68
- asksEveryTime: "Fragt immer",
69
62
  expiresIn: ({ time }) => `Läuft ab ${time}`,
70
63
  details: "Details",
71
64
  reject: "Ablehnen",
72
65
  approve: "Freigeben",
73
- approveForChat: "Für diesen Chat freigeben",
74
- allowWebsiteForChat: "Diese Website in diesem Chat erlauben",
75
- alwaysApprove: "Immer freigeben",
76
- moreOptions: "Weitere Freigabeoptionen",
77
- decidedHere: "Gerade eben, hier entschieden",
78
- notApplied: "Deine Entscheidung wurde nicht übernommen",
66
+ notApplied: "Deine Entscheidung wurde nicht übernommen.",
67
+ sending: "Deine Antwort wird gesendet …",
68
+ approvedHere: "Freigegeben.",
69
+ continuesInChat: "Der Chat, aus dem sie kommt, führt sie aus und zeigt, wie sie endet.",
70
+ declinedHere: "Du hast abgelehnt.",
71
+ answerAgain: "Noch einmal antworten",
79
72
  approved: "Freigegeben",
80
73
  rejected: "Abgelehnt",
81
74
  decidedElsewhere: "Schon anderswo entschieden",
82
75
  gone: "Abgelaufen oder zurückgezogen",
83
76
  decisionFailed: "Die Entscheidung wurde nicht gespeichert. Versuche es erneut.",
84
- openIn: ({ app }) => `In ${app} öffnen`,
85
- editIn: ({ app }) => `In ${app} bearbeiten`,
86
- statusIn: ({ app }) => `Status in ${app}`,
87
- preview: "Vorschau",
88
- download: "Herunterladen",
89
77
  exactlyThis: "Diese Freigabe gilt genau für diese Aktion. Ändert sie sich, bevor sie läuft, fragt Cloud neu.",
90
78
  approvalFor: ({ title }) => `Freigabe: ${title}`,
91
79
  close: "Schließen",
@@ -1,3 +1,4 @@
1
+ import { appAccentStyle } from "../ssr/app-appearance";
1
2
  import { createGlobalSearchHost } from "../ssr/global-search-host";
2
3
  import { openCloudResourcePicker } from "./resource-picker";
3
4
 
@@ -7,5 +8,12 @@ import { openCloudResourcePicker } from "./resource-picker";
7
8
  */
8
9
  const params = new URLSearchParams(location.search);
9
10
  const app = params.get("app") ?? undefined;
10
- if (params.has("picker")) void openCloudResourcePicker({ initialAppId: app });
11
- else createGlobalSearchHost(() => []).open(app ? { scope: { appId: app, label: params.get("label") ?? app } } : {});
11
+ if (params.has("picker")) {
12
+ // The page shell renders `<body class="k2b-ui app-accent-scope" style="…">` for an app page.
13
+ const style = appAccentStyle(params.get("accent") ?? undefined);
14
+ if (style) {
15
+ document.body.classList.add("app-accent-scope");
16
+ document.body.style.cssText = style;
17
+ }
18
+ void openCloudResourcePicker({ initialAppId: app });
19
+ } else createGlobalSearchHost(() => []).open(app ? { scope: { appId: app, label: params.get("label") ?? app } } : {});
@@ -7,11 +7,19 @@ import {
7
7
  APP_ACTION_LIMITS,
8
8
  APP_APPROVAL_LIMITS,
9
9
  APP_APPROVAL_PATH,
10
+ APP_APPROVAL_PENDING_ACCEPT,
10
11
  APP_APPROVAL_PROTOCOL,
11
12
  type AppActionView,
12
13
  AppActionViewSchema,
13
14
  type AppApprovalAccept,
15
+ type AppApprovalPendingAccept,
16
+ type AppDeviceChange,
17
+ AppDeviceChangeSchema,
14
18
  AppDeviceCommandSchema,
19
+ type AppDeviceInbox,
20
+ AppDeviceInboxSchema,
21
+ type AppDeviceInboxWithChanges,
22
+ AppDeviceInboxWithChangesSchema,
15
23
  AppDeviceProofSchema,
16
24
  type AppDevicePublicKey,
17
25
  AppDevicePublicKeySchema,
@@ -197,7 +205,8 @@ const connect = async (options: {
197
205
  );
198
206
  },
199
207
  pairingResult: (payload: AppPairingPayload, key: AppApprovalKey, signal?: AbortSignal) => {
200
- const p = pairing(payload);
208
+ const p = AppPairingPayloadSchema.parse(payload);
209
+ if (p.issuer !== issuer) throw new AppApprovalClientError("INVALID_INPUT");
201
210
  return request(
202
211
  "/pairings/result",
203
212
  AppPairingResultSchema,
@@ -205,8 +214,10 @@ const connect = async (options: {
205
214
  signal,
206
215
  );
207
216
  },
208
- pending: async (device: AppApprovalDevice, signal?: AbortSignal) => {
209
- const result = await command(device, { operation: "pending" }, signal);
217
+ pending: async (device: AppApprovalDevice, signal?: AbortSignal, options?: { accept?: AppApprovalPendingAccept[] }) => {
218
+ // Untyped callers may reuse their inbox list; drop what pending does not serve instead of failing every poll.
219
+ const accept = options?.accept?.filter((entry) => APP_APPROVAL_PENDING_ACCEPT.includes(entry) && info.features?.includes(entry));
220
+ const result = await command(device, { operation: "pending", ...(accept?.length ? { accept } : {}) }, signal);
210
221
  if (!("requests" in result)) throw new AppApprovalClientError("INVALID_RESPONSE");
211
222
  return result;
212
223
  },
@@ -226,12 +237,46 @@ const connect = async (options: {
226
237
  actions: info.features?.includes("actions") === true,
227
238
  /** Whether this Cloud can include terminal consent in an explicitly opted-in inbox. */
228
239
  terminalLogins: info.features?.includes("terminal-logins") === true,
240
+ /** Whether this Cloud serves sign-in context through a signed opt-in. */
241
+ loginContext: info.features?.includes("login-context") === true,
242
+ /** Whether this Cloud lists requests to pair or remove a device in an inbox that accepts `device-changes`. */
243
+ deviceChanges: info.features?.includes("device-changes") === true,
229
244
  /** Pending sign-ins and the newest open Action approvals in one call; use it instead of `pending` where `actions` is true. */
230
- inbox: async (device: AppApprovalDevice, signal?: AbortSignal, options?: { accept?: AppApprovalAccept[] }) => {
245
+ inbox: async (
246
+ device: AppApprovalDevice,
247
+ signal?: AbortSignal,
248
+ options?: { accept?: AppApprovalAccept[] },
249
+ ): Promise<AppDeviceInbox | AppDeviceInboxWithChanges> => {
231
250
  // SDK upgrades alone must not expose consent the app cannot show. Old Clouds never see the new key.
232
251
  const accept = options?.accept?.filter((entry) => info.features?.includes(entry));
233
252
  const result = await command(device, { operation: "inbox", ...(accept?.length ? { accept } : {}) }, signal);
234
- if (!("actions" in result)) throw new AppApprovalClientError("INVALID_RESPONSE");
253
+ if (!("actions" in result) || (accept?.includes("device-changes") && (!("changes" in result) || !("moreChanges" in result))))
254
+ throw new AppApprovalClientError("INVALID_RESPONSE");
255
+ return "changes" in result ? AppDeviceInboxWithChangesSchema.parse(result) : AppDeviceInboxSchema.parse(result);
256
+ },
257
+ /**
258
+ * Signs a decision on one entry of `changes`, a request to pair or remove a device of this account. Only call it on an
259
+ * explicit choice of the person. The device being removed can approve its own removal; its `deny` throws
260
+ * `AppApprovalClientError` with code `HTTP` and status 409.
261
+ */
262
+ decideChange: async (device: AppApprovalDevice, change: AppDeviceChange, decision: "approve" | "deny", signal?: AbortSignal) => {
263
+ const parsed = AppDeviceChangeSchema.safeParse(change);
264
+ if (!parsed.success || Date.parse(parsed.data.expiresAt) <= Date.now()) throw new AppApprovalClientError("INVALID_INPUT");
265
+ change = parsed.data;
266
+ const result = await command(
267
+ device,
268
+ {
269
+ operation: "change",
270
+ changeId: change.changeId,
271
+ kind: change.kind,
272
+ targetId: change.device.id,
273
+ endSessions: change.endSessions,
274
+ decision,
275
+ },
276
+ signal,
277
+ );
278
+ if (!("state" in result) || result.state !== (decision === "approve" ? "approved" : "denied"))
279
+ throw new AppApprovalClientError("INVALID_RESPONSE");
235
280
  return result;
236
281
  },
237
282
  /** One open Action approval. The digest is recomputed here, so a view that does not match it is never returned. */
@@ -263,6 +308,10 @@ const connect = async (options: {
263
308
  if (!("state" in result) || result.state !== "updated") throw new AppApprovalClientError("INVALID_RESPONSE");
264
309
  return result;
265
310
  },
311
+ /**
312
+ * Removes this device itself at once, without approval from a paired device. Sign-ins it approved stay signed in.
313
+ * Removing a device from Cloud in the browser waits for a paired device to approve instead.
314
+ */
266
315
  revoke: async (device: AppApprovalDevice, signal?: AbortSignal) => {
267
316
  const result = await command(device, { operation: "revoke" }, signal);
268
317
  if (!("state" in result) || result.state !== "revoked") throw new AppApprovalClientError("INVALID_RESPONSE");
@@ -14,17 +14,33 @@ export type { CommandOptions } from "../contracts/commands";
14
14
  export type { CommandTarget, ContextAwareCommand } from "./command-bridge";
15
15
  export { registerCommandHandler, registerContextAwareCommand } from "./command-bridge";
16
16
 
17
- /** Opens locally only when a mounted owner explicitly accepts; otherwise follows the app's URL. */
18
- export const openCommand = async (command: string, input: unknown = {}, options: CommandOptions = {}): Promise<void> => {
17
+ /**
18
+ * Opens locally only when a mounted owner accepts; otherwise follows the app's URL.
19
+ * Resolves true after the owner's handler resolves or navigation starts.
20
+ * Resolves false if code cannot load (with a reload notice) or `signal` aborts before handling or while resolving the link.
21
+ * An accepting owner takes over without the signal. Handler rejections and link resolution failures still reject.
22
+ */
23
+ export const openCommand = async (
24
+ command: string,
25
+ input: unknown = {},
26
+ options: CommandOptions = {},
27
+ signal?: AbortSignal,
28
+ ): Promise<boolean> => {
19
29
  const modules = await importOnDemand(() => Promise.all([import("../contracts/commands"), import("../capabilities/client")]));
20
- if (!modules) return;
30
+ if (!modules || signal?.aborted) return false;
21
31
  const [contracts, capabilities] = modules;
22
32
  const parsedOptions = contracts.CommandOptionsSchema.parse(options);
23
33
  const handled = requestCommandHandling({ command, input, options: parsedOptions });
24
- if (handled) return handled;
25
- const result = await capabilities.resolveCommand(command, input, parsedOptions);
26
- if (!result.ok) throw new Error(result.error.message);
34
+ if (handled) {
35
+ await handled;
36
+ return true;
37
+ }
38
+ // An abort also ends the request, so a cancelled opening does not wait for its answer.
39
+ const result = await capabilities.resolveCommand(command, input, parsedOptions, { signal });
40
+ if (signal?.aborted) return false;
41
+ if (!result.ok) throw new Error(result.error.message, { cause: result.error });
27
42
  window.location.assign(result.data.href);
43
+ return true;
28
44
  };
29
45
 
30
46
  /** Call after this page's handlers have mounted. Each entry is consumed, including invalid links. */
@@ -2,6 +2,8 @@ import type { ChatCommandSearch } from "@k2b/ui";
2
2
  import { createSignal } from "solid-js";
3
3
  import { listCapabilityCatalog } from "../capabilities/client";
4
4
  import { LOCALE_HEADER } from "../shared/locale";
5
+ import { discoverableCommandInput } from "./discoverable-command";
6
+ import { navigationApps } from "./navigation-apps";
5
7
 
6
8
  export type CloudSearchTag = {
7
9
  appId: string;
@@ -13,12 +15,25 @@ export type CloudSearchTag = {
13
15
  aliases: readonly string[];
14
16
  };
15
17
 
18
+ export type CloudComposerCommand = {
19
+ id: string;
20
+ appId: string;
21
+ appName: string;
22
+ appIcon: string;
23
+ title: string;
24
+ description: string;
25
+ icon: string;
26
+ keywords: readonly string[];
27
+ input: unknown;
28
+ };
29
+
16
30
  /**
17
31
  * What composers read from the page's one Capability catalog load. It leaves out the tags of apps without a resource
18
32
  * reader: a picker that requires one can show none of their results. A composer that reads such a type itself adds its
19
33
  * tag on its own and names the type in `cloudInsertMenu`'s `ownReaders`.
20
34
  */
21
- export type CloudComposerCatalog = { tags: readonly CloudSearchTag[] };
35
+ export type CloudComposerCatalog = { tags: readonly CloudSearchTag[]; commands: readonly CloudComposerCommand[] };
36
+ export type CloudComposerCatalogState = "loading" | "ready" | "failed";
22
37
 
23
38
  /** The budget of one Capability query (and of resolving a Command), applied to each catalog page. */
24
39
  const CATALOG_PAGE_TIMEOUT_MS = 15_000;
@@ -27,6 +42,8 @@ const catalogs = new Map<string, Promise<CloudComposerCatalog>>();
27
42
 
28
43
  const loadCatalog = async (locale: string): Promise<CloudComposerCatalog> => {
29
44
  const tags: CloudSearchTag[] = [];
45
+ const commands: CloudComposerCommand[] = [];
46
+ const { appIds, currentAppId } = navigationApps();
30
47
  const seen = new Set<string>();
31
48
  let cursor: string | undefined;
32
49
  do {
@@ -39,6 +56,23 @@ const loadCatalog = async (locale: string): Promise<CloudComposerCatalog> => {
39
56
  });
40
57
  if (!result.ok) throw new Error(result.error.message);
41
58
  for (const app of result.data.apps) {
59
+ if (app.appId !== currentAppId) {
60
+ for (const command of app.manifest.commands) {
61
+ const input = discoverableCommandInput(command, app.appId, appIds);
62
+ if (!input?.success) continue;
63
+ commands.push({
64
+ id: `${app.appId}.${command.localId}`,
65
+ appId: app.appId,
66
+ appName: app.appName,
67
+ appIcon: app.appIcon,
68
+ title: command.title,
69
+ description: command.description,
70
+ icon: command.icon ?? app.appIcon,
71
+ keywords: command.keywords,
72
+ input: input.data,
73
+ });
74
+ }
75
+ }
42
76
  if (!app.manifest.types.some((type) => type.reader)) continue;
43
77
  for (const query of app.manifest.queries) {
44
78
  for (const tag of query.universalSearch?.tags ?? []) {
@@ -53,7 +87,7 @@ const loadCatalog = async (locale: string): Promise<CloudComposerCatalog> => {
53
87
  if (next && cursor && next <= cursor) throw new Error("Invalid Composer catalog cursor");
54
88
  cursor = next;
55
89
  } while (cursor);
56
- return { tags };
90
+ return { tags, commands };
57
91
  };
58
92
 
59
93
  /**
@@ -72,7 +106,7 @@ export const loadCloudComposerCatalog = (locale: string): Promise<CloudComposerC
72
106
  };
73
107
 
74
108
  /**
75
- * The search tags of one composer. Call `ready` at the start of every `searchCommands` run: each menu opening starts
109
+ * The search tags, Commands, and load state of one composer. Call `ready` at the start of every `searchCommands` run: each menu opening starts
76
110
  * the shared load once, so only the first opening, or one after a failure, sends a request, and no keystroke does.
77
111
  * A search with typed text and no scope waits for that answer, so "/notiz" never settles as "nothing found" before its
78
112
  * tag arrives. The overview and scoped searches do not wait, and after a failed load no search waits again; a later
@@ -80,20 +114,28 @@ export const loadCloudComposerCatalog = (locale: string): Promise<CloudComposerC
80
114
  */
81
115
  export function createCloudComposerCatalog(load: (locale: string) => Promise<CloudComposerCatalog> = loadCloudComposerCatalog) {
82
116
  const [tags, setTags] = createSignal<readonly CloudSearchTag[]>([]);
117
+ const [commands, setCommands] = createSignal<readonly CloudComposerCommand[]>([]);
118
+ const [state, setState] = createSignal<CloudComposerCatalogState>("loading");
83
119
  let opening: number | undefined;
84
120
  let settled: Promise<void> = Promise.resolve();
85
121
  let failed = false;
86
122
  return {
87
123
  tags,
124
+ commands,
125
+ state,
88
126
  ready(query: string, search: ChatCommandSearch, locale: string): Promise<void> {
89
127
  if (opening !== search.opening) {
90
128
  opening = search.opening;
129
+ if (state() === "failed") setState("loading");
91
130
  settled = load(locale).then(
92
131
  (catalog) => {
93
132
  setTags(catalog.tags);
133
+ setCommands(catalog.commands);
134
+ setState("ready");
94
135
  },
95
136
  () => {
96
137
  failed = true;
138
+ setState("failed");
97
139
  },
98
140
  );
99
141
  }
@@ -6,11 +6,21 @@ import { type CloudResourcePickerItem, openCloudResourcePicker } from "./resourc
6
6
 
7
7
  export {
8
8
  type CloudComposerCatalog,
9
+ type CloudComposerCatalogState,
10
+ type CloudComposerCommand,
9
11
  type CloudSearchTag,
10
12
  createCloudComposerCatalog,
11
13
  loadCloudComposerCatalog,
12
14
  } from "./composer-catalog";
13
15
 
16
+ export {
17
+ type CloudCommandMenu,
18
+ type CloudCommandMenuOptions,
19
+ cloudCommandMenu,
20
+ type OpenComposerCommandOptions,
21
+ openComposerCommand,
22
+ } from "./composer-open";
23
+
14
24
  export type CloudInsertMenuOptions = {
15
25
  locale: string;
16
26
  /** Every app's search tags, from `loadCloudComposerCatalog`. */
@@ -0,0 +1,17 @@
1
+ import { i18n } from "@k2b/stdlib";
2
+
3
+ export const composerOpenMessages = i18n.define({
4
+ baseLocale: "en",
5
+ messages: {
6
+ en: {
7
+ appUnavailable: ({ app }: { app: string }) => `${app} is not reachable right now. Choose it again to try once more.`,
8
+ commandsFailed: "Commands from apps could not be loaded",
9
+ tryAgain: "Try again",
10
+ },
11
+ de: {
12
+ appUnavailable: ({ app }) => `${app} ist gerade nicht erreichbar. Erneut wählen versucht es nochmal.`,
13
+ commandsFailed: "Befehle aus Apps konnten nicht geladen werden",
14
+ tryAgain: "Erneut versuchen",
15
+ },
16
+ },
17
+ });
@@ -0,0 +1,110 @@
1
+ import type { ChatCommand, ChatCommandDraft, ChatCommandOpenResult } from "@k2b/ui";
2
+ import { CAPABILITY_FRAMEWORK_ERROR_CODES } from "../contracts/capabilities";
3
+ import { isCommandPath } from "../contracts/command-path";
4
+ import { openCommand } from "./commands";
5
+ import type { CloudComposerCatalogState, CloudComposerCommand } from "./composer-catalog";
6
+ import { composerOpenMessages } from "./composer-open-messages";
7
+
8
+ export type OpenComposerCommandOptions = {
9
+ before: () => Promise<void>;
10
+ returnTo: () => string | undefined;
11
+ /** Read after `before`; once it aborts, for example because the person left the chat, nothing opens. */
12
+ signal?: () => AbortSignal | undefined;
13
+ locale: string;
14
+ };
15
+
16
+ /**
17
+ * Save first, then resolve and open. If nothing opens, cancel so the composer keeps its token and restores the live draft.
18
+ * Save errors reach the status line; resolution errors stay on the retryable row.
19
+ */
20
+ export const openComposerCommand = async (
21
+ command: CloudComposerCommand,
22
+ options: OpenComposerCommandOptions,
23
+ ): Promise<ChatCommandOpenResult> => {
24
+ await options.before();
25
+ const signal = options.signal?.();
26
+ if (signal?.aborted) return "cancelled";
27
+ const returnTo = options.returnTo();
28
+ if (returnTo !== undefined && !isCommandPath(returnTo)) throw new Error("Invalid Command return path");
29
+ try {
30
+ if (!(await openCommand(command.id, command.input, { returnTo }, signal))) return "cancelled";
31
+ } catch (error) {
32
+ if (signal?.aborted) return "cancelled";
33
+ const cause = error instanceof Error ? error.cause : undefined;
34
+ if (
35
+ typeof cause === "object" &&
36
+ cause !== null &&
37
+ "code" in cause &&
38
+ typeof cause.code === "string" &&
39
+ "status" in cause &&
40
+ typeof cause.status === "number" &&
41
+ "message" in cause &&
42
+ typeof cause.message === "string" &&
43
+ cause.code !== CAPABILITY_FRAMEWORK_ERROR_CODES.appUnavailable &&
44
+ ![502, 503, 504].includes(cause.status) &&
45
+ cause.message
46
+ ) {
47
+ return { failed: cause.message };
48
+ }
49
+ return { failed: composerOpenMessages.resolve([options.locale]).t.appUnavailable({ app: command.appName }) };
50
+ }
51
+ if (signal?.aborted) return "cancelled";
52
+ };
53
+
54
+ export type CloudCommandMenuOptions = {
55
+ locale: string;
56
+ commands: readonly CloudComposerCommand[];
57
+ state: CloudComposerCatalogState;
58
+ section?: string;
59
+ before: (draft: ChatCommandDraft, signal: AbortSignal, follow: () => void) => Promise<void>;
60
+ returnTo: () => string | undefined;
61
+ /** See `OpenComposerCommandOptions.signal`. */
62
+ signal?: () => AbortSignal | undefined;
63
+ };
64
+ export type CloudCommandMenu = { commands: ChatCommand[]; pending: boolean };
65
+
66
+ /** Put this section last. Pending placeholders reserve its end; a failed catalog offers one explicit reload. */
67
+ export const cloudCommandMenu = (options: CloudCommandMenuOptions): CloudCommandMenu => {
68
+ const t = composerOpenMessages.resolve([options.locale]).t;
69
+ if (options.state === "loading") return { commands: [], pending: true };
70
+ if (options.state === "failed")
71
+ return {
72
+ commands: [
73
+ {
74
+ id: "cloud-commands-retry",
75
+ name: "cloud-commands-retry",
76
+ label: t.commandsFailed,
77
+ description: t.tryAgain,
78
+ icon: "ti ti-alert-circle",
79
+ searchAgain: true,
80
+ persistent: true,
81
+ section: options.section,
82
+ },
83
+ ],
84
+ pending: false,
85
+ };
86
+ return {
87
+ commands: options.commands.map((command) => ({
88
+ id: `cloud-command:${command.id}`,
89
+ name: command.id,
90
+ label: command.title,
91
+ description: `${command.appName} · ${command.description}`,
92
+ icon: command.icon,
93
+ keywords: [command.title, ...command.keywords, command.appName, command.description],
94
+ app: command.appName,
95
+ section: options.section,
96
+ // The composer's signal aborts when the person closes the list; the opener's, when it leaves the chat.
97
+ open: (draft, closed, follow) =>
98
+ openComposerCommand(command, {
99
+ before: () => options.before(draft, closed, follow),
100
+ returnTo: options.returnTo,
101
+ signal: () => {
102
+ const left = options.signal?.();
103
+ return left ? AbortSignal.any([closed, left]) : closed;
104
+ },
105
+ locale: options.locale,
106
+ }),
107
+ })),
108
+ pending: false,
109
+ };
110
+ };
@@ -0,0 +1,6 @@
1
+ import { z } from "zod";
2
+ import type { CapabilityCommandManifest } from "../contracts/capabilities";
3
+
4
+ /** Shared discovery rule for the palette and composers; it grants no access to the destination. */
5
+ export const discoverableCommandInput = (command: CapabilityCommandManifest, appId: string, appIds: ReadonlySet<string>) =>
6
+ appIds.has(appId) ? z.fromJSONSchema(structuredClone(command.inputSchema)).safeParse({}) : undefined;
@@ -0,0 +1,6 @@
1
+ import { mergeSeenHints, parseSeenHintsCookieHeader, seenHintsSetCookie } from "../contracts/seen-hints";
2
+
3
+ /** Persist dismissal before navigating or closing the hint. */
4
+ export const markHintSeen = (id: string): void => {
5
+ document.cookie = seenHintsSetCookie(mergeSeenHints(parseSeenHintsCookieHeader(document.cookie), id), location.protocol === "https:");
6
+ };
@@ -10,6 +10,8 @@ export type LiveSubscriptionHandlers<T> = {
10
10
  apply: (events: { data: T; cursor: string }[]) => Promise<void>;
11
11
  /** Loads the canonical state again; events after it follow. */
12
12
  resync: () => Promise<void>;
13
+ /** Reports changes in whether the subscription is confirmed and has no pending work. Never called after `close()`. */
14
+ caughtUp?: (caughtUp: boolean) => void;
13
15
  /** The subscription ended because its resource is gone or no longer readable. */
14
16
  revoked?: (code: "not_found" | "access_denied") => void;
15
17
  /** Live updates stopped: the session ended, or `apply` or `resync` kept failing. Never called after `close()`. */
@@ -25,6 +27,7 @@ export type LiveConnection = {
25
27
  type Subscriber = {
26
28
  frame: () => unknown;
27
29
  confirmed: boolean;
30
+ report: () => void;
28
31
  receive: (message: LiveServerMessage) => void;
29
32
  end: () => void;
30
33
  };
@@ -56,9 +59,17 @@ const connect = (url: string, activity: "visible" | "always"): Shared => {
56
59
  onOpen: (controls) => {
57
60
  for (const subscriber of subscribers.values()) {
58
61
  subscriber.confirmed = false;
62
+ subscriber.report();
59
63
  controls.send(subscriber.frame());
60
64
  }
61
65
  },
66
+ onStatus: (status) => {
67
+ if (status === "open") return;
68
+ for (const subscriber of subscribers.values()) {
69
+ subscriber.confirmed = false;
70
+ subscriber.report();
71
+ }
72
+ },
62
73
  onMessage: (message) => {
63
74
  if (message.t === "progress") {
64
75
  for (const subscriber of subscribers.values()) if (subscriber.confirmed) subscriber.receive(message);
@@ -96,8 +107,16 @@ export const liveConnection = (url: string, options: { activity?: "visible" | "a
96
107
  let resyncAt: string | null = null;
97
108
  let ended = false;
98
109
  let running = false;
110
+ let caughtUp = false;
99
111
  const work: Work<T>[] = [];
100
112
 
113
+ const report = () => {
114
+ const next = !ended && subscriber.confirmed && !running && work.length === 0;
115
+ if (next === caughtUp) return;
116
+ caughtUp = next;
117
+ handlers.caughtUp?.(next);
118
+ };
119
+
101
120
  const attempt = async (run: () => Promise<void>): Promise<boolean> => {
102
121
  for (let tries = 0; ; tries++) {
103
122
  try {
@@ -122,6 +141,7 @@ export const liveConnection = (url: string, options: { activity?: "visible" | "a
122
141
  cursor = item.cursor;
123
142
  continue;
124
143
  }
144
+ report();
125
145
  const applied = item.kind === "events" ? await attempt(() => handlers.apply(item.events)) : await attempt(handlers.resync);
126
146
  if (!applied) {
127
147
  // A closed subscription reports nothing: its owner ended it.
@@ -135,6 +155,7 @@ export const liveConnection = (url: string, options: { activity?: "visible" | "a
135
155
  }
136
156
  } finally {
137
157
  running = false;
158
+ report();
138
159
  }
139
160
  };
140
161
 
@@ -153,6 +174,7 @@ export const liveConnection = (url: string, options: { activity?: "visible" | "a
153
174
  return { t: "sub", id, channel, scope, ...(after ? { after } : {}) };
154
175
  },
155
176
  confirmed: false,
177
+ report,
156
178
  receive: (message) => {
157
179
  if (ended) return;
158
180
  if (message.t === "revoked") {
@@ -189,10 +211,12 @@ export const liveConnection = (url: string, options: { activity?: "visible" | "a
189
211
  },
190
212
  };
191
213
 
192
- const stop = () => {
214
+ const stop = (notify = true) => {
193
215
  if (ended) return;
194
216
  ended = true;
195
217
  work.length = 0;
218
+ if (notify) report();
219
+ else caughtUp = false;
196
220
  if (subscribers.get(id) !== subscriber) return;
197
221
  subscribers.delete(id);
198
222
  socket.send({ t: "unsub", id });
@@ -205,6 +229,6 @@ export const liveConnection = (url: string, options: { activity?: "visible" | "a
205
229
  subscribers.set(id, subscriber);
206
230
  if (subscribers.size === 1) socket.connect();
207
231
  else socket.send(subscriber.frame());
208
- return { close: stop };
232
+ return { close: () => stop(false) };
209
233
  },
210
234
  });
@@ -0,0 +1,7 @@
1
+ import { readShellData } from "../ssr/shell-data";
2
+
3
+ /** Navigation controls discoverability, never authorization; the target application still checks access. */
4
+ export const navigationApps = (): { appIds: ReadonlySet<string>; currentAppId?: string } => {
5
+ const shell = readShellData();
6
+ return { appIds: new Set(shell?.apps.map((app) => app.id) ?? []), currentAppId: shell?.appId };
7
+ };
@@ -0,0 +1,30 @@
1
+ import type { CloudResourceRef } from "../contracts/capabilities";
2
+ import type { PanelPageContext } from "../contracts/panel-context";
3
+
4
+ export const PAGE_CONTEXT_COLLECT = "cloud.page-context.collect";
5
+ export const PAGE_CONTEXT_CHANGED = "cloud.page-context.changed";
6
+ export const PANEL_CONTEXT_CHANGED = "cloud.panel.context.changed";
7
+ export const PANEL_CONTEXT_ELEMENT = "cloud-panel-context";
8
+ export const PANEL_SHORTCUTS_ELEMENT = "cloud-panel-shortcuts";
9
+ export const PANEL_SHORTCUTS_CHANGED = "cloud.panel.shortcuts.changed";
10
+ export type PageContextSource = { scope?: CloudResourceRef };
11
+ export type PageContextCollection = { sources: PageContextSource[] };
12
+ export type PanelConsentRequest = { provider: string; allowed: boolean };
13
+ export type PanelState = { provider: string; appName: string; consent: boolean; allowed: boolean; saving: boolean; open: boolean };
14
+
15
+ declare global {
16
+ interface WindowEventMap {
17
+ "cloud.panel.consent": CustomEvent<PanelConsentRequest>;
18
+ "cloud.panel.state": CustomEvent<PanelState>;
19
+ }
20
+ }
21
+
22
+ /** Framework-validated SSR data, then receiver-validated messages. Shared DOM state survives independent bundles. */
23
+ export function readProviderContext(): PanelPageContext | null {
24
+ if (typeof document === "undefined") return null;
25
+ try {
26
+ return JSON.parse(document.getElementById(PANEL_CONTEXT_ELEMENT)?.textContent ?? "null");
27
+ } catch {
28
+ return null;
29
+ }
30
+ }