@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -70,15 +70,18 @@
70
70
  "./ai/ui": "./src/ai/ui.tsx",
71
71
  "./account/ui": "./src/account/ui.ts",
72
72
  "./access/ui": "./src/access/ui.ts",
73
+ "./approvals/ui": "./src/approvals/ui.ts",
73
74
  "./ssr": "./src/ssr/index.ts",
74
75
  "./ssr/islands": "./src/ssr/islands/index.ts",
75
76
  "./ssr/WorkspaceNavigation.island": "./src/ssr/WorkspaceNavigation.island.tsx",
77
+ "./ssr/layout-runtime": "./src/ssr/layout-runtime.ts",
76
78
  "./ssr/*": "./src/ssr/*",
77
79
  "./config": "./src/config/index.ts",
78
80
  "./config/*": "./src/config/*",
79
81
  "./contracts": "./src/contracts/index.ts",
80
82
  "./contracts/notifications": "./src/contracts/notification-types.ts",
81
83
  "./contracts/*": "./src/contracts/*",
84
+ "./events/testing": "./src/events/testing.ts",
82
85
  "./events": "./src/events/index.ts",
83
86
  "./workflows": "./src/workflows/index.ts",
84
87
  "./workflows/ai": "./src/workflows/ai/index.ts",
@@ -102,14 +105,14 @@
102
105
  "@simplewebauthn/server": "14.0.3",
103
106
  "@tabler/icons": "3.48.0",
104
107
  "@tailwindcss/typography": "0.5.20",
105
- "@k2b/nessi": "0.17.0",
108
+ "@k2b/nessi": "0.17.1",
106
109
  "@k2b/ssr": "0.15.0",
107
- "@k2b/ui": "0.21.0",
110
+ "@k2b/ui": "0.23.0",
108
111
  "@k2b/stdlib": "0.28.0",
109
112
  "@k2b/sync": "7.0.0",
110
113
  "@nats-io/jetstream": "3.4.0",
111
114
  "@nats-io/transport-node": "3.4.0",
112
- "@modelcontextprotocol/sdk": "1.30.1",
115
+ "@modelcontextprotocol/sdk": "1.32.1",
113
116
  "bun-plugin-tailwind": "0.1.2",
114
117
  "hono-openapi": "1.3.3",
115
118
  "jose": "6.2.12",
@@ -1,4 +1,5 @@
1
- import type { AppMeta, AppPresentationCatalog, AppPresentationTranslation } from "../contracts/app";
1
+ import type { AppMeta, AppPresentationCatalog, AppPresentationTranslation, AppWidgetTranslation } from "../contracts/app";
2
+ import { WIDGET_DESCRIPTION_MAX_LENGTH, WIDGET_TITLE_MAX_LENGTH } from "../contracts/widgets";
2
3
  import { canonicalLocale } from "../shared/locale";
3
4
 
4
5
  export const APP_PRESENTATION_MAX_BYTES = 64 * 1024;
@@ -21,6 +22,24 @@ const labels = (values: Readonly<Record<string, string>> | undefined, allowed: S
21
22
  return normalized;
22
23
  };
23
24
 
25
+ const widgetTexts = (
26
+ values: Readonly<Record<string, AppWidgetTranslation>>,
27
+ allowed: Set<string>,
28
+ label: string,
29
+ ): Record<string, AppWidgetTranslation> => {
30
+ const normalized: Record<string, AppWidgetTranslation> = {};
31
+ for (const [id, value] of Object.entries(values)) {
32
+ if (!allowed.has(id)) throw new Error(`${label} contains unknown key ${JSON.stringify(id)}`);
33
+ normalized[id] = {
34
+ ...(value.title !== undefined ? { title: text(value.title, `${label}.${id}.title`, WIDGET_TITLE_MAX_LENGTH) } : {}),
35
+ ...(value.description !== undefined
36
+ ? { description: text(value.description, `${label}.${id}.description`, WIDGET_DESCRIPTION_MAX_LENGTH) }
37
+ : {}),
38
+ };
39
+ }
40
+ return normalized;
41
+ };
42
+
24
43
  /** Validate and canonicalize one static app-presentation catalog before it enters the registry. */
