@k2b/cloud 0.38.0 → 0.40.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 (244) hide show
  1. package/README.md +8 -0
  2. package/package.json +12 -5
  3. package/scripts/build-canvas-workers.ts +109 -29
  4. package/scripts/build.ts +3 -5
  5. package/src/_internal/app-favicon.ts +4 -2
  6. package/src/_internal/app-path.ts +25 -0
  7. package/src/_internal/app-presentation.ts +4 -0
  8. package/src/_internal/canvas-worker.ts +5 -3
  9. package/src/_internal/capabilities.ts +72 -48
  10. package/src/_internal/capability-reviews.ts +81 -0
  11. package/src/_internal/capability-schemas.ts +29 -0
  12. package/src/_internal/capability-sentences.ts +216 -1
  13. package/src/_internal/capability-streams.ts +7 -0
  14. package/src/_internal/define-app.ts +63 -16
  15. package/src/_internal/font-preloads.ts +2 -1
  16. package/src/_internal/panel-cookie.ts +39 -0
  17. package/src/_internal/registry-validation.ts +21 -26
  18. package/src/_internal/runtime-context.ts +1 -0
  19. package/src/_internal/runtime-watcher.ts +6 -0
  20. package/src/_internal/static-assets.ts +52 -3
  21. package/src/_internal/status-preserving-ssr.ts +2 -0
  22. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  23. package/src/access/PermissionEditor.tsx +32 -7
  24. package/src/access/ResourceApiKeys.tsx +5 -1
  25. package/src/account/EntitySearch.tsx +1 -1
  26. package/src/ai/action-review.ts +101 -0
  27. package/src/ai/approval-routes.ts +5 -2
  28. package/src/ai/approvals.ts +139 -28
  29. package/src/ai/background-review.ts +72 -0
  30. package/src/ai/browser.ts +10 -0
  31. package/src/ai/capabilities.ts +29 -11
  32. package/src/ai/capability-execution.ts +76 -43
  33. package/src/ai/capability-result.ts +95 -0
  34. package/src/ai/chat/action-card.ts +398 -0
  35. package/src/ai/chat/action-messages.ts +129 -0
  36. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  37. package/src/ai/chat/action-question.fixtures.ts +733 -0
  38. package/src/ai/chat/action-question.tsx +669 -0
  39. package/src/ai/chat/action-sentences.ts +1 -44
  40. package/src/ai/chat/blocks.tsx +19 -25
  41. package/src/ai/chat/message-actions.tsx +6 -0
  42. package/src/ai/chat/tool-action.tsx +481 -0
  43. package/src/ai/chat/turn-view.tsx +45 -163
  44. package/src/ai/chat/view-block.tsx +22 -7
  45. package/src/ai/chat-task-contracts.ts +31 -3
  46. package/src/ai/chat-task-reasons-migrate.ts +75 -0
  47. package/src/ai/chat-task-reasons.ts +92 -0
  48. package/src/ai/chat-tasks.ts +438 -78
  49. package/src/ai/client/controller.ts +59 -0
  50. package/src/ai/default-tools.ts +9 -1
  51. package/src/ai/executor.ts +190 -39
  52. package/src/ai/index.ts +14 -0
  53. package/src/ai/migrate.ts +39 -4
  54. package/src/ai/projects-routes.ts +3 -0
  55. package/src/ai/protocol.ts +14 -2
  56. package/src/ai/recent-resources.ts +240 -0
  57. package/src/ai/recent-sent-refs.ts +38 -0
  58. package/src/ai/request-context.ts +7 -1
  59. package/src/ai/routes.ts +172 -2
  60. package/src/ai/runtime.ts +13 -27
  61. package/src/ai/skills-routes.ts +3 -0
  62. package/src/ai/skills.ts +10 -1
  63. package/src/ai/store.ts +232 -27
  64. package/src/ai/timeline.ts +9 -1
  65. package/src/ai/tool-audit.ts +8 -0
  66. package/src/ai/tool-wording.ts +67 -0
  67. package/src/ai/turn-abort.ts +33 -0
  68. package/src/ai/types.ts +40 -4
  69. package/src/api/accounts-entities.ts +2 -1
  70. package/src/api/admin-ai-projects.ts +2 -0
  71. package/src/api/admin-ai-skills.ts +2 -0
  72. package/src/api/admin-ai-usage.ts +2 -0
  73. package/src/api/admin-core-settings.ts +190 -139
  74. package/src/api/admin-geoip.ts +80 -0
  75. package/src/api/admin-identity.ts +3 -0
  76. package/src/api/admin-lifecycle.ts +3 -0
  77. package/src/api/admin-outgoing-mail.ts +6 -2
  78. package/src/api/app-approval.ts +54 -6
  79. package/src/api/approvals-loader.ts +23 -1
  80. package/src/api/approvals.ts +3 -1
  81. package/src/api/auth.ts +25 -6
  82. package/src/api/capabilities.ts +44 -11
  83. package/src/api/capability-streams.ts +12 -0
  84. package/src/api/help.ts +4 -28
  85. package/src/api/index.ts +7 -1
  86. package/src/api/lasting-access-messages.ts +17 -0
  87. package/src/api/mcp.ts +3 -0
  88. package/src/api/me-panel-consent.ts +71 -0
  89. package/src/api/me.ts +80 -33
  90. package/src/api/search.ts +3 -1
  91. package/src/api/sync-ops.ts +2 -0
  92. package/src/api/widgets.ts +3 -0
  93. package/src/approvals/Approvals.tsx +180 -238
  94. package/src/approvals/messages.ts +12 -24
  95. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  96. package/src/browser/CloudResourceSearch.tsx +10 -6
  97. package/src/browser/app-approval.ts +60 -6
  98. package/src/browser/commands.ts +22 -6
  99. package/src/browser/composer-catalog.ts +145 -0
  100. package/src/browser/composer-insert-messages.ts +31 -0
  101. package/src/browser/composer-insert.ts +160 -0
  102. package/src/browser/composer-open-messages.ts +17 -0
  103. package/src/browser/composer-open.ts +110 -0
  104. package/src/browser/discoverable-command.ts +6 -0
  105. package/src/browser/hints.ts +6 -0
  106. package/src/browser/live-connection.ts +26 -2
  107. package/src/browser/navigation-apps.ts +7 -0
  108. package/src/browser/panel-context-bridge.ts +30 -0
  109. package/src/browser/panel-context-receiver.ts +53 -0
  110. package/src/browser/panel-menu.tsx +56 -0
  111. package/src/browser/panel-messages.ts +35 -0
  112. package/src/browser/panel-shortcuts.ts +31 -0
  113. package/src/browser/panel-slot.ts +756 -0
  114. package/src/browser/panel.ts +65 -0
  115. package/src/browser/resource-picker.tsx +12 -1
  116. package/src/browser/search-commands.ts +6 -3
  117. package/src/capabilities/client.ts +8 -2
  118. package/src/capabilities/reviews.ts +205 -0
  119. package/src/capabilities/server.ts +10 -3
  120. package/src/capabilities/testing.ts +47 -0
  121. package/src/capabilities/types.ts +2 -2
  122. package/src/cli/account.ts +13 -3
  123. package/src/cli/admin/geoip.ts +115 -0
  124. package/src/cli/admin/index.ts +3 -0
  125. package/src/contracts/app-approval.ts +159 -12
  126. package/src/contracts/app.ts +14 -0
  127. package/src/contracts/approvals.ts +2 -2
  128. package/src/contracts/capabilities.ts +410 -18
  129. package/src/contracts/geoip.ts +42 -0
  130. package/src/contracts/index.ts +3 -0
  131. package/src/contracts/panel-context-plain.ts +99 -0
  132. package/src/contracts/panel-context.ts +93 -0
  133. package/src/contracts/registry.ts +3 -1
  134. package/src/contracts/seen-hints.ts +35 -0
  135. package/src/contracts/shared.ts +2 -0
  136. package/src/server/client-ip.ts +5 -0
  137. package/src/server/help-payload.ts +38 -0
  138. package/src/server/middleware/auth.ts +11 -1
  139. package/src/server/middleware/middleware.ts +9 -0
  140. package/src/server/middleware/no-transform.ts +19 -0
  141. package/src/server/middleware/rate-limit.ts +4 -6
  142. package/src/server/request-context/address.ts +45 -0
  143. package/src/server/request-context/geoip.ts +380 -0
  144. package/src/server/request-context/index.ts +33 -0
  145. package/src/server/request-context/user-agent.ts +95 -0
  146. package/src/server/services/access.ts +5 -1
  147. package/src/services/accounts/posix.ts +2 -1
  148. package/src/services/app-approval.ts +533 -90
  149. package/src/services/approvals/device.ts +9 -5
  150. package/src/services/approvals/index.ts +6 -5
  151. package/src/services/approvals/list.ts +26 -5
  152. package/src/services/auth-flows/notification-sender.ts +3 -0
  153. package/src/services/geoip/index.ts +164 -0
  154. package/src/services/geoip/store.ts +30 -0
  155. package/src/services/geoip/upload.ts +150 -0
  156. package/src/services/identity/routes.ts +5 -1
  157. package/src/services/index.ts +32 -3
  158. package/src/services/known-browsers.ts +312 -0
  159. package/src/services/login-address-store.ts +51 -0
  160. package/src/services/login-context.ts +118 -0
  161. package/src/services/mandates/index.ts +3 -0
  162. package/src/services/mandates/policy.ts +15 -3
  163. package/src/services/notifications/browser.ts +16 -5
  164. package/src/services/notifications/email-frame.ts +1 -1
  165. package/src/services/oauth-tokens.ts +16 -2
  166. package/src/services/panel-context-consent.ts +58 -0
  167. package/src/services/pdf/image-protocol.ts +135 -0
  168. package/src/services/pdf/image-variants.ts +124 -0
  169. package/src/services/pdf/image-worker.ts +31 -0
  170. package/src/services/pdf/image.ts +109 -0
  171. package/src/services/pdf/index.ts +9 -0
  172. package/src/services/service-account-credentials.ts +21 -0
  173. package/src/services/session/index.ts +97 -16
  174. package/src/services/session/recent.ts +13 -0
  175. package/src/services/settings/api.ts +11 -6
  176. package/src/services/settings/app.ts +5 -4
  177. package/src/services/settings/core-settings.ts +4 -3
  178. package/src/services/settings/index.ts +19 -17
  179. package/src/services/settings/namespace.ts +2 -1
  180. package/src/services/settings/store.ts +83 -11
  181. package/src/services/webauthn.ts +12 -0
  182. package/src/shared/ai-platform-prompt.ts +1 -1
  183. package/src/shared/app-presentation.ts +1 -0
  184. package/src/shared/email-html.ts +2 -0
  185. package/src/shared/help.ts +3 -0
  186. package/src/shared/redirect.ts +12 -0
  187. package/src/shared-text/cursor.ts +22 -0
  188. package/src/shared-text/documents.ts +142 -0
  189. package/src/shared-text/events.ts +43 -0
  190. package/src/shared-text/index.ts +12 -0
  191. package/src/shared-text/log.ts +48 -0
  192. package/src/shared-text/text-diff.ts +23 -0
  193. package/src/shared-text/types.ts +41 -0
  194. package/src/ssr/AdminLayout.tsx +2 -0
  195. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  196. package/src/ssr/CloudShell.island.tsx +95 -10
  197. package/src/ssr/Layout.tsx +66 -10
  198. package/src/ssr/LayoutHeader.tsx +15 -9
  199. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  200. package/src/ssr/LayoutHelp.tsx +435 -274
  201. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
  202. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  203. package/src/ssr/LayoutRail.tsx +2 -2
  204. package/src/ssr/LayoutTabBar.tsx +29 -0
  205. package/src/ssr/MobileNavigation.tsx +2 -2
  206. package/src/ssr/PanelLayout.tsx +35 -0
  207. package/src/ssr/PanelRuntime.island.tsx +94 -0
  208. package/src/ssr/PanelSlot.tsx +158 -0
  209. package/src/ssr/PanelTrigger.tsx +49 -0
  210. package/src/ssr/ShellRuntime.island.tsx +69 -5
  211. package/src/ssr/app-appearance.ts +3 -2
  212. package/src/ssr/app-color.ts +105 -0
  213. package/src/ssr/app-launchpad.tsx +18 -1
  214. package/src/ssr/app-navigation.ts +28 -0
  215. package/src/ssr/compact-shell.baseline.json +5270 -0
  216. package/src/ssr/compact-shell.browser-harness.ts +439 -0
  217. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  218. package/src/ssr/help-messages.ts +4 -0
  219. package/src/ssr/help.ts +20 -0
  220. package/src/ssr/hints.ts +25 -0
  221. package/src/ssr/index.ts +5 -0
  222. package/src/ssr/islands/index.ts +4 -3
  223. package/src/ssr/layout-context.ts +2 -0
  224. package/src/ssr/layout-help-article.server.ts +50 -0
  225. package/src/ssr/layout-help-start.ts +33 -7
  226. package/src/ssr/panel-consent.ts +29 -0
  227. package/src/ssr/panel-context.ts +10 -0
  228. package/src/ssr/panel-slot.browser-harness.tsx +159 -0
  229. package/src/ssr/platform-messages.ts +48 -0
  230. package/src/ssr/rail-navigation.ts +44 -0
  231. package/src/ssr/shell-data.tsx +5 -0
  232. package/src/ssr/shell-shortcuts.ts +13 -2
  233. package/src/styles/effects.css +201 -11
  234. package/src/styles/file-chooser.css +2 -1
  235. package/src/styles/global.css +314 -63
  236. package/src/styles/panel.css +416 -0
  237. package/src/styles/resource-search.css +6 -2
  238. package/src/styles/tokens.css +36 -13
  239. package/src/styles/utilities-buttons.css +3 -2
  240. package/src/styles/utilities-data.css +2 -1
  241. package/src/styles/utilities-feedback.css +2 -2
  242. package/src/styles/utilities-layout.css +3 -13
  243. package/src/styles/utilities-markdown-editor.css +6 -6
  244. package/src/styles/utilities-navigation.css +22 -43
