@k2b/cloud 0.29.1 → 0.31.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 (136) hide show
  1. package/package.json +4 -2
  2. package/src/_internal/app-presentation.ts +22 -1
  3. package/src/_internal/app-roles.ts +14 -0
  4. package/src/_internal/capabilities.ts +113 -5
  5. package/src/_internal/capability-sentences.ts +413 -0
  6. package/src/_internal/define-app.ts +24 -5
  7. package/src/_internal/registry-validation.ts +5 -0
  8. package/src/_internal/registry.ts +50 -6
  9. package/src/_internal/widget-declarations.ts +53 -0
  10. package/src/access/PermissionEditor.tsx +43 -24
  11. package/src/ai/app-skill-store.ts +298 -0
  12. package/src/ai/app-skills.ts +105 -0
  13. package/src/ai/approval-routes.ts +40 -4
  14. package/src/ai/approvals.ts +68 -10
  15. package/src/ai/browser.ts +10 -0
  16. package/src/ai/capabilities.ts +68 -9
  17. package/src/ai/chart-block.ts +380 -0
  18. package/src/ai/chat/action-sentences.ts +49 -0
  19. package/src/ai/chat/blocks.tsx +97 -40
  20. package/src/ai/chat/chart-block.tsx +79 -0
  21. package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
  22. package/src/ai/chat/message-actions.tsx +3 -1
  23. package/src/ai/chat/message-utils.ts +3 -0
  24. package/src/ai/chat/messages.ts +26 -2
  25. package/src/ai/chat/tool-groups.ts +66 -12
  26. package/src/ai/chat/tool-names.ts +6 -2
  27. package/src/ai/chat/turn-layout.ts +10 -2
  28. package/src/ai/chat/turn-view.tsx +123 -36
  29. package/src/ai/chat/web-tools.tsx +95 -1
  30. package/src/ai/client/controller.ts +6 -3
  31. package/src/ai/code-runtime-tools.ts +109 -61
  32. package/src/ai/code-source-contracts.ts +25 -21
  33. package/src/ai/code-source-tools.ts +3 -1
  34. package/src/ai/default-tool-contracts.ts +198 -0
  35. package/src/ai/default-tools.ts +25 -1
  36. package/src/ai/executor.ts +189 -35
  37. package/src/ai/fetch-file-tool.ts +41 -4
  38. package/src/ai/firecrawl-tools.ts +87 -4
  39. package/src/ai/index.ts +17 -1
  40. package/src/ai/migrate.ts +172 -14
  41. package/src/ai/protocol.ts +22 -1
  42. package/src/ai/routes.ts +11 -0
  43. package/src/ai/runtime-tools.ts +1 -0
  44. package/src/ai/runtime.ts +46 -7
  45. package/src/ai/skill-content.ts +54 -0
  46. package/src/ai/skills-routes.ts +22 -13
  47. package/src/ai/skills.ts +177 -231
  48. package/src/ai/store.ts +66 -1
  49. package/src/ai/tool-audit.ts +11 -1
  50. package/src/ai/tool-sentences.ts +38 -0
  51. package/src/ai/tools.ts +91 -3
  52. package/src/ai/types.ts +47 -1
  53. package/src/ai/web-provenance.ts +103 -0
  54. package/src/api/admin-ai-skills.ts +35 -16
  55. package/src/api/capabilities.ts +3 -2
  56. package/src/api/fanout.ts +128 -0
  57. package/src/api/help.ts +8 -3
  58. package/src/api/mcp.ts +4 -1
  59. package/src/api/search.ts +31 -106
  60. package/src/api/widgets.ts +291 -101
  61. package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
  62. package/src/browser/CloudResourceSearch.tsx +1 -1
  63. package/src/browser/command-shortcuts.ts +21 -6
  64. package/src/browser/ndjson.ts +34 -0
  65. package/src/browser/reload.ts +46 -0
  66. package/src/browser/search-stream.ts +4 -26
  67. package/src/browser/spotlight-position.ts +4 -1
  68. package/src/browser/widgets.ts +30 -0
  69. package/src/capabilities/executions.ts +3 -0
  70. package/src/capabilities/server.ts +20 -2
  71. package/src/cli/access.ts +48 -14
  72. package/src/cli/admin/ai-skills.ts +59 -39
  73. package/src/cli/commands.ts +20 -0
  74. package/src/cli/index.ts +16 -0
  75. package/src/cli/plugin.ts +30 -0
  76. package/src/contracts/announcement-cookie.ts +62 -0
  77. package/src/contracts/announcements.ts +14 -52
  78. package/src/contracts/app.ts +34 -2
  79. package/src/contracts/capabilities.ts +24 -0
  80. package/src/contracts/pwa-paths.ts +7 -2
  81. package/src/contracts/pwa.ts +2 -7
  82. package/src/contracts/registry.ts +10 -1
  83. package/src/contracts/shared.ts +5 -0
  84. package/src/contracts/widgets.ts +67 -2
  85. package/src/desktop/solid.tsx +6 -7
  86. package/src/index.ts +1 -0
  87. package/src/server/help.ts +8 -1
  88. package/src/server/index.ts +1 -0
  89. package/src/server/widget-request.ts +27 -0
  90. package/src/services/accounts/users.ts +15 -0
  91. package/src/services/approvals/index.ts +225 -0
  92. package/src/services/approvals/migrate.ts +33 -0
  93. package/src/services/help/index.ts +18 -5
  94. package/src/services/help/types.ts +3 -2
  95. package/src/services/identity/constants.ts +2 -0
  96. package/src/services/identity/key-ring.ts +123 -42
  97. package/src/services/identity/runtime-config.ts +13 -2
  98. package/src/shared/app-presentation.ts +11 -0
  99. package/src/shared/email-html.ts +43 -8
  100. package/src/ssr/AdminLayout.tsx +4 -7
  101. package/src/ssr/AppLaunchpad.island.tsx +1 -1
  102. package/src/ssr/AppLaunchpadPanel.tsx +12 -3
  103. package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
  104. package/src/ssr/GlobalSearchDialog.tsx +1 -43
  105. package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
  106. package/src/ssr/Layout.tsx +38 -14
  107. package/src/ssr/LayoutFooter.tsx +1 -1
  108. package/src/ssr/LayoutHeader.tsx +17 -20
  109. package/src/ssr/LayoutHelp.tsx +1 -1
  110. package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
  111. package/src/ssr/LayoutRail.tsx +1 -2
  112. package/src/ssr/PageTrail.tsx +23 -0
  113. package/src/ssr/PageTrailRow.island.tsx +20 -0
  114. package/src/ssr/PageTrailRuntime.island.tsx +26 -0
  115. package/src/ssr/ProfilePreferences.island.tsx +7 -3
  116. package/src/ssr/PwaRuntime.island.tsx +1 -1
  117. package/src/ssr/app-navigation.ts +5 -7
  118. package/src/ssr/global-search-host.tsx +55 -0
  119. package/src/ssr/help.ts +3 -3
  120. package/src/ssr/islands/index.ts +1 -0
  121. package/src/ssr/layout-runtime.ts +60 -0
  122. package/src/ssr/mobile-menu-history.ts +1 -1
  123. package/src/ssr/page-trail-runtime.ts +313 -0
  124. package/src/ssr/page-trail.browser-harness.tsx +216 -0
  125. package/src/ssr/page-trail.ts +59 -0
  126. package/src/ssr/platform-messages.ts +4 -0
  127. package/src/ssr/rail-context.ts +4 -3
  128. package/src/ssr/runtime.ts +14 -1
  129. package/src/styles/effects.css +21 -4
  130. package/src/styles/global.css +35 -2
  131. package/src/styles/utilities-layout.css +24 -1
  132. package/src/styles/utilities-navigation.css +6 -18
  133. package/src/ai/code-mode-skill.ts +0 -114
  134. package/src/ai/data-analysis-skill.ts +0 -10
  135. package/src/ai/grids-skill.ts +0 -158
  136. package/src/ai/skill-seeds.ts +0 -560
