@k2b/cloud 0.30.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 (171) hide show
  1. package/package.json +7 -4
  2. package/src/_internal/app-presentation.ts +22 -1
  3. package/src/_internal/capabilities.ts +2 -2
  4. package/src/_internal/define-app.ts +17 -5
  5. package/src/_internal/registry.ts +50 -6
  6. package/src/_internal/widget-declarations.ts +53 -0
  7. package/src/ai/app-skills.ts +1 -7
  8. package/src/ai/approval-routes.ts +40 -4
  9. package/src/ai/approvals.ts +68 -10
  10. package/src/ai/audio-tool.ts +2 -1
  11. package/src/ai/browser.ts +109 -0
  12. package/src/ai/capabilities.ts +6 -2
  13. package/src/ai/chart-block.ts +431 -81
  14. package/src/ai/chat/action-sentences.ts +13 -7
  15. package/src/ai/chat/blocks.tsx +64 -39
  16. package/src/ai/chat/builtin-tools.tsx +9 -7
  17. package/src/ai/chat/chart-block.tsx +218 -44
  18. package/src/ai/chat/explorer-block.tsx +276 -0
  19. package/src/ai/chat/file-tools.tsx +40 -1
  20. package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
  21. package/src/ai/chat/message-actions.tsx +9 -1
  22. package/src/ai/chat/message-utils.ts +7 -0
  23. package/src/ai/chat/messages.ts +34 -0
  24. package/src/ai/chat/presentation.tsx +13 -3
  25. package/src/ai/chat/reference-text.tsx +34 -0
  26. package/src/ai/chat/tool-groups.ts +67 -12
  27. package/src/ai/chat/tool-names.ts +18 -2
  28. package/src/ai/chat/turn-layout.ts +8 -0
  29. package/src/ai/chat/turn-view.tsx +111 -32
  30. package/src/ai/chat/view-block.browser-harness.tsx +35 -0
  31. package/src/ai/chat/view-block.tsx +417 -0
  32. package/src/ai/chat/visual-tools.tsx +32 -53
  33. package/src/ai/chat/web-tools.tsx +95 -1
  34. package/src/ai/client/controller.ts +6 -3
  35. package/src/ai/client-tool-results.ts +9 -0
  36. package/src/ai/code-runtime-tools.ts +50 -17
  37. package/src/ai/code-source-contracts.ts +25 -21
  38. package/src/ai/code-source-tools.ts +3 -1
  39. package/src/ai/default-tool-contracts.ts +334 -91
  40. package/src/ai/default-tools.ts +87 -11
  41. package/src/ai/executor.ts +343 -45
  42. package/src/ai/fetch-file-tool.ts +41 -4
  43. package/src/ai/file-error.ts +2 -0
  44. package/src/ai/file-tools.ts +27 -7
  45. package/src/ai/files-store.ts +60 -0
  46. package/src/ai/firecrawl-tools.ts +87 -4
  47. package/src/ai/html-pdf-tool.ts +3 -2
  48. package/src/ai/http.ts +37 -12
  49. package/src/ai/index.ts +98 -0
  50. package/src/ai/local-tool-contracts.ts +244 -0
  51. package/src/ai/malformed-tool-call-repair.ts +144 -0
  52. package/src/ai/markdown-pdf-tool.ts +2 -1
  53. package/src/ai/migrate.ts +120 -2
  54. package/src/ai/openai-api-messages.ts +53 -0
  55. package/src/ai/openai-api-slots.ts +40 -0
  56. package/src/ai/openai-api.ts +383 -0
  57. package/src/ai/protocol.ts +28 -3
  58. package/src/ai/provider.ts +16 -4
  59. package/src/ai/request-context.ts +33 -0
  60. package/src/ai/routes.ts +99 -49
  61. package/src/ai/runtime.ts +90 -15
  62. package/src/ai/settings.ts +5 -0
  63. package/src/ai/skill-catalog.ts +20 -5
  64. package/src/ai/skill-format.ts +17 -7
  65. package/src/ai/skill-tool.ts +15 -5
  66. package/src/ai/skills-routes.ts +20 -6
  67. package/src/ai/skills.ts +26 -7
  68. package/src/ai/store.ts +91 -1
  69. package/src/ai/system-prompt.ts +69 -10
  70. package/src/ai/timeline.ts +3 -1
  71. package/src/ai/tool-audit.ts +11 -1
  72. package/src/ai/tools.ts +94 -3
  73. package/src/ai/types.ts +48 -0
  74. package/src/ai/view-block.ts +941 -0
  75. package/src/ai/vision-tool.ts +2 -1
  76. package/src/ai/web-provenance.ts +103 -0
  77. package/src/api/admin-identity.ts +6 -1
  78. package/src/api/app-approval-actions.ts +36 -0
  79. package/src/api/app-approval.ts +10 -2
  80. package/src/api/approvals-loader.ts +15 -0
  81. package/src/api/approvals.ts +120 -0
  82. package/src/api/capabilities.ts +3 -2
  83. package/src/api/index.ts +3 -0
  84. package/src/api/me.ts +12 -3
  85. package/src/api/widgets.ts +36 -5
  86. package/src/approvals/Approvals.browser-harness.tsx +16 -0
  87. package/src/approvals/Approvals.tsx +621 -0
  88. package/src/approvals/ApprovalsNavStatus.tsx +20 -0
  89. package/src/approvals/messages.ts +95 -0
  90. package/src/approvals/ui.ts +8 -0
  91. package/src/browser/CloudResourceSearch.tsx +1 -1
  92. package/src/browser/app-approval.ts +32 -0
  93. package/src/browser/choose-files.tsx +26 -13
  94. package/src/browser/command-shortcuts.ts +21 -6
  95. package/src/browser/commands.ts +13 -4
  96. package/src/browser/file-provider-error.ts +11 -0
  97. package/src/browser/file-providers.ts +3 -12
  98. package/src/browser/files.tsx +1 -2
  99. package/src/browser/live-connection.ts +2 -3
  100. package/src/browser/provider-list.ts +2 -1
  101. package/src/browser/save-files.tsx +13 -2
  102. package/src/browser/spotlight-position.ts +4 -1
  103. package/src/browser/widgets.ts +7 -5
  104. package/src/capabilities/executions.ts +3 -0
  105. package/src/cli/account.ts +21 -2
  106. package/src/cli/commands.ts +24 -2
  107. package/src/cli/index.ts +16 -0
  108. package/src/cli/plugin.ts +30 -0
  109. package/src/contracts/app-approval.ts +120 -0
  110. package/src/contracts/app.ts +34 -2
  111. package/src/contracts/approval-paths.ts +4 -0
  112. package/src/contracts/approvals.ts +81 -0
  113. package/src/contracts/command-path.ts +17 -0
  114. package/src/contracts/commands.ts +5 -15
  115. package/src/contracts/registry.ts +9 -1
  116. package/src/contracts/shared.ts +5 -0
  117. package/src/contracts/widgets.ts +47 -2
  118. package/src/desktop/solid.tsx +6 -7
  119. package/src/events/live-protocol.ts +2 -1
  120. package/src/events/live-server-message.ts +40 -0
  121. package/src/events/testing.ts +88 -0
  122. package/src/server/index.ts +3 -0
  123. package/src/server/middleware/auth.ts +11 -1
  124. package/src/server/request-timeout.ts +22 -0
  125. package/src/server/services/access.ts +4 -3
  126. package/src/server/widget-request.ts +27 -0
  127. package/src/services/app-approval.ts +103 -15
  128. package/src/services/approvals/cursor.ts +15 -0
  129. package/src/services/approvals/device.ts +112 -0
  130. package/src/services/approvals/index.ts +241 -0
  131. package/src/services/approvals/list.ts +120 -0
  132. package/src/services/approvals/migrate.ts +41 -0
  133. package/src/services/identity/models-only-auth.ts +25 -0
  134. package/src/services/notifications/email-frame.ts +16 -3
  135. package/src/services/service-account-credentials.ts +22 -10
  136. package/src/services/settings/core-settings.ts +27 -0
  137. package/src/shared/ai-platform-prompt.ts +26 -1
  138. package/src/shared/app-presentation.ts +11 -0
  139. package/src/shared/email-html.ts +46 -7
  140. package/src/shared/index.ts +6 -1
  141. package/src/shared/markdown/extensions/code.ts +14 -0
  142. package/src/shared/markdown/index.ts +4 -4
  143. package/src/ssr/AdminLayout.tsx +4 -7
  144. package/src/ssr/AppBadge.tsx +4 -4
  145. package/src/ssr/AppLaunchpad.island.tsx +1 -1
  146. package/src/ssr/AppLaunchpadPanel.tsx +4 -4
  147. package/src/ssr/Layout.tsx +38 -14
  148. package/src/ssr/LayoutFooter.tsx +1 -1
  149. package/src/ssr/LayoutHeader.tsx +17 -20
  150. package/src/ssr/LayoutHelp.tsx +1 -1
  151. package/src/ssr/LayoutRail.tsx +1 -2
  152. package/src/ssr/PageTrail.tsx +23 -0
  153. package/src/ssr/PageTrailRow.island.tsx +20 -0
  154. package/src/ssr/PageTrailRuntime.island.tsx +26 -0
  155. package/src/ssr/ProfilePreferences.island.tsx +7 -3
  156. package/src/ssr/RailApps.island.tsx +2 -2
  157. package/src/ssr/app-badges.ts +50 -6
  158. package/src/ssr/islands/index.ts +1 -0
  159. package/src/ssr/layout-runtime.ts +60 -0
  160. package/src/ssr/mobile-menu-history.ts +1 -1
  161. package/src/ssr/page-trail-runtime.ts +313 -0
  162. package/src/ssr/page-trail.browser-harness.tsx +265 -0
  163. package/src/ssr/page-trail.ts +59 -0
  164. package/src/ssr/platform-messages.ts +4 -0
  165. package/src/ssr/rail-navigation.ts +5 -3
  166. package/src/ssr/runtime.ts +6 -1
  167. package/src/styles/approvals.css +390 -0
  168. package/src/styles/effects.css +21 -4
  169. package/src/styles/global.css +39 -3
  170. package/src/styles/utilities-layout.css +24 -1
  171. package/src/styles/utilities-navigation.css +6 -18
