@k2b/cloud 0.22.0 → 0.24.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 (117) hide show
  1. package/package.json +4 -3
  2. package/scripts/README.md +1 -1
  3. package/scripts/build-canvas-workers.ts +58 -0
  4. package/scripts/build.ts +4 -5
  5. package/src/_internal/canvas-worker.ts +24 -0
  6. package/src/_internal/capabilities.ts +230 -75
  7. package/src/_internal/define-app.ts +90 -7
  8. package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
  9. package/src/_internal/page-responses.ts +20 -4
  10. package/src/_internal/registry-validation.ts +6 -0
  11. package/src/_internal/registry.ts +78 -3
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/_internal/static-assets.ts +18 -3
  14. package/src/access/ResourceApiKeys.tsx +30 -30
  15. package/src/ai/capability-execution.ts +5 -0
  16. package/src/ai/chat/blocks.tsx +2 -1
  17. package/src/ai/chat/messages.ts +6 -0
  18. package/src/ai/client/controller.ts +80 -21
  19. package/src/ai/client/transport.ts +47 -3
  20. package/src/ai/code-mode-skill.ts +2 -2
  21. package/src/ai/index.ts +0 -7
  22. package/src/ai/live-events.ts +1 -262
  23. package/src/ai/live.ts +31 -2
  24. package/src/ai/migrate.ts +35 -16
  25. package/src/ai/pdf-render.ts +2 -22
  26. package/src/ai/routes.ts +5 -4
  27. package/src/ai/runtime.ts +1 -6
  28. package/src/ai/solid.ts +0 -5
  29. package/src/ai/stream.ts +89 -65
  30. package/src/api/app-approval.ts +5 -1
  31. package/src/api/help.ts +2 -2
  32. package/src/api/index.ts +3 -0
  33. package/src/api/me.ts +19 -5
  34. package/src/api/pwa-phone.ts +168 -0
  35. package/src/api/pwa.ts +179 -0
  36. package/src/api/search/schemas.ts +34 -0
  37. package/src/api/search.ts +118 -50
  38. package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
  39. package/src/browser/CloudResourceSearch.tsx +208 -80
  40. package/src/browser/app-session.ts +69 -0
  41. package/src/browser/live-connection.ts +211 -0
  42. package/src/browser/live-websocket.ts +31 -11
  43. package/src/browser/live.ts +2 -0
  44. package/src/browser/resource-search-messages.ts +15 -2
  45. package/src/browser/search-stream.ts +151 -0
  46. package/src/contracts/app.ts +10 -0
  47. package/src/contracts/capabilities.ts +89 -24
  48. package/src/contracts/capability-compatibility.ts +28 -14
  49. package/src/contracts/file-provider.ts +158 -0
  50. package/src/contracts/index.ts +2 -0
  51. package/src/contracts/pwa-paths.ts +6 -0
  52. package/src/contracts/pwa.ts +156 -0
  53. package/src/contracts/registry.ts +7 -0
  54. package/src/contracts/shared.ts +7 -1
  55. package/src/events/index.ts +2 -0
  56. package/src/events/live-engine.ts +830 -0
  57. package/src/events/live-protocol.ts +31 -0
  58. package/src/events/live.ts +298 -0
  59. package/src/server/actor.ts +7 -0
  60. package/src/server/api-client.ts +23 -2
  61. package/src/server/help.ts +3 -3
  62. package/src/server/index.ts +3 -1
  63. package/src/server/middleware/auth.ts +80 -8
  64. package/src/server/middleware/openapi.ts +2 -1
  65. package/src/server/services/access.ts +49 -0
  66. package/src/server/services/index.ts +1 -0
  67. package/src/services/app-approval.ts +6 -25
  68. package/src/services/audit/index.ts +2 -0
  69. package/src/services/branding/app-icon-source.ts +40 -0
  70. package/src/services/branding/app-icons.ts +112 -0
  71. package/src/services/branding/icon-render-worker.ts +69 -0
  72. package/src/services/branding/icon-svg.ts +22 -0
  73. package/src/services/identity/invocation-actor.ts +8 -5
  74. package/src/services/identity/invocation-authority.ts +1 -0
  75. package/src/services/identity/invocation-token.ts +6 -0
  76. package/src/services/index.ts +1 -0
  77. package/src/services/outbox.ts +114 -80
  78. package/src/services/pairing-secret.ts +16 -0
  79. package/src/services/pdf/markdown.ts +22 -2
  80. package/src/services/pwa-devices.ts +640 -0
  81. package/src/services/session/index.ts +212 -57
  82. package/src/services/session/recent.ts +42 -0
  83. package/src/services/session/user.ts +15 -5
  84. package/src/shared/index.ts +1 -0
  85. package/src/{browser → shared}/locale-preference.ts +1 -1
  86. package/src/shared/markdown/formula.ts +122 -26
  87. package/src/shared/markdown/index.ts +27 -23
  88. package/src/ssr/AppLaunchpad.island.tsx +8 -1
  89. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  90. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  91. package/src/ssr/Layout.tsx +10 -12
  92. package/src/ssr/LayoutHeader.tsx +14 -2
  93. package/src/ssr/LayoutHelp.tsx +2 -4
  94. package/src/ssr/LayoutRail.tsx +13 -4
  95. package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
  96. package/src/ssr/MobileProfileActions.tsx +19 -13
  97. package/src/ssr/PageError.tsx +23 -1
  98. package/src/ssr/ProfilePreferences.island.tsx +9 -1
  99. package/src/ssr/PwaLayout.tsx +70 -0
  100. package/src/ssr/PwaRuntime.island.tsx +105 -0
  101. package/src/ssr/TimezoneCookie.island.tsx +7 -1
  102. package/src/ssr/app-navigation.ts +25 -10
  103. package/src/ssr/index.ts +8 -1
  104. package/src/ssr/layout-context.ts +1 -1
  105. package/src/ssr/preference-controller.ts +2 -2
  106. package/src/ssr/profile-actions.ts +3 -1
  107. package/src/ssr/profile-preferences-messages.ts +2 -0
  108. package/src/ssr/pwa-messages.ts +24 -0
  109. package/src/styles/global.css +6 -0
  110. package/src/styles/resource-search.css +19 -0
  111. package/src/styles/utilities-feedback.css +2 -2
  112. package/scripts/build-pdf-renderer.ts +0 -39
  113. package/src/ai/client/live-connection.ts +0 -141
  114. package/src/ai/live-messages.ts +0 -45
  115. package/src/ai/live-outbox.ts +0 -102
  116. package/src/ai/live-routes.ts +0 -412
  117. package/src/shared/markdown/extensions/info-blocks.ts +0 -108
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.22.0",
3
+ "version": "0.24.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": {
@@ -30,7 +30,7 @@
30
30
  "./server/*": "./src/server/*",
31
31
  "./browser": "./src/server/api-client.ts",
32
32
  "./browser/app-approval": "./src/browser/app-approval.ts",
33
- "./browser/live": "./src/browser/live-websocket.ts",
33
+ "./browser/live": "./src/browser/live.ts",
34
34
  "./browser/mermaid": "./src/browser/mermaid.ts",
35
35
  "./browser/reload": "./src/browser/reload.ts",
36
36
  "./browser/notifications": "./src/browser/notifications.ts",
@@ -74,6 +74,7 @@
74
74
  "./contracts": "./src/contracts/index.ts",
75
75
  "./contracts/notifications": "./src/contracts/notification-types.ts",
76
76
  "./contracts/*": "./src/contracts/*",
77
+ "./events": "./src/events/index.ts",
77
78
  "./workflows": "./src/workflows/index.ts",
78
79
  "./workflows/ai": "./src/workflows/ai/index.ts",
79
80
  "./workflows/editor": "./src/workflows/editor.ts",
@@ -98,7 +99,7 @@
98
99
  "@tailwindcss/typography": "0.5.20",
99
100
  "@k2b/nessi": "0.12.1",
100
101
  "@k2b/ssr": "0.14.0",
101
- "@k2b/ui": "0.12.0",
102
+ "@k2b/ui": "0.14.0",
102
103
  "@k2b/stdlib": "0.27.0",
103
104
  "@k2b/sync": "7.0.0",
104
105
  "@nats-io/jetstream": "3.4.0",
package/scripts/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  - `build.ts` — production build for one Cloud app (`APP_ID`); used by the Dockerfiles and `bun run build` targets.
4
4
  - `preload.ts` — dev-mode preload that registers the SSR plugin and builds CSS before app code loads.
5
- - `build-pdf-renderer.ts` — packages the PDF decoder and native assets for bundles that import PDF vision.
5
+ - `build-canvas-workers.ts` — packages the isolated canvas workers (PDF pages, mobile app icons) and the native canvas for bundles that start them.
6
6
  - `app-favicon.ts` — generates app favicons from Tabler icons; imported by `build.ts` and `preload.ts`.
7
7
  - `browser-performance.ts` — bundles the web-vitals browser asset for core; imported by `build.ts` and `preload.ts`.
8
8
  - `runtime-recovery-acceptance.ts` — shared-runtime recovery acceptance on disposable Docker containers: `bun packages/cloud/scripts/runtime-recovery-acceptance.ts` (also `bun run test:runtime-recovery`).
@@ -0,0 +1,58 @@
1
+ import { cp, mkdir } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { z } from "zod";
5
+
6
+ /** Isolated native canvas workers, each packaged only when the server bundle reaches the module that starts it. */
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
+ {
10
+ reachedBy: "/services/branding/app-icons.ts",
11
+ entry: "src/services/branding/icon-render-worker.ts",
12
+ output: "icon-worker.js",
13
+ packages: [],
14
+ },
15
+ ] as const;
16
+
17
+ /**
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`.
20
+ */
21
+ export async function buildCanvasWorkers(dist: string, bundledInputs: readonly string[]) {
22
+ const workers = CANVAS_WORKERS.filter((worker) => bundledInputs.some((path) => path.endsWith(worker.reachedBy)));
23
+ if (workers.length === 0) return;
24
+ const framework = resolve(dirname(fileURLToPath(import.meta.url)), "..");
25
+ const out = resolve(dist, "canvas");
26
+ await mkdir(out, { recursive: true });
27
+ const external = ["@napi-rs/canvas", ...workers.flatMap((worker) => worker.packages)];
28
+ for (const worker of workers) {
29
+ const build = await Bun.build({
30
+ entrypoints: [resolve(framework, worker.entry)],
31
+ target: "bun",
32
+ outdir: out,
33
+ naming: worker.output,
34
+ external,
35
+ });
36
+ if (!build.success) throw new Error(`Canvas worker ${worker.output} build failed: ${build.logs.join("\n")}`);
37
+ }
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());
45
+ let nativePackages = 0;
46
+ 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++;
55
+ }
56
+ if (!nativePackages) throw new Error("Canvas workers need an installed native canvas binary for the build platform.");
57
+ }
58
+ }
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 { buildPdfRenderer } from "./build-pdf-renderer";
38
+ import { buildCanvasWorkers } 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");
@@ -132,7 +132,8 @@ try {
132
132
  __CLOUD_VERSION__: JSON.stringify(version),
133
133
  __CLOUD_RELEASE__: JSON.stringify(release),
134
134
  __CLOUD_SYNC_VERSION__: JSON.stringify(syncVersion),
135
- __CLOUD_PDF_RENDER_WORKER__: JSON.stringify("./pdf-render/worker.js"),
135
+ __CLOUD_PDF_RENDER_WORKER__: JSON.stringify("./canvas/pdf-worker.js"),
136
+ __CLOUD_ICON_RENDER_WORKER__: JSON.stringify("./canvas/icon-worker.js"),
136
137
  __CLOUD_APP_ASSETS__: JSON.stringify("./assets/"),
137
138
  __CLOUD_CLI_PLUGINS__: JSON.stringify("./cli/"),
138
139
  },
