@k2b/cloud 0.40.0 → 0.42.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 (279) hide show
  1. package/package.json +9 -5
  2. package/src/_internal/bounded-json.ts +14 -2
  3. package/src/_internal/capabilities.ts +87 -8
  4. package/src/_internal/capability-reviews.ts +6 -0
  5. package/src/_internal/capability-sentences.ts +2 -1
  6. package/src/_internal/capability-timeout.ts +71 -0
  7. package/src/_internal/define-app.ts +158 -92
  8. package/src/_internal/heartbeat.ts +203 -14
  9. package/src/_internal/http-drain.ts +56 -0
  10. package/src/_internal/registry-validation.ts +3 -0
  11. package/src/_internal/registry.ts +28 -1
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/_internal/setup-lock.ts +103 -0
  14. package/src/_internal/status-preserving-ssr.ts +2 -0
  15. package/src/access/PermissionEditor.tsx +16 -17
  16. package/src/access/PrincipalPicker.tsx +45 -10
  17. package/src/access/messages.ts +8 -0
  18. package/src/access/principal-label.ts +18 -0
  19. package/src/access/ui.ts +1 -0
  20. package/src/account/EntitySearch.tsx +5 -4
  21. package/src/ai/action-review.ts +2 -1
  22. package/src/ai/app-skill-store.ts +1 -1
  23. package/src/ai/capabilities.ts +21 -3
  24. package/src/ai/capability-execution.ts +14 -12
  25. package/src/ai/chat/action-card.ts +76 -4
  26. package/src/ai/chat/action-messages.ts +14 -0
  27. package/src/ai/chat/action-question.browser-harness.tsx +12 -0
  28. package/src/ai/chat/action-question.tsx +214 -23
  29. package/src/ai/chat/action-text.ts +288 -0
  30. package/src/ai/chat/tool-action-words.ts +31 -0
  31. package/src/ai/chat/tool-action.tsx +1 -26
  32. package/src/ai/chat-tasks.ts +2 -1
  33. package/src/ai/client/controller.ts +150 -32
  34. package/src/ai/client/transport.ts +14 -2
  35. package/src/ai/code-capability-routes.ts +27 -23
  36. package/src/ai/code-runtime-tools.ts +14 -16
  37. package/src/ai/code-source-tools.ts +16 -18
  38. package/src/ai/executor.ts +64 -21
  39. package/src/ai/fetch-file-tool.ts +67 -76
  40. package/src/ai/firecrawl-tools.ts +15 -2
  41. package/src/ai/http.ts +1 -0
  42. package/src/ai/index.ts +1 -0
  43. package/src/ai/migrate.ts +28 -0
  44. package/src/ai/open-questions.ts +168 -0
  45. package/src/ai/projects.ts +1 -2
  46. package/src/ai/protocol.ts +2 -2
  47. package/src/ai/question-text.ts +8 -0
  48. package/src/ai/recent-resources.ts +12 -5
  49. package/src/ai/review-content.ts +77 -0
  50. package/src/ai/routes.ts +119 -35
  51. package/src/ai/runtime.ts +1 -0
  52. package/src/ai/skill-tool.ts +17 -6
  53. package/src/ai/skills-routes.ts +72 -27
  54. package/src/ai/skills.ts +139 -60
  55. package/src/ai/store.ts +184 -31
  56. package/src/ai/testing.ts +36 -0
  57. package/src/ai/tool-sentences.ts +152 -34
  58. package/src/ai/tool-wording.ts +12 -6
  59. package/src/ai/tools.ts +8 -3
  60. package/src/ai/types.ts +25 -4
  61. package/src/ai/usage.ts +2 -2
  62. package/src/api/admin-core-settings.ts +15 -2
  63. package/src/api/admin-geo.ts +54 -0
  64. package/src/api/admin-linux-identities.ts +6 -3
  65. package/src/api/admin-outgoing-mail.ts +29 -15
  66. package/src/api/app-approval-actions.ts +31 -7
  67. package/src/api/approvals-loader.ts +3 -3
  68. package/src/api/approvals.ts +51 -0
  69. package/src/api/apps.ts +4 -2
  70. package/src/api/auth/schemas.ts +3 -0
  71. package/src/api/auth.ts +54 -18
  72. package/src/api/capabilities.ts +258 -181
  73. package/src/api/capability-streams.ts +21 -13
  74. package/src/api/cli-plugins.ts +4 -1
  75. package/src/api/geo-glyphs.ts +11 -0
  76. package/src/api/geo.ts +128 -0
  77. package/src/api/help.ts +12 -4
  78. package/src/api/index.ts +5 -0
  79. package/src/api/mcp.ts +73 -41
  80. package/src/api/me-panel-consent.ts +8 -5
  81. package/src/api/me.ts +79 -3
  82. package/src/api/search.ts +21 -14
  83. package/src/api/sync-ops.ts +19 -13
  84. package/src/api/widgets.ts +42 -18
  85. package/src/approvals/Approvals.tsx +30 -66
  86. package/src/approvals/approval-card.ts +68 -0
  87. package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
  88. package/src/browser/CloudResourceSearch.tsx +3 -13
  89. package/src/browser/PanelLeaveNotice.tsx +18 -0
  90. package/src/browser/navigation-search.ts +78 -25
  91. package/src/browser/panel-leave.ts +69 -0
  92. package/src/browser/panel-messages.ts +12 -0
  93. package/src/browser/panel-slot.ts +173 -24
  94. package/src/browser/panel.ts +3 -0
  95. package/src/browser/resource-search-messages.ts +2 -0
  96. package/src/capabilities/client.ts +28 -1
  97. package/src/capabilities/executions.ts +22 -3
  98. package/src/capabilities/geo-client.ts +57 -0
  99. package/src/capabilities/response.ts +2 -1
  100. package/src/capabilities/review-storage.ts +24 -0
  101. package/src/capabilities/reviews.ts +17 -3
  102. package/src/capabilities/server.ts +36 -16
  103. package/src/cli/access.ts +15 -23
  104. package/src/cli/admin/linux.ts +13 -2
  105. package/src/cli/admin/outgoing-mail.ts +26 -4
  106. package/src/cli/capabilities.ts +19 -0
  107. package/src/config/env.ts +0 -6
  108. package/src/contracts/app-access.ts +48 -0
  109. package/src/contracts/app-approval.ts +3 -3
  110. package/src/contracts/app.ts +4 -1
  111. package/src/contracts/approvals.ts +2 -2
  112. package/src/contracts/avatar.ts +186 -0
  113. package/src/contracts/capabilities.ts +149 -55
  114. package/src/contracts/geo-admin.ts +127 -0
  115. package/src/contracts/geo-map.ts +19 -0
  116. package/src/contracts/geo-route.ts +168 -0
  117. package/src/contracts/geo.ts +248 -0
  118. package/src/contracts/index.ts +4 -0
  119. package/src/contracts/outgoing-mail.ts +17 -4
  120. package/src/contracts/registry.ts +2 -0
  121. package/src/contracts/shared.ts +3 -0
  122. package/src/events/app-access.ts +68 -0
  123. package/src/events/live-delivery.ts +128 -0
  124. package/src/events/live.ts +14 -130
  125. package/src/geo/CloudMap.tsx +95 -0
  126. package/src/geo/map-messages.ts +17 -0
  127. package/src/geo/ui.ts +1 -0
  128. package/src/index.ts +2 -0
  129. package/src/server/client-ip.ts +36 -0
  130. package/src/server/index.ts +3 -4
  131. package/src/server/middleware/app-access.ts +312 -0
  132. package/src/server/middleware/auth.ts +5 -3
  133. package/src/server/middleware/invocation.ts +11 -0
  134. package/src/server/middleware/rate-limit.ts +4 -4
  135. package/src/server/services/access.ts +13 -9
  136. package/src/server/services/index.ts +0 -2
  137. package/src/server/services/services.ts +0 -2
  138. package/src/services/accounts/app.ts +23 -3
  139. package/src/services/accounts/posix.ts +21 -7
  140. package/src/services/accounts/users.ts +12 -1
  141. package/src/services/allowed-network-fetch.ts +16 -10
  142. package/src/services/app-access-metrics.ts +24 -0
  143. package/src/services/app-access.ts +123 -0
  144. package/src/services/app-discovery.ts +27 -0
  145. package/src/services/approvals/content.ts +40 -0
  146. package/src/services/approvals/device.ts +49 -33
  147. package/src/services/approvals/index.ts +15 -6
  148. package/src/services/approvals/list.ts +6 -11
  149. package/src/services/approvals/question.ts +72 -0
  150. package/src/services/audit/index.ts +1 -0
  151. package/src/services/auth-flows/ipa.ts +39 -2
  152. package/src/services/auth-flows/password-reset.ts +32 -6
  153. package/src/services/downstream-budget.ts +87 -0
  154. package/src/services/geo/admin.ts +262 -0
  155. package/src/services/geo/bucket.ts +67 -0
  156. package/src/services/geo/cache.ts +57 -0
  157. package/src/services/geo/costs.ts +5 -0
  158. package/src/services/geo/errors.ts +41 -0
  159. package/src/services/geo/fixtures/README.md +17 -0
  160. package/src/services/geo/fixtures/along.json +355 -0
  161. package/src/services/geo/fixtures/autocomplete.json +218 -0
  162. package/src/services/geo/fixtures/geocode.json +129 -0
  163. package/src/services/geo/fixtures/places.json +355 -0
  164. package/src/services/geo/fixtures/reverse.json +61 -0
  165. package/src/services/geo/fixtures/routing.json +57 -0
  166. package/src/services/geo/geoapify-route.ts +93 -0
  167. package/src/services/geo/geoapify.ts +281 -0
  168. package/src/services/geo/ledger.ts +122 -0
  169. package/src/services/geo/map.ts +160 -0
  170. package/src/services/geo/migrate.ts +25 -0
  171. package/src/services/geo/polyline.ts +184 -0
  172. package/src/services/geo/route-chain.ts +145 -0
  173. package/src/services/geo/route-costs.ts +9 -0
  174. package/src/services/geo/route-geometry.ts +39 -0
  175. package/src/services/geo/route-plan.ts +172 -0
  176. package/src/services/geo/route-service.ts +433 -0
  177. package/src/services/geo/service.ts +300 -0
  178. package/src/services/geo/settings.ts +49 -0
  179. package/src/services/help/index.ts +16 -7
  180. package/src/services/help/types.ts +6 -1
  181. package/src/services/identity/invocation-actor.ts +20 -2
  182. package/src/services/identity/issue-invocation.ts +67 -0
  183. package/src/services/identity/key-ring.ts +5 -3
  184. package/src/services/identity/runtime-config.ts +23 -0
  185. package/src/services/index.ts +11 -0
  186. package/src/services/mandates/index.ts +40 -3
  187. package/src/services/network-security.ts +6 -3
  188. package/src/services/network-transport.ts +99 -0
  189. package/src/services/notifications/catalog.ts +3 -2
  190. package/src/services/notifications/runtime.ts +1 -0
  191. package/src/services/notifications/web-push-transport.ts +45 -38
  192. package/src/services/outgoing-mail/dispatcher.ts +6 -1
  193. package/src/services/outgoing-mail/index.ts +55 -1
  194. package/src/services/outgoing-mail/messages.ts +82 -3
  195. package/src/services/outgoing-mail/resolve-host.ts +2 -2
  196. package/src/services/outgoing-mail/retention.ts +13 -1
  197. package/src/services/outgoing-mail/store.ts +36 -6
  198. package/src/services/outgoing-mail/test-send.ts +34 -4
  199. package/src/services/outgoing-mail/transport.ts +1 -1
  200. package/src/services/pdf/gotenberg.ts +11 -38
  201. package/src/services/pdf/markdown.ts +160 -2
  202. package/src/services/pdf/template-preview.ts +1 -1
  203. package/src/services/public-http.ts +28 -40
  204. package/src/services/session/index.ts +203 -29
  205. package/src/services/settings/app.ts +4 -1
  206. package/src/services/settings/core-settings.ts +88 -0
  207. package/src/services/settings/defaults.ts +8 -2
  208. package/src/services/settings/errors.ts +11 -0
  209. package/src/services/settings/geo-budget.ts +40 -0
  210. package/src/services/settings/index.ts +4 -3
  211. package/src/services/settings/store.ts +25 -17
  212. package/src/services/weather/geo.ts +29 -111
  213. package/src/services/weather/index.ts +4 -3
  214. package/src/services/weather/locations.ts +8 -5
  215. package/src/services/weather/migrate.ts +1 -0
  216. package/src/services/weather/settings.ts +0 -16
  217. package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
  218. package/src/shared/avatar-art.ts +400 -0
  219. package/src/shared/index.ts +2 -0
  220. package/src/shared/network-address.ts +22 -3
  221. package/src/shared/template-rendering.ts +36 -4
  222. package/src/shared-text/documents.ts +1 -1
  223. package/src/shared-text/index.ts +2 -0
  224. package/src/shared-text/log.ts +51 -3
  225. package/src/shared-text/snapshots.ts +340 -0
  226. package/src/ssr/AdminLayout.tsx +4 -2
  227. package/src/ssr/CloudShell.island.tsx +6 -0
  228. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  229. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  230. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  231. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  232. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  233. package/src/ssr/HelpPanelView.island.tsx +27 -0
  234. package/src/ssr/Layout.tsx +65 -15
  235. package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
  236. package/src/ssr/LayoutHelp.tsx +185 -542
  237. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
  238. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  239. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  240. package/src/ssr/PanelRuntime.island.tsx +11 -7
  241. package/src/ssr/PwaLayout.tsx +2 -2
  242. package/src/ssr/RailApps.tsx +6 -0
  243. package/src/ssr/ShellRuntime.island.tsx +23 -7
  244. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
  245. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  246. package/src/ssr/admin-navigation.ts +1 -0
  247. package/src/ssr/app-badges.ts +6 -1
  248. package/src/ssr/compact-shell.browser-harness.ts +44 -5
  249. package/src/ssr/help.ts +7 -5
  250. package/src/ssr/index.ts +2 -1
  251. package/src/ssr/islands/index.ts +3 -4
  252. package/src/ssr/layout-context.ts +2 -0
  253. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  254. package/src/ssr/layout-help-panel-document.ts +67 -0
  255. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  256. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  257. package/src/ssr/layout-help-panel-modules.ts +7 -0
  258. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  259. package/src/ssr/layout-help-panel-search.tsx +69 -0
  260. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  261. package/src/ssr/layout-help-panel-state.ts +141 -0
  262. package/src/ssr/layout-help-panel-types.ts +19 -0
  263. package/src/ssr/layout-help-views.tsx +484 -0
  264. package/src/ssr/mobile-menu-history.ts +4 -0
  265. package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
  266. package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
  267. package/src/ssr/page-swap.browser-harness.tsx +177 -0
  268. package/src/ssr/page-swap.ts +28 -0
  269. package/src/ssr/page-trail-runtime.ts +32 -13
  270. package/src/ssr/panel-consent.ts +2 -2
  271. package/src/ssr/panel-idle.ts +9 -0
  272. package/src/ssr/panel-slot.browser-harness.tsx +3 -0
  273. package/src/ssr/platform-messages.ts +8 -0
  274. package/src/ssr/runtime.ts +17 -0
  275. package/src/ssr/shell-data.tsx +10 -2
  276. package/src/styles/approvals.css +6 -0
  277. package/src/styles/effects.css +15 -0
  278. package/src/styles/global.css +70 -6
  279. package/src/server/services/geo.ts +0 -215