package/src/cli/index.ts CHANGED
@@ -51,6 +51,12 @@ export type CloudCliContext = {
51
51
  profiles?: {
52
52
  saveClientCredentials: (input: CloudCliClientCredentialsProfile) => Promise<void>;
53
53
  };
54
+ /**
55
+ * Open an `http:` or `https:` URL in the user's browser, when the host can.
56
+ * Resolves `false` when nothing was opened, so the command still prints the
57
+ * link. Absent in hosts without a local browser.
58
+ */
59
+ openUrl?: (url: string) => Promise<boolean>;
54
60
  };
55
61
 
56
62
  /** A profile that signs in with the OAuth client-credentials grant. */
@@ -70,6 +76,16 @@ export type CloudCliClientCredentialsProfile = {
70
76
  export type CloudCliModule = {
71
77
  name: string;
72
78
  summary: string;
79
+ /**
80
+ * `cld <alias> …` runs `cld <name> …`. Names match CLOUD_CLI_MODULE_NAME;
81
+ * cld uses them only when exactly one installed module claims them and no built-in or module has that name.
82
+ */
83
+ alias?: string;
84
+ /**
85
+ * Top-level names mapped to space-separated command paths relative to this module, never preset flags or args.
86
+ * Names match CLOUD_CLI_MODULE_NAME; cld uses them only when exactly one installed module claims them and no built-in or module has that name.
87
+ */
88
+ shortcuts?: Readonly<Record<string, string>>;
73
89
  booleanFlags?: readonly string[];
74
90
  requiresCloud?: boolean;
75
91
  requiresCloudFor?: (args: string[], flags: CloudCliFlags) => boolean;
package/src/cli/plugin.ts CHANGED
@@ -67,6 +67,27 @@ export const cloudCliPluginDigest = (files: readonly Pick<CloudCliPluginFile, "p
67
67
 
68
68
  const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
69
69
 
70
+ /** Whether an alias is a valid extra name for this module. */
71
+ export const isCloudCliAlias = (value: unknown, name: string): value is string =>
72
+ typeof value === "string" && CLOUD_CLI_MODULE_NAME.test(value) && value !== name;
73
+
74
+ /** Validate short names and command-path syntax without loading a module's command tree. */
75
+ export const isCloudCliShortcuts = (value: unknown, name: string, alias?: string): value is Record<string, string> =>
76
+ isRecord(value) &&
77
+ (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) &&
78
+ Object.entries(value).every(
79
+ ([key, target]) =>
80
+ CLOUD_CLI_MODULE_NAME.test(key) &&
81
+ key !== name &&
82
+ key !== alias &&
83
+ typeof target === "string" &&
84
+ target.trim() !== "" &&
85
+ target
86
+ .trim()
87
+ .split(/\s+/)
88
+ .every((segment) => !segment.startsWith("-")),
89
+ );
90
+
70
91
  /** Validate a served manifest, including its digest. Throws an `Error` naming the first problem. */
71
92
  export const parseCloudCliPluginManifest = (value: unknown): CloudCliPluginManifest => {
72
93
  if (!isRecord(value)) throw new Error("plugin manifest is not an object");
@@ -111,6 +132,15 @@ export const validateCloudCliModule = (value: unknown): CloudCliModule => {
111
132
  if (typeof value.name !== "string" || !CLOUD_CLI_MODULE_NAME.test(value.name)) {
112
133
  throw new Error("default export needs a lowercase kebab-case name");
113
134
  }
135
+ if (value.alias !== undefined && !isCloudCliAlias(value.alias, value.name)) {
136
+ throw new Error("alias must be a lowercase kebab-case name different from the module name");
137
+ }
138
+ if (
139
+ value.shortcuts !== undefined &&
140
+ !isCloudCliShortcuts(value.shortcuts, value.name, typeof value.alias === "string" ? value.alias : undefined)
141
+ ) {
142
+ throw new Error("shortcuts must be a plain object with unique kebab-case names and non-empty command paths without flags");
143
+ }
114
144
  if (typeof value.summary !== "string") throw new Error("default export needs a string summary");
115
145
  if (typeof value.run !== "function") throw new Error("default export needs a run function");
116
146
  for (const key of ["help", "requiresCloudFor"] as const) {
@@ -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() })
@@ -2,7 +2,7 @@ import type { Sync } from "@k2b/sync";
2
2
  import type { PlatformPermission } from "./outgoing-mail";
3
3
  import type { AppRegistryHelpSummary } from "./registry";
4
4
  import type { Role } from "./shared";
5
- import type { DashboardWidgetPresentation } from "./widgets";
5
+ import type { DashboardWidgetPresentation, DashboardWidgetSize } from "./widgets";
6
6
 
7
7
  /**
8
8
  * One link entry contributed by an app to the global legal/info footer
@@ -69,6 +69,13 @@ export type AppPresentationTranslation = {
69
69
  searchLinks?: Readonly<Record<string, string>>;
70
70
  /** Search-link descriptions keyed by their stable same-origin href. */
71
71
  searchLinkDescriptions?: Readonly<Record<string, string>>;
72
+ /** Dashboard widget titles and descriptions keyed by `widgets[].id`. */
73
+ widgets?: Readonly<Record<string, AppWidgetTranslation>>;
74
+ };
75
+
76
+ export type AppWidgetTranslation = {
77
+ title?: string;
78
+ description?: string;
72
79
  };
73
80
 
74
81
  export type AppPresentationCatalog = {
@@ -161,7 +168,32 @@ export type WidgetEndpoint = {
161
168
  id: string;
162
169
  /** Absolute path on the app's HTTP service, e.g. "/api/accounts/widget/open-requests". */
163
170
  path: string;
164
- /** Optional initial layout recommendation. Explicit user choices win. */
171
+ /**
172
+ * Name of the widget in the dashboard and its widget gallery, up to 80 characters, in the app's `baseLocale`;
173
+ * translate it with `presentation.translations[locale].widgets[id].title`. Defaults to the app's name.
174
+ */
175
+ title?: string;
176
+ /** One sentence for the widget gallery, up to 200 characters; translated like `title`. Defaults to the app's description. */
177
+ description?: string;
178
+ /** Sizes the widget offers on the board. Defaults to `["large"]`; offer smaller sizes only when the handler fills them. */
179
+ sizes?: readonly DashboardWidgetSize[];
180
+ /** Size the widget starts in, one of `sizes`. Defaults to the largest offered size. */
181
+ defaultSize?: DashboardWidgetSize;
182
+ /**
183
+ * Put the widget on the default board of everyone who may see it and list it under "Suggested for you" in the
184
+ * gallery. Suggest only widgets that are useful without any setup.
185
+ */
186
+ suggest?: boolean;
187
+ /**
188
+ * Show the widget in the gallery and on the default board only to people with one of these roles, like
189
+ * `nav.requiresRoles`. Visibility only: the handler still authorizes every request and answers `403`.
190
+ */
191
+ requiresRoles?: readonly Role[];
192
+ /**
193
+ * @deprecated The dashboard has no zones or widths any more; declare `sizes` and `defaultSize` instead. Keep an
194
+ * existing value: the dashboard reads it only to convert a board saved before sizes existed into the one the person
195
+ * saw.
196
+ */
165
197
  presentation?: DashboardWidgetPresentation;
166
198
  };
167
199
 
@@ -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";
@@ -0,0 +1,81 @@
1
+ import { z } from "zod";
2
+ import { APP_APPROVAL_LIMITS } from "./app-approval";
3
+ import { CapabilityActionReviewSchema, CapabilityAppIdSchema } from "./capabilities";
4
+
5
+ export { APPROVAL_COUNTS_PATH, APPROVALS_API_PATH, APPROVALS_PAGE_PATH } from "./approval-paths";
6
+
7
+ /**
8
+ * Pending Action approvals as every app lists and decides them: one Core route under `/api/approvals` and the
9
+ * `Approvals` component from `@k2b/cloud/approvals/ui`. A list shows only the signed-in person's own open requests.
10
+ */
11
+
12
+ /** One page of a list, the same as a Cloud Login inbox page. */
13
+ export const APPROVALS_PAGE_SIZE = APP_APPROVAL_LIMITS.pageSize;
14
+
15
+ /**
16
+ * A client that relays decisions from an editor over the Agent Client Protocol sends this header with the value
17
+ * `acp`. Such a client may reject any request, but approves only `standard` ones. Cloud cannot verify the header: it is
18
+ * the bridge's own declaration, which keeps the editor that drives the bridge from approving a `confirm` request.
19
+ */
20
+ export const APPROVAL_CLIENT_HEADER = "x-cloud-client";
21
+ export const APPROVAL_ACP_CLIENT = "acp";
22
+
23
+ export const ApprovalListQuerySchema = z
24
+ .object({
25
+ /** Requests whose effect belongs to this app, or that this app asked for. */
26
+ app: CapabilityAppIdSchema.optional(),
27
+ /** Requests whose review links to this same-origin path, such as the draft a send Action would send. */
28
+ link: z.string().startsWith("/").max(2048).optional(),
29
+ cursor: z.string().min(1).max(200).optional(),
30
+ })
31
+ .strict();
32
+ export type ApprovalListQuery = z.infer<typeof ApprovalListQuerySchema>;
33
+
34
+ export const PendingApprovalSchema = z
35
+ .object({
36
+ id: z.uuid(),
37
+ /** The app that owns the effect, named from the registry in the reader's language. */
38
+ app: z.object({ id: z.string(), name: z.string(), icon: z.string().nullable() }).strict(),
39
+ action: z.string(),
40
+ /** `confirm` asks every time and never offers to remember; `standard` may offer it. */
41
+ assurance: z.enum(["standard", "confirm"]),
42
+ /** What the person reads: the first line names the app and the Action, later lines add `Label: value` details. */
43
+ sentence: z.string(),
44
+ /** The owning app's review as the person saw it when Cloud asked. */
45
+ review: CapabilityActionReviewSchema.nullable(),
46
+ /** Where the request waits. The title is chat content, written by a person or a model, never Cloud's own words. */
47
+ requester: z.object({ kind: z.literal("chat"), appId: z.string(), title: z.string().nullable(), href: z.string() }).strict(),
48
+ /** Ways the person may remember this approval, the same the chat card offers. */
49
+ remember: z.object({ chat: z.boolean(), always: z.boolean(), website: z.boolean() }).strict(),
50
+ createdAt: z.string(),
51
+ expiresAt: z.string(),
52
+ })
53
+ .strict();
54
+ export type PendingApproval = z.infer<typeof PendingApprovalSchema>;
55
+
56
+ export const ApprovalPageSchema = z
57
+ .object({
58
+ items: z.array(PendingApprovalSchema).max(APPROVALS_PAGE_SIZE),
59
+ nextCursor: z.string().nullable(),
60
+ /** Open requests that match the query, across all pages. */
61
+ count: z.number().int().nonnegative(),
62
+ /** Open requests of the person that do not match the query; 0 without `app` or `link`. */
63
+ elsewhere: z.number().int().nonnegative(),
64
+ })
65
+ .strict();
66
+ export type ApprovalPage = z.infer<typeof ApprovalPageSchema>;
67
+
68
+ export const ApprovalCountsSchema = z
69
+ .object({
70
+ total: z.number().int().nonnegative(),
71
+ /** Per app ID: open requests whose effect belongs to the app or that the app asked for, as `?app=` lists them. */
72
+ apps: z.record(z.string(), z.number().int().positive()),
73
+ })
74
+ .strict();
75
+ export type ApprovalCounts = z.infer<typeof ApprovalCountsSchema>;
76
+
77
+ export const ApprovalDecisionInputSchema = z.object({ approved: z.boolean(), remember: z.enum(["chat", "always"]).optional() }).strict();
78
+ export type ApprovalDecisionInput = z.infer<typeof ApprovalDecisionInputSchema>;
79
+
80
+ export const ApprovalDecisionResultSchema = z.object({ status: z.enum(["approved", "rejected"]) }).strict();
81
+ export type ApprovalDecisionResult = z.infer<typeof ApprovalDecisionResultSchema>;
@@ -0,0 +1,17 @@
1
+ /** Commands carry small public references/options, never document bodies or secrets. */
2
+ export const COMMAND_MAX_URL_LENGTH = 8192;
3
+
4
+ /** The same-origin path rule of `CommandPathSchema`, without its length limit. */
5
+ export const isCommandPathSyntax = (value: string): boolean => {
6
+ if (!value.startsWith("/") || /[\\\u0000-\u0020]/u.test(value)) return false;
7
+ try {
8
+ const url = new URL(value, "https://cloud.invalid");
9
+ // Dot segments can resolve to `//host`, which a browser reads as another host.
10
+ return url.origin === "https://cloud.invalid" && !url.pathname.startsWith("//");
11
+ } catch {
12
+ return false;
13
+ }
14
+ };
15
+
16
+ /** Whether `value` is a Cloud path that `CommandPathSchema` accepts; loads no validators. */
17
+ export const isCommandPath = (value: string): boolean => (value.length <= 2000 || [...value].length <= 2000) && isCommandPathSyntax(value);
@@ -1,20 +1,10 @@
1
1
  import { z } from "zod";
2
+ import { COMMAND_MAX_URL_LENGTH, isCommandPathSyntax } from "./command-path";
2
3
 
3
- /** Commands carry small public references/options, never document bodies or secrets. */
4
- export const COMMAND_MAX_URL_LENGTH = 8192;
5
- export const CommandPathSchema = z
6
- .string()
7
- .max(2000)
8
- .refine((value) => {
9
- if (!value.startsWith("/") || /[\\\u0000-\u0020]/u.test(value)) return false;
10
- try {
11
- const url = new URL(value, "https://cloud.invalid");
12
- // Dot segments can resolve to `//host`, which a browser reads as another host.
13
- return url.origin === "https://cloud.invalid" && !url.pathname.startsWith("//");
14
- } catch {
15
- return false;
16
- }
17
- }, "Expected a Cloud path");
4
+ export { COMMAND_MAX_URL_LENGTH, isCommandPath } from "./command-path";
5
+
6
+ // Keep the size error separate from the syntax error, as before.
7
+ export const CommandPathSchema = z.string().max(2000).refine(isCommandPathSyntax, "Expected a Cloud path");
18
8
 
19
9
  export const CommandOptionsSchema = z.object({ returnTo: CommandPathSchema.optional() }).strict();
20
10
  export type CommandOptions = z.infer<typeof CommandOptionsSchema>;
@@ -2,7 +2,7 @@ import type { AppAdminNavigationGroup, AppAppearance, AppAppearanceColor, AppPre
2
2
  import type { CapabilityManifest, CapabilityPresentationCatalog } from "./capabilities";
3
3
  import type { PlatformPermission } from "./outgoing-mail";
4
4
  import type { Role } from "./shared";
5
- import type { DashboardWidgetPresentation } from "./widgets";
5
+ import type { DashboardWidgetPresentation, DashboardWidgetSize } from "./widgets";
6
6
 
7
7
  /**
8
8
  * App-registry entry type. Populated internally by `defineApp()` + the
@@ -52,10 +52,18 @@ export type AppRegistryLegalLink = {
52
52
  icon?: string;
53
53
  };
54
54
 
55
+ /** A widget declaration as the registry carries it; see `WidgetEndpoint`. Older apps send only `id` and `path`. */
55
56
  export type AppRegistryWidget = {
56
57
  id: string;
57
58
  /** Absolute path on the app's HTTP service, e.g. "/api/quotes/widget/random". */
58
59
  path: string;
60
+ title?: string;
61
+ description?: string;
62
+ sizes?: readonly DashboardWidgetSize[];
63
+ defaultSize?: DashboardWidgetSize;
64
+ suggest?: boolean;
65
+ requiresRoles?: readonly Role[];
66
+ /** @deprecated Read only to convert dashboard boards saved before sizes existed. */
59
67
  presentation?: DashboardWidgetPresentation;
60
68
  };
61
69
 
@@ -248,7 +248,12 @@ export const ServiceAccountCredentialSchema = z.object({
248
248
  });
249
249
  export type ServiceAccountCredential = z.infer<typeof ServiceAccountCredentialSchema>;
250
250
 
251
+ export const AI_INFERENCE_SCOPE = "ai:inference" as const;
252
+
253
+ export const isModelsOnlyApiKey = (credential: { scopes: readonly string[] }): boolean => credential.scopes.includes(AI_INFERENCE_SCOPE);
254
+
251
255
  export const CreateUserApiKeySchema = z.object({
256
+ access: z.enum(["full", "models"]).optional(),
252
257
  name: z.string().trim().min(1).max(120),
253
258
  expiresAt: z.string().datetime().nullable().optional(),
254
259
  });
@@ -38,18 +38,63 @@ const WidgetHrefSchema = z
38
38
  export const WidgetToneSchema = z.enum(["emerald", "amber", "red", "blue", "zinc"]);
39
39
  export type WidgetTone = z.infer<typeof WidgetToneSchema>;
40
40
 
41
+ /** @deprecated The dashboard no longer has zones; declare `sizes` and `defaultSize` instead. */
41
42
  export type DashboardWidgetZone = "focus" | "overview" | "context";
43
+ /** @deprecated The dashboard no longer has spans; declare `sizes` and `defaultSize` instead. */
42
44
  export type DashboardWidgetSpan = "standard" | "wide";
43
45
 
44
46
  /**
45
- * Stable, app-owned recommendation for a widget's initial dashboard layout.
46
- * Explicit user layout settings always take precedence.
47
+ * @deprecated The dashboard has no zones or widths any more. Declare `sizes` and `defaultSize` on the widget instead;
48
+ * the dashboard reads this recommendation only to convert a board saved before sizes existed.
47
49
  */
48
50
  export type DashboardWidgetPresentation = {
49
51
  defaultZone?: DashboardWidgetZone;
50
52
  defaultSpan?: DashboardWidgetSpan;
51
53
  };
52
54
 
55
+ /**
56
+ * The three sizes a widget can take on the dashboard board, which has four columns on wider screens and two on
57
+ * phones: `small` is one column and one row, `medium` two columns and one row, and `large` two columns and two rows.
58
+ * On a phone, `small` takes half the width and `medium` and `large` the full width.
59
+ */
60
+ export const DASHBOARD_WIDGET_SIZES = ["small", "medium", "large"] as const;
61
+ export type DashboardWidgetSize = (typeof DASHBOARD_WIDGET_SIZES)[number];
62
+
63
+ /** Longest declared widget title, in characters, the same bound as a shortcut title. */
64
+ export const WIDGET_TITLE_MAX_LENGTH = 80;
65
+ /** Longest declared widget description, in characters: one sentence in the widget gallery. */
66
+ export const WIDGET_DESCRIPTION_MAX_LENGTH = 200;
67
+
68
+ export const isDashboardWidgetSize = (value: unknown): value is DashboardWidgetSize =>
69
+ DASHBOARD_WIDGET_SIZES.some((size) => size === value);
70
+
71
+ /**
72
+ * The sizes a widget offers and the size it starts with. A widget that declares no valid size offers only `large`,
73
+ * the height every widget had before sizes existed; without a valid `defaultSize`, it starts at its largest size.
74
+ */
75
+ export const resolveWidgetSizes = (declaration: {
76
+ sizes?: readonly unknown[];
77
+ defaultSize?: unknown;
78
+ }): { sizes: DashboardWidgetSize[]; defaultSize: DashboardWidgetSize } => {
79
+ const declared = DASHBOARD_WIDGET_SIZES.filter((size) => declaration.sizes?.includes(size));
80
+ const sizes: DashboardWidgetSize[] = declared.length > 0 ? declared : ["large"];
81
+ const defaultSize =
82
+ isDashboardWidgetSize(declaration.defaultSize) && sizes.includes(declaration.defaultSize) ? declaration.defaultSize : sizes.at(-1)!;
83
+ return { sizes, defaultSize };
84
+ };
85
+
86
+ /** `requested` when the widget offers it, otherwise the widget's default size. */
87
+ export const fitWidgetSize = (
88
+ requested: unknown,
89
+ widget: { sizes: readonly DashboardWidgetSize[]; defaultSize: DashboardWidgetSize },
90
+ ): DashboardWidgetSize => (isDashboardWidgetSize(requested) && widget.sizes.includes(requested) ? requested : widget.defaultSize);
91
+
92
+ /** What the dashboard asks a widget handler for, read with `getWidgetRequest(c)`. */
93
+ export type WidgetRequest = {
94
+ /** The size the widget is shown in, always one the widget declares. */
95
+ size: DashboardWidgetSize;
96
+ };
97
+
53
98
  export const WidgetAccentSchema = z
54
99
  .object({
55
100
  tone: WidgetToneSchema,
@@ -1,3 +1,4 @@
1
+ import { suppressTextSelection } from "@k2b/ui";
1
2
  import { deserialize as deserializeProps, serialize as serializeProps } from "seroval";
2
3
  import type { Component, JSX, ParentProps } from "solid-js";
3
4
  import { children, createContext, createMemo, createSignal, For, onCleanup, onMount, Show, useContext } from "solid-js";
@@ -483,10 +484,11 @@ const startDesktopResize = (event: PointerEvent, onMove: (dx: number, dy: number
483
484
  const startX = event.clientX;
484
485
  const startY = event.clientY;
485
486
  const move = (next: PointerEvent) => onMove(next.clientX - startX, next.clientY - startY);
487
+ const releaseTextSelection = suppressTextSelection(event.pointerId);
486
488
  const stop = () => {
487
489
  window.removeEventListener("pointermove", move);
488
490
  window.removeEventListener("pointerup", stop);
489
- document.body.style.userSelect = "";
491
+ releaseTextSelection();
490
492
  document.body.style.cursor = "";
491
493
  };
492
494
  window.addEventListener("pointermove", move);
@@ -631,10 +633,9 @@ export const DesktopWorkspace = ((props: DesktopWorkspaceProps) => {
631
633
 
632
634
  const resizeSidebar = (event: PointerEvent) => {
633
635
  const pane = sidebar();
634
- if (!pane?.resizable) return;
636
+ if (!pane?.resizable || event.button !== 0) return;
635
637
  const start = sidebarOpen() ? sidebarSize() : sidebarColumnSize();
636
638
  document.body.style.cursor = "col-resize";
637
- document.body.style.userSelect = "none";
638
639
  startDesktopResize(event, (dx) => {
639
640
  const raw = start + dx;
640
641
  if (shouldRail(pane, raw)) {
@@ -650,10 +651,9 @@ export const DesktopWorkspace = ((props: DesktopWorkspaceProps) => {
650
651
 
651
652
  const resizeRight = (event: PointerEvent) => {
652
653
  const pane = right();
653
- if (!pane?.resizable) return;
654
+ if (!pane?.resizable || event.button !== 0) return;
654
655
  const start = rightOpen() ? rightSize() : rightColumnSize();
655
656
  document.body.style.cursor = "col-resize";
656
- document.body.style.userSelect = "none";
657
657
  startDesktopResize(event, (dx) => {
658
658
  const raw = start - dx;
659
659
  if (shouldRail(pane, raw)) {
@@ -669,10 +669,9 @@ export const DesktopWorkspace = ((props: DesktopWorkspaceProps) => {
669
669
 
670
670
  const resizeBottom = (event: PointerEvent) => {
671
671
  const pane = bottom();
672
- if (!pane?.resizable) return;
672
+ if (!pane?.resizable || event.button !== 0) return;
673
673
  const start = bottomOpen() ? bottomSize() : bottomRowSize();
674
674
  document.body.style.cursor = "row-resize";
675
- document.body.style.userSelect = "none";
676
675
  startDesktopResize(event, (_dx, dy) => {
677
676
  const raw = start - dy;
678
677
  if (shouldRail(pane, raw)) {
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
2
 
3
+ export type { LiveServerMessage } from "./live-server-message";
4
+
3
5
  /** Messages a live socket accepts: subscriptions only. Writes go over HTTP. */
4
6
  export const LiveClientMessageSchema = z.discriminatedUnion("t", [
5
7
  z
@@ -28,4 +30,3 @@ export const LiveServerMessageSchema = z.discriminatedUnion("t", [
28
30
  z.object({ t: z.literal("revoked"), id: z.string(), code: z.enum(["not_found", "access_denied"]) }),
29
31
  z.object({ t: z.literal("error"), code: z.string(), message: z.string() }),
30
32
  ]);
31
- export type LiveServerMessage = z.infer<typeof LiveServerMessageSchema>;
@@ -0,0 +1,40 @@
1
+ /** The server's live wire messages; validation equivalence is checked against live-protocol. */
2
+ export type LiveServerMessage =
3
+ | { t: "ready"; id: string; cursor: string }
4
+ | { t: "event"; id: string; cursor: string; data: unknown }
5
+ | { t: "progress"; cursor: string }
6
+ | { t: "resync"; id: string; cursor: string }
7
+ | { t: "revoked"; id: string; code: "not_found" | "access_denied" }
8
+ | { t: "error"; code: string; message: string };
9
+
10
+ /** Strip undeclared keys just like the server-message schema. */
11
+ export const parseLiveServerMessage = (value: unknown): LiveServerMessage | null => {
12
+ if (typeof value !== "object" || value === null || Array.isArray(value) || !("t" in value)) return null;
13
+ switch (value.t) {
14
+ case "ready":
15
+ case "resync":
16
+ case "event": {
17
+ if (!("id" in value) || typeof value.id !== "string" || !("cursor" in value) || typeof value.cursor !== "string") return null;
18
+ if (value.t === "event") {
19
+ // Zod v4 requires the key even though its value is unknown (and may be undefined).
20
+ return "data" in value ? { t: value.t, id: value.id, cursor: value.cursor, data: value.data } : null;
21
+ }
22
+ return { t: value.t, id: value.id, cursor: value.cursor };
23
+ }
24
+ case "progress":
25
+ return "cursor" in value && typeof value.cursor === "string" ? { t: value.t, cursor: value.cursor } : null;
26
+ case "revoked":
27
+ return "id" in value &&
28
+ typeof value.id === "string" &&
29
+ "code" in value &&
30
+ (value.code === "not_found" || value.code === "access_denied")
31
+ ? { t: value.t, id: value.id, code: value.code }
32
+ : null;
33
+ case "error":
34
+ return "code" in value && typeof value.code === "string" && "message" in value && typeof value.message === "string"
35
+ ? { t: value.t, code: value.code, message: value.message }
36
+ : null;
37
+ default:
38
+ return null;
39
+ }
40
+ };