@k2b/cloud 0.31.0 → 0.32.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 (119) hide show
  1. package/package.json +6 -4
  2. package/src/_internal/define-app.ts +3 -1
  3. package/src/ai/app-skills.ts +1 -7
  4. package/src/ai/audio-tool.ts +2 -1
  5. package/src/ai/browser.ts +109 -0
  6. package/src/ai/capabilities.ts +6 -2
  7. package/src/ai/chart-block.ts +431 -81
  8. package/src/ai/chat/action-sentences.ts +13 -7
  9. package/src/ai/chat/blocks.tsx +11 -7
  10. package/src/ai/chat/builtin-tools.tsx +9 -7
  11. package/src/ai/chat/chart-block.tsx +218 -44
  12. package/src/ai/chat/explorer-block.tsx +276 -0
  13. package/src/ai/chat/file-tools.tsx +40 -1
  14. package/src/ai/chat/message-actions.tsx +6 -0
  15. package/src/ai/chat/message-utils.ts +7 -0
  16. package/src/ai/chat/messages.ts +16 -0
  17. package/src/ai/chat/presentation.tsx +13 -3
  18. package/src/ai/chat/reference-text.tsx +34 -0
  19. package/src/ai/chat/tool-groups.ts +1 -0
  20. package/src/ai/chat/tool-names.ts +14 -0
  21. package/src/ai/chat/turn-view.tsx +17 -7
  22. package/src/ai/chat/view-block.browser-harness.tsx +35 -0
  23. package/src/ai/chat/view-block.tsx +417 -0
  24. package/src/ai/chat/visual-tools.tsx +32 -53
  25. package/src/ai/client-tool-results.ts +9 -0
  26. package/src/ai/default-tool-contracts.ts +334 -91
  27. package/src/ai/default-tools.ts +87 -11
  28. package/src/ai/executor.ts +173 -31
  29. package/src/ai/file-error.ts +2 -0
  30. package/src/ai/file-tools.ts +27 -7
  31. package/src/ai/files-store.ts +60 -0
  32. package/src/ai/html-pdf-tool.ts +3 -2
  33. package/src/ai/http.ts +37 -12
  34. package/src/ai/index.ts +90 -0
  35. package/src/ai/local-tool-contracts.ts +244 -0
  36. package/src/ai/malformed-tool-call-repair.ts +144 -0
  37. package/src/ai/markdown-pdf-tool.ts +2 -1
  38. package/src/ai/migrate.ts +7 -0
  39. package/src/ai/openai-api-messages.ts +53 -0
  40. package/src/ai/openai-api-slots.ts +40 -0
  41. package/src/ai/openai-api.ts +383 -0
  42. package/src/ai/protocol.ts +6 -2
  43. package/src/ai/provider.ts +16 -4
  44. package/src/ai/request-context.ts +33 -0
  45. package/src/ai/routes.ts +96 -57
  46. package/src/ai/runtime.ts +48 -12
  47. package/src/ai/settings.ts +5 -0
  48. package/src/ai/skill-catalog.ts +20 -5
  49. package/src/ai/skill-format.ts +17 -7
  50. package/src/ai/skill-tool.ts +15 -5
  51. package/src/ai/skills-routes.ts +20 -6
  52. package/src/ai/skills.ts +26 -7
  53. package/src/ai/store.ts +25 -0
  54. package/src/ai/system-prompt.ts +69 -10
  55. package/src/ai/timeline.ts +3 -1
  56. package/src/ai/tools.ts +3 -0
  57. package/src/ai/types.ts +9 -0
  58. package/src/ai/view-block.ts +941 -0
  59. package/src/ai/vision-tool.ts +2 -1
  60. package/src/api/admin-identity.ts +6 -1
  61. package/src/api/app-approval-actions.ts +36 -0
  62. package/src/api/app-approval.ts +10 -2
  63. package/src/api/approvals-loader.ts +15 -0
  64. package/src/api/approvals.ts +120 -0
  65. package/src/api/index.ts +3 -0
  66. package/src/api/me.ts +12 -3
  67. package/src/approvals/Approvals.browser-harness.tsx +16 -0
  68. package/src/approvals/Approvals.tsx +621 -0
  69. package/src/approvals/ApprovalsNavStatus.tsx +20 -0
  70. package/src/approvals/messages.ts +95 -0
  71. package/src/approvals/ui.ts +8 -0
  72. package/src/browser/app-approval.ts +32 -0
  73. package/src/browser/choose-files.tsx +26 -13
  74. package/src/browser/commands.ts +13 -4
  75. package/src/browser/file-provider-error.ts +11 -0
  76. package/src/browser/file-providers.ts +3 -12
  77. package/src/browser/files.tsx +1 -2
  78. package/src/browser/live-connection.ts +2 -3
  79. package/src/browser/provider-list.ts +2 -1
  80. package/src/browser/save-files.tsx +13 -2
  81. package/src/cli/account.ts +21 -2
  82. package/src/cli/commands.ts +4 -2
  83. package/src/contracts/app-approval.ts +120 -0
  84. package/src/contracts/approval-paths.ts +4 -0
  85. package/src/contracts/approvals.ts +81 -0
  86. package/src/contracts/command-path.ts +17 -0
  87. package/src/contracts/commands.ts +5 -15
  88. package/src/contracts/shared.ts +5 -0
  89. package/src/events/live-protocol.ts +2 -1
  90. package/src/events/live-server-message.ts +40 -0
  91. package/src/events/testing.ts +88 -0
  92. package/src/server/index.ts +2 -0
  93. package/src/server/middleware/auth.ts +11 -1
  94. package/src/server/request-timeout.ts +22 -0
  95. package/src/server/services/access.ts +4 -3
  96. package/src/services/app-approval.ts +103 -15
  97. package/src/services/approvals/cursor.ts +15 -0
  98. package/src/services/approvals/device.ts +112 -0
  99. package/src/services/approvals/index.ts +27 -11
  100. package/src/services/approvals/list.ts +120 -0
  101. package/src/services/approvals/migrate.ts +9 -1
  102. package/src/services/identity/models-only-auth.ts +25 -0
  103. package/src/services/notifications/email-frame.ts +16 -3
  104. package/src/services/service-account-credentials.ts +22 -10
  105. package/src/services/settings/core-settings.ts +27 -0
  106. package/src/shared/ai-platform-prompt.ts +26 -1
  107. package/src/shared/email-html.ts +4 -0
  108. package/src/shared/index.ts +6 -1
  109. package/src/shared/markdown/extensions/code.ts +14 -0
  110. package/src/shared/markdown/index.ts +4 -4
  111. package/src/ssr/AppBadge.tsx +4 -4
  112. package/src/ssr/AppLaunchpadPanel.tsx +4 -4
  113. package/src/ssr/RailApps.island.tsx +2 -2
  114. package/src/ssr/app-badges.ts +50 -6
  115. package/src/ssr/page-trail.browser-harness.tsx +52 -3
  116. package/src/ssr/rail-navigation.ts +5 -3
  117. package/src/ssr/runtime.ts +6 -1
  118. package/src/styles/approvals.css +390 -0
  119. package/src/styles/global.css +5 -2