@@ -4,13 +4,12 @@ import {
4
4
  capabilityActionSubject,
5
5
  capabilityReviewSentence,
6
6
  capabilitySentenceText,
7
- resolveCapabilityActionSentences,
8
7
  } from "../_internal/capability-sentences";
9
8
  import { CapabilityActionReceiptSchema } from "../contracts/capabilities";
10
9
  import { type AiStoredActionReview, isLegacyActionReview, storedActionReview } from "./action-review";
11
- import { displayToolName, isRecord } from "./chat/message-utils";
10
+ import { isRecord } from "./chat/message-utils";
12
11
  import type { AiTurnBlock } from "./protocol";
13
- import { CLOUD_AI_TOOL_PRESENTATION } from "./tool-sentences";
12
+ import { cloudToolReview } from "./tool-sentences";
14
13
 
15
14
  type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
16
15
 
@@ -20,7 +19,7 @@ const reviewOf = (block: ToolBlock): AiStoredActionReview | null =>
20
19
 
21
20
  /**
22
21
  * How the chat words one call that acts on something: an app Action through its saved presentation, a
23
- * Cloud tool through Cloud's own catalog of the same shape. `null` for calls that only read.
22
+ * Cloud tool through its per-call review. `null` for calls that only read.
24
23
  */
25
24
  export const toolWording = (block: ToolBlock, locale: string): CapabilityActionWording | null => {
26
25
  const presentation = block.presentation;
@@ -32,8 +31,10 @@ export const toolWording = (block: ToolBlock, locale: string): CapabilityActionW
32
31
  ...(presentation.fields ? { fields: presentation.fields } : {}),
33
32
  };