@@ -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
 
@@ -349,6 +349,26 @@ export type CapabilityUniversalSearchDefinition = {
349
349
 
350
350
  export type CapabilitySchemaPresentation = Readonly<Record<string, string>>;
351
351
 
352
+ /**
353
+ * What one Action does, in words people read in approvals, receipts, and step rows. Each sentence is a
354
+ * template: `{input.to}` inserts a field of the validated call input by its dotted schema path, and
355
+ * `done` may also read the Action's result data with `{data.id}`. Cloud inserts values as escaped,
356
+ * bounded plain text, formats fields with a `date` or `date-time` schema format for the reader, and
357
+ * names an object by its email, address, or path before its display name. A sentence whose value is
358
+ * missing falls back to the generic wording, so every sentence stays optional. A `done` sentence
359
+ * replaces the result's runtime summary in the receipt.
360
+ */
361
+ export type CapabilityActionSentences = {
362
+ /** What will happen, as a short instruction: "Send email to {input.to}". */
363
+ approval?: string;
364
+ /** What happened after the Action ran: "Sent email to {input.to}". */
365
+ done?: string;
366
+ /** What did not happen because the person rejected it: "Did not send email to {input.to}". */
367
+ rejected?: string;
368
+ /** What did not happen because the turn ended first: "Email to {input.to} not sent". */
369
+ notRun?: string;
370
+ };
371
+
352
372
  export type CapabilityOperationPresentationTranslation = {
353
373
  title?: string;
354
374
  description?: string;
@@ -358,6 +378,8 @@ export type CapabilityOperationPresentationTranslation = {
358
378
  data?: CapabilitySchemaPresentation;
359
379
  /** Search-tag presentation keyed by the stable tag token. */
360
380
  searchTags?: Readonly<Record<string, { title?: string; description?: string }>>;
381
+ /** Actions only: the complete set of sentences in this locale. */
382
+ sentences?: CapabilityActionSentences;
361
383
  };
362
384
 
363
385
  export type CapabilityPresentationTranslation = {
@@ -370,6 +392,8 @@ export type CapabilityPresentationTranslation = {
370
392
  export type CapabilityPresentationCatalog = {
371
393
  /** Locale of the complete Type, Query, Action, search-tag, and schema presentation. */
372
394
  baseLocale: string;
395
+ /** Optional Action sentences in the base locale, keyed by Action local ID. */
396
+ sentences?: Readonly<Record<string, CapabilityActionSentences>>;
373
397
  /** Partial presentation overlays with exact -> ancestor -> base fallback. */
374
398
  translations: Readonly<Record<string, CapabilityPresentationTranslation>>;
375
399
  };
@@ -1,6 +1,11 @@
1
1
  /**
2
- * Paths of the installable mobile app, without schemas, so browser code that runs on every page can test for an
3
- * app page without loading the contract's validators.
2
+ * Paths and ids of the installable mobile app, without schemas, so browser code that runs on every page can test for
3
+ * an app page without loading the contract's validators.
4
4
  */
5
5
  export const PWA_SCOPE = "/pwa/";
6
6
  export const PWA_AUTH_PATH = "/pwa/_auth";
7
+ export const PWA_SERVICE_WORKER_PATH = "/pwa/sw.js";
8
+ /** Ids that can never own a part: the shell itself and its own pages `/pwa/settings` and `/pwa/offline`. */
9
+ export const PWA_RESERVED_IDS = ["pwa", "settings", "offline"] as const;
10
+ /** Whether the app with this id may have a part at `/pwa/<id>`: a plain app id that the shell does not reserve. */
11
+ export const isPwaPartId = (id: string): boolean => /^[a-z][a-z0-9-]*$/.test(id) && !PWA_RESERVED_IDS.some((reserved) => reserved === id);
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
- import { PWA_AUTH_PATH, PWA_SCOPE } from "./pwa-paths";
2
+ import { isPwaPartId, PWA_AUTH_PATH, PWA_RESERVED_IDS, PWA_SCOPE, PWA_SERVICE_WORKER_PATH } from "./pwa-paths";
3
3
 
4
- export { PWA_AUTH_PATH, PWA_SCOPE };
4
+ export { isPwaPartId, PWA_AUTH_PATH, PWA_RESERVED_IDS, PWA_SCOPE, PWA_SERVICE_WORKER_PATH };
5
5
 
6
6
  /**
7
7
  * The installable mobile app (preview). One shell application `pwa` owns the
@@ -11,11 +11,6 @@ export { PWA_AUTH_PATH, PWA_SCOPE };
11
11
  export const PWA_SHELL_APP_ID = "pwa";
12
12
  export const PWA_API_PATH = "/api/auth/pwa/v1";
13
13
  export const PWA_MANIFEST_PATH = "/pwa/manifest.webmanifest";
14
- export const PWA_SERVICE_WORKER_PATH = "/pwa/sw.js";
15
- /** Ids that can never own a part: the shell itself and its own pages `/pwa/settings` and `/pwa/offline`. */
16
- export const PWA_RESERVED_IDS = ["pwa", "settings", "offline"] as const;
17
- /** Whether the app with this id may have a part at `/pwa/<id>`: a plain app id that the shell does not reserve. */
18
- export const isPwaPartId = (id: string): boolean => /^[a-z][a-z0-9-]*$/.test(id) && !PWA_RESERVED_IDS.some((reserved) => reserved === id);
19
14
  /**
20
15
  * The page canvas of the app (`--k2b-surface-canvas` of `@k2b/ui`) in light and dark. App documents paint it
21
16
  * before any stylesheet loads and give it to the status bar.
@@ -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
 
@@ -88,6 +96,7 @@ export type AppRegistryEntry = {
88
96
  adminNav?: AppAdminNavigationGroup[];
89
97
  capabilities?: AppRegistryCapabilitySummary;
90
98
  help?: AppRegistryHelpSummary;
99
+ skills?: { manifestHash: string };
91
100
  legalLinks?: AppRegistryLegalLink[];
92
101
  searchLinks?: readonly AppSearchLink[];
93
102
  /** The app's part of the mobile app (preview); `href` is `/pwa/<id>`. Older readers ignore it. */
@@ -329,6 +329,11 @@ export const AccessEntrySchema = z.object({
329
329
  avatarHash: z.string().nullable().optional(),
330
330
  /** Kind of a `service_account` principal; presentation only, never authorization. */
331
331
  serviceAccountKind: ServiceAccountKindSchema.optional(),
332
+ /**
333
+ * A narrower part of the resource the grant covers, defined by the application, such as Mail's
334
+ * `assigned` conversations. Absent for a grant on the whole resource.
335
+ */
336
+ scope: z.string().min(1).max(64).optional(),
332
337
  });
333
338
  export type AccessEntry = z.infer<typeof AccessEntrySchema>;
334
339
 
@@ -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,
@@ -181,3 +226,23 @@ export const WidgetResponseSchema = z
181
226
  })
182
227
  .strip();
183
228
  export type WidgetResponse = z.infer<typeof WidgetResponseSchema>;
229
+
230
+ /** A widget's stable key on the dashboard and in the widget stream: `<appId>/<widgetId>`. */
231
+ export const widgetKey = (appId: string, widgetId: string): string => `${appId}/${widgetId}`;
232
+
233
+ /**
234
+ * How one widget answered in the widget stream: `ok` carries its response, `empty` is a `204`, `forbidden` a `403`,
235
+ * `timeout` means it did not answer within its own budget, and `error` covers every other failure.
236
+ */
237
+ export type WidgetStreamStatus = "ok" | "empty" | "forbidden" | "timeout" | "error";
238
+
239
+ /**
240
+ * One line of `GET /api/widgets/v1` (`application/x-ndjson`): `start` names the widgets Core asks, in registry
241
+ * order; one `widget` line follows per widget in the order they finish; `done` closes the stream. Every widget named
242
+ * in `start` gets exactly one line.
243
+ */
244
+ export type WidgetStreamLine =
245
+ | { type: "start"; widgets: string[] }
246
+ | { type: "widget"; key: string; status: "ok"; widget: WidgetResponse; ms: number }
247
+ | { type: "widget"; key: string; status: Exclude<WidgetStreamStatus, "ok">; ms: number }
248
+ | { type: "done"; status: "complete" | "partial" };
@@ -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)) {
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ export {
35
35
  export type { RuntimeCompatibilityIssue } from "./_internal/runtime-compatibility";
36
36
  export { assessRuntimeCompatibility } from "./_internal/runtime-compatibility";
37
37
  export { buildRuntimeFromRegistry } from "./_internal/runtime-context";
38
+ export { type AppSkillDefinition, skill } from "./ai/app-skills";
38
39
  export { defineCapabilities } from "./contracts/capabilities";
39
40
  export type {
40
41
  AnyBoundNotificationDefinition,
@@ -3,6 +3,7 @@ import { parse as parseYaml } from "yaml";
3
3
  import { z } from "zod";
4
4
  import { type HelpDocumentManifest, type HelpDocumentPayload, type HelpSearchPayload, helpLocaleChain } from "../shared/help";
5
5
  import { markdownToPlainText, renderHelpMarkdown } from "../shared/markdown";
6
+ import { auth } from "./middleware/auth";
6
7
 
7
8
  const metadataSchema = z.object({
8
9
  id: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
@@ -133,6 +134,9 @@ export const defineHelp = (
133
134
  * Define one app-owned help corpus. The explicit source list is deliberate:
134
135
  * IDs, ordering and ownership stay visible in code; no filesystem scanning or
135
136
  * build-time convention is required.
137
+ *
138
+ * @deprecated Declare Help with `defineHelp()` and pass it to `app.start({ help })`. This router only
139
+ * requires sign-in; it does not apply the app's Help visibility, so the mounting app's routes must guard it.
136
140
  */
137
141
  export const defineHelpCollection = (options: { basePath: string; sources: readonly string[] }): HelpCollection => {
138
142
  const basePath = options.basePath.replace(/\/$/, "");
@@ -151,7 +155,10 @@ export const defineHelpCollection = (options: { basePath: string; sources: reado
151
155
  url: `${basePath}/${encodeURIComponent(id)}`,
152
156
  }));
153
157
 
154
- const router = new Hono()
158
+ // Help is for signed-in people wherever an app mounts this router; its own route policy may narrow it further.
159
+ const router = new Hono();
160
+ router.use(auth.requireRole("authenticated"), auth.requireUser());
161
+ router
155
162
  .get("/search", (context) => {
156
163
  const query = context.req.query("q")?.trim().toLocaleLowerCase().slice(0, 200) ?? "";
157
164
  const payload: HelpSearchPayload = {
@@ -94,3 +94,4 @@ export {
94
94
  updateAccess,
95
95
  } from "./services";
96
96
  export { getDateConfig, getTimeZone, TIMEZONE_COOKIE, time } from "./time";
97
+ export { getWidgetRequest } from "./widget-request";
@@ -0,0 +1,27 @@
1
+ import type { Context } from "hono";
2
+ import { type DashboardWidgetSize, fitWidgetSize, resolveWidgetSizes, type WidgetRequest } from "../contracts/widgets";
3
+
4
+ /** The query parameter Core uses to ask a widget handler for one size. */
5
+ export const WIDGET_SIZE_PARAM = "size";
6
+
7
+ const requests = new WeakMap<Request, WidgetRequest>();
8
+
9
+ /**
10
+ * Records what the dashboard asked a declared widget for, already fitted to the declaration. Only the framework's
11
+ * internal widget route calls this, before it runs the handler.
12
+ */
13
+ export const setWidgetRequest = (
14
+ c: Context,
15
+ declaration: { sizes?: readonly DashboardWidgetSize[]; defaultSize?: DashboardWidgetSize },
16
+ ): WidgetRequest => {
17
+ const request = { size: fitWidgetSize(c.req.query(WIDGET_SIZE_PARAM), resolveWidgetSizes(declaration)) };
18
+ requests.set(c.req.raw, request);
19
+ return request;
20
+ };
21
+
22
+ /**
23
+ * What the dashboard asks this widget handler for. `size` is always one of the sizes the widget declares; return the
24
+ * content that fits it, for example only the one number in `small`. Outside a dashboard invocation, such as a request
25
+ * to the handler's own public route, the request is `large`, the height every widget had before sizes existed.
26
+ */
27
+ export const getWidgetRequest = (c: Context): WidgetRequest => requests.get(c.req.raw) ?? { size: "large" };
@@ -223,6 +223,21 @@ export const getMinimal = async (params: { id: string } | { uid: string }): Prom
223
223
  return buildUserMutationTarget(rows[0]!);
224
224
  };
225
225
 
226
+ /** Audience roles for a resolved access subject, including delegated users and group managers. */
227
+ export const getRoles = async (userId: string): Promise<Role[]> => {
228
+ const { groupsAdmin } = await getFreeIpaConfig();
229
+ const [row] = await sql<DbRow[]>`SELECT u.provider, u.profile,
230
+ CASE WHEN u.provider='local' THEN u.admin ELSE EXISTS (
231
+ SELECT 1 FROM auth.ipa_user_effective_groups eg WHERE eg.user_id=u.id
232
+ AND eg.group_name=ANY(${toPgTextArray(groupsAdmin)}::text[])
233
+ ) END AS effective_admin,
234
+ ARRAY(${managedGroupsNamesSubquery(sql`u.id`)}) AS manages
235
+ FROM auth.users u WHERE u.id=${userId}::uuid`;
236
+ if (!row) return [];
237
+ const { provider, profile } = resolveProviderProfile(row);
238
+ return buildRoles({ provider, profile, memberofGroup: [], manages: stringArray(row.manages), admin: Boolean(row.effective_admin) });
239
+ };
240
+
226
241
  export const getByUid = async (params: { uid: string }): Promise<{ id: string; roles: Role[] } | null> => {
227
242
  const { groupsAdmin } = await getFreeIpaConfig();
228
243
  const rows = await sql<DbRow[]>`
@@ -0,0 +1,225 @@
1
+ import { type SQL, sql } from "bun";
2
+ import { capabilityRequestHash } from "../../capabilities/claims";
3
+ import type { CapabilityActionReview } from "../../contracts/capabilities";
4
+ import { isAppSessionActor } from "../../server/actor";
5
+ import type { RequestActor, RequestCredentialKind } from "../../server/middleware/auth";
6
+ import { audit } from "../audit";
7
+ import { toPgTextArray } from "../postgres";
8
+
9
+ export { migrateApprovals } from "./migrate";
10
+
11
+ // Derived from chat's AI_ACTION_BUDGET_MS and Studio's one-day abandonment of pending calls.
12
+ export const APPROVAL_MAX_WAIT_MS = 24 * 60 * 60_000;
13
+ export type ApprovalDecisionChannel = "web_session" | "app_session" | "oauth" | "api_key" | "invocation";
14
+ export type ApprovalRequesterKind = "assistant_turn" | "studio_app";
15
+ export type ApprovalStatus = "pending" | "approved" | "rejected" | "expired" | "cancelled" | "consumed";
16
+ export type ApprovalAssurance = "standard" | "confirm";
17
+
18
+ /**
19
+ * `standard` when the person may remember the approval of this call: an Action with `approval: "rememberable"` whose
20
+ * review names a scope for these arguments, a tool whose policy allows remembering, or a website. Otherwise `confirm`.
21
+ */
22
+ export const approvalAssurance = (rememberable: boolean): ApprovalAssurance => (rememberable ? "standard" : "confirm");
23
+ export const approvalArgsDigest = (input: unknown): string => capabilityRequestHash(input);
24
+ export const approvalReviewDigest = (review: CapabilityActionReview | null | undefined): string => capabilityRequestHash(review ?? null);
25
+ export const approvalDecisionChannel = (input: {
26
+ credentialKind?: RequestCredentialKind;
27
+ actor?: RequestActor;
28
+ }): ApprovalDecisionChannel =>
29
+ input.credentialKind === "session"
30
+ ? isAppSessionActor(input.actor)
31
+ ? "app_session"
32
+ : "web_session"
33
+ : (input.credentialKind ?? "invocation");
34
+ export const approvalScopeFromToolName = (name: string): { appId: string; action: string } => {
35
+ const dot = name.indexOf(".");
36
+ return dot < 0 ? { appId: "assistant", action: name } : { appId: name.slice(0, dot), action: name.slice(dot + 1) };
37
+ };
38
+
39
+ export type ApprovalRequestInput = {
40
+ appId: string;
41
+ action: string;
42
+ approvalScope?: string | null;
43
+ requester: { kind: ApprovalRequesterKind; ref: string; appId: string; title?: string | null };
44
+ subjectId: string;
45
+ approverUserId: string;
46
+ assurance: ApprovalAssurance;
47
+ sentence: string;
48
+ review?: CapabilityActionReview | null;
49
+ input: unknown;
50
+ expiresInMs: number;
51
+ };
52
+ export type ApprovalRequest = {
53
+ id: string;
54
+ appId: string;
55
+ action: string;
56
+ approvalScope: string | null;
57
+ requesterKind: ApprovalRequesterKind;
58
+ requesterRef: string;
59
+ requesterAppId: string;
60
+ requesterTitle: string | null;
61
+ subjectId: string;
62
+ approverUserId: string;
63
+ assurance: ApprovalAssurance;
64
+ sentence: string;
65
+ details: CapabilityActionReview | null;
66
+ argsDigest: string;
67
+ reviewDigest: string;
68
+ status: ApprovalStatus;
69
+ decidedVia: ApprovalDecisionChannel | null;
70
+ decidedAt: Date | null;
71
+ consumedAt: Date | null;
72
+ createdAt: Date;
73
+ expiresAt: Date;
74
+ };
75
+ // SQL aliases keep the public row shape identical for reads and locked transaction reads. A function, because a
76
+ // fragment built at import time would bind the default sql handle before a test can choose its database.
77
+ const columns = () => sql`id, app_id AS "appId", action, approval_scope AS "approvalScope",
78
+ requester_kind AS "requesterKind", requester_ref AS "requesterRef", requester_app_id AS "requesterAppId",
79
+ requester_title AS "requesterTitle", subject_id AS "subjectId", approver_user_id AS "approverUserId",
80
+ assurance, sentence, details, args_digest AS "argsDigest", review_digest AS "reviewDigest", status,
81
+ decided_via AS "decidedVia", decided_at AS "decidedAt", consumed_at AS "consumedAt",
82
+ created_at AS "createdAt", expires_at AS "expiresAt"`;
83
+
84
+ const transaction = <T>(db: SQL | undefined, run: (tx: SQL) => Promise<T>): Promise<T> => (db && db !== sql ? run(db) : sql.begin(run));
85
+
86
+ export type ApprovalRequestResult = { ok: true; id: string } | { ok: false; code: "CONFLICT" };
87
+ /**
88
+ * Records the question once per requester reference. Asking again, for example after a worker restart, returns the
89
+ * open request unchanged: its snapshot is what the person may already see, so a changed effect fails `consume` and
90
+ * asks again instead of rebinding that decision. A reference whose request is decided, expired, or another person's
91
+ * is a conflict.
92
+ */
93
+ export const request = async (input: ApprovalRequestInput, db?: SQL): Promise<ApprovalRequestResult> => {
94
+ // Zero or less records a request that is already expired, such as a call that waited too long before the upgrade.
95
+ const expiresInMs = Number.isNaN(input.expiresInMs)
96
+ ? APPROVAL_MAX_WAIT_MS
97
+ : Math.max(0, Math.min(input.expiresInMs, APPROVAL_MAX_WAIT_MS));
98
+ return transaction(db, async (tx): Promise<ApprovalRequestResult> => {
99
+ const [row] = await tx<{ id: string }[]>`
100
+ INSERT INTO approvals.requests (app_id, action, approval_scope, requester_kind, requester_ref, requester_app_id,
101
+ requester_title, subject_id, approver_user_id, assurance, sentence, details, args_digest, review_digest, expires_at)
102
+ VALUES (${input.appId}, ${input.action}, ${input.approvalScope ?? null}, ${input.requester.kind}, ${input.requester.ref},
103
+ ${input.requester.appId}, ${input.requester.title ?? null}, ${input.subjectId}::uuid, ${input.approverUserId}::uuid,
104
+ ${input.assurance}, ${input.sentence}, ${JSON.stringify(input.review ?? null)}::text::jsonb,
105
+ ${approvalArgsDigest(input.input)}, ${approvalReviewDigest(input.review)}, now() + (${expiresInMs} * interval '1 millisecond'))
106
+ ON CONFLICT (requester_kind, requester_ref) DO NOTHING
107
+ RETURNING id
108
+ `;
109
+ if (row) return { ok: true, id: row.id };
110
+ const [open] = await tx<{ id: string }[]>`SELECT id FROM approvals.requests
111
+ WHERE requester_kind=${input.requester.kind} AND requester_ref=${input.requester.ref}
112
+ AND status='pending' AND expires_at > now() AND subject_id=${input.subjectId}::uuid
113
+ AND approver_user_id=${input.approverUserId}::uuid AND app_id=${input.appId} AND action=${input.action}`;
114
+ return open ? { ok: true, id: open.id } : { ok: false, code: "CONFLICT" };
115
+ });
116
+ };
117
+
118
+ export const get = async (id: string, db?: SQL): Promise<ApprovalRequest | null> =>
119
+ transaction(db, async (tx) => {
120
+ const [row] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests WHERE id=${id}::uuid`;
121
+ return row ?? null;
122
+ });
123
+
124
+ export type ApprovalDecisionResult =
125
+ | { ok: true; status: ApprovalStatus; replay: boolean }
126
+ | { ok: false; code: "EXPIRED" | "NOT_FOUND" }
127
+ | { ok: false; code: "ALREADY_DECIDED"; status: ApprovalStatus };
128
+ export const decide = async (
129
+ id: string,
130
+ decision: { approved: boolean },
131
+ actor: { userId: string; via: ApprovalDecisionChannel },
132
+ db?: SQL,
133
+ ): Promise<ApprovalDecisionResult> => {
134
+ const result = await transaction(db, async (tx): Promise<ApprovalDecisionResult> => {
135
+ const [row] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests
136
+ WHERE id=${id}::uuid AND approver_user_id=${actor.userId}::uuid FOR UPDATE`;
137
+ if (!row) return { ok: false, code: "NOT_FOUND" };
138
+ const status = decision.approved ? "approved" : "rejected";
139
+ const [won] = await tx<ApprovalRequest[]>`
140
+ UPDATE approvals.requests SET status=${status}, decided_via=${actor.via}, decided_at=now()
141
+ WHERE id=${id}::uuid AND approver_user_id=${actor.userId}::uuid AND status='pending' AND expires_at > now()
142
+ RETURNING ${columns()}
143
+ `;
144
+ if (won) {
145
+ await audit.record(
146
+ {
147
+ action: decision.approved ? "auth.action.approve" : "auth.action.deny",
148
+ outcome: "allowed",
149
+ actor: { userId: actor.userId },
150
+ target: { type: "approval_request", id, label: `${won.appId}.${won.action}` },
151
+ metadata: {
152
+ appId: won.appId,
153
+ action: won.action,
154
+ assurance: won.assurance,
155
+ decidedVia: actor.via,
156
+ requesterKind: won.requesterKind,
157
+ requesterAppId: won.requesterAppId,
158
+ },
159
+ },
160
+ tx,
161
+ );
162
+ return { ok: true, status, replay: false };
163
+ }
164
+ if (row.status === "pending") {
165
+ await tx`UPDATE approvals.requests SET status='expired' WHERE id=${id}::uuid AND status='pending' AND expires_at <= now()`;
166
+ return { ok: false, code: "EXPIRED" };
167
+ }
168
+ if (
169
+ (decision.approved && (row.status === "approved" || row.status === "consumed")) ||
170
+ (!decision.approved && row.status === "rejected")
171
+ )
172
+ return { ok: true, status: row.status, replay: true };
173
+ return { ok: false, code: "ALREADY_DECIDED", status: row.status };
174
+ });
175
+ // Publish a later NATS decision hint only after the owning transaction commits.
176
+ return result;
177
+ };
178
+
179
+ export type ApprovalConsumeResult =
180
+ | { ok: true }
181
+ | { ok: false; code: "NOT_FOUND" | "NOT_APPROVED" | "ALREADY_CONSUMED" | "INPUT_CHANGED" | "REVIEW_CHANGED" };
182
+ export const consume = async (
183
+ id: string,
184
+ input: { input: unknown; review: CapabilityActionReview | null; requester: { kind: ApprovalRequesterKind; ref: string } },
185
+ db?: SQL,
186
+ ): Promise<ApprovalConsumeResult> => {
187
+ const argsDigest = approvalArgsDigest(input.input),
188
+ reviewDigest = approvalReviewDigest(input.review);
189
+ return transaction(db, async (tx): Promise<ApprovalConsumeResult> => {
190
+ const [row] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests
191
+ WHERE id=${id}::uuid AND requester_kind=${input.requester.kind} AND requester_ref=${input.requester.ref} FOR UPDATE`;
192
+ if (!row) return { ok: false, code: "NOT_FOUND" };
193
+ if (row.status === "consumed") return { ok: false, code: "ALREADY_CONSUMED" };
194
+ if (row.status !== "approved") return { ok: false, code: "NOT_APPROVED" };
195
+ if (row.argsDigest !== argsDigest || row.reviewDigest !== reviewDigest) {
196
+ await tx`UPDATE approvals.requests SET status='cancelled' WHERE id=${id}::uuid AND status='approved'`;
197
+ return { ok: false, code: row.argsDigest !== argsDigest ? "INPUT_CHANGED" : "REVIEW_CHANGED" };
198
+ }
199
+ await tx`UPDATE approvals.requests SET status='consumed', consumed_at=now() WHERE id=${id}::uuid AND status='approved'
200
+ AND requester_kind=${input.requester.kind} AND requester_ref=${input.requester.ref}
201
+ AND args_digest=${argsDigest} AND review_digest=${reviewDigest}`;
202
+ return { ok: true };
203
+ });
204
+ };
205
+
206
+ export const cancel = async (ids: string[], db?: SQL): Promise<void> => {
207
+ if (!ids.length) return;
208
+ await transaction(db, async (tx) => {
209
+ await tx`UPDATE approvals.requests SET status='cancelled' WHERE id=ANY(${toPgTextArray(ids)}::uuid[]) AND status IN ('pending','approved')`;
210
+ });
211
+ };
212
+
213
+ export const pruneApprovalRequests = async (before: Date): Promise<number> => {
214
+ let total = 0;
215
+ let deleted: number;
216
+ do {
217
+ const result = await sql`WITH expired AS (SELECT id FROM approvals.requests WHERE expires_at < ${before} LIMIT 5000)
218
+ DELETE FROM approvals.requests request USING expired WHERE request.id=expired.id`;
219
+ deleted = result.count;
220
+ total += deleted;
221
+ } while (deleted === 5000);
222
+ return total;
223
+ };
224
+
225
+ export const approvals = { request, get, decide, consume, cancel };