@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
@@ -2,8 +2,13 @@ import { z } from "zod";
2
2
  import {
3
3
  CAPABILITY_FRAMEWORK_ERROR_CODES,
4
4
  CAPABILITY_MAX_CATALOG_BYTES,
5
+ CAPABILITY_MAX_REVIEW_CONTENT_BYTES,
6
+ CAPABILITY_MAX_REVIEW_RESPONSE_BYTES,
5
7
  CapabilityActionReviewDataSchema,
6
8
  CapabilityCatalogSchema,
9
+ type CapabilityReviewContentRef,
10
+ CapabilityReviewContentRefSchema,
11
+ CapabilityReviewContentSchema,
7
12
  capabilityResultSchema,
8
13
  } from "../contracts/capabilities";
9
14
  import { capabilityMessages } from "../shared/capability-messages";
@@ -142,7 +147,29 @@ export const reviewCapabilityAction = async <TInput = unknown>(
142
147
  body: JSON.stringify({ input: invocation.input }),
143
148
  signal: invocation.signal,
144
149
  });
145
- return readCapabilityResponse(response, CapabilityActionReviewDataSchema);
150
+ return await readCapabilityResponse(
151
+ response,
152
+ CapabilityActionReviewDataSchema,
153
+ CAPABILITY_MAX_REVIEW_RESPONSE_BYTES,
154
+ invocation.signal,
155
+ );
156
+ } catch (cause) {
157
+ return unavailable(cause, undefined, clientLocale(options.headers));
158
+ }
159
+ };
160
+
161
+ /** Load exactly one stored snapshot using the caller's normal Cloud authentication. A locator grants no access. */
162
+ export const readCapabilityReviewContent = async (
163
+ reference: CapabilityReviewContentRef,
164
+ options: CapabilityHttpOptions & { signal?: AbortSignal } = {},
165
+ ) => {
166
+ try {
167
+ const ref = CapabilityReviewContentRefSchema.parse(reference);
168
+ const response = await (options.fetch ?? globalThis.fetch)(
169
+ requestUrl(options.baseUrl ?? "/api", `${ref.href.slice(4)}?digest=${encodeURIComponent(ref.digest)}`),
170
+ { headers: options.headers, credentials: "same-origin", cache: "no-store", signal: options.signal },
171
+ );
172
+ return await readCapabilityResponse(response, CapabilityReviewContentSchema, CAPABILITY_MAX_REVIEW_CONTENT_BYTES, options.signal);
146
173
  } catch (cause) {
147
174
  return unavailable(cause, undefined, clientLocale(options.headers));
148
175
  }
@@ -1,5 +1,7 @@
1
1
  import { sql } from "bun";
2
+ import { createCapabilityDeadline } from "../_internal/capability-timeout";
2
3
  import type { CapabilityOrigin } from "../contracts/capabilities";
4
+ import { REQUEST_IDLE_TIMEOUT_SECONDS } from "../server/request-timeout";
3
5
  import { pruneApprovalRequests } from "../services/approvals";
4
6
  import { logger } from "../services/logging";
5
7
  import { CAPABILITY_CLAIM_RETENTION_HOURS, pruneCapabilityIdempotencyClaims } from "./claims";
@@ -101,7 +103,17 @@ export type CapabilityExecution = {
101
103
  /** Insert one execution record. Called only by the capability dispatcher and the surfaces that deny before it. */
102
104
  export const recordCapabilityExecution = async (input: CapabilityExecutionInput): Promise<string> => {
103
105
  const durationMs = Math.max(0, Math.round(input.completedAt.getTime() - input.startedAt.getTime()));
104
- const [row] = await sql<{ id: string }[]>`
106
+ // An observational write has its own transport-headroom budget, even when
107
+ // the invocation already expired. Do not hold its caller on a dead database.
108
+ const deadline = createCapabilityDeadline(REQUEST_IDLE_TIMEOUT_SECONDS * 1000, new AbortController().signal);
109
+ try {
110
+ return await deadline.wait(() =>
111
+ sql.begin(async (db) => {
112
+ // A connection obtained after the deadline must never start the insert.
113
+ deadline.check();
114
+ await db`SELECT set_config('statement_timeout', ${`${deadline.remainingMs()}ms`}, true)`;
115
+ deadline.check();
116
+ const [row] = await db<{ id: string }[]>`
105
117
  INSERT INTO capabilities.executions (
106
118
  request_id, origin, app_id, capability, kind, destructive,
107
119
  actor_kind, actor_id, user_id, access_subject_type, access_subject_id,
@@ -117,8 +129,15 @@ export const recordCapabilityExecution = async (input: CapabilityExecutionInput)
117
129
  ${input.idempotencyKey ?? null}, ${input.replayed ?? false}, ${input.startedAt}, ${input.completedAt}, ${durationMs}
118
130
  )
119
131
  RETURNING id::text AS id
120
- `;
121
- return row!.id;
132
+ `;
133
+ // Roll back a late result rather than commit an abandoned transaction.
134
+ deadline.check();
135
+ return row!.id;
136
+ }),
137
+ );
138
+ } finally {
139
+ deadline.clear();
140
+ }
122
141
  };