34
33
  }
35
- const sentences = resolveCapabilityActionSentences(block.name, CLOUD_AI_TOOL_PRESENTATION, locale);
36
- return sentences ? { title: displayToolName(block.name, locale), sentences } : null;
34
+ const review = cloudToolReview(block.name, block.args, locale);
35
+ return review
36
+ ? { title: capabilitySentenceText(review.sentence.action, review.slots, { locale: review.locale, timeZone: "UTC" }) }
37
+ : null;
37
38
  };
38
39
 
39
40
  /**
@@ -45,6 +46,8 @@ export const toolSubject = (block: ToolBlock, locale: string, timeZone: string):
45
46
  const review = reviewOf(block);
46
47
  if (review && !isLegacyActionReview(review))
47
48
  return capabilitySentenceText(review.sentence.action, review.slots, { locale: review.locale, timeZone });
49
+ const cloudReview = cloudToolReview(block.name, block.args, locale);
50
+ if (cloudReview) return capabilitySentenceText(cloudReview.sentence.action, cloudReview.slots, { locale: cloudReview.locale, timeZone });
48
51
  const wording = toolWording(block, locale);
49
52
  return wording ? capabilityActionSubject(wording, block.args, { locale, timeZone }) : null;
50
53
  };
@@ -59,6 +62,9 @@ export const toolOutcome = (block: ToolBlock, key: "done" | "rejected" | "notRun
59
62
  return capabilityReviewSentence(review, key, { timeZone });
60
63
  }
61
64
  if (review && key === "done" && review.legacy.summary) return review.legacy.summary;
65
+ const cloudReview = cloudToolReview(block.name, block.args, locale);
66
+ // Only a form the tool names; otherwise the card keeps Cloud's own outcome words for the call.
67
+ if (cloudReview) return key === "done" || cloudReview.sentence[key] ? capabilityReviewSentence(cloudReview, key, { timeZone }) : null;
62
68
  const wording = toolWording(block, locale);
63
69
  if (!wording) return null;
64
70
  // App Actions return their data inside the result envelope; Cloud tools return the data itself.
package/src/ai/tools.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Tool, ToolContext } from "@k2b/nessi";
2
2
  import { defineTool as defineNessiTool } from "@k2b/nessi";
3
3
  import type { z } from "zod";
4
+ import type { CapabilityActionCallReview } from "../contracts/capabilities";
4
5
  import type { RequestActor } from "../server";
5
6
  import { aiToolNeedsApproval } from "./approvals";
6
7
  import type {
@@ -64,7 +65,7 @@ export const defineAiTool = <TInput extends z.ZodType, TOutput extends z.ZodType
64
65
  /** Localized short status; never include payloads or secrets. */
65
66
  reportProgress?: (message: string) => Promise<void>;
66
67
  /** Asks like `requestApproval`, and lets the person remember the approval of `target`. */
67
- requestApprovalFor?: (message: string, target: AiApprovalTarget) => Promise<boolean>;
68
+ requestApprovalFor?: (message: string, target: AiApprovalTarget, review?: CapabilityActionCallReview) => Promise<boolean>;
68
69
  /** Relays a decision for an effect another platform component consumes and executes. */
69
70
  requestRelayedApproval?: (message: string, options: { approvalId?: string; target?: AiApprovalTarget }) => Promise<boolean>;