@@ -145,9 +146,7 @@ if (!server.success) {
145
146
  for (const m of server.logs) console.error(m);
146
147
  throw new Error("Server bundle failed");
147
148
  }
148
- if (Object.keys(server.metafile?.inputs ?? {}).some((path) => path.endsWith("/ai/pdf-render.ts"))) {
149
- await buildPdfRenderer(dist);
150
- }
149
+ await buildCanvasWorkers(dist, Object.keys(server.metafile?.inputs ?? {}));
151
150
 
152
151
  // 2. Per-app Tailwind stylesheet.
153
152
  const appCss = resolve(appDir, "src/styles/app.css");
@@ -0,0 +1,24 @@
1
+ import { fileURLToPath } from "node:url";
2
+
3
+ /**
4
+ * Starts a native canvas worker (PDF pages, app icons) in a process of its own. RLIMIT_DATA bounds the decoder heap
5
+ * and native writable mappings on Linux. Positional arguments keep paths out of shell code; no elevated privileges.
6
+ * The caller owns the deadline and kills the process on abort.
7
+ */
8
+ export const spawnCanvasWorker = (worker: URL, name: string, input: string) =>
9
+ Bun.spawn(["/bin/sh", "-c", 'ulimit -d 524288 || exit 70; exec "$@"', name, process.execPath, "--no-env-file", fileURLToPath(worker)], {
10
+ stdin: new Blob([input]),
11
+ stdout: "pipe",
12
+ stderr: "ignore",
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
15
+ // markers otherwise grow with the host's cores, and RLIMIT_DATA also counts
16
+ // each thread's stack and allocator memory.
17
+ env: {
18
+ PATH: process.env.PATH,
19
+ LANG: "C.UTF-8",
20
+ UV_THREADPOOL_SIZE: "2",
21
+ TOKIO_WORKER_THREADS: "2",
22
+ BUN_JSC_numberOfGCMarkers: "2",
23
+ },
24
+ });
@@ -10,12 +10,14 @@ import {
10
10
  type CapabilityActionManifest,
11
11
  type CapabilityActionReviewResult,
12
12
  CapabilityActionReviewSchema,
13
+ type CapabilityCommandDefinition,
13
14
  type CapabilityCommandManifest,
14
15
  CapabilityCommandManifestSchema,
15
16
  type CapabilityDefinitions,
16
17
  type CapabilityError,
17
18
  CapabilityErrorSchema,
18
19
  type CapabilityExecutionContext,
20
+ type CapabilityFileProviderDeclaration,
19
21
  CapabilityIdempotencyKeySchema,
20
22
  type CapabilityInvocationResult,
21
23
  CapabilityLocalIdSchema,
@@ -26,15 +28,97 @@ import {
26
28
  type CapabilityPresentationTranslation,
27
29
  type CapabilityQueryDefinition,
28
30
  type CapabilityQueryManifest,
31
+ type CapabilityResourceTypeDefinition,
32
+ type CapabilitySearchTagDefinition,
33
+ type CapabilityStreamDefinition,
34
+ type CapabilityUniversalSearchDefinition,
29
35
  capabilityResultSchema,
30
36
  UniversalSearchDataSchema,
31
37
  UniversalSearchInputSchema,
32
38
  } from "../contracts/capabilities";
39
+ import { fileProviderIssues } from "../contracts/file-provider";
33
40
  import { canonicalLocale, localeFallbackChain, normalizeLocale } from "../shared/locale";
34
41
 
35
42
  type JsonSchema = Record<string, unknown>;
36
43
  const MAX_CAPABILITY_MANIFEST_BYTES = 256 * 1024;
37
44
 
45
+ /**
46
+ * The producer side of the shared manifest shape: an app registers exactly the fields and entries its
47
+ * release defines. Readers use `CapabilityManifestSchema`, which ignores what a newer release added.
48
+ */
49
+ const StrictCapabilityManifestSchema = CapabilityManifestSchema.out.strict();
50
+
51
+ /**
52
+ * Every declaration field this release defines, per kind. The compiler rejects any other field instead
53
+ * of dropping it: a declaration written for a newer release may restrict how an entry runs. Each list
54
+ * is typed as the complete key set, so a new declaration field does not compile until it is listed.
55
+ */
56
+ type DefinedFields<T> = Readonly<Record<keyof T, true>>;
57
+ const DECLARATION_FIELDS = {
58
+ protocolVersion: true,
59
+ presentation: true,
60
+ types: true,
61
+ queries: true,
62
+ actions: true,
63
+ commands: true,
64
+ fileProvider: true,
65
+ } satisfies DefinedFields<CapabilityDefinitions>;
66
+ const FILE_PROVIDER_FIELDS = { list: true, read: true, save: true } satisfies DefinedFields<CapabilityFileProviderDeclaration>;
67
+ const TYPE_FIELDS = { title: true, description: true, icon: true, reader: true } satisfies DefinedFields<CapabilityResourceTypeDefinition>;
68
+ const QUERY_FIELDS = {
69
+ title: true,
70
+ description: true,
71
+ input: true,
72
+ data: true,
73
+ openWorld: true,
74
+ stream: true,
75
+ universalSearch: true,
76
+ run: true,
77
+ } satisfies DefinedFields<CapabilityQueryDefinition>;
78
+ const ACTION_FIELDS = {
79
+ title: true,
80
+ description: true,
81
+ input: true,
82
+ data: true,
83
+ destructive: true,
84
+ openWorld: true,
85
+ stream: true,
86
+ idempotency: true,
87
+ approval: true,
88
+ review: true,
89
+ run: true,
90
+ } satisfies DefinedFields<CapabilityActionDefinition>;
91
+ const COMMAND_FIELDS = {
92
+ title: true,
93
+ description: true,
94
+ icon: true,
95
+ keywords: true,
96
+ input: true,
97
+ path: true,
98
+ } satisfies DefinedFields<CapabilityCommandDefinition>;
99
+ const UNIVERSAL_SEARCH_FIELDS = { tags: true, scopeTypes: true } satisfies DefinedFields<CapabilityUniversalSearchDefinition>;
100
+ const SEARCH_TAG_FIELDS = {
101
+ tag: true,
102
+ title: true,
103
+ description: true,
104
+ aliases: true,
105
+ } satisfies DefinedFields<CapabilitySearchTagDefinition>;
106
+ const READ_STREAM_FIELDS = { direction: true, maxBytes: true, read: true } satisfies DefinedFields<
107
+ Extract<CapabilityStreamDefinition, { direction: "read" }>
108
+ >;
109
+ const WRITE_STREAM_FIELDS = { direction: true, maxBytes: true, write: true, status: true, abort: true } satisfies DefinedFields<
110
+ Extract<CapabilityStreamDefinition, { direction: "write" }>
111
+ >;
112
+
113
+ const assertDefinedFields = (definition: object, fields: object, label: string): void => {
114
+ const field = Object.keys(definition).find((key) => !Object.hasOwn(fields, key));
115
+ if (field) throw new Error(`${label} contains unsupported field "${field}"`);
116
+ };
117
+
118
+ const assertDefinedStreamFields = (stream: CapabilityStreamDefinition | undefined, label: string): void => {
119
+ if (stream) assertDefinedFields(stream, stream.direction === "write" ? WRITE_STREAM_FIELDS : READ_STREAM_FIELDS, `${label} stream`);
120
+ };
121
+
38
122
  export type CompiledCapabilityQuery = {
39
123
  definition: CapabilityQueryDefinition;
40
124
  manifest: CapabilityQueryManifest;
@@ -194,6 +278,7 @@ const compileOperationPresentation = (
194
278
  value: unknown,
195
279
  operation: CapabilityQueryManifest | CapabilityActionManifest | CapabilityCommandManifest,
196
280
  label: string,
281
+ strict = true,
197
282
  ): CapabilityOperationPresentationTranslation => {
198
283
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
199
284
  const translation = value as Record<string, unknown>;
@@ -201,7 +286,7 @@ const compileOperationPresentation = (
201
286
  "path" in operation ? ["title", "description", "input"] : ["title", "description", "input", "data", "searchTags"],
202
287
  );
203
288
  const extra = Object.keys(translation).find((key) => !allowed.has(key));
204
- if (extra) throw new Error(`${label} contains unsupported field "${extra}"`);
289
+ if (strict && extra) throw new Error(`${label} contains unsupported field "${extra}"`);
205
290
  if (translation.title !== undefined) {
206
291
  if (typeof translation.title !== "string") throw new Error(`${label}.title must be text`);
207
292
  assertText(translation.title, `${label}.title`, 120);
@@ -211,12 +296,14 @@ const compileOperationPresentation = (
211
296
  assertText(translation.description, `${label}.description`, 1000);
212
297
  }
213
298
  let searchTags: CapabilityOperationPresentationTranslation["searchTags"];
214
- if (translation.searchTags !== undefined) {
215
- if (!("universalSearch" in operation) || !operation.universalSearch) throw new Error(`${label}.searchTags requires Universal Search`);
299
+ const universalSearch = "universalSearch" in operation ? operation.universalSearch : undefined;
300
+ // A reader skips search-tag copy for an operation without Universal Search instead of losing its other copy.
301
+ if (translation.searchTags !== undefined && (strict || universalSearch)) {
302
+ if (!universalSearch) throw new Error(`${label}.searchTags requires Universal Search`);
216
303
  if (!translation.searchTags || typeof translation.searchTags !== "object" || Array.isArray(translation.searchTags)) {
217
304
  throw new Error(`${label}.searchTags must be an object`);
218
305
  }
219
- const tags = new Map(operation.universalSearch.tags.map((tag) => [tag.tag, tag]));
306
+ const tags = new Map(universalSearch.tags.map((tag) => [tag.tag, tag]));
220
307
  searchTags = Object.fromEntries(
221
308
  Object.entries(translation.searchTags as Record<string, unknown>).map(([tag, presentation]) => {
222
309
  if (!tags.has(tag)) throw new Error(`${label}.searchTags references unknown stable tag "${tag}"`);
@@ -225,7 +312,7 @@ const compileOperationPresentation = (
225
312
  }
226
313
  const copy = presentation as Record<string, unknown>;
227
314
  const extraField = Object.keys(copy).find((key) => key !== "title" && key !== "description");
228
- if (extraField) throw new Error(`${label}.searchTags.${tag} contains unsupported field "${extraField}"`);
315
+ if (strict && extraField) throw new Error(`${label}.searchTags.${tag} contains unsupported field "${extraField}"`);
229
316
  if (copy.title !== undefined) {
230
317
  if (typeof copy.title !== "string") throw new Error(`${label}.searchTags.${tag}.title must be text`);
231
318
  assertText(copy.title, `${label}.searchTags.${tag}.title`, 120);
@@ -252,11 +339,21 @@ const compileOperationPresentation = (
252
339
  };
253
340
  };
254
341
 
255
- export const compileCapabilityPresentation = (manifest: CapabilityManifest, value: unknown): CapabilityPresentationCatalog | undefined => {
342
+ /**
343
+ * Compiles human presentation for one manifest. A producer must declare exactly what its release
344
+ * supports. A reader of a registered manifest ignores fields from a newer release and skips the
345
+ * entry translations it cannot apply, such as those of left-out entries; presentation never grants access.
346
+ */
347
+ export const compileCapabilityPresentation = (
348
+ manifest: CapabilityManifest,
349
+ value: unknown,
350
+ mode: "producer" | "reader" = "producer",
351
+ ): CapabilityPresentationCatalog | undefined => {
352
+ const strict = mode === "producer";
256
353
  if (value === undefined) return undefined;
257
354
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Capability presentation must be an object");
258
355
  const catalog = value as Record<string, unknown>;
259
- if (Object.keys(catalog).some((key) => key !== "baseLocale" && key !== "translations")) {
356
+ if (strict && Object.keys(catalog).some((key) => key !== "baseLocale" && key !== "translations")) {
260
357
  throw new Error("Capability presentation contains unsupported fields");
261
358
  }
262
359
  const baseLocale = typeof catalog.baseLocale === "string" ? canonicalLocale(catalog.baseLocale) : undefined;
@@ -279,7 +376,7 @@ export const compileCapabilityPresentation = (manifest: CapabilityManifest, valu
279
376
  }
280
377
  const raw = rawTranslation as Record<string, unknown>;
281
378
  const extra = Object.keys(raw).find((key) => key !== "types" && key !== "queries" && key !== "actions" && key !== "commands");
282
- if (extra) throw new Error(`Capability presentation translation ${canonical} contains unsupported field "${extra}"`);
379
+ if (strict && extra) throw new Error(`Capability presentation translation ${canonical} contains unsupported field "${extra}"`);
283
380
  const compileGroup = <T>(
284
381
  group: unknown,
285
382
  definitions: ReadonlyMap<string, T>,
@@ -289,10 +386,18 @@ export const compileCapabilityPresentation = (manifest: CapabilityManifest, valu
289
386
  if (group === undefined) return undefined;
290
387
  if (!group || typeof group !== "object" || Array.isArray(group)) throw new Error(`${kind} translations must be an object`);
291
388
  return Object.fromEntries(
292
- Object.entries(group as Record<string, unknown>).map(([localId, entry]) => {
389
+ Object.entries(group as Record<string, unknown>).flatMap(([localId, entry]) => {
293
390
  const definition = definitions.get(localId);
294
- if (!definition) throw new Error(`${kind} translation references unknown localId "${localId}"`);
295
- return [localId, compile(entry, definition, `${kind} ${localId}`)];
391
+ if (!definition) {
392
+ if (strict) throw new Error(`${kind} translation references unknown localId "${localId}"`);
393
+ return [];
394
+ }
395
+ try {
396
+ return [[localId, compile(entry, definition, `${kind} ${localId}`)]];
397
+ } catch (error) {
398
+ if (strict) throw error;
399
+ return [];
400
+ }
296
401
  }),
297
402
  ) as Readonly<Record<string, never>>;
298
403
  };
@@ -300,7 +405,7 @@ export const compileCapabilityPresentation = (manifest: CapabilityManifest, valu
300
405
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw new Error(`${label} must be an object`);
301
406
  const copy = entry as Record<string, unknown>;
302
407
  const extraField = Object.keys(copy).find((key) => key !== "title" && key !== "description");
303
- if (extraField) throw new Error(`${label} contains unsupported field "${extraField}"`);
408
+ if (strict && extraField) throw new Error(`${label} contains unsupported field "${extraField}"`);
304
409
  if (copy.title !== undefined) {
305
410
  if (typeof copy.title !== "string") throw new Error(`${label}.title must be text`);
306
411
  assertText(copy.title, `${label}.title`, 120);
@@ -314,9 +419,11 @@ export const compileCapabilityPresentation = (manifest: CapabilityManifest, valu
314
419
  ...(copy.description ? { description: String(copy.description).trim() } : {}),
315
420
  };
316
421
  });
317
- const translatedQueries = compileGroup(raw.queries, queries, "Query", compileOperationPresentation);
318
- const translatedActions = compileGroup(raw.actions, actions, "Action", compileOperationPresentation);
319
- const translatedCommands = compileGroup(raw.commands, commands, "Command", compileOperationPresentation);
422
+ const operationPresentation = (entry: unknown, operation: Parameters<typeof compileOperationPresentation>[1], label: string) =>
423
+ compileOperationPresentation(entry, operation, label, strict);
424
+ const translatedQueries = compileGroup(raw.queries, queries, "Query", operationPresentation);
425
+ const translatedActions = compileGroup(raw.actions, actions, "Action", operationPresentation);
426
+ const translatedCommands = compileGroup(raw.commands, commands, "Command", operationPresentation);
320
427
  translations[canonical] = {
321
428
  ...(translatedTypes ? { types: translatedTypes } : {}),
322
429
  ...(translatedQueries ? { queries: translatedQueries } : {}),
@@ -394,8 +501,10 @@ const assertLocalId = (localId: string, label: string): void => {
394
501
 
395
502
  const normalizeSearchTags = (definition: CapabilityQueryDefinition, label: string) => {
396
503
  if (!definition.universalSearch) return undefined;
504
+ assertDefinedFields(definition.universalSearch, UNIVERSAL_SEARCH_FIELDS, `${label} universalSearch`);
397
505
  const seen = new Set<string>();
398
506
  const tags = definition.universalSearch.tags.map((tag) => {
507
+ assertDefinedFields(tag, SEARCH_TAG_FIELDS, `${label} search tag ${tag.tag}`);
399
508
  assertText(tag.tag, `${label} search tag`, 64);
400
509
  assertText(tag.title, `${label} search tag title`, 120);
401
510
  assertText(tag.description, `${label} search tag description`, 500);
@@ -445,6 +554,8 @@ export const compileCapabilities = (appId: string, definitions: CapabilityDefini
445
554
  if (definitions.protocolVersion !== CAPABILITY_PROTOCOL_VERSION) {
446
555
  throw new Error(`Unsupported capability protocol version ${String(definitions.protocolVersion)}`);
447
556
  }
557
+ assertDefinedFields(definitions, DECLARATION_FIELDS, "Capability declaration");
558
+ if (definitions.fileProvider) assertDefinedFields(definitions.fileProvider, FILE_PROVIDER_FIELDS, "fileProvider");
448
559
 
449
560
  const localIds = new Set<string>();
450
561
  const registerLocalId = (localId: string, kind: string): void => {
@@ -457,6 +568,7 @@ export const compileCapabilities = (appId: string, definitions: CapabilityDefini
457
568
  .sort(([left], [right]) => left.localeCompare(right))
458
569
  .map(([localId, definition]) => {
459
570
  registerLocalId(localId, "Resource type");
571
+ assertDefinedFields(definition, TYPE_FIELDS, `Resource type ${localId}`);
460
572
  assertText(definition.title, `Resource type ${localId} title`, 120);
461
573
  assertText(definition.description, `Resource type ${localId} description`, 500);
462
574
  return {
@@ -473,6 +585,8 @@ export const compileCapabilities = (appId: string, definitions: CapabilityDefini
473
585
  for (const [localId, definition] of Object.entries(definitions.queries ?? {}).sort(([left], [right]) => left.localeCompare(right))) {
474
586
  registerLocalId(localId, "Query");
475
587
  const label = `Query ${localId}`;
588
+ assertDefinedFields(definition, QUERY_FIELDS, label);
589
+ assertDefinedStreamFields(definition.stream, label);
476
590
  if (
477
591
  definition.stream &&
478
592
  ((definition.stream.direction === "write" && !("idempotency" in definition)) || definition.stream.maxBytes <= 0)
@@ -521,6 +635,8 @@ export const compileCapabilities = (appId: string, definitions: CapabilityDefini
521
635
  for (const [localId, definition] of Object.entries(definitions.actions ?? {}).sort(([left], [right]) => left.localeCompare(right))) {
522
636
  registerLocalId(localId, "Action");
523
637
  const label = `Action ${localId}`;
638
+ assertDefinedFields(definition, ACTION_FIELDS, label);
639
+ assertDefinedStreamFields(definition.stream, label);
524
640
  // Declaration rule, documented in docs/platform/capabilities.md:
525
641
  // destructive means irreversible or externally visible. An irreversible or
526
642
  // open-world effect must always be reviewable and safely repeatable, and
@@ -582,6 +698,7 @@ export const compileCapabilities = (appId: string, definitions: CapabilityDefini
582
698
  .sort(([a], [b]) => a.localeCompare(b))
583
699
  .map(([localId, definition]) => {
584
700
  registerLocalId(localId, "Command");
701
+ assertDefinedFields(definition, COMMAND_FIELDS, `Command ${localId}`);
585
702
  const inputSchema = projectSchema(definition.input, `Command ${localId} input`, "input");
586
703
  assertClosedObjectInput(inputSchema, `Command ${localId} input`);
587
704
  return CapabilityCommandManifestSchema.parse({
@@ -595,6 +712,7 @@ export const compileCapabilities = (appId: string, definitions: CapabilityDefini
595
712
  });
596
713
  });
597
714
 
715
+ const fileProvider = definitions.fileProvider;
598
716
  const manifestBase = {
599
717
  protocolVersion: CAPABILITY_PROTOCOL_VERSION,
600
718
  appId,
@@ -602,11 +720,23 @@ export const compileCapabilities = (appId: string, definitions: CapabilityDefini
602
720
  queries: [...queries.values()].map((entry) => entry.manifest),
603
721
  actions: [...actions.values()].map((entry) => entry.manifest),
604
722
  commands,
723
+ // Omitted when unused so manifests without a provider keep their earlier hash.
724
+ ...(fileProvider
725
+ ? { fileProvider: { list: fileProvider.list, read: fileProvider.read, ...(fileProvider.save ? { save: fileProvider.save } : {}) } }
726
+ : {}),
605
727
  };
606
- const manifest = CapabilityManifestSchema.parse({
728
+ const manifest = StrictCapabilityManifestSchema.parse({
607
729
  ...manifestBase,
608
730
  manifestHash: capabilityHash(manifestBase),
609
731
  });
732
+ const providerIssues = fileProviderIssues(manifest);
733
+ if (providerIssues.length > 0) {
734
+ throw new Error(
735
+ `fileProvider does not match the file-provider contract:\n- ${providerIssues
736
+ .map((issue) => `${issue.function} (${issue.localId}) ${issue.path}: ${issue.message}`)
737
+ .join("\n- ")}`,
738
+ );
739
+ }
610
740
  const manifestBytes = new TextEncoder().encode(JSON.stringify(manifest)).byteLength;
611
741
  if (manifestBytes > MAX_CAPABILITY_MANIFEST_BYTES) {
612
742
  throw new Error(`Capability manifest exceeds the ${MAX_CAPABILITY_MANIFEST_BYTES}-byte registry limit`);
@@ -719,77 +849,102 @@ export const resolveCapabilityManifestPresentation = (
719
849
  }
720
850
  : operation;
721
851
  }),
852
+ ...(current.fileProvider ? { fileProvider: current.fileProvider } : {}),
722
853
  };
723
854
  current = CapabilityManifestSchema.parse({ ...manifestBase, manifestHash: capabilityHash(manifestBase) });
724
855
  }
725
856
  return current;
726
857
  };
727
858
 
728
- /** Validates an untrusted live manifest and recomputes every integrity hash. */
729
- export const parseCapabilityManifest = (value: unknown, expectedAppId: string): CapabilityManifest => {
730
- const manifest = CapabilityManifestSchema.parse(value);
731
- if (manifest.appId !== expectedAppId) throw new Error(`manifest appId must be ${expectedAppId}`);
859
+ const passes = (check: () => void): boolean => {
860
+ try {
861
+ check();
862
+ return true;
863
+ } catch {
864
+ return false;
865
+ }
866
+ };
867
+
868
+ /** Checks what the manifest schema cannot: that this release can call the operation as described. */
869
+ const assertUsableOperation = (operation: CapabilityQueryManifest | CapabilityActionManifest): void => {
870
+ try {
871
+ z.fromJSONSchema(structuredClone(operation.inputSchema));
872
+ z.fromJSONSchema(structuredClone(operation.dataSchema));
873
+ } catch (error) {
874
+ throw new Error(
875
+ `Operation ${operation.localId} contains unsupported JSON Schema: ${error instanceof Error ? error.message : String(error)}`,
876
+ );
877
+ }
878
+ assertClosedObjectInput(operation.inputSchema, `Operation ${operation.localId} input`);
879
+ if (operation.stream?.direction === "write" && (!("idempotency" in operation) || operation.idempotency !== "required")) {
880
+ throw new Error(`Operation ${operation.localId} write streams require an idempotent Action`);
881
+ }
882
+ const expectedSchemaHash = capabilityHash({
883
+ inputSchema: operation.inputSchema,
884
+ dataSchema: operation.dataSchema,
885
+ ...(operation.stream ? { stream: operation.stream } : {}),
886
+ });
887
+ if (operation.schemaHash !== expectedSchemaHash) throw new Error(`Operation ${operation.localId} schemaHash does not match its schemas`);
888
+ if ("universalSearch" in operation && operation.universalSearch) {
889
+ const expectedInput = projectSchema(UniversalSearchInputSchema, "Universal Search input", "input");
890
+ const expectedData = projectSchema(UniversalSearchDataSchema, "Universal Search data", "output");
891
+ if (
892
+ capabilityHash(operation.inputSchema) !== capabilityHash(expectedInput) ||
893
+ capabilityHash(operation.dataSchema) !== capabilityHash(expectedData)
894
+ ) {
895
+ throw new Error(`Operation ${operation.localId} advertises Universal Search with non-canonical schemas`);
896
+ }
897
+ }
898
+ };
732
899
 
900
+ const assertUsableCommand = (command: CapabilityCommandManifest): void => {
901
+ z.fromJSONSchema(structuredClone(command.inputSchema));
902
+ assertClosedObjectInput(command.inputSchema, `Command ${command.localId} input`);
903
+ };
904
+
905
+ /**
906
+ * Validates an untrusted live manifest and recomputes every integrity hash.
907
+ *
908
+ * The manifest as a whole must be intact: the app id, unique local IDs, and the manifest hash. That
909
+ * hash is checked against what the app sent, including everything ignored here, and stays the
910
+ * manifest's identity: the app's registry summary still matches, so the app is not treated as changed
911
+ * and a manifest without newer fields keeps its hash.
912
+ *
913
+ * Entries read like every reader reads them (`CapabilityManifestSchema`). An operation or Command this
914
+ * release cannot use as described, for example because it cannot read its JSON Schema, is left out the
915
+ * same way, together with what depends on it, and a Type whose reader Query does not take a single `id`
916
+ * keeps no reader. The app's other entries stay available.
917
+ */
918
+ export const parseCapabilityManifest = (value: unknown, expectedAppId: string): CapabilityManifest => {
919
+ const sent = CapabilityManifestSchema.parse(value);
920
+ if (sent.appId !== expectedAppId) throw new Error(`manifest appId must be ${expectedAppId}`);
733
921
  const localIds = new Set<string>();
734
- const registerLocalId = (localId: string, kind: string): void => {
922
+ for (const { localId } of [...sent.types, ...sent.queries, ...sent.actions, ...sent.commands]) {
735
923
  if (localIds.has(localId)) throw new Error(`localId ${localId} is declared more than once across Types, Queries, and Actions`);
736
924
  localIds.add(localId);
737
- assertLocalId(localId, kind);
738
- };
739
- for (const type of manifest.types) registerLocalId(type.localId, "Resource type");
740
- for (const operation of [...manifest.queries, ...manifest.actions]) {
741
- registerLocalId(operation.localId, "Operation");
742
- const inputSchema = structuredClone(operation.inputSchema);
743
- const dataSchema = structuredClone(operation.dataSchema);
744
- try {
745
- z.fromJSONSchema(inputSchema);
746
- z.fromJSONSchema(dataSchema);
747
- } catch (error) {
748
- throw new Error(
749
- `Operation ${operation.localId} contains unsupported JSON Schema: ${error instanceof Error ? error.message : String(error)}`,
750
- );
751
- }
752
- assertClosedObjectInput(inputSchema, `Operation ${operation.localId} input`);
753
- if (operation.stream?.direction === "write" && (!("idempotency" in operation) || operation.idempotency !== "required")) {
754
- throw new Error(`Operation ${operation.localId} write streams require an idempotent Action`);
755
- }
756
- const expectedSchemaHash = capabilityHash({
757
- inputSchema: operation.inputSchema,
758
- dataSchema: operation.dataSchema,
759
- ...(operation.stream ? { stream: operation.stream } : {}),
760
- });
761
- if (operation.schemaHash !== expectedSchemaHash)
762
- throw new Error(`Operation ${operation.localId} schemaHash does not match its schemas`);
763
- if ("universalSearch" in operation && operation.universalSearch) {
764
- for (const type of operation.universalSearch.scopeTypes ?? []) {
765
- if (!manifest.types.some((entry) => entry.localId === type)) throw new Error(`Unknown search scope type ${type}`);
766
- }
767
- const expectedInput = projectSchema(UniversalSearchInputSchema, "Universal Search input", "input");
768
- const expectedData = projectSchema(UniversalSearchDataSchema, "Universal Search data", "output");
769
- if (
770
- capabilityHash(operation.inputSchema) !== capabilityHash(expectedInput) ||
771
- capabilityHash(operation.dataSchema) !== capabilityHash(expectedData)
772
- ) {
773
- throw new Error(`Operation ${operation.localId} advertises Universal Search with non-canonical schemas`);
774
- }
775
- }
776
- }
777
- for (const command of manifest.commands) {
778
- registerLocalId(command.localId, "Command");
779
- z.fromJSONSchema(structuredClone(command.inputSchema));
780
- assertClosedObjectInput(command.inputSchema, `Command ${command.localId} input`);
781
- }
782
- const queries = new Map(manifest.queries.map((query) => [query.localId, query]));
783
- for (const type of manifest.types) {
784
- if (!type.reader) continue;
785
- const reader = queries.get(type.reader);
786
- if (!reader) throw new Error(`Resource type ${type.localId} reader ${type.reader} must name an existing Query`);
787
- assertCanonicalReaderInput(reader.inputSchema, `Resource type ${type.localId} reader ${type.reader} input`);
788
925
  }
926
+ // The schema accepted `value`, so it is an object; hash all of it, not only what this release read.
927
+ const { manifestHash: _sentHash, ...sentBase } = value as Record<string, unknown>;
928
+ if (sent.manifestHash !== capabilityHash(sentBase)) throw new Error("manifestHash does not match the manifest");
789
929
 
790
- const { manifestHash: _manifestHash, ...manifestBase } = manifest;
791
- if (manifest.manifestHash !== capabilityHash(manifestBase)) throw new Error("manifestHash does not match the manifest");
792
- return manifest;
930
+ // Reading the usable entries again leaves out what depends on an entry left out here.
931
+ const manifest = CapabilityManifestSchema.parse({
932
+ ...sent,
933
+ queries: sent.queries.filter((query) => passes(() => assertUsableOperation(query))),
934
+ actions: sent.actions.filter((action) => passes(() => assertUsableOperation(action))),
935
+ commands: sent.commands.filter((command) => passes(() => assertUsableCommand(command))),
936
+ });
937
+ const queries = new Map(manifest.queries.map((query) => [query.localId, query]));
938
+ return {
939
+ ...manifest,
940
+ types: manifest.types.map((type) => {
941
+ const reader = type.reader === undefined ? undefined : queries.get(type.reader);
942
+ if (!reader || passes(() => assertCanonicalReaderInput(reader.inputSchema, `Resource type ${type.localId} reader input`)))
943
+ return type;
944
+ const { reader: _unusable, ...withoutReader } = type;
945
+ return withoutReader;
946
+ }),
947
+ };
793
948
  };
794
949
 
795
950
  const schemaSemantics = (value: unknown): unknown => {