25
44
  export const compileAppPresentation = (app: AppMeta, catalog: AppPresentationCatalog | undefined): AppPresentationCatalog | undefined => {
26
45
  if (!catalog) return undefined;
@@ -36,6 +55,7 @@ export const compileAppPresentation = (app: AppMeta, catalog: AppPresentationCat
36
55
  const adminHrefs = new Set((app.adminNav ?? []).flatMap((group) => group.links.map((link) => link.href)));
37
56
  const searchHrefs = new Set((app.searchLinks ?? []).map((link) => link.href));
38
57
  const legalHrefs = new Set((app.legalLinks ?? []).map((link) => link.href));
58
+ const widgetIds = new Set((app.widgets ?? []).map((widget) => widget.id));
39
59
  const translations: Record<string, AppPresentationTranslation> = {};
40
60
 
41
61
  for (const [locale, translation] of Object.entries(catalog.translations)) {
@@ -62,6 +82,7 @@ export const compileAppPresentation = (app: AppMeta, catalog: AppPresentationCat
62
82
  }
63
83
  : {}),
64
84
  ...(translation.legalLinks ? { legalLinks: labels(translation.legalLinks, legalHrefs, `${canonical}.legalLinks`) } : {}),
85
+ ...(translation.widgets ? { widgets: widgetTexts(translation.widgets, widgetIds, `${canonical}.widgets`) } : {}),
65
86
  };
66
87
  }
67
88
 