70
71
  /** Shows requests a website approval let through as they go out, so the chat keeps them whatever the outcome. */
@@ -122,6 +123,8 @@ export type AiToolPreparationContext = {
122
123
  linkApproval?: (approvalCallId: string, approvalId: string) => void;
123
124
  /** Hears what a nested approval asks for before the approval is requested. */
124
125
  describeApproval?: (approvalCallId: string, target: AiApprovalTarget) => void;
126
+ /** Registers the deterministic review before requesting or consuming a nested approval. */
127
+ describeReview?: (approvalCallId: string, review: CapabilityActionCallReview) => void | Promise<void>;
125
128
  reportWebsiteReceipts?: (callId: string, receipts: AiWebsiteReceipt[]) => Promise<void>;
126
129
  };
127
130
 
@@ -168,10 +171,11 @@ export const prepareAiTools = (input: AiToolPreparationContext & { tools?: AiRun
168
171
  }
169
172
  // Nessi numbers the approvals of one execution in request order; count them to name each one's call ID.
170
173
  let approvals = 0;
171
- const requestApproval = async (message: string, target?: AiApprovalTarget): Promise<boolean> => {
174
+ const requestApproval = async (message: string, target?: AiApprovalTarget, review?: CapabilityActionCallReview): Promise<boolean> => {
172
175
  while (true) {
173
176
  const approvalCallId = `${callId}-approval-${approvals++}`;
174
177
  if (target) input.describeApproval?.(approvalCallId, target);
178
+ if (review) await input.describeReview?.(approvalCallId, review);
175
179
  if (!(await ctx.requestApproval(message))) return false;
176
180
  const used = await use(approvalCallId);
177
181
  if (used === "approved") return true;
@@ -182,7 +186,8 @@ export const prepareAiTools = (input: AiToolPreparationContext & { tools?: AiRun
182
186
  return {
183
187
  ...ctx,
184
188
  requestApproval,
185
- requestApprovalFor: (message: string, target: AiApprovalTarget) => requestApproval(message, target),
189
+ requestApprovalFor: (message: string, target: AiApprovalTarget, review?: CapabilityActionCallReview) =>
190
+ requestApproval(message, target, review),
186
191
  requestRelayedApproval: async (message: string, options: { approvalId?: string; target?: AiApprovalTarget }) => {
187
192
  const approvalCallId = `${callId}-approval-${approvals++}`;
188
193
  if (options.approvalId) input.linkApproval?.(approvalCallId, options.approvalId);
package/src/ai/types.ts CHANGED
@@ -13,7 +13,12 @@ import type {
13
13
  import type { Usage } from "@k2b/nessi/ai";
14
14
  import type { z } from "zod";
15
15
  import type { CapabilitySentenceField } from "../_internal/capability-sentences";
16
- import type { CapabilityActionReviewData, CapabilityActionSentences, CloudResourceRef } from "../contracts/capabilities";
16
+ import type {
17
+ CapabilityActionCallReview,
18
+ CapabilityActionReviewPresentation,
19
+ CapabilityActionSentences,
20
+ CloudResourceRef,
21
+ } from "../contracts/capabilities";
17
22
  import type { RequestActor } from "../server";
18
23
  import type { AiActionCheck, AiStoredActionReview } from "./action-review";
19
24
  import type { AiLocalClient, AiLocalContext, AiLocalToolName } from "./local-tool-contracts";
@@ -451,7 +456,14 @@ export type AiPendingTurnAction =
451
456
  name: string;
452
457
  args: unknown;
453
458
  message?: string;
454
- review?: CapabilityActionReviewData;
459
+ /** Stored review, including the declared ask reason for new-shape reviews. */
460
+ review?: CapabilityActionReviewPresentation;
461
+ /** Owning application, with its registry name in the turn's request locale. */
462
+ app?: { id: string; name: string };
463
+ /** The chat's current title, when known. */
464
+ chatTitle?: string | null;
465
+ /** Expiry of the platform approval request, as an ISO timestamp. */
466
+ expiresAt?: string;
455
467
  allowAlways: boolean;
456
468
  /** Whether the approval can be remembered for this chat. */
457
469
  allowChat: boolean;
@@ -487,7 +499,7 @@ export type AiPendingTurnActionRecord = {
487
499
  name: string;
488
500
  args: unknown;
489
501
  message?: string;
490
- review?: CapabilityActionReviewData;
502
+ review?: CapabilityActionReviewPresentation;
491
503
  approvalScope: string;
492
504
  allowAlways: boolean;
493
505
  /** Whether the approval can be remembered for this chat; defaults to `allowAlways`. */
@@ -724,7 +736,14 @@ export type AiTurnSweepResult = {
724
736
  };
725
737
 
726
738
  export type AiConversationService = {
739
+ getDraftSubmission(input: {
740
+ conversationId: string;
741
+ ownerUserId: string;
742
+ revision: number;
743
+ }): Promise<{ messageId: string; turnId: string | null } | null>;
727
744
  createConversation(input: {
745
+ /** Stable identity for one logical creation; replay never creates a replacement. */
746
+ requestId?: string;
728
747
  ownerUserId: string;
729
748
  title?: string;
730
749
  description?: string;
@@ -1032,6 +1051,8 @@ export type AiConversationService = {
1032
1051
  getPendingTurnAction(input: { conversationId: string; turnId: string; callId: string }): Promise<AiPendingTurnActionRecord | null>;
1033
1052
  findPendingTurnActionByApproval(input: { approvalId: string; ownerUserId: string }): Promise<AiPendingTurnActionRecord | null>;
1034
1053
  listPendingActionRecords(input: { conversationId: string; turnId: string }): Promise<AiPendingTurnActionRecord[]>;
1054
+ /** The reviews Cloud stored with a turn's tool calls, keyed by call ID; the newest message wins. */
1055
+ listTurnToolReviews(input: { conversationId: string; turnId: string }): Promise<Map<string, AiStoredActionReview>>;
1035
1056
  listResolvedPendingActions(input: { conversationId: string; turnId: string }): Promise<AiPendingTurnActionRecord[]>;
1036
1057
  resolvePendingTurnAction(input: {
1037
1058
  conversationId: string;
@@ -1151,7 +1172,7 @@ export type AiToolRuntime<TInput extends z.ZodType = z.ZodType, TOutput extends
1151
1172
  /** Localized short status; never include payloads or secrets. */
1152
1173
  reportProgress?: (message: string) => Promise<void>;
1153
1174
  /** Asks like `requestApproval`, and lets the person remember the approval of `target`. */
1154
- requestApprovalFor?: (message: string, target: AiApprovalTarget) => Promise<boolean>;
1175
+ requestApprovalFor?: (message: string, target: AiApprovalTarget, review?: CapabilityActionCallReview) => Promise<boolean>;
1155
1176
  /** Relays a decision for an effect another platform component consumes and executes. */
1156
1177
  requestRelayedApproval?: (message: string, options: { approvalId?: string; target?: AiApprovalTarget }) => Promise<boolean>;
1157
1178
  /** Shows requests a website approval let through as they go out, so the chat keeps them whatever the outcome. */
package/src/ai/usage.ts CHANGED
@@ -317,8 +317,8 @@ export const aiUsage = {
317
317
  : field === "appId"
318
318
  ? sql`e.app_id`
319
319
  : sql`e.task`;
320
- const label = field === "userId" ? sql`COALESCE(NULLIF(u.display_name,''),u.uid,sa.name,e.user_id::text)` : key;
321
- return sql<{ id: string; label: string }[]>`SELECT DISTINCT ${key} AS id,${label} AS label FROM (${events(since, until)}) e
320
+ const label = field === "userId" ? sql`COALESCE(NULLIF(u.display_name,''),u.uid,sa.name)` : key;
321
+ return sql<{ id: string; label: string | null }[]>`SELECT DISTINCT ${key} AS id,${label} AS label FROM (${events(since, until)}) e
322
322
  LEFT JOIN auth.users u ON u.id=e.user_id LEFT JOIN auth.service_accounts sa ON sa.id=e.user_id WHERE ${key} IS NOT NULL
323
323
  AND (position(lower(${search}) in lower(${label}))>0 OR ${key}=${search} OR (${field === "userId"} AND position(lower(${search}) in lower(COALESCE(u.uid,'')))>0))
324
324
  ORDER BY label,id LIMIT ${query.perPage}`;
@@ -39,6 +39,7 @@ import { GotenbergRenderError, testGotenberg } from "../services/pdf";
39
39
  import * as settings from "../services/settings";
40
40
  import { SETTINGS_MAP, validateSettingValue } from "../services/settings/defaults";
41
41
 
42
+ import { assertGeoBudgetPair, GeoBudgetSettingError } from "../services/settings/geo-budget";
42
43
  import { invalidateSettingsCacheForAdmin } from "../services/settings/store";
43
44
 
44
45
  const BulkSaveSchema = z.union([
@@ -67,6 +68,9 @@ const settingsNotSavedMessage = (locale: string): string =>
67
68
  locale.startsWith("de") ? "Die Einstellungen wurden nicht gespeichert." : "The settings were not saved.";
68
69
 
69
70
  const isKnownSetting = (key: string): boolean => SETTINGS_MAP.has(key);
71
+ // The Geoapify key is written only where Cloud verifies it with the provider first, so a refused key never replaces a working one.
72
+ const GEO_KEY = "geo.api_key";
73
+ const GEO_KEY_DEDICATED = "Set or remove the Geoapify key with /api/admin/core/geo/key, which verifies it first.";
70
74
 
71
75
  const AI_PROFILES_KEY = "ai.model_profiles_json";
72
76
  const AI_ENABLED_KEY = "ai.enabled";
@@ -333,7 +337,10 @@ const app = new Hono<AuthContext>()
333
337
  return c.json({ ok: true, bytes: result.bytes, contentType: result.contentType });
334
338
  } catch (error) {
335
339
  if (error instanceof GotenbergRenderError) {
336
- return c.json({ message: error.message, code: error.code, status: error.status }, error.code === "not_configured" ? 400 : 502);
340
+ return c.json(
341
+ { message: error.message, code: error.code, status: error.status },
342
+ error.code === "busy" ? 503 : error.code === "not_configured" ? 400 : 502,
343
+ );
337
344
  }
338
345
  return c.json({ message: "Failed to test Gotenberg PDF rendering" }, 500);
339
346
  }
@@ -384,6 +391,7 @@ const app = new Hono<AuthContext>()
384
391
  ownership[key] = `Setting "${key}" cannot be updated and reset in the same save`;
385
392
  }
386
393
  }
394
+ if (keys.includes(GEO_KEY)) ownership[GEO_KEY] = GEO_KEY_DEDICATED;
387
395
  if (Object.keys(ownership).length > 0) {
388
396
  return c.json({ message: "Invalid keys", errors: ownership }, 400);
389
397
  }
@@ -469,11 +477,12 @@ const app = new Hono<AuthContext>()
469
477
  // Every settings and AI credential write runs on the transaction's own
470
478
  // connection, so a failure rolls the whole save back.
471
479
  await sql.begin(async (tx) => {
480
+ await assertGeoBudgetPair(tx, validatedValues, resets);
472
481
  for (const [key, value] of Object.entries(validatedValues)) {
473
482
  try {
474
483
  await settings.set(key, value, tx, { replaceUnreadable: replace.has(key) });
475
484
  } catch (error) {
476
- fieldErrors[key] = `Failed to update ${key}`;
485
+ fieldErrors[key] = `Failed to save ${key}`;
477
486
  throw error;
478
487
  }
479
488
  }
@@ -514,6 +523,7 @@ const app = new Hono<AuthContext>()
514
523
  }
515
524
  });
516
525
  } catch (error) {
526
+ if (error instanceof GeoBudgetSettingError) return error.getResponse();
517
527
  if (error instanceof settings.SettingUnreadableError) {
518
528
  return c.json(
519
529
  {
@@ -553,6 +563,7 @@ const app = new Hono<AuthContext>()
553
563
  if (!isKnownSetting(key)) {
554
564
  return c.json({ message: `Unknown setting "${key}"` }, 400);
555
565
  }
566
+ if (key === GEO_KEY) return c.json({ message: GEO_KEY_DEDICATED, errors: { [GEO_KEY]: GEO_KEY_DEDICATED } }, 400);
556
567
 
557
568
  let aiPlan: AiSettingsMutationPlan;
558
569
  try {
@@ -567,6 +578,7 @@ const app = new Hono<AuthContext>()
567
578
 
568
579
  try {
569
580
  await sql.begin(async (tx) => {
581
+ await assertGeoBudgetPair(tx, {}, [key]);
570
582
  await settings.remove(key, tx);
571
583
  if (key.startsWith("user.category."))
572
584
  await audit.record(
@@ -588,6 +600,7 @@ const app = new Hono<AuthContext>()
588
600
  }
589
601
  });
590
602
  } catch (error) {
603
+ if (error instanceof GeoBudgetSettingError || error instanceof settings.SettingUnreadableError) return error.getResponse();
591
604
  console.error(`[settings] failed to reset "${key}"`, error);
592
605
  return c.json({ message: "Reset failed" }, 500);
593
606
  }
@@ -0,0 +1,54 @@
1
+ import { Hono, type MiddlewareHandler } from "hono";
2
+ import { describeRoute } from "hono-openapi";
3
+ import { GeoBudgetInputSchema, GeoKeyInputSchema } from "../contracts/geo-admin";
4
+ import { type AuthContext, auth, v } from "../server";
5
+ import { GeoAdminError, geoAdmin } from "../services/geo/admin";
6
+
7
+ export const createAdminGeoRoutes = (
8
+ authenticate: MiddlewareHandler<AuthContext> = auth.requireRole("admin"),
9
+ service: Pick<typeof geoAdmin, "status" | "usage" | "setKey" | "removeKey" | "saveBudget"> = geoAdmin,
10
+ ) =>
11
+ new Hono<AuthContext>()
12
+ .use(authenticate)
13
+ .onError((error, c) => {
14
+ if (error instanceof GeoAdminError) return c.json(error.body, error.status);
15
+ if (c.req.raw.signal.aborted) return new Response(null, { status: 499 });
16
+ throw error;
17
+ })
18
+ .get(
19
+ "/",
20
+ describeRoute({ tags: ["Places and maps"], summary: "Show the Geoapify key state, plan and budgets" }),
21
+ auth.requireOAuthScope("read", "admin"),
22
+ async (c) => c.json(await service.status(c.get("user"))),
23
+ )
24
+ .get(
25
+ "/usage",
26
+ describeRoute({ tags: ["Places and maps"], summary: "Show this month's and today's geo credits with shares per function" }),
27
+ auth.requireOAuthScope("read", "admin"),
28
+ async (c) => c.json(await service.usage(c.get("user"))),
29
+ )
30
+ .put(
31
+ "/key",
32
+ describeRoute({
33
+ tags: ["Places and maps"],
34
+ summary: "Verify a Geoapify key with one provider lookup, then store it; a refused key changes nothing",
35
+ }),
36
+ auth.requireOAuthScope("write", "admin"),
37
+ v("json", GeoKeyInputSchema),
38
+ async (c) => c.json(await service.setKey(c.get("user"), c.req.valid("json").apiKey, c.req.raw.signal)),
39
+ )
40
+ .delete(
41
+ "/key",
42
+ describeRoute({ tags: ["Places and maps"], summary: "Remove the Geoapify key; places and maps turn off for everyone" }),
43
+ auth.requireOAuthScope("write", "admin"),
44
+ async (c) => c.json(await service.removeKey(c.get("user"))),
45
+ )
46
+ .put(
47
+ "/budget",
48
+ describeRoute({ tags: ["Places and maps"], summary: "Save the plan, the monthly budget and the daily limit per person" }),
49
+ auth.requireOAuthScope("write", "admin"),
50
+ v("json", GeoBudgetInputSchema),
51
+ async (c) => c.json(await service.saveBudget(c.get("user"), c.req.valid("json"))),
52
+ );
53
+
54
+ export default createAdminGeoRoutes();
@@ -6,7 +6,9 @@ import { type AuthContext, auth, v } from "../server";
6
6
  import { PosixError, posix } from "../services/accounts/posix";
7
7
 
8
8
  const Id = z.object({ id: z.uuid() });
9
- const Configure = z.object({ config: LinuxIdentityConfigurationSchema, rangeReserved: z.boolean() }).strict();
9
+ const Configure = z
10
+ .object({ config: LinuxIdentityConfigurationSchema, rangeReserved: z.boolean(), replaceUnreadable: z.boolean().optional() })
11
+ .strict();
10
12
 
11
13
  export const createAdminLinuxIdentityRoutes = (
12
14
  authenticate: MiddlewareHandler<AuthContext> = auth.requireRole("admin"),
@@ -32,12 +34,13 @@ export const createAdminLinuxIdentityRoutes = (
32
34
  describeRoute({
33
35
  tags: ["Linux identities"],
34
36
  summary: "Configure automatic local Linux identity assignment; does not enable computer login",
37
+ description: "Unreadable stored configuration returns 409 setting_unreadable unless replaceUnreadable is explicitly true.",
35
38
  }),
36
39
  v("json", Configure),
37
40
  async (c) => {
38
- const { config, rangeReserved } = c.req.valid("json");
41
+ const { config, rangeReserved, replaceUnreadable } = c.req.valid("json");
39
42
  if (config.enabled && !rangeReserved) throw new PosixError("range_confirmation_required", 400);
40
- return c.json(await service.configure(c.get("user"), config));
43
+ return c.json(await service.configure(c.get("user"), config, { replaceUnreadable }));
41
44
  },
42
45
  )
43
46
  .get(
@@ -17,15 +17,18 @@ import { type AuthContext, auth, jsonResponse, requiresAdmin } from "../server";
17
17
  import { noTransform } from "../server/middleware/no-transform";
18
18
  import { audit } from "../services/audit";
19
19
  import { outgoingMailLog } from "../services/outgoing-mail/admin";
20
- import { MAIL_RETENTION_KEYS, readMailRetention } from "../services/outgoing-mail/retention";
20
+ import { MAIL_RETENTION_KEYS, readMailRetentionSettings } from "../services/outgoing-mail/retention";
21
21
  import { type MailAuditContext, OutgoingMailError, outgoingMailStore } from "../services/outgoing-mail/store";
22
22
  import { outgoingMailTest } from "../services/outgoing-mail/test-send";
23
23
  import * as settings from "../services/settings";
24
24
 
25
- const retentionInput = MailRetentionSchema.refine((value) => value.recordDays >= value.contentDays, {
26
- message: "Record retention must be at least content retention.",
27
- path: ["recordDays"],
28
- });
25
+ const retentionView = MailRetentionSchema.extend({ unreadable: z.record(z.string(), z.boolean()) });
26
+ const retentionInput = MailRetentionSchema.partial()
27
+ .extend({ replaceUnreadable: z.array(z.enum(MAIL_RETENTION_KEYS)).optional() })
28
+ .refine((value) => value.recordDays === undefined || value.contentDays === undefined || value.recordDays >= value.contentDays, {
29
+ message: "Record retention must be at least content retention.",
30
+ path: ["recordDays"],
31
+ });
29
32
 
30
33
  const validate = <Target extends "param" | "json" | "query", Schema extends z.ZodType>(
31
34
  target: Target,
@@ -113,9 +116,9 @@ export const createAdminOutgoingMailRoutes = (authenticate: MiddlewareHandler<Au
113
116
  summary: "Read outgoing mail retention",
114
117
  tags: ["Outgoing mail"],
115
118
  ...requiresAdmin,
116
- responses: { 200: jsonResponse(MailRetentionSchema, "Effective retention in days") },
119
+ responses: { 200: jsonResponse(retentionView, "Effective retention in days") },
117
120
  }),
118
- async (c) => c.json(await readMailRetention()),
121
+ async (c) => c.json(await readMailRetentionSettings()),
119
122
  )
120
123
  .put(
121
124
  "/retention",
@@ -124,19 +127,30 @@ export const createAdminOutgoingMailRoutes = (authenticate: MiddlewareHandler<Au
124
127
  tags: ["Outgoing mail"],
125
128
  ...requiresAdmin,
126
129
  responses: {
127
- 200: jsonResponse(MailRetentionSchema, "Updated retention in days"),
130
+ 200: jsonResponse(retentionView, "Updated retention in days"),
128
131
  400: jsonResponse(z.object({ code: z.literal("bad_input"), message: z.string() }), "Invalid retention"),
132
+ 409: jsonResponse(
133
+ z.object({ code: z.literal("setting_unreadable"), message: z.string() }),
134
+ "Stored period needs explicit replacement",
135
+ ),
129
136
  },
130
137
  }),
131
138
  validate("json", retentionInput, "bad_input"),
132
139
  async (c) => {
133
140
  const next = c.req.valid("json");
134
- const old = await readMailRetention();
135
141
  await sql.begin(async (tx) => {
136
- // complete value from explicit admin input; nothing is merged over a read (#1303)
137
- await settings.set(MAIL_RETENTION_KEYS[0], next.contentDays, tx, { replaceUnreadable: true });
138
- // complete value from explicit admin input; nothing is merged over a read (#1303)
139
- await settings.set(MAIL_RETENTION_KEYS[1], next.recordDays, tx, { replaceUnreadable: true });
142
+ await tx`SELECT pg_advisory_xact_lock(hashtextextended('outgoing-mail.retention-settings', 0))`;
143
+ const old = await readMailRetentionSettings(tx);
144
+ if ((next.recordDays ?? old.recordDays) < (next.contentDays ?? old.contentDays))
145
+ throw new OutgoingMailError("bad_input", "Record retention must be at least content retention.");
146
+ if (next.contentDays !== undefined)
147
+ await settings.set(MAIL_RETENTION_KEYS[0], next.contentDays, tx, {
148
+ replaceUnreadable: next.replaceUnreadable?.includes(MAIL_RETENTION_KEYS[0]) === true,
149
+ });
150
+ if (next.recordDays !== undefined)
151
+ await settings.set(MAIL_RETENTION_KEYS[1], next.recordDays, tx, {
152
+ replaceUnreadable: next.replaceUnreadable?.includes(MAIL_RETENTION_KEYS[1]) === true,
153
+ });
140
154
  await audit.record(
141
155
  {
142
156
  ...context(c),
@@ -149,7 +163,7 @@ export const createAdminOutgoingMailRoutes = (authenticate: MiddlewareHandler<Au
149
163
  );
150
164
  });
151
165
  await settings.invalidateSettingsCache(MAIL_RETENTION_KEYS);
152
- return c.json(next);
166
+ return c.json(await readMailRetentionSettings());
153
167
  },
154
168
  )
155
169
  .get(
@@ -245,7 +259,7 @@ export const createAdminOutgoingMailRoutes = (authenticate: MiddlewareHandler<Au
245
259
  .put(
246
260
  "/apps/:appId/log-access",
247
261
  describeRoute({
248
- summary: "Set application mail log access",
262
+ summary: "Replace application metadata and content mail log access",
249
263
  tags: ["Outgoing mail"],
250
264
  ...requiresAdmin,
251
265
  responses: {
@@ -1,20 +1,44 @@
1
1
  import { submitAiTurnAction } from "../ai/runtime";
2
2
  import { aiConversations } from "../ai/store";
3
- import { APP_ACTION_LIMITS } from "../contracts/app-approval";
3
+ import { APP_ACTION_LIMITS, type AppActionSummary } from "../contracts/app-approval";
4
+ import { APPROVALS_PAGE_SIZE } from "../contracts/approvals";
4
5
  import type { AppDeviceActions } from "../services/app-approval";
5
- import { listPending } from "../services/approvals";
6
- import { appActionSummary, appActionView } from "../services/approvals/device";
6
+ import { listPending, loadApprovalActionPolicies, pendingApprovalCard } from "../services/approvals";
7
+ import { appActionOffered, appActionSummary, appActionView } from "../services/approvals/device";
8
+
9
+ /** Pages of open requests one inbox poll reads at most while it skips browser-only questions. */
10
+ const INBOX_PAGES = 5;
7
11
 
8
12
  export const createDeviceActions = (appName: (appId: string, locale: string) => string): AppDeviceActions => {
9
13
  const view: AppDeviceActions["view"] = async ({ userId, issuer, actionId }) => {
10
14
  const { items } = await listPending({ approverUserId: userId, id: actionId, limit: 1 });
11
- return items[0] ? appActionView(items[0], { issuer, appName }) : null;
15
+ const row = items[0];
16
+ if (!row) return null;
17
+ const policy = (await loadApprovalActionPolicies([row]))(row);
18
+ const mapped = await appActionView(row, { issuer, appName, policy });
19
+ return appActionOffered(mapped, pendingApprovalCard(row, appName, policy), policy) ? mapped : null;
12
20
  };
13
21
  return {
14
22
  inbox: async ({ userId, issuer }) => {
15
- const { items, count } = await listPending({ approverUserId: userId, limit: APP_ACTION_LIMITS.inboxActions });
16
- const views = await Promise.all(items.map((row) => appActionView(row, { issuer, appName })));
17
- return { actions: views.map(appActionSummary), moreActions: Math.max(0, count - items.length) };
23
+ // Browser-only questions are skipped, so read on past them. One poll reads at most this many pages of open
24
+ // requests, newest first, which keeps each 5-second poll bounded.
25
+ const actions: AppActionSummary[] = [];
26
+ let count = 0;
27
+ let cursor: string | undefined;
28
+ for (let page = 0; page < INBOX_PAGES && actions.length < APP_ACTION_LIMITS.inboxActions; page += 1) {
29
+ const result = await listPending({ approverUserId: userId, limit: APPROVALS_PAGE_SIZE, ...(cursor ? { cursor } : {}) });
30
+ count = result.count;
31
+ const policyOf = await loadApprovalActionPolicies(result.items);
32
+ for (const row of result.items) {
33
+ const policy = policyOf(row);
34
+ const mapped = await appActionView(row, { issuer, appName, policy });
35
+ if (appActionOffered(mapped, pendingApprovalCard(row, appName, policy), policy)) actions.push(appActionSummary(mapped));
36
+ if (actions.length === APP_ACTION_LIMITS.inboxActions) break;
37
+ }
38
+ if (!result.nextCursor) break;
39
+ cursor = result.nextCursor;
40
+ }
41
+ return { actions, moreActions: Math.max(0, count - actions.length) };
18
42
  },
19
43
  view,
20
44
  decide: async ({ userId, deviceId, issuer, actionId, digest, approved }) => {
@@ -3,7 +3,7 @@ import { HTTPException } from "hono/http-exception";
3
3
  import { listCapabilities } from "../_internal/registry";
4
4
  import { resolveAiChatRequestContext } from "../ai/request-context";
5
5
  import type { ApprovalListQuery, ApprovalPage } from "../contracts/approvals";
6
- import { CapabilityActionCallReviewSchema, capabilityActionPolicy } from "../contracts/capabilities";
6
+ import { CapabilityActionReviewPresentationSchema, capabilityActionPolicy } from "../contracts/capabilities";
7
7
  import type { AuthContext } from "../server/middleware/auth";
8
8
  import { listPending, presentPendingApproval } from "../services/approvals/list";
9
9
  import { logger } from "../services/logging";
@@ -16,8 +16,8 @@ export const loadPendingApprovals = async <T extends AuthContext>(c: Context<T>,
16
16
  const apps = getLocalizedRuntimeContext(c).apps;
17
17
  const needsPolicy = page.items.some((row) => {
18
18
  if (row.storedReview && "sentence" in row.storedReview) return false;
19
- const review = CapabilityActionCallReviewSchema.safeParse(row.details);
20
- return review.success && !review.data.ask;
19
+ const review = CapabilityActionReviewPresentationSchema.safeParse(row.details);
20
+ return review.success && "sentence" in review.data && !review.data.ask;
21
21
  });
22
22
  const capabilities = needsPolicy
23
23
  ? await listCapabilities().catch((error: unknown) => {
@@ -4,6 +4,7 @@ import { describeRoute } from "hono-openapi";
4
4
  import { z } from "zod";
5
5
  import { toAiActionFailureResponse } from "../ai/http";
6
6
  import { resolveAiChatRequestContext } from "../ai/request-context";
7
+ import { readReviewContent } from "../ai/review-content";
7
8
  import { submitAiTurnAction } from "../ai/runtime";
8
9
  import { aiConversations } from "../ai/store";
9
10
  import {
@@ -14,6 +15,7 @@ import {
14
15
  ApprovalListQuerySchema,
15
16
  ApprovalPageSchema,
16
17
  } from "../contracts/approvals";
18
+ import { CapabilityReviewContentSchema } from "../contracts/capabilities";
17
19
  import { ErrorResponseSchema } from "../contracts/shared";
18
20
  import { rateLimit } from "../server";
19
21
  import { respond } from "../server/api";
@@ -23,6 +25,7 @@ import { jsonResponse, requiresAuth } from "../server/middleware/openapi";
23
25
  import { v } from "../server/middleware/validator";
24
26
  import { ok } from "../server/services";
25
27
  import { approvalDecisionChannel, countPending } from "../services/approvals";
28
+ import { readApprovalContent } from "../services/approvals/content";
26
29
  import { decodeApprovalCursor } from "../services/approvals/cursor";
27
30
  import { loadPendingApprovals } from "./approvals-loader";
28
31
 
@@ -30,6 +33,54 @@ export const approvalRoutes = new Hono<AuthContext>()
30
33
  .use("*", noTransform())
31
34
  .use(rateLimit())
32
35
  .use(auth.requireRole("authenticated"))
36
+ .get(
37
+ "/:id/content",
38
+ describeRoute({
39
+ tags: ["Approvals"],
40
+ summary: "Read one complete pending approval snapshot",
41
+ ...requiresAuth,
42
+ responses: {
43
+ 200: jsonResponse(CapabilityReviewContentSchema, "Complete reviewed content"),
44
+ 404: jsonResponse(ErrorResponseSchema, "Snapshot unavailable"),
45
+ },
46
+ }),
47
+ v("param", z.object({ id: z.uuid() })),
48
+ v("query", z.object({ digest: z.string().regex(/^[a-f0-9]{64}$/) }).strict()),
49
+ async (c) => {
50
+ const ctx = await resolveAiChatRequestContext(c);
51
+ if (ctx instanceof Response) return ctx;
52
+ const content = await readApprovalContent({
53
+ ...c.req.valid("param"),
54
+ ...c.req.valid("query"),
55
+ approverUserId: ctx.ownerUserId,
56
+ signal: c.req.raw.signal,
57
+ });
58
+ c.header("Cache-Control", "private, no-store");
59
+ return content ? c.json(content) : c.json({ message: "This review is no longer available." }, 404);
60
+ },
61
+ )
62
+ .get(
63
+ "/conversations/:conversationId/turns/:turnId/reviews/:callId",
64
+ describeRoute({
65
+ tags: ["Approvals"],
66
+ summary: "Read one historical conversation review snapshot",
67
+ ...requiresAuth,
68
+ responses: {
69
+ 200: jsonResponse(CapabilityReviewContentSchema, "Complete reviewed content"),
70
+ 404: jsonResponse(ErrorResponseSchema, "Snapshot unavailable"),
71
+ },
72
+ }),
73
+ v("param", z.object({ conversationId: z.uuid(), turnId: z.uuid(), callId: z.string().min(1).max(200) })),
74
+ v("query", z.object({ digest: z.string().regex(/^[a-f0-9]{64}$/) }).strict()),
75
+ async (c) => {
76
+ const ctx = await resolveAiChatRequestContext(c);
77
+ if (ctx instanceof Response) return ctx;
78
+ const location = c.req.valid("param");
79
+ c.header("Cache-Control", "private, no-store");
80
+ const content = await readReviewContent(location, c.req.valid("query").digest, ctx.ownerUserId, c.req.raw.signal);
81
+ return content ? c.json(content) : c.json({ message: "This review is no longer available." }, 404);
82
+ },
83
+ )
33
84
  .get(
34
85
  "/",
35
86
  describeRoute({
package/src/api/apps.ts CHANGED
@@ -3,6 +3,8 @@ import { describeRoute } from "hono-openapi";
3
3
  import { z } from "zod";
4
4
  import { ErrorResponseSchema } from "../contracts";
5
5
  import { type AuthContext, auth, jsonResponse, requiresAuth, v } from "../server";
6
+ import { appAccessAuthority } from "../services/app-access";
7
+ import { discoverableApps } from "../services/app-discovery";
6
8
  import { visibleNavigationApps } from "../ssr/app-navigation";
7
9
  import { getLocalizedRuntimeContext } from "../ssr/runtime";
8
10
 
@@ -35,9 +37,9 @@ export const appDiscoveryRoutes = new Hono<AuthContext>().use(auth.requireRole("
35
37
  },
36
38
  }),
37
39
  v("query", AppListQuerySchema),
38
- (c) => {
40
+ async (c) => {
39
41
  const search = c.req.valid("query").search?.toLowerCase();
40
- const items = visibleNavigationApps(getLocalizedRuntimeContext(c).apps, c.get("user"))
42
+ const items = visibleNavigationApps(await discoverableApps(appAccessAuthority(c), getLocalizedRuntimeContext(c).apps), c.get("user"))
41
43
  .map((app) => ({
42
44
  id: app.id,
43
45
  name: app.name,
@@ -54,6 +54,9 @@ export const AuthResponseSchema = z.object({
54
54
  user: UserSchema,
55
55
  });
56
56
 
57
+ /** Password-change sign-in result; retry ending other sign-ins when signOutOthersFailed is true. */
58
+ export const PasswordChangeAuthResponseSchema = AuthResponseSchema.extend({ signOutOthersFailed: z.boolean() });
59
+
57
60
  export const VerifyPasskeyAuthenticationSchema = z.object({
58
61
  response: z.unknown(),
59
62
  acceptedAgb: z.literal(true).optional(),