123
142
 
124
143
  export type CapabilityExecutionFilter = {
@@ -0,0 +1,57 @@
1
+ import type { z } from "zod";
2
+ import { geoContract } from "../contracts/geo";
3
+ import { geoMapContract } from "../contracts/geo-map";
4
+ import { geoRouteContract } from "../contracts/geo-route";
5
+ import { type CapabilityCaller, invokeCapabilityWithDataSchema } from "./server";
6
+
7
+ /** Calls Core using the caller's interactive identity, invocation transport, or durable mandate. */
8
+ export const geoClient = {
9
+ mapStyle: (input: z.input<typeof geoMapContract.style.input>, caller: CapabilityCaller) =>
10
+ invokeCapabilityWithDataSchema(
11
+ { kind: "query", appId: "core", capabilityId: geoMapContract.style.id, input },
12
+ geoMapContract.style.data,
13
+ caller,
14
+ ),
15
+ calculateRoute: (input: z.input<typeof geoRouteContract.calculateRoute.input>, caller: CapabilityCaller) =>
16
+ invokeCapabilityWithDataSchema(
17
+ { kind: "query", appId: "core", capabilityId: geoRouteContract.calculateRoute.id, input },
18
+ geoRouteContract.calculateRoute.data,
19
+ caller,
20
+ ),
21
+ updateRoute: (input: z.input<typeof geoRouteContract.updateRoute.input>, caller: CapabilityCaller) =>
22
+ invokeCapabilityWithDataSchema(
23
+ { kind: "query", appId: "core", capabilityId: geoRouteContract.updateRoute.id, input },
24
+ geoRouteContract.updateRoute.data,
25
+ caller,
26
+ ),
27
+ autocomplete: (input: z.input<typeof geoContract.autocomplete.input>, caller: CapabilityCaller) =>
28
+ invokeCapabilityWithDataSchema(
29
+ { kind: "query", appId: "core", capabilityId: geoContract.autocomplete.id, input },
30
+ geoContract.autocomplete.data,
31
+ caller,
32
+ ),
33
+ geocode: (input: z.input<typeof geoContract.geocode.input>, caller: CapabilityCaller) =>
34
+ invokeCapabilityWithDataSchema(
35
+ { kind: "query", appId: "core", capabilityId: geoContract.geocode.id, input },
36
+ geoContract.geocode.data,
37
+ caller,
38
+ ),
39
+ reverse: (input: z.input<typeof geoContract.reverse.input>, caller: CapabilityCaller) =>
40
+ invokeCapabilityWithDataSchema(
41
+ { kind: "query", appId: "core", capabilityId: geoContract.reverse.id, input },
42
+ geoContract.reverse.data,
43
+ caller,
44
+ ),
45
+ searchPlaces: (input: z.input<typeof geoContract.searchPlaces.input>, caller: CapabilityCaller) =>
46
+ invokeCapabilityWithDataSchema(
47
+ { kind: "query", appId: "core", capabilityId: geoContract.searchPlaces.id, input },
48
+ geoContract.searchPlaces.data,
49
+ caller,
50
+ ),
51
+ availability: (caller: CapabilityCaller) =>
52
+ invokeCapabilityWithDataSchema(
53
+ { kind: "query", appId: "core", capabilityId: geoContract.availability.id, input: {} },
54
+ geoContract.availability.data,
55
+ caller,
56
+ ),
57
+ };
@@ -7,8 +7,9 @@ export const readCapabilityResponse = async <T>(
7
7
  response: Response,
8
8
  schema: z.ZodType<T>,
9
9
  maxBytes = CAPABILITY_MAX_RESULT_BYTES,
10
+ signal?: AbortSignal,
10
11
  ): Promise<CapabilityResultState<T>> => {
11
- const parsedBody = await readBoundedJson(response, maxBytes);
12
+ const parsedBody = await readBoundedJson(response, maxBytes, signal);
12
13
  if (!parsedBody.ok && parsedBody.reason === "too_large") {
13
14
  return {
14
15
  ok: false,
@@ -0,0 +1,24 @@
1
+ import { CapabilityReviewContentSchema } from "../contracts/capabilities";
2
+
3
+ /** JSONB cannot store NUL or lone UTF-16 surrogates. Nest the content's JSON as
4
+ * text in the existing snapshot, preserving every code unit and the public digest. */
5
+ export const serializeReviewSnapshot = (review: unknown): string => {
6
+ if (review && typeof review === "object" && "content" in review && review.content !== undefined)
7
+ return JSON.stringify({ ...review, content: JSON.stringify(CapabilityReviewContentSchema.parse(review.content)) });
8
+ return JSON.stringify(review);
9
+ };
10
+
11
+ export const parseStoredReviewContent = (value: unknown) => {
12
+ try {
13
+ return CapabilityReviewContentSchema.safeParse(typeof value === "string" ? JSON.parse(value) : value);
14
+ } catch {
15
+ return CapabilityReviewContentSchema.safeParse(null);
16
+ }
17
+ };
18
+
19
+ export const restoreReviewSnapshot = (review: unknown): unknown => {
20
+ if (!review || typeof review !== "object" || !("content" in review)) return review;
21
+ const content = parseStoredReviewContent(review.content);
22
+ // Invalid stored content must fail validation, never become a content-free review.
23
+ return { ...review, content: content.success ? content.data : null };
24
+ };
@@ -14,10 +14,11 @@ export const capabilityPreviewIssues = (
14
14
  preview: CapabilityPreview | undefined,
15
15
  links: readonly CapabilitySemanticLink[] | undefined,
16
16
  sends = false,
17
+ completeContent = false,
17
18
  ): string[] => {
18
19
  if (!preview) return [];
19
20
  const issues: string[] = [];
20
- const open = links?.some((link) => link.rel === "open");
21
+ const open = completeContent || links?.some((link) => link.rel === "open");
21
22
  if ("truncated" in preview && preview.truncated && !open) issues.push("A truncated preview requires an open link");
22
23
  if (preview.kind === "message") {
23
24
  if ((Object.values(preview.more ?? {}).some((count) => count > 0) || (preview.moreAttachments ?? 0) > 0) && !open)
@@ -33,9 +34,22 @@ export const capabilityReviewFormIssues = (review: CapabilityActionCallReview):
33
34
  for (const form of ["confirm", "decline"] as const)
34
35
  if (Array.from(capabilityReviewSentence(review, form, { timeZone: "UTC" })).length > 40)
35
36
  issues.push(`${form} exceeds 40 rendered characters`);
36
- issues.push(...capabilityPreviewIssues(review.preview, review.links, review.ask === "sends"));
37
+ issues.push(...capabilityPreviewIssues(review.preview, review.links, review.ask === "sends", Boolean(review.content)));
38
+ if (
39
+ review.content &&
40
+ review.preview &&
41
+ review.content.kind !== review.preview.kind &&
42
+ !(review.content.kind === "message" && review.preview.kind === "page")
43
+ )
44
+ issues.push("Full content must have the same kind as its preview");
45
+ if (review.content?.kind === "message" && review.ask === "sends") {
46
+ if (!review.content.from) issues.push("Full sends content requires from");
47
+ if (!review.content.to.length && !review.content.cc?.length && !review.content.bcc?.length)
48
+ issues.push("Full sends content requires a recipient");
49
+ }
37
50
  try {
38
- if (new TextEncoder().encode(JSON.stringify(review)).byteLength > CAPABILITY_MAX_REVIEW_BYTES) issues.push("Review exceeds 32 KiB");
51
+ const { content: _content, ...compact } = review;
52
+ if (new TextEncoder().encode(JSON.stringify(compact)).byteLength > CAPABILITY_MAX_REVIEW_BYTES) issues.push("Review exceeds 32 KiB");
39
53
  } catch {
40
54
  issues.push("Review is not JSON serializable");
41
55
  }
@@ -6,11 +6,13 @@ import { dispatchCapability, loadCapabilityCatalogPage } from "../api/capabiliti
6
6
  import { env } from "../config/env";
7
7
  import {
8
8
  CAPABILITY_FRAMEWORK_ERROR_CODES,
9
+ CAPABILITY_MAX_REVIEW_RESPONSE_BYTES,
9
10
  CapabilityActionReviewDataSchema,
10
11
  type CapabilityOrigin,
11
12
  capabilityResultSchema,
12
13
  } from "../contracts/capabilities";
13
14
  import type { RequestAuthority } from "../server";
15
+ import { canUseApp } from "../services/app-access";
14
16
  import { get } from "../services/settings";
15
17
  import { resolveAppPresentation } from "../shared/app-presentation";
16
18
  import { publicCloudOrigin } from "../shared/app-url";
@@ -184,7 +186,7 @@ export const invokeCapabilityWithDataSchema = <TDataSchema extends z.ZodType, TI
184
186
  ): Promise<CapabilityClientResult<z.output<TDataSchema>>> => invokeCapabilityWithResultSchema(invocation, dataSchema, caller);
185
187
 
186
188
  export const listCapabilityCatalog = async (
187
- options: { cursor?: string; limit?: number; locale?: string } = {},
189
+ options: { cursor?: string; limit?: number; locale?: string; authority?: RequestAuthority | null } = {},
188
190
  ): Promise<CapabilityCatalogClientResult> => {
189
191
  const limit = options.limit ?? 25;
190
192
  const messages = capabilityMessages(options.locale);
@@ -197,30 +199,35 @@ export const listCapabilityCatalog = async (
197
199
  try {
198
200
  return {
199
201
  ok: true,
200
- data: await loadCapabilityCatalogPage({ cursor: options.cursor, limit }, {}, options.locale),
202
+ data: await loadCapabilityCatalogPage({ cursor: options.cursor, limit }, {}, options.locale, options.authority ?? null),
201
203
  };
202
204
  } catch {
203
205
  return { ok: false, error: { code: "APP_UNAVAILABLE", message: messages.cloudUnavailable, status: 503 } };
204
206
  }
205
207
  };
206
208
 
207
- export const getCapabilityCatalogApp = async (appId: string, locale?: string): Promise<CapabilityCatalogAppClientResult> => {
209
+ export const getCapabilityCatalogApp = async (
210
+ appId: string,
211
+ locale?: string,
212
+ authority: RequestAuthority | null = null,
213
+ ): Promise<CapabilityCatalogAppClientResult> => {
208
214
  try {
209
215
  const [capability, registeredApp] = await Promise.all([getCapability(appId), getApp(appId)]);
210
216
  const presentedApp = registeredApp && locale ? resolveAppPresentation(registeredApp, locale) : registeredApp;
211
217
  return {
212
218
  ok: true,
213
- data: capability
214
- ? {
215
- appId: capability.appId,
216
- appName: presentedApp?.name ?? capability.appName,
217
- appIcon: capability.appIcon,
218
- appDescription: presentedApp?.description ?? capability.appDescription,
219
- manifest: locale
220
- ? resolveCapabilityManifestPresentation(capability.manifest, capability.presentation, locale)
221
- : capability.manifest,
222
- }
223
- : null,
219
+ data:
220
+ capability && registeredApp && (await canUseApp(authority, registeredApp))
221
+ ? {
222
+ appId: capability.appId,
223
+ appName: presentedApp?.name ?? capability.appName,
224
+ appIcon: capability.appIcon,
225
+ appDescription: presentedApp?.description ?? capability.appDescription,
226
+ manifest: locale
227
+ ? resolveCapabilityManifestPresentation(capability.manifest, capability.presentation, locale)
228
+ : capability.manifest,
229
+ }
230
+ : null,
224
231
  };
225
232
  } catch {
226
233
  return { ok: false, error: { code: "APP_UNAVAILABLE", message: capabilityMessages(locale).cloudUnavailable, status: 503 } };
@@ -237,7 +244,10 @@ export const getCapabilityActionWording = async (
237
244
  appId: string,
238
245
  localId: string,
239
246
  locale?: string,
247
+ authority: RequestAuthority | null = null,
240
248
  ): Promise<CapabilityActionWording | null> => {
249
+ const registeredApp = await getApp(appId);
250
+ if (!registeredApp || !(await canUseApp(authority, registeredApp))) return null;
241
251
  const capability = await getCapability(appId);
242
252
  const action = capability?.manifest.actions.find((candidate) => candidate.localId === localId);
243
253
  return capability && action ? resolveCapabilityActionWording(action, capability.presentation, locale ?? DEFAULT_LOCALE) : null;
@@ -263,7 +273,12 @@ export const reviewCapabilityAction = async <TInput = unknown>(
263
273
  locale: caller.locale ?? undefined,
264
274
  origin: caller.origin ?? "app",
265
275
  });
266
- return readCapabilityResponse(outcome, CapabilityActionReviewDataSchema);
276
+ return await readCapabilityResponse(
277
+ outcome,
278
+ CapabilityActionReviewDataSchema,
279
+ CAPABILITY_MAX_REVIEW_RESPONSE_BYTES,
280
+ combineCapabilitySignals(caller.signal, invocation.signal),
281
+ );
267
282
  }
268
283
  const response = await (caller.transport ?? fetch)(
269
284
  new Request(
@@ -276,7 +291,12 @@ export const reviewCapabilityAction = async <TInput = unknown>(
276
291
  { body: JSON.stringify({ input: invocation.input }) },
277
292
  ),
278
293
  );
279
- return readCapabilityResponse(response, CapabilityActionReviewDataSchema);
294
+ return await readCapabilityResponse(
295
+ response,
296
+ CapabilityActionReviewDataSchema,
297
+ CAPABILITY_MAX_REVIEW_RESPONSE_BYTES,
298
+ combineCapabilitySignals(caller.signal, invocation.signal),
299
+ );
280
300
  } catch (cause) {
281
301
  return unavailable(cause, undefined, caller.locale ?? undefined);
282
302
  }
package/src/cli/access.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { principalLabel } from "../access/principal-label";
1
2
  import type { AccessEntry, EntityListItem, PermissionLevel, Principal } from "../contracts";
2
3
  import { arg, command, confirmFlag, flag, paginationFlags } from "./commands";
3
4
  import type { CloudCliContext, CloudCliTableColumn } from "./index";
@@ -120,21 +121,7 @@ const principalKey = (principal: Principal): string => {
120
121
  }
121
122
  };
122
123
 
123
- const entryDisplayName = (entry: AccessEntry): string => {
124
- if (entry.displayName) return entry.displayName;
125
- switch (entry.principal.type) {
126
- case "user":
127
- return entry.principal.userId;
128
- case "group":
129
- return entry.principal.groupId;
130
- case "service_account":
131
- return entry.principal.serviceAccountId;
132
- case "authenticated":
133
- return "All users (incl. guests)";
134
- case "public":
135
- return "Public";
136
- }
137
- };
124
+ const entryDisplayName = (entry: AccessEntry, locale?: string): string => principalLabel(entry, locale);
138
125
 
139
126
  const formatEntityCandidate = (item: EntityListItem): string => {
140
127
  if (item.kind === "user") return `${item.user.displayName} (${item.user.uid}${item.user.mail ? `, ${item.user.mail}` : ""})`;
@@ -257,17 +244,20 @@ const entryTypeLabel = (entry: AccessEntry): string => {
257
244
  };
258
245
 
259
246
  // Resource-bound service accounts back resource API keys, which are managed with those keys.
260
- const accessRows = (entries: AccessEntry[], options: { includeServiceAccounts?: boolean; scopes?: readonly string[] } = {}) =>
247
+ const accessRows = (
248
+ entries: AccessEntry[],
249
+ options: { includeServiceAccounts?: boolean; scopes?: readonly string[]; locale?: string } = {},
250
+ ) =>
261
251
  entries
262
252
  .filter((entry) => options.includeServiceAccounts || entry.serviceAccountKind !== "resource_bound")
263
253
  .sort((a, b) => {
264
254
  const rank = permissionRank(b.permission) - permissionRank(a.permission);
265
255
  if (rank !== 0) return rank;
266
- return entryDisplayName(a).localeCompare(entryDisplayName(b));
256
+ return entryDisplayName(a, options.locale).localeCompare(entryDisplayName(b, options.locale));
267
257
  })
268
258
  .map((entry) => ({
269
259
  accessId: entry.id,
270
- principal: entryDisplayName(entry),
260
+ principal: entryDisplayName(entry, options.locale),
271
261
  type: entryTypeLabel(entry),
272
262
  permission: entry.permission,
273
263
  ...(options.scopes ? { scope: entry.scope ?? options.scopes[0] } : {}),
@@ -287,7 +277,7 @@ export const printAccessEntries = (
287
277
  options: { includeServiceAccounts?: boolean; jsonValue?: unknown; scopes?: readonly string[] } = {},
288
278
  ) => {
289
279
  if (printStructured(ctx, options.jsonValue ?? entries)) return;
290
- const rows = accessRows(entries, options);
280
+ const rows = accessRows(entries, { ...options, locale: ctx.options.locale });
291
281
  if (rows.length === 0) {
292
282
  ctx.print("No direct grants.");
293
283
  return;
@@ -426,7 +416,7 @@ export const createAccessCommands = <TResource extends AccessResource>(adapter:
426
416
  const principal = await resolveAccessPrincipal(ctx, flags as PrincipalFlags, adapter);
427
417
  const entry = await adapter.grant(ctx, resource, principal, permission, scope);
428
418
  if (!printStructured(ctx, { resource, entry }))
429
- ctx.print(`Granted ${permission}${scopeSuffix(scope)} on ${resource.label} to ${entryDisplayName(entry)}.`);
419
+ ctx.print(`Granted ${permission}${scopeSuffix(scope)} on ${resource.label} to ${entryDisplayName(entry, ctx.options.locale)}.`);
430
420
  },
431
421
  });
432
422
 
@@ -462,13 +452,15 @@ export const createAccessCommands = <TResource extends AccessResource>(adapter:
462
452
  if (existing) {
463
453
  await adapter.update(ctx, resource, existing.id, permission, scope);
464
454
  if (!printStructured(ctx, { resource, accessId: existing.id, permission, scope, action: "updated" })) {
465
- ctx.print(`Updated ${entryDisplayName(existing)} to ${permission}${scopeSuffix(scope)} on ${resource.label}.`);
455
+ ctx.print(
456
+ `Updated ${entryDisplayName(existing, ctx.options.locale)} to ${permission}${scopeSuffix(scope)} on ${resource.label}.`,
457
+ );
466
458
  }
467
459
  return;
468
460
  }
469
461
  const entry = await adapter.grant(ctx, resource, principal, permission, scope);
470
462
  if (!printStructured(ctx, { resource, entry, action: "created" })) {
471
- ctx.print(`Granted ${permission}${scopeSuffix(scope)} on ${resource.label} to ${entryDisplayName(entry)}.`);
463
+ ctx.print(`Granted ${permission}${scopeSuffix(scope)} on ${resource.label} to ${entryDisplayName(entry, ctx.options.locale)}.`);
472
464
  }
473
465
  },
474
466
  });
@@ -496,7 +488,7 @@ export const createAccessCommands = <TResource extends AccessResource>(adapter:
496
488
  const entry = entries.find((candidate) => principalKey(candidate.principal) === principalKey(principal));
497
489
  if (!entry) throw new Error("No direct grant for that principal.");
498
490
  accessId = entry.id;
499
- label = entryDisplayName(entry);
491
+ label = entryDisplayName(entry, ctx.options.locale);
500
492
  } else if (principalFlagCount(flags as PrincipalFlags) > 0) {
501
493
  throw new Error("Pass either --access-id or one principal flag, not both.");
502
494
  }
@@ -1,3 +1,4 @@
1
+ import { z } from "zod";
1
2
  import { type LinuxIdentityConfiguration, LinuxIdentityConfigurationSchema } from "../../contracts/posix";
2
3
  import { cliText, command, confirmFlag, flag, printStructured, readCliInput } from "../index";
3
4
  import { apiGet, apiJson, queryString } from "./shared";
@@ -31,6 +32,10 @@ export const linuxCommands = [
31
32
  flags: {
32
33
  config: flag.input({ required: true, description: "Configuration JSON: --config, --config-file, or --stdin" }),
33
34
  rangeReserved: flag.boolean({ name: "range-reserved", description: "Confirm the range is reserved across all connected systems" }),
35
+ replaceUnreadable: flag.boolean({
36
+ name: "replace-unreadable",
37
+ description: "Explicitly replace an unreadable stored Linux configuration",
38
+ }),
34
39
  yes: confirmFlag("Confirm replacing Linux identity configuration"),
35
40
  },
36
41
  async run({ ctx, flags }) {
@@ -40,7 +45,9 @@ export const linuxCommands = [
40
45
  );
41
46
  const input = await readCliInput(flags.config, { label: "Linux configuration", required: true });
42
47
  if (input === undefined) throw new Error("Missing Linux configuration.");
43
- const config = LinuxIdentityConfigurationSchema.parse(JSON.parse(input));
48
+ const { unreadable: _unreadable, ...config } = LinuxIdentityConfigurationSchema.safeExtend({
49
+ unreadable: z.boolean().optional(),
50
+ }).parse(JSON.parse(input));
44
51
  if (config.enabled && !flags.rangeReserved)
45
52
  throw new Error(
46
53
  cliText(ctx, {
@@ -48,7 +55,11 @@ export const linuxCommands = [
48
55
  de: "Zum Aktivieren ist --range-reserved erforderlich.",
49
56
  }),
50
57
  );
51
- const result = await apiJson<unknown>(ctx, "PUT", `${path}/configuration`, { config, rangeReserved: flags.rangeReserved });
58
+ const result = await apiJson<unknown>(ctx, "PUT", `${path}/configuration`, {
59
+ config,
60
+ rangeReserved: flags.rangeReserved,
61
+ ...(flags.replaceUnreadable ? { replaceUnreadable: true } : {}),
62
+ });
52
63
  if (!printStructured(ctx, result)) ctx.print(JSON.stringify(result, null, 2));
53
64
  },
54
65
  }),
@@ -35,13 +35,25 @@ export const outgoingMailCommands = [
35
35
  flags: {
36
36
  contentDays: flag.string({ required: true, description: "Content retention in days" }),
37
37
  recordDays: flag.string({ required: true, description: "Record retention in days (at least content retention)" }),
38
+ replaceContent: flag.boolean({ name: "replace-unreadable-content", description: "Explicitly replace unreadable content retention" }),
39
+ replaceRecords: flag.boolean({ name: "replace-unreadable-records", description: "Explicitly replace unreadable record retention" }),
38
40
  yes: confirmFlag("Confirm changing outgoing mail retention"),
39
41
  },
40
42
  async run({ ctx, flags }) {
41
43
  confirmed(flags.yes);
42
44
  const retention = MailRetentionSchema.parse({ contentDays: Number(flags.contentDays), recordDays: Number(flags.recordDays) });
43
45
  if (retention.recordDays < retention.contentDays) throw new Error("Record retention must be at least content retention.");
44
- const result = await apiJson<MailRetention>(ctx, "PUT", `${root}/retention`, retention);
46
+ const result = await apiJson<MailRetention>(ctx, "PUT", `${root}/retention`, {
47
+ ...retention,
48
+ ...(flags.replaceContent || flags.replaceRecords
49
+ ? {
50
+ replaceUnreadable: [
51
+ ...(flags.replaceContent ? ["outgoing_mail.content_retention_days"] : []),
52
+ ...(flags.replaceRecords ? ["outgoing_mail.record_retention_days"] : []),
53
+ ],
54
+ }
55
+ : {}),
56
+ });
45
57
  if (!printStructured(ctx, result)) print(ctx, result);
46
58
  },
47
59
  }),
@@ -183,7 +195,12 @@ export const outgoingMailCommands = [
183
195
  printJsonOrTable(
184
196
  ctx,
185
197
  result,
186
- result.items.map((app) => ({ ...app, reads: app.readDeclared, profiles: app.profiles.join(","), logApps: app.logApps.join(",") })),
198
+ result.items.map((app) => ({
199
+ ...app,
200
+ reads: app.readDeclared,
201
+ profiles: app.profiles.join(","),
202
+ logApps: app.logApps.map((id) => `${id}:${app.logContentApps?.includes(id) ? "content" : "metadata"}`).join(","),
203
+ })),
187
204
  [
188
205
  { key: "appId" },
189
206
  { key: "name" },
@@ -226,15 +243,20 @@ export const outgoingMailCommands = [
226
243
  summary: "Choose which other applications' mail logs an app may read",
227
244
  args: { app: arg.required({ valueLabel: "app" }) },
228
245
  flags: {
229
- apps: flag.string({ description: "Comma-separated source application IDs" }),
246
+ apps: flag.string({ description: "Comma-separated source application IDs (replaces all grants)" }),
247
+ contentApps: flag.string({ description: "Subset of --apps allowed to read text and attachment names" }),
230
248
  ownOnly: flag.boolean({ description: "Remove all other application log grants" }),
231
249
  yes: confirmFlag("Confirm application mail log access change"),
232
250
  },
233
251
  async run({ ctx, args, flags }) {
234
252
  confirmed(flags.yes);
235
253
  if ((flags.apps !== undefined) === !!flags.ownOnly) throw new Error("Choose exactly one of --apps or --own-only.");
254
+ if (flags.contentApps !== undefined && flags.apps === undefined) throw new Error("--content-apps requires --apps.");
236
255
  const reader = MailLogAppIdSchema.safeParse(args.app);
237
- const value = MailLogAccessSchema.safeParse({ apps: flags.apps?.split(",").map((app) => app.trim()) ?? [] });
256
+ const value = MailLogAccessSchema.safeParse({
257
+ apps: flags.apps?.split(",").map((app) => app.trim()) ?? [],
258
+ ...(flags.contentApps !== undefined ? { contentApps: flags.contentApps.split(",").map((app) => app.trim()) } : {}),
259
+ });
238
260
  if (!reader.success || args.app === "core" || !value.success || value.data.apps.includes(args.app))
239
261
  throw new Error("Choose a valid reader and distinct source apps other than Core or the reader itself.");
240
262
  print(ctx, await apiJson(ctx, "PUT", `${root}/apps/${encodeURIComponent(args.app)}/log-access`, value.data));
@@ -4,6 +4,8 @@ import {
4
4
  CAPABILITY_FRAMEWORK_ERROR_CODES,
5
5
  CAPABILITY_MAX_CATALOG_BYTES,
6
6
  CAPABILITY_MAX_RESULT_BYTES,
7
+ CAPABILITY_MAX_REVIEW_RESPONSE_BYTES,
8
+ CapabilityActionReviewDataSchema,
7
9
  CapabilityCatalogSchema,
8
10
  CapabilityErrorSchema,
9
11
  type CapabilityManifest,
@@ -295,6 +297,23 @@ export default defineCliCommands({
295
297
  await invoke({ ctx, kind: "queries", appId, capabilityId: reader.localId, inputValue: { id: ref.id }, manifest: app.manifest });
296
298
  },
297
299
  }),
300
+ command("review", {
301
+ summary: "Read the complete proposed Action without executing it",
302
+ args: { appId: arg.required({ description: "Owning app id" }), actionId: arg.required({ description: "App-local action id" }) },
303
+ flags: { input: flag.input({ required: true, description: "Strict JSON input; supports --input-file or --stdin" }) },
304
+ run: async ({ ctx, args, flags }) => {
305
+ const response = await ctx.fetch(
306
+ `/api/capabilities/v1/actions/${encodeURIComponent(args.appId)}/${encodeURIComponent(args.actionId)}/review`,
307
+ {
308
+ method: "POST",
309
+ headers: { "content-type": "application/json" },
310
+ body: JSON.stringify({ input: await parseInput(flags.input) }),
311
+ },
312
+ );
313
+ const review = CapabilityActionReviewDataSchema.parse(await readCapabilityJson(response, CAPABILITY_MAX_REVIEW_RESPONSE_BYTES));
314
+ printGenericResult(ctx, review);
315
+ },
316
+ }),
298
317
  command("action", {
299
318
  summary: "Invoke an app mutation",
300
319
  args: {
package/src/config/env.ts CHANGED
@@ -98,12 +98,6 @@ const registry = defineEnv({
98
98
  scope: "runtime",
99
99
  doc: "Comma-separated host names, IP addresses and CIDR ranges on internal networks allowed for outgoing mail SMTP and bounce IMAP connections; unset allows only public targets, an explicit value replaces this default, and invalid entries including `/0` stop the owning process at startup.",
100
100
  },
101
- GEO_ALLOWED_TARGETS: {
102
- schema: envList,
103
- default: ["geo"],
104
- scope: "runtime",
105
- doc: "Comma-separated host names, IP addresses and CIDR ranges on internal networks allowed for Geo requests; unset allows public targets and `geo`, an explicit value replaces this default, and invalid entries including `/0` stop the owning process at startup.",
106
- },
107
101
 
108
102
  // Identity and OAuth
109
103
  CLOUD_IDENTITY_KEY_ENCRYPTION_KEY: {
@@ -0,0 +1,48 @@
1
+ import { z } from "zod";
2
+ import type { RequestAuthority } from "../server/middleware/auth";
3
+ import { type Role, RoleSchema } from "./shared";
4
+
5
+ const publicRoutes = z
6
+ .array(
7
+ z
8
+ .string()
9
+ .startsWith("/")
10
+ .refine((path) => !path.startsWith("//") && !/[?#]/.test(path)),
11
+ )
12
+ .readonly()
13
+ .optional();
14
+ export const FixedAppAccessRuleSchema = z.discriminatedUnion("kind", [
15
+ z.object({ kind: z.literal("signedIn") }).strict(),
16
+ z.object({ kind: z.literal("noGuests") }).strict(),
17
+ z.object({ kind: z.literal("roles"), roles: z.array(RoleSchema).min(1).readonly() }).strict(),
18
+ ]);
19
+ export type FixedAppAccessRule = z.infer<typeof FixedAppAccessRuleSchema>;
20
+ export const AppAccessRuleSchema = z.union([
21
+ z.object({ kind: z.literal("signedIn"), publicRoutes }).strict(),
22
+ z.object({ kind: z.literal("noGuests"), publicRoutes }).strict(),
23
+ z.object({ kind: z.literal("roles"), roles: z.array(RoleSchema).min(1).readonly(), publicRoutes }).strict(),
24
+ z.object({ kind: z.literal("grants"), until: FixedAppAccessRuleSchema.optional(), publicRoutes }).strict(),
25
+ ]);
26
+ /** Serializable entry rule evaluated by the platform; never application callback code. */
27
+ export type AppAccessRule = z.infer<typeof AppAccessRuleSchema>;
28
+ /** The optional management restriction runs in the owning application, in addition to a management grant. */
29
+ export type AppAccessDeclaration = AppAccessRule & {
30
+ canManage?: (authority: RequestAuthority) => boolean | Promise<boolean>;
31
+ };
32
+ type PublicOptions = { publicRoutes?: readonly string[] };
33
+ export const appAccess = {
34
+ signedIn: (options: PublicOptions = {}): AppAccessRule & { kind: "signedIn" } => ({ kind: "signedIn", ...options }),
35
+ noGuests: (options: PublicOptions = {}): AppAccessRule & { kind: "noGuests" } => ({ kind: "noGuests", ...options }),
36
+ roles: (roles: readonly Role[], options: PublicOptions = {}): AppAccessRule & { kind: "roles" } => ({ kind: "roles", roles, ...options }),
37
+ grants: (
38
+ options: PublicOptions & { until?: FixedAppAccessRule; canManage?: AppAccessDeclaration["canManage"] } = {},
39
+ ): AppAccessDeclaration => ({ kind: "grants", ...options }),
40
+ };
41
+
42
+ export const compileAppAccess = (declaration: AppAccessDeclaration | undefined): AppAccessRule | undefined => {
43
+ if (!declaration) return undefined;
44
+ const { canManage, ...rule } = declaration;
45
+ if (canManage && (rule.kind !== "grants" || typeof canManage !== "function"))
46
+ throw new Error("Only configurable app access accepts canManage");
47
+ return AppAccessRuleSchema.parse(rule);
48
+ };
@@ -360,14 +360,14 @@ const actionFields = {
360
360
  createdAt: DateTime,
361
361
  expiresAt: DateTime,
362
362
  };
363
- /** One inbox row. `sentence` is the start of the app's sentence; read the Action for everything the person approves. */
363
+ /** One inbox row. `sentence` is the start of the plain question; read the Action for everything the person approves. */
364
364
  export const AppActionSummarySchema = z.object({ ...actionFields, sentence: PlainText(APP_ACTION_LIMITS.summaryChars).min(1) }).strict();
365
365
  export const AppActionViewSchema = z
366
366
  .object({
367
367
  ...actionFields,
368
- /** What the Action does, in the app's words and the person's language. */
368
+ /** The plain Assistant question, in the originating turn's language. */
369
369
  sentence: PlainText(APP_ACTION_LIMITS.sentenceChars).min(1),
370
- /** The app's review of this call, or `null`. */
370
+ /** The question's reason line, or `null` for older snapshots. */
371
371
  message: PlainText(APP_ACTION_LIMITS.messageChars).nullable(),
372
372
  details: z
373
373
  .array(