@@ -1469,9 +1469,9 @@ export const reviewCompiledCapability = async (params: {
1469
1469
  return normalizeProviderError(reviewed.error, "Capability review returned an invalid error", params.onUnexpectedError);
1470
1470
  }
1471
1471
  const parsed = CapabilityActionReviewSchema.safeParse(reviewed.data);
1472
+ // A rememberable Action may leave out its scope for arguments that should ask every time; any other Action has none.
1472
1473
  const approvalScopeIsValid =
1473
- parsed.success &&
1474
- (operation.manifest.approval === "rememberable" ? parsed.data.approvalScope !== undefined : parsed.data.approvalScope === undefined);
1474
+ parsed.success && (operation.manifest.approval === "rememberable" || parsed.data.approvalScope === undefined);
1475
1475
  if (!parsed.success || !approvalScopeIsValid) {
1476
1476
  try {
1477
1477
  params.onUnexpectedError?.(
@@ -54,6 +54,8 @@ import { matchedRouteTemplate, routeTemplate } from "../server/middleware/route-
54
54
  import { runtime as runtimeMiddleware } from "../server/middleware/runtime";
55
55
  import { preloadLayoutAnnouncements, settings as settingsMiddleware } from "../server/middleware/settings";
56
56
  import { validationErrorResponse } from "../server/middleware/validator";
57
+ import { REQUEST_IDLE_TIMEOUT_SECONDS } from "../server/request-timeout";
58
+ import { setWidgetRequest } from "../server/widget-request";
57
59
  import {
58
60
  capabilityInvocationOperation,
59
61
  searchInvocationOperation,
@@ -92,6 +94,7 @@ import { ensureRuntimeWatcher, getCurrentRuntime, stopRuntimeWatcher } from "./r
92
94
  import { servePublicAsset } from "./static-assets";
93
95
  import { createStatusPreservingSsrHandler } from "./status-preserving-ssr";
94
96
  import { WEB_VITALS_ASSET_HREF } from "./web-vitals-asset";
97
+ import { compileWidgetDeclarations } from "./widget-declarations";
95
98
 
96
99
  /** Cache-busting version stamp — changes on every server start / rebuild. */
97
100
  const v = Date.now();
@@ -302,6 +305,7 @@ export type StartResult = {
302
305
  hostname: "0.0.0.0";
303
306
  port: number;
304
307
  development: boolean;
308
+ idleTimeout: number;
305
309
  fetch: Hono["fetch"];
306
310
  };
307
311
 
@@ -515,7 +519,7 @@ export const defineApp = <
515
519
  legalLinks: opts.legalLinks ? [...opts.legalLinks] : undefined,
516
520
  searchLinks: opts.searchLinks?.map((link) => ({ ...link, keywords: link.keywords ? [...link.keywords] : undefined })),
517
521
  pwa: pwaPart,
518
- widgets: opts.widgets ? opts.widgets.map((w) => ({ ...w })) : undefined,
522
+ widgets: compileWidgetDeclarations(opts.id, opts.widgets),
519
523
  settingKeys: opts.settings ? Object.keys(opts.settings) : undefined,
520
524
  openapi: opts.openapi,
521
525
  cli: opts.cli,
@@ -614,7 +618,11 @@ export const defineApp = <
614
618
  pwa: meta.pwa
615
619
  ? { href: meta.pwa.href, requiresRoles: meta.pwa.requiresRoles ? [...meta.pwa.requiresRoles] : undefined }
616
620
  : undefined,
617
- widgets: meta.widgets ? meta.widgets.map((w) => ({ ...w })) : undefined,
621
+ widgets: meta.widgets?.map((widget) => ({
622
+ ...widget,
623
+ sizes: widget.sizes ? [...widget.sizes] : undefined,
624
+ requiresRoles: widget.requiresRoles ? [...widget.requiresRoles] : undefined,
625
+ })),
618
626
  platformPermissions: meta.platformPermissions ? [...meta.platformPermissions] : undefined,
619
627
  settingKeys: meta.settingKeys ? [...meta.settingKeys] : undefined,
620
628
  openapi: advertiseOpenapi ? opts.openapi : undefined,
@@ -863,8 +871,12 @@ export const defineApp = <
863
871
  runtimeMiddleware(),
864
872
  settingsMiddleware(),
865
873
  async (c) => {
866
- const handler = startOpts.widgets?.[c.req.param("widgetId") ?? ""];
867
- return handler ? handler(c, async () => {}) : c.json({ message: "Widget not found" }, 404);
874
+ const widgetId = c.req.param("widgetId") ?? "";
875
+ const handler = startOpts.widgets?.[widgetId];
876
+ const declaration = meta.widgets?.find((widget) => widget.id === widgetId);
877
+ if (!handler || !declaration) return c.json({ message: "Widget not found" }, 404);
878
+ setWidgetRequest(c, declaration);
879
+ return handler(c, async () => {});
868
880
  },
869
881
  );
870
882
  }
@@ -962,7 +974,7 @@ export const defineApp = <
962
974
  process.on("SIGTERM", () => void shutdown().then(() => process.exit(0)));
963
975
  process.on("SIGINT", () => void shutdown().then(() => process.exit(0)));
964
976
 
965
- return { hostname: "0.0.0.0", port, development: isDevelopment, fetch: server.fetch };
977
+ return { hostname: "0.0.0.0", port, development: isDevelopment, idleTimeout: REQUEST_IDLE_TIMEOUT_SECONDS, fetch: server.fetch };
966
978
  } catch (error) {
967
979
  await stopStartedResources();
968
980
  throw error;
@@ -3,7 +3,14 @@ import type { AppAppearanceColor } from "../contracts/app";
3
3
  import type { CapabilityManifest, CapabilityPresentationCatalog } from "../contracts/capabilities";
4
4
  import { fileProviderIssues } from "../contracts/file-provider";
5
5
  import type { AppRegistryEntry, CapabilityRegistryEntry } from "../contracts/registry";
6
- import type { DashboardWidgetPresentation } from "../contracts/widgets";
6
+ import { type Role, RoleSchema } from "../contracts/shared";
7
+ import {
8
+ type DashboardWidgetPresentation,
9
+ type DashboardWidgetSize,
10
+ resolveWidgetSizes,
11
+ WIDGET_DESCRIPTION_MAX_LENGTH,
12
+ WIDGET_TITLE_MAX_LENGTH,
13
+ } from "../contracts/widgets";
7
14
  import { resolveAppPresentations } from "../shared/app-presentation";
8
15
  import { compileCapabilityPresentation, parseCapabilityManifest } from "./capabilities";
9
16
  import { lazySync } from "./process-sync";
@@ -332,7 +339,9 @@ export const listLegalLinks = async (locale?: string): Promise<Array<{ label: st
332
339
  * the dashboard app to build the widget grid and for Core to resolve the
333
340
  * exact target before proxying a widget request.
334
341
  *
335
- * Order = registration order across apps.
342
+ * Order = registration order across apps. Every optional declaration field is
343
+ * resolved here, so readers never see a registry record from an older app
344
+ * without `title`, `sizes`, or `defaultSize`, nor an unknown size or role.
336
345
  */
337
346
  export type DashboardWidget = {
338
347
  appId: string;
@@ -341,23 +350,58 @@ export type DashboardWidget = {
341
350
  widgetId: string;
342
351
  /** Fully-qualified URL — `<baseUrl>/<path>`. */
343
352
  url: string;
353
+ /** The declared title in the requested locale, or the app's name. */
354
+ title: string;
355
+ /** The declared description in the requested locale, or the app's description. */
356
+ description: string;
357
+ sizes: DashboardWidgetSize[];
358
+ defaultSize: DashboardWidgetSize;
359
+ suggest: boolean;
360
+ /** Visibility only, like `nav.requiresRoles`; empty means everyone. */
361
+ requiresRoles: Role[];
362
+ /**
363
+ * @deprecated The zone and width the app recommended before widgets had sizes. Only the dashboard reads it, to
364
+ * convert a board saved then into the board the person saw.
365
+ */
344
366
  presentation?: DashboardWidgetPresentation;
345
367
  };
346
368
 
347
- export const listWidgets = async (): Promise<DashboardWidget[]> => {
348
- const apps = await listApps();
369
+ const LEGACY_ZONES = ["focus", "overview", "context"] as const;
370
+ const LEGACY_SPANS = ["standard", "wide"] as const;
371
+
372
+ const legacyPresentation = (value: DashboardWidgetPresentation | undefined): DashboardWidgetPresentation | undefined => {
373
+ const defaultZone = LEGACY_ZONES.find((zone) => zone === value?.defaultZone);
374
+ const defaultSpan = LEGACY_SPANS.find((span) => span === value?.defaultSpan);
375
+ return defaultZone || defaultSpan ? { defaultZone, defaultSpan } : undefined;
376
+ };
377
+
378
+ const declaredText = (value: unknown, max: number): string | undefined =>
379
+ typeof value === "string" && value.trim() && value.trim().length <= max ? value.trim() : undefined;
380
+
381
+ /** The widgets `apps` declare, resolved as `listWidgets()` returns them. */
382
+ export const dashboardWidgetsOf = (apps: readonly AppRegistryEntry[], locale?: string): DashboardWidget[] => {
349
383
  const out: DashboardWidget[] = [];
350
- for (const app of apps) {
384
+ for (const app of locale ? resolveAppPresentations(apps, locale) : apps) {
351
385
  for (const w of app.widgets ?? []) {
386
+ const { sizes, defaultSize } = resolveWidgetSizes(w);
352
387
  out.push({
353
388
  appId: app.id,
354
389
  appName: app.name,
355
390
  appIcon: app.icon,
356
391
  widgetId: w.id,
357
392
  url: `${app.baseUrl.replace(/\/$/, "")}${w.path.startsWith("/") ? w.path : `/${w.path}`}`,
358
- presentation: w.presentation,
393
+ title: declaredText(w.title, WIDGET_TITLE_MAX_LENGTH) ?? app.name,
394
+ description: declaredText(w.description, WIDGET_DESCRIPTION_MAX_LENGTH) ?? app.description,
395
+ sizes,
396
+ defaultSize,
397
+ suggest: w.suggest === true,
398
+ requiresRoles: Array.isArray(w.requiresRoles) ? w.requiresRoles.filter((role) => RoleSchema.safeParse(role).success) : [],
399
+ presentation: legacyPresentation(w.presentation),
359
400
  });
360
401
  }
361
402
  }
362
403
  return out;
363
404
  };
405
+
406
+ /** Every declared widget, with titles and descriptions in `locale` when given. */
407
+ export const listWidgets = async (locale?: string): Promise<DashboardWidget[]> => dashboardWidgetsOf(await listApps(), locale);
@@ -0,0 +1,53 @@
1
+ import type { WidgetEndpoint } from "../contracts/app";
2
+ import { RoleSchema } from "../contracts/shared";
3
+ import {
4
+ DASHBOARD_WIDGET_SIZES,
5
+ isDashboardWidgetSize,
6
+ WIDGET_DESCRIPTION_MAX_LENGTH,
7
+ WIDGET_TITLE_MAX_LENGTH,
8
+ } from "../contracts/widgets";
9
+
10
+ const text = (value: string | undefined, label: string, max: number): string | undefined => {
11
+ if (value === undefined) return undefined;
12
+ const normalized = value.trim();
13
+ if (!normalized || normalized.length > max) throw new Error(`${label} must contain 1 to ${max} characters`);
14
+ return normalized;
15
+ };
16
+
17
+ /**
18
+ * Validates and copies an app's widget declarations at `defineApp()`, so a typo fails at startup instead of a widget
19
+ * silently missing from the gallery: unique ids without `@`, which separates a widget's key from the size the
20
+ * dashboard asks for, bounded titles and descriptions, known sizes with the default among them, a boolean `suggest`,
21
+ * and known roles.
22
+ */
23
+ export const compileWidgetDeclarations = (
24
+ appId: string,
25
+ widgets: ReadonlyArray<WidgetEndpoint> | undefined,
26
+ ): WidgetEndpoint[] | undefined => {
27
+ if (!widgets) return undefined;
28
+ const ids = new Set<string>();
29
+ return widgets.map((widget) => {
30
+ const label = `Widget ${JSON.stringify(widget.id)} of app ${JSON.stringify(appId)}`;
31
+ if (!widget.id.trim()) throw new Error(`Widgets of app ${JSON.stringify(appId)} need a non-empty id`);
32
+ if (widget.id.includes("@")) throw new Error(`${label} must not contain "@"`);
33
+ if (ids.has(widget.id)) throw new Error(`${label} is declared twice`);
34
+ ids.add(widget.id);
35
+ const sizes = widget.sizes ? [...widget.sizes] : undefined;
36
+ if (sizes && (sizes.length === 0 || sizes.some((size) => !isDashboardWidgetSize(size)) || new Set(sizes).size !== sizes.length)) {
37
+ throw new Error(`${label} must declare sizes as distinct values of ${DASHBOARD_WIDGET_SIZES.join(", ")}`);
38
+ }
39
+ if (widget.defaultSize !== undefined && !(sizes ?? ["large"]).includes(widget.defaultSize)) {
40
+ throw new Error(`${label} must declare a defaultSize it offers in sizes`);
41
+ }
42
+ if (widget.suggest !== undefined && typeof widget.suggest !== "boolean")
43
+ throw new Error(`${label} must declare suggest as true or false`);
44
+ if (widget.requiresRoles?.some((role) => !RoleSchema.safeParse(role).success)) throw new Error(`${label} requires an unknown role`);
45
+ return {
46
+ ...widget,
47
+ title: text(widget.title, `${label} title`, WIDGET_TITLE_MAX_LENGTH),
48
+ description: text(widget.description, `${label} description`, WIDGET_DESCRIPTION_MAX_LENGTH),
49
+ sizes: sizes ? DASHBOARD_WIDGET_SIZES.filter((size) => sizes.includes(size)) : undefined,
50
+ requiresRoles: widget.requiresRoles ? [...widget.requiresRoles] : undefined,
51
+ };
52
+ });
53
+ };
@@ -23,13 +23,7 @@ const freezeJson = (value: unknown): void => {
23
23
 
24
24
  export const validateAppSkillContent = (input: AiSkillContent): AppSkillDefinition => {
25
25
  try {
26
- const fields = validateSkillContent(input);
27
- fields.instructions = fields.instructions.replace(
28
- /\]\((references\/[a-z0-9][a-z0-9._-]*\.md)\)/g,
29
- (_, path: string) => `](/skills/${fields.name}/${path})`,
30
- );
31
- // Link expansion must respect the same instruction size limits.
32
- const validated = validateSkillContent(fields);
26
+ const validated = validateSkillContent(input);
33
27
  const paths = new Set(validated.references.map((ref) => ref.path));
34
28
  for (const match of validated.instructions.matchAll(/references\/[^\s\])`"'<>]+\.md/g)) {
35
29
  const path = match[0];
@@ -5,8 +5,15 @@ import { listCapabilities } from "../_internal/registry";
5
5
  import { ErrorResponseSchema } from "../contracts";
6
6
  import type { CapabilityRegistryEntry } from "../contracts/registry";
7
7
  import { type AuthContext, auth, expectUserBackedActor, getLocale, jsonResponse, rateLimit, requiresAuth, v } from "../server";
8
- import { type AiToolApprovalPreference, listAiToolApprovalPreferences, revokeAiToolApprovalPreference } from "./approvals";
8
+ import {
9
+ AI_WEBSITE_APPROVAL_TOOL,
10
+ type AiToolApprovalPreference,
11
+ listAiToolApprovalPreferences,
12
+ parseAiWebsiteApprovalScope,
13
+ revokeAiToolApprovalPreference,
14
+ } from "./approvals";
9
15
  import { buildAiCapabilityCatalog } from "./capabilities";
16
+ import { aiConversations } from "./store";
10
17
 
11
18
  export type AiApprovalPreferenceView = AiToolApprovalPreference & {
12
19
  title: string;
@@ -16,6 +23,8 @@ export type AiApprovalPreferenceView = AiToolApprovalPreference & {
16
23
  icon: string;
17
24
  accent?: string;
18
25
  } | null;
26
+ /** For a website approval: the exact origin it lets code read, and the Studio app it is limited to, if any. */
27
+ website: { origin: string; resourceId: string | null } | null;
19
28
  };
20
29
 
21
30
  const AiApprovalPreferenceSchema = z.object({
@@ -24,6 +33,7 @@ const AiApprovalPreferenceSchema = z.object({
24
33
  approvalScope: z.string(),
25
34
  createdAt: z.iso.datetime(),
26
35
  lastUsedAt: z.iso.datetime().nullable(),
36
+ conversationId: z.uuid().nullable(),
27
37
  expiresAt: z.iso.datetime().nullable(),
28
38
  title: z.string(),
29
39
  app: z
@@ -34,20 +44,29 @@ const AiApprovalPreferenceSchema = z.object({
34
44
  accent: z.string().optional(),
35
45
  })
36
46
  .nullable(),
47
+ website: z.object({ origin: z.string(), resourceId: z.string().nullable() }).nullable(),
37
48
  });
38
49
 
39
50
  const AiApprovalPreferenceListSchema = z.object({ approvals: z.array(AiApprovalPreferenceSchema) });
40
51
  const AiApprovalPreferenceParamSchema = z.object({ preferenceId: z.uuid() });
52
+ const AiApprovalPreferenceQuerySchema = z.object({ conversation: z.string().min(1).max(80).optional() });
41
53
  const AiApprovalPreferenceDeleteSchema = z.object({ deleted: z.literal(true) });
42
54
 
43
55
  type AiApprovalRouteDependencies = {
44
56
  limit?: MiddlewareHandler<AuthContext>;
45
57
  authenticate?: MiddlewareHandler<AuthContext>;
46
- listPreferences?: (actorUserId: string) => Promise<AiToolApprovalPreference[]>;
58
+ listPreferences?: (actorUserId: string, options: { conversationId?: string }) => Promise<AiToolApprovalPreference[]>;
47
59
  revokePreference?: (actorUserId: string, preferenceId: string) => Promise<boolean>;
48
60
  listCapabilities?: () => Promise<CapabilityRegistryEntry[]>;
61
+ /** Resolves a chat the user owns by its ID or short ID. */
62
+ findConversation?: (ownerUserId: string, conversation: string) => Promise<{ id: string } | null>;
49
63
  };
50
64
 
65
+ const findOwnConversation = async (ownerUserId: string, conversation: string) =>
66
+ z.uuid().safeParse(conversation).success
67
+ ? aiConversations.getConversation({ conversationId: conversation, ownerUserId })
68
+ : aiConversations.getConversationByShortId({ shortId: conversation, ownerUserId });
69
+
51
70
  const fallbackTitle = (toolName: string): string => {
52
71
  const label = toolName
53
72
  .replaceAll("__", " ")
@@ -61,6 +80,7 @@ export const createAiApprovalPreferenceRoutes = (dependencies: AiApprovalRouteDe
61
80
  const listPreferences = dependencies.listPreferences ?? listAiToolApprovalPreferences;
62
81
  const revokePreference = dependencies.revokePreference ?? revokeAiToolApprovalPreference;
63
82
  const registry = dependencies.listCapabilities ?? listCapabilities;
83
+ const findConversation = dependencies.findConversation ?? findOwnConversation;
64
84
 
65
85
  return new Hono<AuthContext>()
66
86
  .use(dependencies.limit ?? rateLimit())
@@ -70,25 +90,41 @@ export const createAiApprovalPreferenceRoutes = (dependencies: AiApprovalRouteDe
70
90
  describeRoute({
71
91
  tags: ["AI"],
72
92
  summary: "List remembered AI approvals",
73
- description: "Lists AI tool approvals remembered for the current user, with titles in the request locale.",
93
+ description:
94
+ "Lists AI tool approvals remembered for the current user, with titles in the request locale. Without `conversation`, it lists the approvals that apply everywhere; with it, those limited to that chat.",
74
95
  ...requiresAuth,
75
96
  responses: {
76
97
  200: jsonResponse(AiApprovalPreferenceListSchema, "Remembered approvals"),
77
98
  401: jsonResponse(ErrorResponseSchema, "Authentication required"),
78
99
  403: jsonResponse(ErrorResponseSchema, "User-backed actor required"),
100
+ 404: jsonResponse(ErrorResponseSchema, "Chat not found"),
79
101
  },
80
102
  }),
103
+ v("query", AiApprovalPreferenceQuerySchema),
81
104
  async (c) => {
82
105
  const user = expectUserBackedActor(c);
106
+ const requested = c.req.valid("query").conversation;
107
+ const conversation = requested ? await findConversation(user.id, requested) : null;
108
+ if (requested && !conversation) return c.json({ message: "Chat not found", code: "NOT_FOUND" }, 404);
83
109
  const [preferences, capabilities] = await Promise.all([
84
- listPreferences(user.id),
110
+ listPreferences(user.id, conversation ? { conversationId: conversation.id } : {}),
85
111
  registry().catch(() => [] as CapabilityRegistryEntry[]),
86
112
  ]);
87
113
  const catalogByName = new Map(buildAiCapabilityCatalog(capabilities, getLocale(c)).map((entry) => [entry.name, entry]));
88
114
  const approvals: AiApprovalPreferenceView[] = preferences.map((preference) => {
115
+ if (preference.toolName === AI_WEBSITE_APPROVAL_TOOL) {
116
+ const website = parseAiWebsiteApprovalScope(preference.approvalScope);
117
+ return {
118
+ ...preference,
119
+ title: URL.canParse(website.origin) ? new URL(website.origin).host : website.origin,
120
+ app: null,
121
+ website,
122
+ };
123
+ }
89
124
  const capability = catalogByName.get(preference.toolName);
90
125
  return {
91
126
  ...preference,
127
+ website: null,
92
128
  title: capability?.display.title ?? fallbackTitle(preference.toolName),
93
129
  app: capability
94
130
  ? {
@@ -5,14 +5,41 @@ import type { AiToolApprovalPolicy, AiTurnRunConfig } from "./types";
5
5
  export const aiTurnAllowsRememberedApprovals = (config: AiTurnRunConfig | null | undefined): boolean =>
6
6
  config == null || config.kind === "compact" || config.mandate === undefined;
7
7
 
8
+ /**
9
+ * Reserved remembered-approval name for reading one website in one chat or one managed app. No capability or tool can
10
+ * collide with it: tool names never contain a colon.
11
+ */
12
+ export const AI_WEBSITE_APPROVAL_TOOL = "website:read";
13
+
14
+ /** The remembered scope of a website approval: one exact origin, in a chat or for one managed Studio app. */
15
+ export const aiWebsiteApprovalScope = (origin: string, resourceId?: string): string =>
16
+ resourceId ? `${origin} resource:${resourceId}` : origin;
17
+
18
+ export const parseAiWebsiteApprovalScope = (scope: string): { origin: string; resourceId: string | null } => {
19
+ const [origin = scope, resource] = scope.split(" ");
20
+ return { origin, resourceId: resource?.startsWith("resource:") ? resource.slice("resource:".length) : null };
21
+ };
22
+
23
+ /**
24
+ * Website approvals apply only to a turn a person started in a signed-in browser session: never to a scheduled task,
25
+ * a mandate, `cld`, an API key, or another delegated credential.
26
+ */
27
+ export const aiTurnAllowsWebsiteApprovals = (config: AiTurnRunConfig | null | undefined): boolean =>
28
+ config != null && config.kind !== "compact" && config.signedInSession === true && aiTurnAllowsRememberedApprovals(config);
29
+
8
30
  export type AiToolApprovalContext = {
9
31
  actorUserId: string;
10
32
  };
11
33
 
34
+ /** Where a remembered approval applies: in one chat until it is deleted, or always. */
35
+ export type AiToolApprovalRemember = "chat" | "always";
36
+
12
37
  export type AiToolApprovalPreference = {
13
38
  id: string;
14
39
  toolName: string;
15
40
  approvalScope: string;
41
+ /** The chat this approval is limited to; null for an approval that applies everywhere. */
42
+ conversationId: string | null;
16
43
  createdAt: string;
17
44
  lastUsedAt: string | null;
18
45
  expiresAt: string | null;
@@ -22,6 +49,7 @@ type AiToolApprovalPreferenceRow = {
22
49
  id: string;
23
50
  tool_name: string;
24
51
  approval_scope: string;
52
+ conversation_id: string | null;
25
53
  created_at: Date | string;
26
54
  last_used_at: Date | string | null;
27
55
  expires_at: Date | string | null;
@@ -37,36 +65,52 @@ export const aiToolNeedsApproval = (policy: AiToolApprovalPolicy | undefined): b
37
65
  export const aiToolAllowsAlways = (policy: AiToolApprovalPolicy | undefined): boolean =>
38
66
  policy === "always" || (typeof policy === "object" && policy.kind === "user-configurable");
39
67
 
40
- export const hasRememberedAiToolApproval = async (
68
+ /**
69
+ * The remembered approval that covers this tool and scope: one that applies everywhere, or one limited to
70
+ * `conversationId`; with `chatOnly`, only the latter. Returns its ID and marks it used.
71
+ */
72
+ export const findRememberedAiToolApproval = async (
41
73
  context: AiToolApprovalContext,
42
- input: { toolName: string; approvalScope: string },
43
- ): Promise<boolean> => {
74
+ input: { toolName: string; approvalScope: string; conversationId?: string | null; chatOnly?: boolean },
75
+ ): Promise<string | null> => {
76
+ const conversationId = input.conversationId ?? null;
77
+ if (input.chatOnly && !conversationId) return null;
44
78
  const rows = await sql<{ id: string }[]>`
45
79
  SELECT id
46
80
  FROM ai.tool_approval_preferences
47
81
  WHERE actor_user_id = ${context.actorUserId}
48
82
  AND tool_name = ${input.toolName}
49
83
  AND approval_scope = ${input.approvalScope}
84
+ AND (conversation_id = ${conversationId}::uuid OR (conversation_id IS NULL AND NOT ${input.chatOnly === true}))
50
85
  AND (expires_at IS NULL OR expires_at > now())
86
+ ORDER BY conversation_id NULLS LAST
51
87
  LIMIT 1
52
88
  `;
53
89
 
54
90
  const id = rows[0]?.id;
55
- if (!id) return false;
91
+ if (!id) return null;
56
92
  await sql`UPDATE ai.tool_approval_preferences SET last_used_at = now() WHERE id = ${id}`;
57
- return true;
93
+ return id;
58
94
  };
59
95
 
96
+ export const hasRememberedAiToolApproval = async (
97
+ context: AiToolApprovalContext,
98
+ input: { toolName: string; approvalScope: string; conversationId?: string | null },
99
+ ): Promise<boolean> => (await findRememberedAiToolApproval(context, input)) !== null;
100
+
101
+ /** Remembers an approval everywhere, or in one chat when `conversationId` is set; a chat approval ends with its chat. */
60
102
  export const rememberAiToolApproval = async (
61
103
  context: AiToolApprovalContext,
62
- input: { toolName: string; approvalScope: string; expiresAt?: Date | null },
104
+ input: { toolName: string; approvalScope: string; conversationId?: string | null; expiresAt?: Date | null },
63
105
  ): Promise<void> => {
106
+ const conversationId = input.conversationId ?? null;
64
107
  await sql.begin(async () => {
65
108
  await sql`
66
109
  DELETE FROM ai.tool_approval_preferences
67
110
  WHERE actor_user_id = ${context.actorUserId}
68
111
  AND tool_name = ${input.toolName}
69
112
  AND approval_scope = ${input.approvalScope}
113
+ AND conversation_id IS NOT DISTINCT FROM ${conversationId}::uuid
70
114
  `;
71
115
 
72
116
  await sql`
@@ -74,6 +118,7 @@ export const rememberAiToolApproval = async (
74
118
  actor_user_id,
75
119
  tool_name,
76
120
  approval_scope,
121
+ conversation_id,
77
122
  last_used_at,
78
123
  expires_at
79
124
  )
@@ -81,6 +126,7 @@ export const rememberAiToolApproval = async (
81
126
  ${context.actorUserId},
82
127
  ${input.toolName},
83
128
  ${input.approvalScope},
129
+ ${conversationId}::uuid,
84
130
  now(),
85
131
  ${input.expiresAt ?? null}
86
132
  )
@@ -88,16 +134,20 @@ export const rememberAiToolApproval = async (
88
134
  });
89
135
  };
90
136
 
137
+ /** Forgets the approval with exactly this reach: everywhere, or in the one chat `conversationId` names. */
91
138
  export const forgetAiToolApproval = async (
92
139
  context: AiToolApprovalContext,
93
- input: { toolName: string; approvalScope: string },
94
- ): Promise<void> => {
95
- await sql`
140
+ input: { toolName: string; approvalScope: string; conversationId?: string | null },
141
+ ): Promise<boolean> => {
142
+ const rows = await sql<{ id: string }[]>`
96
143
  DELETE FROM ai.tool_approval_preferences
97
144
  WHERE actor_user_id = ${context.actorUserId}
98
145
  AND tool_name = ${input.toolName}
99
146
  AND approval_scope = ${input.approvalScope}
147
+ AND conversation_id IS NOT DISTINCT FROM ${input.conversationId ?? null}::uuid
148
+ RETURNING id
100
149
  `;
150
+ return rows.length > 0;
101
151
  };
102
152
 
103
153
  const toIsoString = (value: Date | string): string => (value instanceof Date ? value : new Date(value)).toISOString();
@@ -106,22 +156,30 @@ const approvalPreferenceFromRow = (row: AiToolApprovalPreferenceRow): AiToolAppr
106
156
  id: row.id,
107
157
  toolName: row.tool_name,
108
158
  approvalScope: row.approval_scope,
159
+ conversationId: row.conversation_id,
109
160
  createdAt: toIsoString(row.created_at),
110
161
  lastUsedAt: row.last_used_at ? toIsoString(row.last_used_at) : null,
111
162
  expiresAt: row.expires_at ? toIsoString(row.expires_at) : null,
112
163
  });
113
164
 
114
- export const listAiToolApprovalPreferences = async (actorUserId: string): Promise<AiToolApprovalPreference[]> => {
165
+ /** Lists the approvals that apply everywhere, or only those of one chat when `conversationId` is set. */
166
+ export const listAiToolApprovalPreferences = async (
167
+ actorUserId: string,
168
+ options: { conversationId?: string } = {},
169
+ ): Promise<AiToolApprovalPreference[]> => {
170
+ const conversationId = options.conversationId ?? null;
115
171
  const rows = await sql<AiToolApprovalPreferenceRow[]>`
116
172
  SELECT
117
173
  id,
118
174
  tool_name,
119
175
  approval_scope,
176
+ conversation_id,
120
177
  created_at,
121
178
  last_used_at,
122
179
  expires_at
123
180
  FROM ai.tool_approval_preferences
124
181
  WHERE actor_user_id = ${actorUserId}
182
+ AND conversation_id IS NOT DISTINCT FROM ${conversationId}::uuid
125
183
  AND (expires_at IS NULL OR expires_at > now())
126
184
  ORDER BY last_used_at DESC NULLS LAST, created_at DESC, id
127
185
  `;
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { aiChatAccessSubject, resolveAssistantAudioModel } from "./assistant-models";
3
+ import { aiMissingChatFileError } from "./file-error";
3
4
  import { aiProjectFilePathFromMount } from "./file-mount";
4
5
  import { aiFileStore, normalizeAiFilePath } from "./files-store";
5
6
  import { defineAiTool } from "./tools";
@@ -43,7 +44,7 @@ export const createCloudAiTranscribeAudioTool = (options: { resolveModel?: () =>
43
44
  projectPath !== null
44
45
  ? await ctx.projectFiles?.read(projectPath)
45
46
  : (snapshot ?? (await aiFileStore.read({ conversationId: ctx.conversationId, path })));
46
- if (!stored) throw new Error(`No such audio file: ${path}`);
47
+ if (!stored) throw aiMissingChatFileError(path, projectPath === null && ctx.localToolsEnabled, `No such audio file: ${path}`);
47
48
  const result = await runAiTranscription({
48
49
  task: "transcribe-audio",
49
50
  file: new Blob([new Uint8Array(stored.bytes)]),