@k2b/cloud 0.23.0 → 0.25.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.
- package/package.json +3 -2
- package/src/_internal/capabilities.ts +230 -75
- package/src/_internal/define-app.ts +12 -3
- package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
- package/src/_internal/page-responses.ts +1 -1
- package/src/_internal/registry.ts +78 -3
- package/src/access/PermissionEditor.tsx +32 -11
- package/src/access/managers.ts +32 -0
- package/src/access/messages.ts +6 -0
- package/src/ai/chat/blocks.tsx +2 -1
- package/src/ai/chat/messages.ts +6 -0
- package/src/ai/client/controller.ts +65 -20
- package/src/ai/client/transport.ts +5 -5
- package/src/ai/code-mode-skill.ts +2 -2
- package/src/ai/index.ts +0 -7
- package/src/ai/live-events.ts +1 -262
- package/src/ai/live.ts +31 -2
- package/src/ai/memory-learning.ts +7 -12
- package/src/ai/memory-workflow-evidence.ts +10 -2
- package/src/ai/migrate.ts +80 -18
- package/src/ai/model-access.ts +4 -2
- package/src/ai/prefs.ts +19 -5
- package/src/ai/projects.ts +5 -2
- package/src/ai/runtime.ts +1 -6
- package/src/ai/skills.ts +5 -2
- package/src/ai/solid.ts +0 -5
- package/src/ai/store.ts +14 -0
- package/src/api/help.ts +2 -2
- package/src/api/pwa-phone.ts +10 -3
- package/src/api/search/schemas.ts +34 -0
- package/src/api/search.ts +118 -50
- package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
- package/src/browser/CloudResourceSearch.tsx +208 -80
- package/src/browser/FileChooser.tsx +600 -0
- package/src/browser/choose-files.browser-harness.tsx +32 -0
- package/src/browser/choose-files.tsx +70 -0
- package/src/browser/file-chooser-messages.ts +86 -0
- package/src/browser/file-providers.ts +217 -0
- package/src/browser/files.tsx +23 -0
- package/src/browser/resource-search-messages.ts +15 -2
- package/src/browser/search-stream.ts +151 -0
- package/src/cli/admin/account-administration.ts +2 -2
- package/src/contracts/capabilities.ts +89 -24
- package/src/contracts/capability-compatibility.ts +28 -14
- package/src/contracts/file-provider.ts +158 -0
- package/src/contracts/index.ts +1 -0
- package/src/events/live-engine.ts +11 -7
- package/src/events/live.ts +32 -32
- package/src/server/help.ts +3 -3
- package/src/server/index.ts +1 -0
- package/src/server/services/access.ts +44 -11
- package/src/server/services/index.ts +1 -0
- package/src/services/index.ts +1 -1
- package/src/services/outbox.ts +29 -115
- package/src/services/pdf/markdown.ts +22 -2
- package/src/services/pwa-devices.ts +74 -8
- package/src/shared/markdown/formula.ts +122 -26
- package/src/shared/markdown/index.ts +27 -14
- package/src/ssr/PwaLayout.tsx +2 -1
- package/src/ssr/platform-messages.ts +2 -2
- package/src/styles/file-chooser.css +169 -0
- package/src/styles/global.css +1 -0
- package/src/styles/resource-search.css +19 -0
- package/src/ai/client/live-connection.ts +0 -141
- package/src/ai/live-messages.ts +0 -45
- package/src/ai/live-outbox.ts +0 -102
- package/src/ai/live-routes.ts +0 -412
- 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.
|
|
3
|
+
"version": "0.25.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": {
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"./browser/search": "./src/browser/search.ts",
|
|
40
40
|
"./browser/testing": "./src/browser/testing.ts",
|
|
41
41
|
"./browser/resource-picker": "./src/browser/resource-picker.tsx",
|
|
42
|
+
"./browser/files": "./src/browser/files.tsx",
|
|
42
43
|
"./capabilities": "./src/capabilities/client.ts",
|
|
43
44
|
"./capabilities/server": "./src/capabilities/server.ts",
|
|
44
45
|
"./capabilities/store": "./src/capabilities/executions.ts",
|
|
@@ -99,7 +100,7 @@
|
|
|
99
100
|
"@tailwindcss/typography": "0.5.20",
|
|
100
101
|
"@k2b/nessi": "0.12.1",
|
|
101
102
|
"@k2b/ssr": "0.14.0",
|
|
102
|
-
"@k2b/ui": "0.
|
|
103
|
+
"@k2b/ui": "0.15.0",
|
|
103
104
|
"@k2b/stdlib": "0.27.0",
|
|
104
105
|
"@k2b/sync": "7.0.0",
|
|
105
106
|
"@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
|
-
|
|
215
|
-
|
|
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(
|
|
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
|
-
|
|
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>).
|
|
389
|
+
Object.entries(group as Record<string, unknown>).flatMap(([localId, entry]) => {
|
|
293
390
|
const definition = definitions.get(localId);
|
|
294
|
-
if (!definition)
|
|
295
|
-
|
|
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
|
|
318
|
-
|
|
319
|
-
const
|
|
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 =
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
|
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
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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
|
|