@k2b/cloud 0.23.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 (45) hide show
  1. package/package.json +2 -2
  2. package/src/_internal/capabilities.ts +230 -75
  3. package/src/_internal/define-app.ts +12 -3
  4. package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
  5. package/src/_internal/page-responses.ts +1 -1
  6. package/src/_internal/registry.ts +78 -3
  7. package/src/ai/chat/blocks.tsx +2 -1
  8. package/src/ai/chat/messages.ts +6 -0
  9. package/src/ai/client/controller.ts +65 -20
  10. package/src/ai/client/transport.ts +5 -5
  11. package/src/ai/code-mode-skill.ts +2 -2
  12. package/src/ai/index.ts +0 -7
  13. package/src/ai/live-events.ts +1 -262
  14. package/src/ai/live.ts +31 -2
  15. package/src/ai/migrate.ts +35 -16
  16. package/src/ai/runtime.ts +1 -6
  17. package/src/ai/solid.ts +0 -5
  18. package/src/api/help.ts +2 -2
  19. package/src/api/pwa-phone.ts +10 -3
  20. package/src/api/search/schemas.ts +34 -0
  21. package/src/api/search.ts +118 -50
  22. package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
  23. package/src/browser/CloudResourceSearch.tsx +208 -80
  24. package/src/browser/resource-search-messages.ts +15 -2
  25. package/src/browser/search-stream.ts +151 -0
  26. package/src/contracts/capabilities.ts +89 -24
  27. package/src/contracts/capability-compatibility.ts +28 -14
  28. package/src/contracts/file-provider.ts +158 -0
  29. package/src/contracts/index.ts +1 -0
  30. package/src/events/live-engine.ts +11 -7
  31. package/src/events/live.ts +32 -32
  32. package/src/server/help.ts +3 -3
  33. package/src/services/index.ts +1 -1
  34. package/src/services/outbox.ts +29 -115
  35. package/src/services/pdf/markdown.ts +22 -2
  36. package/src/services/pwa-devices.ts +74 -8
  37. package/src/shared/markdown/formula.ts +122 -26
  38. package/src/shared/markdown/index.ts +27 -14
  39. package/src/ssr/PwaLayout.tsx +2 -1
  40. package/src/styles/resource-search.css +19 -0
  41. package/src/ai/client/live-connection.ts +0 -141
  42. package/src/ai/live-messages.ts +0 -45
  43. package/src/ai/live-outbox.ts +0 -102
  44. package/src/ai/live-routes.ts +0 -412
  45. package/src/shared/markdown/extensions/info-blocks.ts +0 -79
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.23.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": {
@@ -99,7 +99,7 @@
99
99
  "@tailwindcss/typography": "0.5.20",
100
100
  "@k2b/nessi": "0.12.1",
101
101
  "@k2b/ssr": "0.14.0",
102
- "@k2b/ui": "0.13.0",
102
+ "@k2b/ui": "0.14.0",
103
103
  "@k2b/stdlib": "0.27.0",
104
104
  "@k2b/sync": "7.0.0",
105
105
  "@nats-io/jetstream": "3.4.0",
@@ -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 => {
@@ -62,6 +62,7 @@ import { normalizeInvocationRequestId } from "../services/identity/invocation-to
62
62
  import { logger } from "../services/logging";
63
63
  import { startNotificationDefinitionRegistration } from "../services/notifications/catalog";
64
64
  import { readRailSnapshot } from "../services/rail-snapshot";
65
+ import { isAppPagePath } from "../services/session";
65
66
  import { get, loadCache as loadSettingsCache, set } from "../services/settings";
66
67
  import { createSettingsAPI, type SettingsAPI } from "../services/settings/api";
67
68
  import { registerSettings, toLegacySettingDefs } from "../services/settings/defaults";
@@ -455,6 +456,9 @@ export const defineApp = <
455
456
  const user = c.get("user");
456
457
  c.get("page").performanceRoute =
457
458
  user && c.get("settings")?.observability?.web_vitals?.enabled === true ? (matchedRouteTemplate(c) ?? undefined) : undefined;
459
+ // Pages of the mobile app render in PwaLayout, which shows no announcements, Help, or rail; reading them would
460
+ // only delay every tab switch. Core's pages below /pwa/_auth, such as its not-found page, keep the web layout.
461
+ if (isAppPagePath(c.req.path)) return;
458
462
  await Promise.all([
459
463
  preloadLayoutAnnouncements(c),
460
464
  preloadLayoutHelp(c),
@@ -882,14 +886,19 @@ export const defineApp = <
882
886
  const stopLifecycle = startOpts.lifecycle?.stop;
883
887
  if (stopLifecycle) cleanup.push(() => stopLifecycle(cloudCtx));
884
888
 
885
- // Fails before any setup when Core has not created the platform outbox yet.
886
- const stopLiveOutbox = await startLiveOutbox(meta.id);
887
- if (stopLiveOutbox) cleanup.push(stopLiveOutbox);
889
+ const startOutbox = async () => {
890
+ const stopLiveOutbox = await startLiveOutbox(meta.id);
891
+ if (stopLiveOutbox) cleanup.push(stopLiveOutbox);
892
+ };
893
+ // An application fails before any setup when Core has not created the platform outbox yet.
894
+ // Core creates it in its own setup, so it starts publishing after that.
895
+ if (meta.id !== "core") await startOutbox();
888
896
 
889
897
  if (!startOpts.skipSetup && startOpts.lifecycle?.setup) {
890
898
  log.info(`Setup: ${meta.id}`);
891
899
  await startOpts.lifecycle.setup(cloudCtx);
892
900
  }
901
+ if (meta.id === "core") await startOutbox();
893
902
 
894
903
  stopNotificationRegistration = await startNotificationDefinitionRegistration(meta.id, notifications);
895
904