@@ -0,0 +1,95 @@
1
+ import { i18n } from "@k2b/stdlib";
2
+
3
+ export const approvalMessages = i18n.define({
4
+ baseLocale: "en",
5
+ messages: {
6
+ en: {
7
+ title: "Approvals",
8
+ open: ({ count }: { count: number }) => `${count.toLocaleString("en")} open`,
9
+ rejectAll: "Reject all",
10
+ rejectAllTitle: "Reject all open approvals?",
11
+ rejectAllBody: ({ count }: { count: number }) =>
12
+ `${count === 1 ? "The open Action does not run." : `None of the ${count.toLocaleString("en")} open Actions runs.`} Where Cloud asked, it continues with your rejection.`,
13
+ emptyTitle: "Nothing is waiting for you.",
14
+ emptyBody: "When an Action needs your approval, it appears here and in the chat it comes from.",
15
+ loading: "Loading approvals…",
16
+ loadFailed: "Approvals could not be loaded.",
17
+ retry: "Try again",
18
+ showMore: "Show more",
19
+ loadMoreFailed: "More approvals could not be loaded.",
20
+ elsewhere: ({ count }: { count: number }) => `More approvals in other apps (${count.toLocaleString("en")})`,
21
+ allApprovals: "All approvals",
22
+ fromChat: ({ title }: { title: string }) => `from chat “${title}”`,
23
+ fromUntitledChat: "from a chat",
24
+ asksEveryTime: "Asks every time",
25
+ expiresIn: ({ time }: { time: string }) => `Expires ${time}`,
26
+ details: "Details",
27
+ reject: "Reject",
28
+ 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",
35
+ approved: "Approved",
36
+ rejected: "Rejected",
37
+ decidedElsewhere: "Already decided elsewhere",
38
+ gone: "Expired or withdrawn",
39
+ 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
+ exactlyThis: "This approval applies to exactly this Action. If it changes before it runs, Cloud asks again.",
46
+ approvalFor: ({ title }: { title: string }) => `Approval: ${title}`,
47
+ close: "Close",
48
+ waiting: ({ count }: { count: number }) => (count === 0 ? "nothing waiting" : `${count.toLocaleString("en")} waiting`),
49
+ },
50
+ de: {
51
+ title: "Freigaben",
52
+ open: ({ count }) => `${count.toLocaleString("de")} offen`,
53
+ rejectAll: "Alle ablehnen",
54
+ rejectAllTitle: "Alle offenen Freigaben ablehnen?",
55
+ rejectAllBody: ({ count }) =>
56
+ `${count === 1 ? "Die offene Aktion läuft nicht." : `Keine der ${count.toLocaleString("de")} offenen Aktionen läuft.`} Wo Cloud gefragt hat, geht es mit deiner Ablehnung weiter.`,
57
+ emptyTitle: "Nichts wartet auf dich.",
58
+ emptyBody: "Wenn eine Aktion deine Freigabe braucht, erscheint sie hier und im Chat, aus dem sie kommt.",
59
+ loading: "Freigaben werden geladen …",
60
+ loadFailed: "Die Freigaben konnten nicht geladen werden.",
61
+ retry: "Erneut versuchen",
62
+ showMore: "Mehr anzeigen",
63
+ loadMoreFailed: "Weitere Freigaben konnten nicht geladen werden.",
64
+ elsewhere: ({ count }) => `Weitere Freigaben in anderen Apps (${count.toLocaleString("de")})`,
65
+ allApprovals: "Alle Freigaben",
66
+ fromChat: ({ title }) => `aus Chat „${title}“`,
67
+ fromUntitledChat: "aus einem Chat",
68
+ asksEveryTime: "Fragt immer",
69
+ expiresIn: ({ time }) => `Läuft ab ${time}`,
70
+ details: "Details",
71
+ reject: "Ablehnen",
72
+ 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",
79
+ approved: "Freigegeben",
80
+ rejected: "Abgelehnt",
81
+ decidedElsewhere: "Schon anderswo entschieden",
82
+ gone: "Abgelaufen oder zurückgezogen",
83
+ 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
+ exactlyThis: "Diese Freigabe gilt genau für diese Aktion. Ändert sie sich, bevor sie läuft, fragt Cloud neu.",
90
+ approvalFor: ({ title }) => `Freigabe: ${title}`,
91
+ close: "Schließen",
92
+ waiting: ({ count }) => (count === 0 ? "nichts offen" : `${count.toLocaleString("de")} offen`),
93
+ },
94
+ },
95
+ });
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The shared list of pending Action approvals. Every app mounts the same component for its own scope; Core serves the
3
+ * route behind it and decides through the platform's approval requests.
4
+ */
5
+
6
+ export type { ApprovalsProps } from "./Approvals";
7
+ export { default as Approvals } from "./Approvals";
8
+ export { approvalNavCount, default as ApprovalsNavStatus } from "./ApprovalsNavStatus";
@@ -4,9 +4,12 @@ export { type AppVaultBlob, type AppVaultConfig, AppVaultError, type AppVaultMet
4
4
 
5
5
  import { z } from "zod";
6
6
  import {
7
+ APP_ACTION_LIMITS,
7
8
  APP_APPROVAL_LIMITS,
8
9
  APP_APPROVAL_PATH,
9
10
  APP_APPROVAL_PROTOCOL,
11
+ type AppActionView,
12
+ AppActionViewSchema,
10
13
  AppDeviceCommandSchema,
11
14
  AppDeviceProofSchema,
12
15
  type AppDevicePublicKey,
@@ -19,6 +22,7 @@ import {
19
22
  AppPairingResultSchema,
20
23
  type AppPendingLogin,
21
24
  AppPendingLoginSchema,
25
+ appActionDigest,
22
26
  appDeviceProofMessage,
23
27
  appPairingLink,
24
28
  appPairingProofMessage,
@@ -84,6 +88,8 @@ const InfoSchema = z.object({
84
88
  appOrigin: z.string(),
85
89
  algorithm: z.literal("ES256"),
86
90
  limits: z.object(Object.fromEntries(Object.entries(APP_APPROVAL_LIMITS).map(([key, value]) => [key, z.literal(value)]))),
91
+ /** Absent on Clouds that serve only the first version's commands. */
92
+ features: z.array(z.string().max(40)).max(16).optional(),
87
93
  });
88
94
 
89
95
  /** Call only after the user explicitly trusts the issuer from a pairing link.
@@ -215,6 +221,31 @@ const connect = async (options: {
215
221
  throw new AppApprovalClientError("INVALID_RESPONSE");
216
222
  return result;
217
223
  },
224
+ /** Whether this Cloud serves `inbox`, `action` and `act`. Older Clouds answer those commands with HTTP 400. */
225
+ actions: info.features?.includes("actions") === true,
226
+ /** Pending sign-ins and the newest open Action approvals in one call; use it instead of `pending` where `actions` is true. */
227
+ inbox: async (device: AppApprovalDevice, signal?: AbortSignal) => {
228
+ const result = await command(device, { operation: "inbox" }, signal);
229
+ if (!("actions" in result)) throw new AppApprovalClientError("INVALID_RESPONSE");
230
+ return result;
231
+ },
232
+ /** One open Action approval. The digest is recomputed here, so a view that does not match it is never returned. */
233
+ action: async (device: AppApprovalDevice, actionId: string, signal?: AbortSignal): Promise<AppActionView> => {
234
+ const result = await command(device, { operation: "action", actionId }, signal);
235
+ if (!("digest" in result) || result.actionId !== actionId || (await appActionDigest(issuer, result)) !== result.digest)
236
+ throw new AppApprovalClientError("INVALID_RESPONSE");
237
+ return result;
238
+ },
239
+ /** Signs the decision over the view's digest. Only call it on an explicit choice of the person. */
240
+ act: async (device: AppApprovalDevice, view: AppActionView, decision: "approve" | "deny", signal?: AbortSignal) => {
241
+ view = AppActionViewSchema.parse(view);
242
+ if (Date.parse(view.expiresAt) <= Date.now() || (await appActionDigest(issuer, view)) !== view.digest)
243
+ throw new AppApprovalClientError("INVALID_INPUT");
244
+ const result = await command(device, { operation: "act", actionId: view.actionId, digest: view.digest, decision }, signal);
245
+ if (!("state" in result) || result.state !== (decision === "approve" ? "approved" : "denied"))
246
+ throw new AppApprovalClientError("INVALID_RESPONSE");
247
+ return result;
248
+ },
218
249
  /** Reads the account this device signs in and the device's own record. Older Clouds answer HTTP 400. */
219
250
  account: async (device: AppApprovalDevice, signal?: AbortSignal) => {
220
251
  const result = await command(device, { operation: "account" }, signal);
@@ -243,4 +274,5 @@ export const appApproval = {
243
274
  createKey,
244
275
  connect,
245
276
  limits: APP_APPROVAL_LIMITS,
277
+ actionLimits: APP_ACTION_LIMITS,
246
278
  };
@@ -1,9 +1,10 @@
1
1
  import { showFileDialog } from "@k2b/stdlib/browser";
2
2
  import { dialogCore, panelDialogWorkspaceOptions } from "@k2b/ui";
3
3
  import { createSignal } from "solid-js";
4
- import { type ChooseFilesOptions, FileChooser, type FileProviderList } from "./FileChooser";
4
+ import type { ChooseFilesOptions, FileProviderList } from "./FileChooser";
5
5
  import type { FileProviderCaller } from "./file-providers";
6
6
  import type { ProviderList } from "./provider-list";
7
+ import { importOnDemand } from "./reload";
7
8
 
8
9
  /**
9
10
  * One page's file choosing over its provider list. `caller` carries the locale and, in tests, the transport.
@@ -39,18 +40,30 @@ export const createFileChoosing = (
39
40
  );
40
41
  };
41
42
  if (!known) load();
42
- return dialogCore
43
- .open<File[]>(
44
- (close) => <FileChooser options={options} providers={providers} retryProviders={load} caller={caller} close={close} />,
45
- {
46
- ...panelDialogWorkspaceOptions,
47
- panelClassName: `${panelDialogWorkspaceOptions.panelClassName} cloud-file-chooser-dialog`,
48
- initialFocus: (dialog) => dialog.querySelector<HTMLElement>('[role="gridcell"][tabindex="0"]'),
49
- signal: options.signal,
50
- history: true,
51
- },
52
- )
53
- .then((files) => files ?? []);
43
+ return (async () => {
44
+ let abort = () => {};
45
+ const chooser = await Promise.race([
46
+ importOnDemand(() => import("./FileChooser")),
47
+ new Promise<undefined>((resolve) => {
48
+ abort = () => resolve(undefined);
49
+ signal?.addEventListener("abort", abort, { once: true });
50
+ if (signal?.aborted) abort();
51
+ }),
52
+ ]).finally(() => signal?.removeEventListener("abort", abort));
53
+ if (!chooser) return [];
54
+ return dialogCore
55
+ .open<File[]>(
56
+ (close) => <chooser.FileChooser options={options} providers={providers} retryProviders={load} caller={caller} close={close} />,
57
+ {
58
+ ...panelDialogWorkspaceOptions,
59
+ panelClassName: `${panelDialogWorkspaceOptions.panelClassName} cloud-file-chooser-dialog`,
60
+ initialFocus: (dialog) => dialog.querySelector<HTMLElement>('[role="gridcell"][tabindex="0"]'),
61
+ signal: options.signal,
62
+ history: true,
63
+ },
64
+ )
65
+ .then((files) => files ?? []);
66
+ })();
54
67
  };
55
68
 
56
69
  return chooseFiles;
@@ -1,12 +1,12 @@
1
1
  import { toast } from "@k2b/ui";
2
- import { resolveCommand } from "../capabilities/client";
3
- import { type CommandOptions, CommandOptionsSchema, clearCommand, readCommand } from "../contracts/commands";
2
+ import type { CommandOptions } from "../contracts/commands";
4
3
  import {
5
4
  type ContextAwareCommand,
6
5
  collectContextAwareCommands,
7
6
  requestCommandHandling,
8
7
  requestContextCommandExecution,
9
8
  } from "./command-bridge";
9
+ import { importOnDemand } from "./reload";
10
10
  import { resourceSearchMessages } from "./resource-search-messages";
11
11
  import { requestGlobalSearch } from "./search-bridge";
12
12
 
@@ -16,17 +16,26 @@ export { registerCommandHandler, registerContextAwareCommand } from "./command-b
16
16
 
17
17
  /** Opens locally only when a mounted owner explicitly accepts; otherwise follows the app's URL. */
18
18
  export const openCommand = async (command: string, input: unknown = {}, options: CommandOptions = {}): Promise<void> => {
19
- const parsedOptions = CommandOptionsSchema.parse(options);
19
+ const modules = await importOnDemand(() => Promise.all([import("../contracts/commands"), import("../capabilities/client")]));
20
+ if (!modules) return;
21
+ const [contracts, capabilities] = modules;
22
+ const parsedOptions = contracts.CommandOptionsSchema.parse(options);
20
23
  const handled = requestCommandHandling({ command, input, options: parsedOptions });
21
24
  if (handled) return handled;
22
- const result = await resolveCommand(command, input, parsedOptions);
25
+ const result = await capabilities.resolveCommand(command, input, parsedOptions);
23
26
  if (!result.ok) throw new Error(result.error.message);
24
27
  window.location.assign(result.data.href);
25
28
  };
26
29
 
27
30
  /** Call after this page's handlers have mounted. Each entry is consumed, including invalid links. */
28
31
  export const consumeCommandLink = async (): Promise<void> => {
32
+ const command = new URL(window.location.href).searchParams.get("command");
33
+ if (!command) return;
34
+ const contracts = await importOnDemand(() => import("../contracts/commands"));
35
+ if (!contracts) return;
29
36
  const url = new URL(window.location.href);
37
+ if (url.searchParams.get("command") !== command) return;
38
+ const { clearCommand, readCommand } = contracts;
30
39
  const messages = resourceSearchMessages.resolve([document.documentElement.lang]).t;
31
40
  let target: ReturnType<typeof readCommand>;
32
41
  try {
@@ -0,0 +1,11 @@
1
+ /** A provider failure the chooser explains; `status` 0 means the browser is offline. */
2
+ export class FileProviderError extends Error {
3
+ constructor(
4
+ readonly code: string,
5
+ message: string,
6
+ readonly status: number,
7
+ ) {
8
+ super(message);
9
+ this.name = "FileProviderError";
10
+ }
11
+ }
@@ -15,6 +15,9 @@ import {
15
15
  fileProviderIssues,
16
16
  } from "../contracts/file-provider";
17
17
  import { LOCALE_HEADER } from "../shared/locale";
18
+ import { FileProviderError } from "./file-provider-error";
19
+
20
+ export { FileProviderError } from "./file-provider-error";
18
21
 
19
22
  /** One live application whose catalog manifest offers files, reduced to what choosing a file needs. */
20
23
  export type FileProviderSource = {
@@ -31,18 +34,6 @@ export type FileProviderSource = {
31
34
 
32
35
  export type FileProviderCaller = Pick<CapabilityHttpOptions, "fetch" | "baseUrl"> & { locale: string };
33
36
 
34
- /** A provider failure the chooser explains; `status` 0 means the browser is offline. */
35
- export class FileProviderError extends Error {
36
- constructor(
37
- readonly code: string,
38
- message: string,
39
- readonly status: number,
40
- ) {
41
- super(message);
42
- this.name = "FileProviderError";
43
- }
44
- }
45
-
46
37
  /** The page size the chooser asks for; the contract allows 1 to 100. */
47
38
  export const FILE_PROVIDER_PAGE_SIZE = 50;
48
39
  /** At most this many provider reads run at the same time. */
@@ -1,5 +1,4 @@
1
1
  import { createFileChoosing } from "./choose-files";
2
- import { loadFileProviders } from "./file-providers";
3
2
  import { createProviderList } from "./provider-list";
4
3
  import { createFileSaving } from "./save-files";
5
4
 
@@ -7,7 +6,7 @@ export type { ChooseFilesOptions } from "./FileChooser";
7
6
  export { SAVE_FILES_ICON, type SavedFile, type SaveFileSource, type SaveFilesOptions } from "./save-files";
8
7
 
9
8
  const pageLocale = () => (typeof document === "undefined" ? "en" : document.documentElement.lang || "en");
10
- const providers = createProviderList(() => loadFileProviders({ locale: pageLocale() }));
9
+ const providers = createProviderList(async () => (await import("./file-providers")).loadFileProviders({ locale: pageLocale() }));
11
10
  const saving = createFileSaving(providers);
12
11
 
13
12
  /**
@@ -1,4 +1,4 @@
1
- import { type LiveServerMessage, LiveServerMessageSchema } from "../events/live-protocol";
1
+ import { type LiveServerMessage, parseLiveServerMessage } from "../events/live-server-message";
2
2
  import { createLiveWebSocket, type LiveWebSocket } from "./live-websocket";
3
3
 
4
4
  export type LiveSubscriptionHandlers<T> = {
@@ -41,8 +41,7 @@ const shared = new Map<string, Shared>();
41
41
 
42
42
  const parseMessage = (raw: string): LiveServerMessage | null => {
43
43
  try {
44
- const parsed = LiveServerMessageSchema.safeParse(JSON.parse(raw));
45
- return parsed.success ? parsed.data : null;
44
+ return parseLiveServerMessage(JSON.parse(raw));
46
45
  } catch {
47
46
  return null;
48
47
  }
@@ -1,5 +1,6 @@
1
1
  import { createSignal } from "solid-js";
2
- import { FileProviderError, type FileProviderSource } from "./file-providers";
2
+ import { FileProviderError } from "./file-provider-error";
3
+ import type { FileProviderSource } from "./file-providers";
3
4
 
4
5
  /** The catalog refuses the caller, such as a visitor of a public page or a session that has just expired. */
5
6
  const refused = (error: unknown) => error instanceof FileProviderError && (error.status === 401 || error.status === 403);
@@ -1,10 +1,11 @@
1
1
  import { dialogCore, IconButton, type IconButtonProps, panelDialogWorkspaceOptions, toast, useLocale } from "@k2b/ui";
2
2
  import { createSignal, type JSX, splitProps } from "solid-js";
3
3
  import type { FileProviderList } from "./FileChooser";
4
- import { FileSaver, type SavedFile } from "./FileSaver";
4
+ import type { SavedFile } from "./FileSaver";
5
5
  import { fileChooserMessages } from "./file-chooser-messages";
6
6
  import type { FileProviderCaller, SaveFileSource } from "./file-providers";
7
7
  import type { ProviderList } from "./provider-list";
8
+ import { importOnDemand } from "./reload";
8
9
 
9
10
  export type { SavedFile } from "./FileSaver";
10
11
  export type { SaveFileSource } from "./file-providers";
@@ -40,9 +41,19 @@ export const createFileSaving = (
40
41
  if (!known) load();
41
42
  const saved: SavedFile[] = [];
42
43
  const running = new Set<Promise<void>>();
44
+ let abort = () => {};
45
+ const saver = await Promise.race([
46
+ importOnDemand(() => import("./FileSaver")),
47
+ new Promise<undefined>((resolve) => {
48
+ abort = () => resolve(undefined);
49
+ options.signal?.addEventListener("abort", abort, { once: true });
50
+ if (options.signal?.aborted) abort();
51
+ }),
52
+ ]).finally(() => options.signal?.removeEventListener("abort", abort));
53
+ if (!saver) return [];
43
54
  await dialogCore.open<void>(
44
55
  (close) => (
45
- <FileSaver
56
+ <saver.FileSaver
46
57
  files={files}
47
58
  providers={providers}
48
59
  retryProviders={load}
@@ -1,4 +1,5 @@
1
1
  import { readFile } from "node:fs/promises";
2
+ import { isModelsOnlyApiKey } from "../contracts/shared";
2
3
  import { arg, type CliInputFlagValue, type CloudCliContext, command, confirmFlag, defineCliCommands, flag, readCliInput } from "./index";
3
4
 
4
5
  type User = {
@@ -31,6 +32,7 @@ type User = {
31
32
  };
32
33
 
33
34
  type ApiKey = {
35
+ scopes: string[];
34
36
  id: string;
35
37
  name: string;
36
38
  status: "active" | "revoked";
@@ -121,6 +123,7 @@ const apiKeyRows = (keys: ApiKey[]) =>
121
123
  id: key.id,
122
124
  name: key.name,
123
125
  prefix: key.tokenPrefix,
126
+ access: isModelsOnlyApiKey(key) ? "models" : "full",
124
127
  expires: key.expiresAt ?? "never",
125
128
  lastUsed: key.lastUsedAt ?? "never",
126
129
  created: key.createdAt,
@@ -283,6 +286,7 @@ export default defineCliCommands({
283
286
  printJsonOrTable(ctx, result, apiKeyRows(result.items), [
284
287
  { key: "name" },
285
288
  { key: "prefix" },
289
+ { key: "access" },
286
290
  { key: "expires" },
287
291
  { key: "lastUsed" },
288
292
  { key: "id" },
@@ -294,15 +298,30 @@ export default defineCliCommands({
294
298
  args: { name: arg.required({ valueLabel: "name", description: "API key name" }) },
295
299
  flags: {
296
300
  expires: flag.string({ description: "ISO timestamp or duration like 90d" }),
301
+ access: flag.enum(["full", "models"], { default: "full", description: "Full access or models-only inference access" }),
297
302
  },
298
303
  run: async ({ ctx, args, flags }) => {
299
304
  const result = await readApi<{ credential: ApiKey; token: string }>(
300
305
  ctx,
301
306
  "/api-keys",
302
- jsonRequest("POST", { name: args.name, expiresAt: parseExpiry(flags.expires) }),
307
+ jsonRequest("POST", { name: args.name, expiresAt: parseExpiry(flags.expires), access: flags.access }),
303
308
  );
309
+ const modelsOnly = isModelsOnlyApiKey(result.credential);
310
+ if (flags.access === "models" && !modelsOnly) {
311
+ try {
312
+ await readApi<{ message: string }>(ctx, `/api-keys/${encodeURIComponent(result.credential.id)}`, jsonRequest("DELETE"));
313
+ } catch {
314
+ throw new Error(
315
+ `The key was created with full access and could not be revoked automatically. Run: cld account api-keys revoke ${result.credential.id} --yes`,
316
+ );
317
+ }
318
+ throw new Error("This Cloud does not support models-only API keys. The full-access key it created was revoked.");
319
+ }
304
320
  if (ctx.options.output === "json") ctx.json(result);
305
- else ctx.print(`Token: ${result.token}\nPrefix: ${result.credential.tokenPrefix}\nStore this token now. It cannot be shown again.`);
321
+ else {
322
+ ctx.print(`Token: ${result.token}\nPrefix: ${result.credential.tokenPrefix}\nStore this token now. It cannot be shown again.`);
323
+ if (modelsOnly) ctx.print("This key works only with the OpenAI-compatible API at /api/ai/openai/v1 on this Cloud.");
324
+ }
306
325
  },
307
326
  }),
308
327
  command("api-keys revoke", {
@@ -617,8 +617,10 @@ const renderFlagUsage = (key: string, spec: CliFlagSpec): string => {
617
617
 
618
618
  const renderFlagDescription = (spec: CliFlagSpec): string => {
619
619
  const parts = [spec.description];
620
- if ("default" in spec && spec.default !== undefined)
621
- parts.push(`Default: ${Array.isArray(spec.default) ? spec.default.join(",") : spec.default}.`);
620
+ const hasDefault = "default" in spec && spec.default !== undefined;
621
+ if ("required" in spec && spec.required && !hasDefault) parts.push("Required.");
622
+ if (hasDefault) parts.push(`Default: ${Array.isArray(spec.default) ? spec.default.join(",") : spec.default}.`);
622
623
  if (spec.kind === "enum") parts.push(`Values: ${spec.values.join(", ")}.`);
624
+ if (parts.length > 1 && parts[0] && !/[.!?:]$/.test(parts[0])) parts[0] += ".";
623
625
  return parts.filter(Boolean).join(" ");
624
626
  };
@@ -14,10 +14,29 @@ export const APP_APPROVAL_LIMITS = {
14
14
  pageSize: 20,
15
15
  pollSeconds: 5,
16
16
  } as const;
17
+ /**
18
+ * Bounds of Action approvals on a device. Kept apart from `APP_APPROVAL_LIMITS`, whose every value a version 1
19
+ * authenticator checks when it connects. The texts are cut so that a whole inbox or Action stays within `bodyBytes`.
20
+ */
21
+ export const APP_ACTION_LIMITS = {
22
+ inboxActions: 5,
23
+ nameChars: 60,
24
+ titleChars: 80,
25
+ summaryChars: 120,
26
+ sentenceChars: 280,
27
+ messageChars: 500,
28
+ details: 8,
29
+ labelChars: 40,
30
+ valueChars: 300,
31
+ } as const;
32
+ /** Commands a Cloud serves beyond the first version, announced in `/info` as `features`. */
33
+ export const APP_APPROVAL_FEATURES = ["actions"] as const;
17
34
 
18
35
  const Id = z.string().uuid();
19
36
  const Secret = z.string().regex(/^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/);
20
37
  const Signature = z.string().regex(/^[A-Za-z0-9_-]{85}[AQgw]$/);
38
+ /** Unpadded base64url of a SHA-256 hash. */
39
+ const Digest = Secret;
21
40
  export const AppDevicePublicKeySchema = z
22
41
  .object({
23
42
  kty: z.literal("EC"),
@@ -57,6 +76,12 @@ export const AppDeviceCommandSchema = z.discriminatedUnion("operation", [
57
76
  z.object({ operation: z.literal("account") }).strict(),
58
77
  /** Stores the authenticator's opaque push token for sign-in wake-ups on this device. */
59
78
  z.object({ operation: z.literal("push"), token: z.string().regex(/^[A-Za-z0-9_-]{43}$/) }).strict(),
79
+ /** Pending sign-ins and the newest open Action approvals of the signing device's account in one call. */
80
+ z.object({ operation: z.literal("inbox") }).strict(),
81
+ /** One open Action approval as the device shows it, with the digest the device recomputes before it decides. */
82
+ z.object({ operation: z.literal("action"), actionId: Id }).strict(),
83
+ /** Decides an Action approval. `digest` binds the decision to exactly the view the person saw. */
84
+ z.object({ operation: z.literal("act"), actionId: Id, digest: Digest, decision: z.enum(["approve", "deny"]) }).strict(),
60
85
  z
61
86
  .object({
62
87
  operation: z.literal("decide"),
@@ -96,6 +121,8 @@ export const appDeviceProofMessage = (proof: AppDeviceProof): string => {
96
121
  c.operation,
97
122
  ...(c.operation === "decide" ? [c.requestId, c.challenge, c.comparison, c.decision] : []),
98
123
  ...(c.operation === "push" ? [c.token] : []),
124
+ ...(c.operation === "action" ? [c.actionId] : []),
125
+ ...(c.operation === "act" ? [c.actionId, c.digest, c.decision] : []),
99
126
  ]);
100
127
  };
101
128
  export const appPairingProofMessage = (issuer: string, claim: Omit<z.infer<typeof AppPairingClaimSchema>, "signature">): string =>
@@ -188,6 +215,97 @@ export const AppDeviceAccountSchema = z
188
215
  })
189
216
  .strict();
190
217
  export type AppDeviceAccount = z.infer<typeof AppDeviceAccountSchema>;
218
+ /** Plain text on one line: no control, format or bidirectional characters, so an app cannot fake Cloud's own frame. */
219
+ const PlainText = (max: number) =>
220
+ z
221
+ .string()
222
+ .refine((value) => Array.from(value).length <= max, `Must contain at most ${max} code points`)
223
+ .regex(/^[^\p{Cc}\p{Cf}\p{Zl}\p{Zp}]*$/u);
224
+ const ActionApp = z
225
+ .object({ id: PlainText(APP_ACTION_LIMITS.nameChars).min(1), name: PlainText(APP_ACTION_LIMITS.nameChars).min(1) })
226
+ .strict();
227
+ const actionFields = {
228
+ actionId: Id,
229
+ /** The app that owns the effect, named from the Cloud's registry, never from the request. */
230
+ app: ActionApp,
231
+ /** The app that asked, such as the Assistant, and the title of its chat. The title is chat content, not Cloud's words. */
232
+ requester: z.object({ app: ActionApp, title: PlainText(APP_ACTION_LIMITS.titleChars).nullable() }).strict(),
233
+ /** `confirm` asks every time; `standard` could also be remembered in Cloud. A device decides both alike. */
234
+ assurance: z.enum(["standard", "confirm"]),
235
+ createdAt: DateTime,
236
+ expiresAt: DateTime,
237
+ };
238
+ /** One inbox row. `sentence` is the start of the app's sentence; read the Action for everything the person approves. */
239
+ export const AppActionSummarySchema = z.object({ ...actionFields, sentence: PlainText(APP_ACTION_LIMITS.summaryChars).min(1) }).strict();
240
+ export const AppActionViewSchema = z
241
+ .object({
242
+ ...actionFields,
243
+ /** What the Action does, in the app's words and the person's language. */
244
+ sentence: PlainText(APP_ACTION_LIMITS.sentenceChars).min(1),
245
+ /** The app's review of this call, or `null`. */
246
+ message: PlainText(APP_ACTION_LIMITS.messageChars).nullable(),
247
+ details: z
248
+ .array(
249
+ z
250
+ .object({
251
+ label: PlainText(APP_ACTION_LIMITS.labelChars).min(1),
252
+ value: PlainText(APP_ACTION_LIMITS.valueChars),
253
+ /** A date (`YYYY-MM-DD` or RFC 3339) or an RFC 3339 date-time the device shows in its own locale and time zone. */
254
+ format: z.enum(["date", "date-time"]).optional(),
255
+ })
256
+ .strict(),
257
+ )
258
+ .max(APP_ACTION_LIMITS.details),
259
+ /** Cloud shortened or left out text; the complete request is in Cloud. */
260
+ truncated: z.boolean(),
261
+ /** SHA-256 of the Action's real input. The device cannot check it; it binds the decision to that input. */
262
+ argsDigest: z.string().min(1).max(128),
263
+ digest: Digest,
264
+ })
265
+ .strict();
266
+ export type AppActionSummary = z.infer<typeof AppActionSummarySchema>;
267
+ export type AppActionView = z.infer<typeof AppActionViewSchema>;
268
+ export const AppDeviceInboxSchema = z
269
+ .object({
270
+ logins: z.array(AppPendingLoginSchema).max(APP_APPROVAL_LIMITS.pendingPerAccount),
271
+ /** Newest first. */
272
+ actions: z.array(AppActionSummarySchema).max(APP_ACTION_LIMITS.inboxActions),
273
+ /** Open Action approvals beyond `actions`. */
274
+ moreActions: z.number().int().nonnegative(),
275
+ pollAfterSeconds: z.number().positive(),
276
+ })
277
+ .strict();
278
+ export type AppDeviceInbox = z.infer<typeof AppDeviceInboxSchema>;
279
+
280
+ /** UTF-8 of this exact JSON array is hashed into an Action's `digest`. Every field the device shows is part of it. */
281
+ export const appActionDigestMessage = (issuer: string, view: Omit<AppActionView, "digest">): string =>
282
+ JSON.stringify([
283
+ "cloud-action-v1",
284
+ issuer,
285
+ view.actionId,
286
+ view.app.id,
287
+ view.app.name,
288
+ view.requester.app.id,
289
+ view.requester.app.name,
290
+ view.requester.title,
291
+ view.assurance,
292
+ view.sentence,
293
+ view.message,
294
+ view.details.map((detail) => [detail.label, detail.value, detail.format ?? null]),
295
+ view.truncated,
296
+ view.createdAt,
297
+ view.expiresAt,
298
+ view.argsDigest,
299
+ ]);
300
+ /** Unpadded base64url of SHA-256 over `appActionDigestMessage`. Cloud and the device compute it alike. */
301
+ export const appActionDigest = async (issuer: string, view: Omit<AppActionView, "digest">): Promise<string> => {
302
+ const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(appActionDigestMessage(issuer, view))));
303
+ return btoa(String.fromCharCode(...hash))
304
+ .replaceAll("+", "-")
305
+ .replaceAll("/", "_")
306
+ .replaceAll("=", "");
307
+ };
308
+
191
309
  export const AppDeviceResponseSchema = z.union([
192
310
  z
193
311
  .object({
@@ -197,6 +315,8 @@ export const AppDeviceResponseSchema = z.union([
197
315
  .strict(),
198
316
  z.object({ state: z.enum(["approved", "denied", "revoked", "updated"]) }).strict(),
199
317
  AppDeviceAccountSchema,
318
+ AppDeviceInboxSchema,
319
+ AppActionViewSchema,
200
320
  ]);
201
321
  export const AppLoginStartResultSchema = z
202
322
  .object({ requestId: Id, browserSecret: Secret, comparison: Comparison, expiresAt: DateTime, pollAfterSeconds: z.number().positive() })
@@ -0,0 +1,4 @@
1
+ export const APPROVALS_API_PATH = "/api/approvals";
2
+ export const APPROVAL_COUNTS_PATH = `${APPROVALS_API_PATH}/counts`;
3
+ /** The Core page that lists every app's open approvals; `?app=<id>` narrows it to one app. */
4
+ export const APPROVALS_PAGE_PATH = "/me/approvals";