package/README.md CHANGED
@@ -83,6 +83,14 @@ Application setup, public contracts, and deployment guidance:
83
83
  - **[Create the first application](https://github.com/k2b-dev/cloud/blob/main/docs-site/docs/en/build/getting-started.md)** — standalone project setup and a verified API endpoint
84
84
  - **[github.com/k2b-dev/cloud](https://github.com/k2b-dev/cloud)** — the platform monorepo (gateway, core, all platform apps)
85
85
 
86
+ ## Security
87
+
88
+ Report a vulnerability privately through
89
+ [GitHub Security Advisories](https://github.com/k2b-dev/cloud/security/advisories/new),
90
+ never in a public issue. Security fixes appear in this package's changelog,
91
+ and a published advisory names the first patched version. See the
92
+ [security policy](https://github.com/k2b-dev/cloud/blob/main/SECURITY.md).
93
+
86
94
  ## License
87
95
 
88
96
  GNU Affero General Public License v3.0 or later.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -34,14 +34,17 @@
34
34
  "./browser/app-badges": "./src/browser/app-badges.ts",
35
35
  "./browser/live": "./src/browser/live.ts",
36
36
  "./browser/mermaid": "./src/browser/mermaid.ts",
37
+ "./browser/hints": "./src/browser/hints.ts",
37
38
  "./browser/reload": "./src/browser/reload.ts",
38
39
  "./browser/notifications": "./src/browser/notifications.ts",
39
40
  "./browser/resource-clipboard": "./src/browser/resource-clipboard.ts",
40
41
  "./browser/commands": "./src/browser/commands.ts",
42
+ "./browser/panel": "./src/browser/panel.ts",
41
43
  "./browser/search": "./src/browser/search.ts",
42
44
  "./browser/widgets": "./src/browser/widgets.ts",
43
45
  "./browser/testing": "./src/browser/testing.ts",
44
46
  "./browser/resource-picker": "./src/browser/resource-picker.tsx",
47
+ "./browser/composer-insert": "./src/browser/composer-insert.ts",
45
48
  "./browser/files": "./src/browser/files.tsx",
46
49
  "./capabilities": "./src/capabilities/client.ts",
47
50
  "./capabilities/server": "./src/capabilities/server.ts",
@@ -54,6 +57,7 @@
54
57
  "./cli/capabilities": "./src/cli/capabilities.ts",
55
58
  "./cli/admin": "./src/cli/admin/index.ts",
56
59
  "./shared": "./src/shared/index.ts",
60
+ "./shared-text": "./src/shared-text/index.ts",
57
61
  "./services": "./src/services/index.ts",
58
62
  "./services/image-metadata": "./src/services/image-metadata.ts",
59
63
  "./services/document-extraction": "./src/services/document-extraction.ts",
@@ -103,13 +107,14 @@
103
107
  "@firecrawl/anydoc": "0.2.4",
104
108
  "@napi-rs/canvas": "1.0.9",
105
109
  "pdfjs-dist": "6.3.289",
110
+ "sharp": "0.35.4",
106
111
  "@simplewebauthn/server": "14.0.3",
107
112
  "@tabler/icons": "3.48.0",
108
113
  "@tailwindcss/typography": "0.5.20",
109
114
  "@k2b/nessi": "0.18.0",
110
- "@k2b/ssr": "0.17.0",
111
- "@k2b/ui": "0.28.0",
112
- "@k2b/stdlib": "0.29.0",
115
+ "@k2b/ssr": "0.18.0",
116
+ "@k2b/ui": "0.30.0",
117
+ "@k2b/stdlib": "0.30.0",
113
118
  "@k2b/sync": "7.0.0",
114
119
  "@nats-io/jetstream": "3.4.0",
115
120
  "@nats-io/transport-node": "3.4.0",
@@ -122,6 +127,7 @@
122
127
  "liquidjs": "10.29.0",
123
128
  "marked": "18.0.14",
124
129
  "mermaid": "12.0.0",
130
+ "mmdb-lib": "3.0.3",
125
131
  "nodemailer": "10.0.10",
126
132
  "postcss": "8.5.28",
127
133
  "reflect-metadata": "0.2.2",
@@ -131,6 +137,7 @@
131
137
  "web-push": "3.6.7",
132
138
  "web-vitals": "6.2.2",
133
139
  "yaml": "2.9.1",
140
+ "yjs": "13.6.33",
134
141
  "hash-wasm": "4.12.0",
135
142
  "@types/sanitize-html": "2.16.1",
136
143
  "@types/web-push": "3.6.4"
@@ -141,7 +148,7 @@
141
148
  "@types/bun": "1.4.2",
142
149
  "babel-preset-solid": "1.9.15",
143
150
  "hono": "4.13.9",
144
- "playwright": "1.63.0",
151
+ "playwright": "1.64.1",
145
152
  "solid-js": "1.9.15",
146
153
  "typescript": "7.0.2",
147
154
  "zod": "4.6.5"
@@ -1,58 +1,138 @@
1
- import { cp, mkdir } from "node:fs/promises";
2
- import { dirname, resolve } from "node:path";
1
+ import { cp, mkdir, realpath } from "node:fs/promises";
2
+ import { basename, dirname, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { z } from "zod";
5
+ import { readImageRecords } from "../src/services/pdf/image-protocol";
5
6
 
6
- /** Isolated native canvas workers, each packaged only when the server bundle reaches the module that starts it. */
7
7
  const CANVAS_WORKERS = [
8
- { reachedBy: "/ai/pdf-render.ts", entry: "src/ai/pdf-render-worker.ts", output: "pdf-worker.js", packages: ["pdfjs-dist"] },
9
8
  {
10
- reachedBy: "/services/branding/app-icons.ts",
9
+ define: "__CLOUD_PDF_RENDER_WORKER__",
10
+ entry: "src/ai/pdf-render-worker.ts",
11
+ output: "pdf-worker.js",
12
+ packages: ["@napi-rs/canvas", "pdfjs-dist"],
13
+ },
14
+ {
15
+ define: "__CLOUD_ICON_RENDER_WORKER__",
11
16
  entry: "src/services/branding/icon-render-worker.ts",
12
17
  output: "icon-worker.js",
13
- packages: [],
18
+ packages: ["@napi-rs/canvas"],
14
19
  },
20
+ { define: "__CLOUD_PDF_IMAGE_WORKER__", entry: "src/services/pdf/image-worker.ts", output: "image-worker.js", packages: ["sharp"] },
15
21
  ] as const;
16
22
 
23
+ /** Server bundle defines: each caller starts its worker from `canvas/` next to `server.js`. */
24
+ export const CANVAS_WORKER_DEFINES = Object.fromEntries(
25
+ CANVAS_WORKERS.map((worker) => [worker.define, JSON.stringify(`./canvas/${worker.output}`)]),
26
+ );
27
+
28
+ const Manifest = z.object({
29
+ version: z.string(),
30
+ dependencies: z.record(z.string(), z.string()).default({}),
31
+ optionalDependencies: z.record(z.string(), z.string()).default({}),
32
+ os: z.array(z.string()).optional(),
33
+ cpu: z.array(z.string()).optional(),
34
+ libc: z.array(z.string()).optional(),
35
+ });
36
+ const matches = (values: string[] | undefined, target: string) =>
37
+ !values || (!values.includes(`!${target}`) && (values.every((value) => value.startsWith("!")) || values.includes(target)));
38
+
17
39
  /**
18
- * Writes `dist/canvas/<worker>.js` for every canvas worker the bundle needs, with one copy of `@napi-rs/canvas`, its
19
- * installed platform binary and the workers' other packages in `dist/canvas/node_modules`.
40
+ * Bundle each reached native worker with only its runtime packages external. Copy their real dependency closures flat,
41
+ * dereferenced and filtered by OS, CPU and libc; reject version collisions and missing native binaries. Smoke-test sharp
42
+ * in the packaged subprocess so the build proves that its native closure loads on this architecture.
20
43
  */
21
- export async function buildCanvasWorkers(dist: string, bundledInputs: readonly string[]) {
22
- const workers = CANVAS_WORKERS.filter((worker) => bundledInputs.some((path) => path.endsWith(worker.reachedBy)));
44
+ export async function buildCanvasWorkers(dist: string, serverCode: string) {
45
+ // A worker ships only when the server code still contains its path, which only the code that starts it carries.
46
+ // Metafile inputs also list tree-shaken modules, and a retained module binding contributes bytes without its caller.
47
+ const workers = CANVAS_WORKERS.filter((worker) => serverCode.includes(`./canvas/${worker.output}`));
23
48
  if (workers.length === 0) return;
24
49
  const framework = resolve(dirname(fileURLToPath(import.meta.url)), "..");
25
50
  const out = resolve(dist, "canvas");
26
51
  await mkdir(out, { recursive: true });
27
- const external = ["@napi-rs/canvas", ...workers.flatMap((worker) => worker.packages)];
28
52
  for (const worker of workers) {
29
53
  const build = await Bun.build({
30
54
  entrypoints: [resolve(framework, worker.entry)],
31
55
  target: "bun",
32
56
  outdir: out,
33
57
  naming: worker.output,
34
- external,
58
+ external: [...worker.packages],
35
59
  });
36
- if (!build.success) throw new Error(`Canvas worker ${worker.output} build failed: ${build.logs.join("\n")}`);
60
+ if (!build.success) throw new Error(`Native worker ${worker.output} build failed: ${build.logs.join("\n")}`);
37
61
  }
38
- for (const name of external) {
39
- const source = dirname(Bun.resolveSync(`${name}/package.json`, framework));
40
- await cp(source, resolve(out, "node_modules", name), { recursive: true, dereference: true });
41
- if (name !== "@napi-rs/canvas") continue;
42
- const manifest = z
43
- .object({ optionalDependencies: z.record(z.string(), z.string()) })
44
- .parse(await Bun.file(resolve(source, "package.json")).json());
62
+ const libc = z.object({ header: z.object({ glibcVersionRuntime: z.string().optional() }) }).parse(process.report.getReport()).header
63
+ .glibcVersionRuntime
64
+ ? "glibc"
65
+ : "musl";
66
+ const copied = new Map<string, string>();
67
+ const copyPackage = async (name: string, dependent: string, optional = false): Promise<boolean> => {
68
+ let source: string;
69
+ try {
70
+ source = await realpath(dirname(Bun.resolveSync(`${name}/package.json`, dependent)));
71
+ } catch (error) {
72
+ if (optional) return false;
73
+ throw error;
74
+ }
75
+ const manifest = Manifest.parse(await Bun.file(resolve(source, "package.json")).json());
76
+ if (!matches(manifest.os, process.platform) || !matches(manifest.cpu, process.arch) || !matches(manifest.libc, libc)) {
77
+ if (optional) return false;
78
+ throw new Error(`Native worker dependency ${name} does not support the build platform.`);
79
+ }
80
+ const existing = copied.get(name);
81
+ if (existing) {
82
+ if (existing !== manifest.version)
83
+ throw new Error(`Native worker dependency collision: ${name} ${existing} and ${manifest.version}.`);
84
+ return true;
85
+ }
86
+ copied.set(name, manifest.version);
87
+ await cp(source, resolve(out, "node_modules", name), {
88
+ recursive: true,
89
+ dereference: true,
90
+ filter: (path) => basename(path) !== "node_modules",
91
+ });
92
+ for (const dependency of Object.keys(manifest.dependencies)) {
93
+ if (dependency in manifest.optionalDependencies) continue;
94
+ await copyPackage(dependency, source);
95
+ }
45
96
  let nativePackages = 0;
46
97
  for (const dependency of Object.keys(manifest.optionalDependencies)) {
47
- let location: string;
48
- try {
49
- location = dirname(Bun.resolveSync(`${dependency}/package.json`, source));
50
- } catch {
51
- continue;
52
- } // Only the installed target-platform binary is needed.
53
- await cp(location, resolve(out, "node_modules", dependency), { recursive: true, dereference: true });
54
- nativePackages++;
98
+ if (await copyPackage(dependency, source, true)) {
99
+ if (
100
+ (name === "sharp" && dependency.startsWith("@img/sharp-") && !dependency.startsWith("@img/sharp-libvips-")) ||
101
+ (name === "@napi-rs/canvas" && dependency.startsWith("@napi-rs/canvas-"))
102
+ )
103
+ nativePackages++;
104
+ }
105
+ }
106
+ if ((name === "sharp" || name === "@napi-rs/canvas") && !nativePackages) {
107
+ throw new Error(`Native workers need an installed ${name} binary for the build platform.`);
108
+ }
109
+ return true;
110
+ };
111
+ for (const name of new Set(workers.flatMap((worker) => [...worker.packages]))) await copyPackage(name, framework);
112
+ if (workers.some((worker) => worker.output === "image-worker.js")) await smokeImageWorker(resolve(out, "image-worker.js"));
113
+ }
114
+
115
+ async function smokeImageWorker(entry: string) {
116
+ // A fixed 2x2 RGBA PNG; the build process never loads sharp.
117
+ const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAEUlEQVR4nGP4z8DQAMIMMAYAOOgF/REzMMkAAAAASUVORK5CYII=", "base64");
118
+ const child = Bun.spawn([process.execPath, "--no-env-file", entry], {
119
+ stdin: new Blob([
120
+ `${JSON.stringify({ bytes: png.byteLength, boxes: [{ width: 2, height: 2 }], maxBytes: 4096, timeoutMs: 10_000 })}\n`,
121
+ png,
122
+ ]),
123
+ stdout: "pipe",
124
+ stderr: "ignore",
125
+ env: { PATH: process.env.PATH },
126
+ });
127
+ const timer = setTimeout(() => child.kill("SIGKILL"), 15_000);
128
+ try {
129
+ const result = await readImageRecords(child.stdout, { boxes: [{ width: 2, height: 2 }], maxBytes: 4096 });
130
+ if ((await child.exited) !== 0 || !result.ok || result.variants.length !== 1 || !result.variants[0]) {
131
+ throw new Error("Packaged image worker could not convert its PNG: sharp's native dependency closure is incomplete or unusable.");
55
132
  }
56
- if (!nativePackages) throw new Error("Canvas workers need an installed native canvas binary for the build platform.");
133
+ } finally {
134
+ clearTimeout(timer);
135
+ child.kill("SIGKILL");
136
+ await child.exited;
57
137
  }
58
138
  }
package/scripts/build.ts CHANGED
@@ -35,7 +35,7 @@ import tailwind from "bun-plugin-tailwind";
35
35
  import type { AppCliModules } from "../src/contracts/app";
36
36
  import { writeAppFavicon } from "./app-favicon";
37
37
  import { buildBrowserPerformance } from "./browser-performance";
38
- import { buildCanvasWorkers } from "./build-canvas-workers";
38
+ import { buildCanvasWorkers, CANVAS_WORKER_DEFINES } from "./build-canvas-workers";
39
39
 
40
40
  const appId = process.env.APP_ID;
41
41
  if (!appId) throw new Error("APP_ID env var required");
@@ -127,13 +127,11 @@ try {
127
127
  naming: "server.js",
128
128
  target: "bun",
129
129
  minify: true,
130
- metafile: true,
131
130
  define: {
132
131
  __CLOUD_VERSION__: JSON.stringify(version),
133
132
  __CLOUD_RELEASE__: JSON.stringify(release),
134
133
  __CLOUD_SYNC_VERSION__: JSON.stringify(syncVersion),
135
- __CLOUD_PDF_RENDER_WORKER__: JSON.stringify("./canvas/pdf-worker.js"),
136
- __CLOUD_ICON_RENDER_WORKER__: JSON.stringify("./canvas/icon-worker.js"),
134
+ ...CANVAS_WORKER_DEFINES,
137
135
  __CLOUD_APP_ASSETS__: JSON.stringify("./assets/"),
138
136
  __CLOUD_CLI_PLUGINS__: JSON.stringify("./cli/"),
139
137
  },
@@ -146,7 +144,7 @@ if (!server.success) {
146
144
  for (const m of server.logs) console.error(m);
147
145
  throw new Error("Server bundle failed");
148
146
  }
149
- await buildCanvasWorkers(dist, Object.keys(server.metafile?.inputs ?? {}));
147
+ await buildCanvasWorkers(dist, await Bun.file(resolve(dist, "server.js")).text());
150
148
 
151
149
  // 2. Per-app Tailwind stylesheet.
152
150
  const appCss = resolve(appDir, "src/styles/app.css");
@@ -1,2 +1,4 @@
1
- export const appFaviconHref = (appId: string, version: number): string =>
2
- appId === "core" ? "/branding/favicon" : `/public/${appId}/favicon.svg?v=${version}`;
1
+ export const appFaviconHref = (appId: string, version: string | number | undefined): string =>
2
+ appId === "core"
3
+ ? "/branding/favicon"
4
+ : `/public/${appId}/favicon.svg${version === undefined ? "" : `?v=${encodeURIComponent(version)}`}`;
@@ -0,0 +1,25 @@
1
+ /** An absolute path on the current origin: one leading slash, no backslash or whitespace. */
2
+ export const SAME_ORIGIN_PATH = /^\/(?![\/\\])[^\\\s]*$/;
3
+
4
+ /** A route as the gateway reads it: trimmed and without empty segments, so `//pwa/x/` is `/pwa/x`. */
5
+ export const canonicalRoute = (route: string): string => {
6
+ const trimmed = route.trim();
7
+ return trimmed.startsWith("/") ? `/${trimmed.split("/").filter(Boolean).join("/")}` : trimmed;
8
+ };
9
+
10
+ /**
11
+ * Whether an application path stays below one of the app's own `routes`. It is
12
+ * checked on the path the browser actually requests, after dot segments, and
13
+ * never starts with `//`, which a URL resolver reads as another host.
14
+ */
15
+ export const isOwnAppPath = (href: string, routes: readonly string[]): boolean => {
16
+ if (!SAME_ORIGIN_PATH.test(href)) return false;
17
+ const path = new URL(href, "https://cloud.invalid").pathname;
18
+ return (
19
+ !path.startsWith("//") &&
20
+ routes.some((route) => {
21
+ const prefix = canonicalRoute(route);
22
+ return prefix === "/" || path === prefix || path.startsWith(`${prefix}/`);
23
+ })
24
+ );
25
+ };
@@ -92,7 +92,11 @@ export const compileAppPresentation = (app: AppMeta, catalog: AppPresentationCat
92
92
  if (!canonical) throw new Error(`App presentation locale ${JSON.stringify(locale)} is not a valid BCP 47 locale`);
93
93
  if (canonical === baseLocale) throw new Error(`App presentation translations must not repeat base locale ${baseLocale}`);
94
94
  if (translations[canonical]) throw new Error(`App presentation locale ${canonical} is duplicated after canonicalization`);
95
+ if (translation.panelLabel !== undefined && !app.panel) {
96
+ throw new Error(`${canonical}.panelLabel contains unknown key "panel"`);
97
+ }
95
98
  translations[canonical] = {
99
+ ...(translation.panelLabel !== undefined ? { panelLabel: text(translation.panelLabel, `${canonical}.panelLabel`) } : {}),
96
100
  ...(translation.name !== undefined ? { name: text(translation.name, `${canonical}.name`) } : {}),
97
101
  ...(translation.description !== undefined
98
102
  ? { description: text(translation.description, `${canonical}.description`, MAX_DESCRIPTION) }
@@ -1,24 +1,26 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
 
3
3
  /**
4
- * Starts a native canvas worker (PDF pages, app icons) in a process of its own. RLIMIT_DATA bounds the decoder heap
4
+ * Starts a native worker (PDF pages, app icons, print images) in a process of its own. RLIMIT_DATA bounds the decoder heap
5
5
  * and native writable mappings on Linux. Positional arguments keep paths out of shell code; no elevated privileges.
6
6
  * The caller owns the deadline and kills the process on abort.
7
7
  */
8
- export const spawnCanvasWorker = (worker: URL, name: string, input: string) =>
8
+ export const spawnCanvasWorker = (worker: URL, name: string, input: string | Blob) =>
9
9
  Bun.spawn(["/bin/sh", "-c", 'ulimit -d 524288 || exit 70; exec "$@"', name, process.execPath, "--no-env-file", fileURLToPath(worker)], {
10
10
  stdin: new Blob([input]),
11
11
  stdout: "pipe",
12
12
  stderr: "ignore",
13
13
  // No application credentials or configuration reach the decoder, only fixed
14
- // thread pool sizes: the Bun work pool, canvas tokio runtime, and JSC GC
14
+ // runtime settings: the Bun work pool, canvas tokio runtime, and JSC GC
15
15
  // markers otherwise grow with the host's cores, and RLIMIT_DATA also counts
16
16
  // each thread's stack and allocator memory.
17
+ // Bun's allocator otherwise commits whole arenas up front, which RLIMIT_DATA counts as used.
17
18
  env: {
18
19
  PATH: process.env.PATH,
19
20
  LANG: "C.UTF-8",
20
21
  UV_THREADPOOL_SIZE: "2",
21
22
  TOKIO_WORKER_THREADS: "2",
22
23
  BUN_JSC_numberOfGCMarkers: "2",
24
+ MIMALLOC_ARENA_EAGER_COMMIT: "0",
23
25
  },
24
26
  });
@@ -1,4 +1,7 @@
1
- import { createHash } from "node:crypto";
1
+ import { capabilityHash, projectSchema, stableJsonValue } from "./capability-schemas";
2
+
3
+ export { capabilityHash } from "./capability-schemas";
4
+
2
5
  import { isServiceError, type Result, type ServiceError } from "@k2b/stdlib";
3
6
  import { z } from "zod";
4
7
  import {
@@ -8,8 +11,8 @@ import {
8
11
  CAPABILITY_PROTOCOL_VERSION,
9
12
  type CapabilityActionDefinition,
10
13
  type CapabilityActionManifest,
14
+ CapabilityActionPolicySchema,
11
15
  type CapabilityActionReviewResult,
12
- CapabilityActionReviewSchema,
13
16
  type CapabilityActionSentences,
14
17
  type CapabilityCommandDefinition,
15
18
  type CapabilityCommandManifest,
@@ -33,12 +36,14 @@ import {
33
36
  type CapabilitySearchTagDefinition,
34
37
  type CapabilityStreamDefinition,
35
38
  type CapabilityUniversalSearchDefinition,
39
+ capabilityActionPolicy,
36
40
  capabilityResultSchema,
37
41
  UniversalSearchDataSchema,
38
42
  UniversalSearchInputSchema,
39
43
  } from "../contracts/capabilities";
40
44
  import { fileProviderIssues } from "../contracts/file-provider";
41
45
  import { canonicalLocale, localeFallbackChain, normalizeLocale } from "../shared/locale";
46
+ import { capabilityActionReceiptIssues, capabilityActionReviewIssues, capabilityQueryChecksActionOutcome } from "./capability-reviews";
42
47
  import {
43
48
  CAPABILITY_SENTENCE_KEYS,
44
49
  CAPABILITY_SENTENCE_MAX_CHARS,
@@ -94,6 +99,7 @@ const ACTION_FIELDS = {
94
99
  stream: true,
95
100
  idempotency: true,
96
101
  approval: true,
102
+ policy: true,
97
103
  review: true,
98
104
  run: true,
99
105
  } satisfies DefinedFields<CapabilityActionDefinition>;
@@ -221,21 +227,6 @@ export const serializeCapabilityProviderResult = (
221
227
  return { body: JSON.stringify(fallback), status: options.nonIdempotentAction ? 502 : 500 };
222
228
  };
223
229
 
224
- const stableJsonValue = (value: unknown): unknown => {
225
- if (Array.isArray(value)) return value.map(stableJsonValue);
226
- if (typeof value !== "object" || value === null) return value;
227
- return Object.fromEntries(
228
- Object.entries(value as Record<string, unknown>)
229
- .sort(([left], [right]) => left.localeCompare(right))
230
- .map(([key, entry]) => [key, stableJsonValue(entry)]),
231
- );
232
- };
233
-
234
- export const capabilityHash = (value: unknown): string =>
235
- createHash("sha256")
236
- .update(JSON.stringify(stableJsonValue(value)))
237
- .digest("hex");
238
-
239
230
  const assertText = (value: string, label: string, max: number): void => {
240
231
  if (!value.trim()) throw new Error(`${label} is required`);
241
232
  if (value.length > max) throw new Error(`${label} must be at most ${max} characters`);
@@ -338,12 +329,12 @@ const compileOperationPresentation = (
338
329
  operation: CapabilityQueryManifest | CapabilityActionManifest | CapabilityCommandManifest,
339
330
  label: string,
340
331
  strict = true,
341
- ): CapabilityOperationPresentationTranslation => {
332
+ ): CapabilityOperationPresentationTranslation & { keywords?: readonly string[] } => {
342
333
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
343
334
  const translation = value as Record<string, unknown>;
344
335
  const allowed = new Set(
345
336
  "path" in operation
346
- ? ["title", "description", "input"]
337
+ ? ["title", "description", "input", "keywords"]
347
338
  : ["title", "description", "input", "data", "searchTags", ...(isAction(operation) ? ["sentences"] : [])],
348
339
  );
349
340
  const extra = Object.keys(translation).find((key) => !allowed.has(key));
@@ -356,6 +347,10 @@ const compileOperationPresentation = (
356
347
  if (typeof translation.description !== "string") throw new Error(`${label}.description must be text`);
357
348
  assertText(translation.description, `${label}.description`, 1000);
358
349
  }
350
+ const keywords =
351
+ "path" in operation && translation.keywords !== undefined
352
+ ? CapabilityCommandManifestSchema.shape.keywords.parse(translation.keywords)
353
+ : undefined;
359
354
  let searchTags: CapabilityOperationPresentationTranslation["searchTags"];
360
355
  const universalSearch = "universalSearch" in operation ? operation.universalSearch : undefined;
361
356
  // A reader skips search-tag copy for an operation without Universal Search instead of losing its other copy.
@@ -398,6 +393,7 @@ const compileOperationPresentation = (
398
393
  return {
399
394
  ...(typeof translation.title === "string" ? { title: translation.title.trim() } : {}),
400
395
  ...(typeof translation.description === "string" ? { description: translation.description.trim() } : {}),
396
+ ...(keywords ? { keywords } : {}),
401
397
  ...(input ? { input } : {}),
402
398
  ...(data ? { data } : {}),
403
399
  ...(searchTags ? { searchTags } : {}),
@@ -517,17 +513,6 @@ export const compileCapabilityPresentation = (
517
513
  return { baseLocale, ...(sentences && Object.keys(sentences).length > 0 ? { sentences } : {}), translations };
518
514
  };
519
515
 
520
- const projectSchema = (schema: z.ZodType, label: string, io: "input" | "output"): JsonSchema => {
521
- try {
522
- const projected = z.toJSONSchema(schema, { io }) as JsonSchema;
523
- z.fromJSONSchema(projected);
524
- return projected;
525
- } catch (error) {
526
- const message = error instanceof Error ? error.message : String(error);
527
- throw new Error(`${label} is not JSON-Schema-round-trippable: ${message}`);
528
- }
529
- };
530
-
531
516
  const assertPropertyDescriptions = (schema: unknown, label: string, path: readonly string[] = []): void => {
532
517
  if (!schema || typeof schema !== "object" || Array.isArray(schema)) return;
533
518
  const value = schema as Record<string, unknown>;
@@ -720,6 +705,20 @@ export const compileCapabilities = (appId: string, definitions: CapabilityDefini
720
705
  const label = `Action ${localId}`;
721
706
  assertDefinedFields(definition, ACTION_FIELDS, label);
722
707
  assertDefinedStreamFields(definition.stream, label);
708
+ if (definition.policy !== undefined) {
709
+ const parsedPolicy = CapabilityActionPolicySchema.safeParse(definition.policy);
710
+ if (!parsedPolicy.success) throw new Error(`${label} has invalid policy: ${parsedPolicy.error.message}`);
711
+ if (definition.approval !== undefined) throw new Error(`${label} policy and approval are mutually exclusive`);
712
+ if (!definition.review) throw new Error(`${label} policy requires review()`);
713
+ if ("silent" in parsedPolicy.data && (definition.destructive || definition.openWorld))
714
+ throw new Error(`${label} destructive or open-world policy must ask`);
715
+ if (
716
+ "ask" in parsedPolicy.data &&
717
+ (parsedPolicy.data.ask === "sends" || parsedPolicy.data.ask === "deletes") &&
718
+ definition.idempotency !== "required"
719
+ )
720
+ throw new Error(`${label} sends or deletes policy requires idempotency: "required"`);
721
+ }
723
722
  // Declaration rule, documented in docs/platform/capabilities.md:
724
723
  // destructive means irreversible or externally visible. An irreversible or
725
724
  // open-world effect must always be reviewable and safely repeatable, and
@@ -767,7 +766,13 @@ export const compileCapabilities = (appId: string, definitions: CapabilityDefini
767
766
  destructive: definition.destructive,
768
767
  openWorld: definition.openWorld,
769
768
  idempotency: definition.idempotency,
770
- ...(definition.approval ? { approval: definition.approval } : {}),
769
+ ...(definition.policy
770
+ ? "silent" in definition.policy
771
+ ? { approval: "rememberable" as const }
772
+ : {}
773
+ : definition.approval
774
+ ? { approval: definition.approval }
775
+ : {}),
771
776
  ...(definition.review ? { review: true as const } : {}),
772
777
  } satisfies CapabilityActionManifest;
773
778
  actions.set(localId, {
@@ -795,6 +800,9 @@ export const compileCapabilities = (appId: string, definitions: CapabilityDefini
795
800
  });
796
801
  });
797
802
 
803
+ const policies = Object.fromEntries(
804
+ [...actions].flatMap(([localId, action]) => (action.definition.policy ? [[localId, action.definition.policy]] : [])),
805
+ );
798
806
  const fileProvider = definitions.fileProvider;
799
807
  const manifestBase = {
800
808
  protocolVersion: CAPABILITY_PROTOCOL_VERSION,
@@ -803,6 +811,7 @@ export const compileCapabilities = (appId: string, definitions: CapabilityDefini
803
811
  queries: [...queries.values()].map((entry) => entry.manifest),
804
812
  actions: [...actions.values()].map((entry) => entry.manifest),
805
813
  commands,
814
+ ...(Object.keys(policies).length ? { policies } : {}),
806
815
  // Omitted when unused so manifests without a provider keep their earlier hash.
807
816
  ...(fileProvider
808
817
  ? { fileProvider: { list: fileProvider.list, read: fileProvider.read, ...(fileProvider.save ? { save: fileProvider.save } : {}) } }
@@ -954,6 +963,7 @@ export const resolveCapabilityManifestPresentation = (
954
963
  title: copy.title ?? operation.title,
955
964
  description: copy.description ?? operation.description,
956
965
  inputSchema: applySchemaPresentation(operation.inputSchema, copy.input),
966
+ keywords: copy.keywords ? [...copy.keywords] : operation.keywords,
957
967
  }
958
968
  : operation;
959
969
  }),
@@ -970,6 +980,7 @@ export const resolveCapabilityManifestPresentation = (
970
980
  : operation;
971
981
  }),
972
982
  ...(current.fileProvider ? { fileProvider: current.fileProvider } : {}),
983
+ ...(current.policies ? { policies: current.policies } : {}),
973
984
  };
974
985
  current = CapabilityManifestSchema.parse({ ...manifestBase, manifestHash: capabilityHash(manifestBase) });
975
986
  }
@@ -1213,6 +1224,15 @@ const resultRefs = (result: unknown): Array<{ type: string; id: string }> => {
1213
1224
  }>;
1214
1225
  };
1215
1226
 
1227
+ const compiledReviewContext = (compiled: CompiledCapabilities, localId: string) => ({
1228
+ policy: capabilityActionPolicy(compiled.manifest, localId),
1229
+ approval: compiled.actions.get(localId)?.manifest.approval,
1230
+ queries: compiled.manifest.queries,
1231
+ validateQueryInput: (query: CapabilityQueryManifest, input: Record<string, unknown>) =>
1232
+ compiled.queries.get(query.localId)?.definition.input.safeParse(input).success === true,
1233
+ ...(compiled.presentation ? { locales: [compiled.presentation.baseLocale, ...Object.keys(compiled.presentation.translations)] } : {}),
1234
+ });
1235
+
1216
1236
  export const validateCapabilityResult = (
1217
1237
  compiled: CompiledCapabilities,
1218
1238
  operation: CompiledCapabilityQuery | CompiledCapabilityAction,
@@ -1220,12 +1240,25 @@ export const validateCapabilityResult = (
1220
1240
  onInvalidResult?: (error: unknown) => void,
1221
1241
  ): Result<unknown, ServiceError> => {
1222
1242
  const parsed = operation.resultSchema.safeParse(result);
1223
- if (!parsed.success) {
1243
+ const field = (value: unknown, key: string): unknown =>
1244
+ value !== null && typeof value === "object" ? Object.getOwnPropertyDescriptor(value, key)?.value : undefined;
1245
+ const receipt = field(result, "receipt");
1246
+ const links = field(result, "links");
1247
+ const receiptIssues =
1248
+ receipt === undefined
1249
+ ? []
1250
+ : "idempotency" in operation.manifest
1251
+ ? capabilityActionReceiptIssues(receipt, links)
1252
+ : ["Queries cannot return a receipt"];
1253
+ const nestedReceipt = field(field(result, "data"), "receipt");
1254
+ if (!("idempotency" in operation.manifest) && nestedReceipt !== undefined && capabilityQueryChecksActionOutcome(operation.manifest))
1255
+ receiptIssues.push(...capabilityActionReceiptIssues(nestedReceipt, links));
1256
+ if (!parsed.success || receiptIssues.length > 0) {
1224
1257
  try {
1225
1258
  onInvalidResult?.(
1226
1259
  new Error(
1227
1260
  `Capability ${operation.manifest.localId} returned data outside its registered schema: ${JSON.stringify(
1228
- parsed.error.issues.map(({ code, path, message }) => ({ code, path, message })),
1261
+ parsed.success ? receiptIssues : parsed.error.issues.map(({ code, path, message }) => ({ code, path, message })),
1229
1262
  )}`,
1230
1263
  ),
1231
1264
  );
@@ -1468,27 +1501,18 @@ export const reviewCompiledCapability = async (params: {
1468
1501
  if (!reviewed.ok) {
1469
1502
  return normalizeProviderError(reviewed.error, "Capability review returned an invalid error", params.onUnexpectedError);
1470
1503
  }
1471
- const parsed = CapabilityActionReviewSchema.safeParse(reviewed.data);
1472
- // A rememberable Action may leave out its scope for arguments that should ask every time; any other Action has none.
1473
- const approvalScopeIsValid =
1474
- parsed.success && (operation.manifest.approval === "rememberable" || parsed.data.approvalScope === undefined);
1475
- if (!parsed.success || !approvalScopeIsValid) {
1504
+ const issues = capabilityActionReviewIssues(reviewed.data, compiledReviewContext(params.compiled, params.localId));
1505
+ if (issues.length > 0) {
1476
1506
  try {
1477
1507
  params.onUnexpectedError?.(
1478
- new Error(
1479
- parsed.success
1480
- ? `Capability ${operation.manifest.localId} review returned an approval scope inconsistent with its manifest`
1481
- : `Capability ${operation.manifest.localId} review returned data outside its registered schema: ${JSON.stringify(
1482
- parsed.error.issues.map(({ code, path, message }) => ({ code, path, message })),
1483
- )}`,
1484
- ),
1508
+ new Error(`Capability ${operation.manifest.localId} review returned an invalid result: ${issues.join("; ")}`),
1485
1509
  );
1486
1510
  } catch {
1487
- // Observability must not change the public failure contract.
1511
+ /* Observability must not change the public failure contract. */
1488
1512
  }
1489
1513
  }
1490
- return parsed.success && approvalScopeIsValid
1491
- ? { ok: true, data: parsed.data }
1514
+ return issues.length === 0
1515
+ ? { ok: true, data: reviewed.data }
1492
1516
  : {
1493
1517
  ok: false,
1494
1518
  error: {