@frockbot/kernel-contracts 0.3.1 → 0.3.2
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 +4 -2
- package/src/applets.test.ts +299 -0
- package/src/applets.ts +902 -0
- package/src/authoring.test.ts +47 -0
- package/src/authoring.ts +57 -21
- package/src/contributions.ts +102 -0
- package/src/iframe-ui.test.ts +238 -37
- package/src/iframe-ui.ts +430 -89
- package/src/index.ts +2 -0
- package/src/isolate-context-catalog.generated.ts +3 -2
- package/src/isolate.ts +98 -0
- package/src/types.ts +10 -3
package/src/iframe-ui.ts
CHANGED
|
@@ -1,46 +1,177 @@
|
|
|
1
|
+
import {
|
|
2
|
+
APPLET_ID_V1,
|
|
3
|
+
type AppletBuildViewV1,
|
|
4
|
+
type AppletSummaryV1,
|
|
5
|
+
} from "./applets.js";
|
|
6
|
+
|
|
1
7
|
/** Versioned, deliberately tiny postMessage seam for sandboxed Package pages. */
|
|
2
|
-
export const PACKAGE_IFRAME_BRIDGE_VERSION =
|
|
8
|
+
export const PACKAGE_IFRAME_BRIDGE_VERSION = 2 as const;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The bridge a page speaks.
|
|
12
|
+
*
|
|
13
|
+
* Version 1 is `init`, `state`, `callTool` and `resize`. Version 2 adds the
|
|
14
|
+
* `applets` state feed and the `hello`, `focus` and `openExternal` page
|
|
15
|
+
* messages. A page announces its version with `hello`; a page that never does
|
|
16
|
+
* is a v1 page and only ever receives `schemaVersion: 1` messages, so a page
|
|
17
|
+
* published against v1 keeps working with no change.
|
|
18
|
+
*/
|
|
19
|
+
export type PackageIframeBridgeVersionV2 = 1 | 2;
|
|
3
20
|
|
|
4
|
-
export type
|
|
21
|
+
export type PackageIframeHostMessageV2 =
|
|
5
22
|
| {
|
|
6
|
-
schemaVersion:
|
|
23
|
+
schemaVersion: PackageIframeBridgeVersionV2;
|
|
7
24
|
type: "init";
|
|
8
25
|
themeTokens: Record<string, string>;
|
|
9
26
|
packageId: string;
|
|
10
27
|
botId: string;
|
|
11
28
|
slot: string;
|
|
29
|
+
/** Manifest v5: which of the Package's pages this frame is showing. */
|
|
30
|
+
pageId?: string;
|
|
12
31
|
}
|
|
13
32
|
| {
|
|
14
|
-
schemaVersion:
|
|
33
|
+
schemaVersion: PackageIframeBridgeVersionV2;
|
|
15
34
|
type: "state";
|
|
16
35
|
name: string;
|
|
17
36
|
value: unknown;
|
|
18
37
|
};
|
|
19
38
|
|
|
20
|
-
export type
|
|
39
|
+
export type PackageIframePageMessageV2 =
|
|
21
40
|
| {
|
|
22
|
-
schemaVersion:
|
|
41
|
+
schemaVersion: PackageIframeBridgeVersionV2;
|
|
23
42
|
type: "callTool";
|
|
24
43
|
name: string;
|
|
25
44
|
input: unknown;
|
|
26
45
|
}
|
|
27
46
|
| {
|
|
28
|
-
schemaVersion:
|
|
47
|
+
schemaVersion: PackageIframeBridgeVersionV2;
|
|
29
48
|
type: "resize";
|
|
30
49
|
height: number;
|
|
50
|
+
}
|
|
51
|
+
/** v2: the page announcing which bridge it speaks, once, on load. */
|
|
52
|
+
| {
|
|
53
|
+
schemaVersion: 2;
|
|
54
|
+
type: "hello";
|
|
55
|
+
bridgeVersion: PackageIframeBridgeVersionV2;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* v2: focus one Applet for this Session, or clear the focus. Allowed only
|
|
59
|
+
* for a page of the Package that declares the Applet focus tool; the host
|
|
60
|
+
* gates on that declaration and the backend gates the route again.
|
|
61
|
+
*/
|
|
62
|
+
| {
|
|
63
|
+
schemaVersion: 2;
|
|
64
|
+
type: "focus";
|
|
65
|
+
appletId: string | null;
|
|
66
|
+
}
|
|
67
|
+
/** v2: open a URL on the Package artifact origin in a new tab. */
|
|
68
|
+
| {
|
|
69
|
+
schemaVersion: 2;
|
|
70
|
+
type: "openExternal";
|
|
71
|
+
url: string;
|
|
31
72
|
};
|
|
32
73
|
|
|
74
|
+
/** The tool a Package declares before its pages may change the focused Applet. */
|
|
75
|
+
export const PACKAGE_IFRAME_FOCUS_TOOL_V2 = "applet_focus";
|
|
76
|
+
|
|
77
|
+
/** The host state name that carries the Applets feed to a v2 page. */
|
|
78
|
+
export const PACKAGE_IFRAME_APPLETS_STATE_V2 = "applets";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What a v2 page receives on the `applets` state.
|
|
82
|
+
*
|
|
83
|
+
* The viewer credential is short-lived and scoped to one Applet generation, so
|
|
84
|
+
* a page holding one past its expiry reconnects; nothing here is durable
|
|
85
|
+
* state, and nothing here is an authority.
|
|
86
|
+
*/
|
|
87
|
+
export interface PackageIframeAppletsStateV2 {
|
|
88
|
+
focused: AppletSummaryV1 | null;
|
|
89
|
+
list: AppletSummaryV1[];
|
|
90
|
+
viewer: {
|
|
91
|
+
token: string;
|
|
92
|
+
socketUrl: string;
|
|
93
|
+
uiUrl: string;
|
|
94
|
+
generationId: string;
|
|
95
|
+
} | null;
|
|
96
|
+
/**
|
|
97
|
+
* The last check or build outcome. The source itself is not on the feed:
|
|
98
|
+
* the shell renders the code view natively, and a source tree is far larger
|
|
99
|
+
* than the 64 KB a bridge message may carry.
|
|
100
|
+
*/
|
|
101
|
+
build?: AppletBuildViewV1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The only entry slot in this slice. */
|
|
105
|
+
export const PACKAGE_IFRAME_ENTRY_SLOT_V1 = "frockbot.sidebar-actions";
|
|
106
|
+
|
|
107
|
+
/** Page and entry ids share one shape; both are Package-scoped. */
|
|
108
|
+
export const PACKAGE_IFRAME_ID_V1 = /^[a-z][a-z0-9-]{0,31}$/;
|
|
109
|
+
|
|
110
|
+
export const PACKAGE_IFRAME_MAX_PAGES_V1 = 8;
|
|
111
|
+
export const PACKAGE_IFRAME_MAX_ENTRIES_V1 = 4;
|
|
112
|
+
export const PACKAGE_IFRAME_ENTRY_LABEL_MAX_V1 = 32;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The one slot rule for an iframe page mount, shared by the manifest decoder,
|
|
116
|
+
* the catalog decoder, and the `package_author` input decoder. A slot a
|
|
117
|
+
* Package could not declare in its manifest must not reach a host through any
|
|
118
|
+
* other seam, so there is exactly one predicate rather than three allowlists.
|
|
119
|
+
*/
|
|
120
|
+
export function iframePageSlotAllowedV1(
|
|
121
|
+
slot: string,
|
|
122
|
+
context: { declaredTools: readonly string[]; pageIds: readonly string[] },
|
|
123
|
+
): boolean {
|
|
124
|
+
if (slot === "frockbot.bot-settings-sections") return true;
|
|
125
|
+
if (slot === "frockbot.right-panel") return true;
|
|
126
|
+
const toolResult = "frockbot.tool-result:";
|
|
127
|
+
if (slot.startsWith(toolResult)) {
|
|
128
|
+
return context.declaredTools.includes(slot.slice(toolResult.length));
|
|
129
|
+
}
|
|
130
|
+
const surface = "frockbot.surface:";
|
|
131
|
+
if (slot.startsWith(surface)) {
|
|
132
|
+
return context.pageIds.includes(slot.slice(surface.length));
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface PackageIframeArtifactViewV1 {
|
|
138
|
+
contentHash: string;
|
|
139
|
+
size: number;
|
|
140
|
+
mediaType: "text/html";
|
|
141
|
+
bundlerVersion: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface PackageIframePageViewV1 {
|
|
145
|
+
id: string;
|
|
146
|
+
artifact: PackageIframeArtifactViewV1;
|
|
147
|
+
mounts: Array<{ slot: string; order?: number }>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface PackageIframeEntryViewV1 {
|
|
151
|
+
id: string;
|
|
152
|
+
slot: "frockbot.sidebar-actions";
|
|
153
|
+
order?: number;
|
|
154
|
+
label: string;
|
|
155
|
+
icon: string;
|
|
156
|
+
opens: { kind: "surface"; page: string };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export type PackageIframeProvenanceV1 =
|
|
160
|
+
"Bot-authored" | "User-installed" | "FrockBot";
|
|
161
|
+
|
|
162
|
+
export const PACKAGE_IFRAME_PROVENANCES_V1: readonly PackageIframeProvenanceV1[] =
|
|
163
|
+
["Bot-authored", "User-installed", "FrockBot"];
|
|
164
|
+
|
|
33
165
|
export interface PackageIframeContributionViewV1 {
|
|
34
166
|
packageId: string;
|
|
35
167
|
displayName: string;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
mounts: Array<{ slot: string; order?: number }>;
|
|
168
|
+
/** How the shell attributes the page. `FrockBot` is a first-party
|
|
169
|
+
* artifact-backed member (ADR 0022 decision 8): shipped by FrockBot, loaded
|
|
170
|
+
* through the same path as a Bot-authored Package. */
|
|
171
|
+
provenance: PackageIframeProvenanceV1;
|
|
172
|
+
/** Manifest v5: 1..8 pages. A v3/v4 single-page record migrates to one. */
|
|
173
|
+
pages: PackageIframePageViewV1[];
|
|
174
|
+
entries: PackageIframeEntryViewV1[];
|
|
44
175
|
declaredTools: string[];
|
|
45
176
|
}
|
|
46
177
|
|
|
@@ -129,14 +260,22 @@ function boundedJsonWire(value: unknown, label: string): void {
|
|
|
129
260
|
}
|
|
130
261
|
}
|
|
131
262
|
|
|
132
|
-
/**
|
|
133
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Exact host-side decoder; unknown message types and fields fail closed.
|
|
265
|
+
*
|
|
266
|
+
* Both bridge versions decode here: a v1 page's messages keep their
|
|
267
|
+
* `schemaVersion: 1`, and the three v2-only messages are refused at v1 rather
|
|
268
|
+
* than silently accepted, so a page cannot reach a v2 capability by claiming
|
|
269
|
+
* the older version.
|
|
270
|
+
*/
|
|
271
|
+
export function decodePackageIframePageMessageV2(
|
|
134
272
|
input: unknown,
|
|
135
|
-
):
|
|
273
|
+
): PackageIframePageMessageV2 {
|
|
136
274
|
const value = record(input, "Package iframe message");
|
|
137
275
|
boundedJsonWire(input, "Package iframe message");
|
|
138
|
-
if (value.schemaVersion !== 1)
|
|
276
|
+
if (value.schemaVersion !== 1 && value.schemaVersion !== 2)
|
|
139
277
|
throw new Error("Package iframe schemaVersion is unsupported");
|
|
278
|
+
const schemaVersion = value.schemaVersion as PackageIframeBridgeVersionV2;
|
|
140
279
|
if (value.type === "callTool") {
|
|
141
280
|
exact(
|
|
142
281
|
value,
|
|
@@ -148,7 +287,7 @@ export function decodePackageIframePageMessageV1(
|
|
|
148
287
|
throw new Error("Package iframe tool name is invalid");
|
|
149
288
|
json(value.input, "Package iframe callTool.input");
|
|
150
289
|
return {
|
|
151
|
-
schemaVersion
|
|
290
|
+
schemaVersion,
|
|
152
291
|
type: "callTool",
|
|
153
292
|
name,
|
|
154
293
|
input: structuredClone(value.input),
|
|
@@ -159,11 +298,90 @@ export function decodePackageIframePageMessageV1(
|
|
|
159
298
|
if (typeof value.height !== "number" || !Number.isFinite(value.height)) {
|
|
160
299
|
throw new Error("Package iframe resize.height must be finite");
|
|
161
300
|
}
|
|
162
|
-
return { schemaVersion
|
|
301
|
+
return { schemaVersion, type: "resize", height: value.height };
|
|
302
|
+
}
|
|
303
|
+
if (schemaVersion === 2 && value.type === "hello") {
|
|
304
|
+
exact(
|
|
305
|
+
value,
|
|
306
|
+
["schemaVersion", "type", "bridgeVersion"],
|
|
307
|
+
"Package iframe hello",
|
|
308
|
+
);
|
|
309
|
+
if (value.bridgeVersion !== 1 && value.bridgeVersion !== 2) {
|
|
310
|
+
throw new Error("Package iframe hello.bridgeVersion is unsupported");
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
schemaVersion: 2,
|
|
314
|
+
type: "hello",
|
|
315
|
+
bridgeVersion: value.bridgeVersion as PackageIframeBridgeVersionV2,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
if (schemaVersion === 2 && value.type === "focus") {
|
|
319
|
+
exact(value, ["schemaVersion", "type", "appletId"], "Package iframe focus");
|
|
320
|
+
if (value.appletId === null) {
|
|
321
|
+
return { schemaVersion: 2, type: "focus", appletId: null };
|
|
322
|
+
}
|
|
323
|
+
const appletId = boundedString(
|
|
324
|
+
value.appletId,
|
|
325
|
+
"Package iframe focus.appletId",
|
|
326
|
+
129,
|
|
327
|
+
);
|
|
328
|
+
if (!APPLET_ID_V1.test(appletId)) {
|
|
329
|
+
throw new Error("Package iframe focus.appletId is invalid");
|
|
330
|
+
}
|
|
331
|
+
return { schemaVersion: 2, type: "focus", appletId };
|
|
332
|
+
}
|
|
333
|
+
if (schemaVersion === 2 && value.type === "openExternal") {
|
|
334
|
+
exact(
|
|
335
|
+
value,
|
|
336
|
+
["schemaVersion", "type", "url"],
|
|
337
|
+
"Package iframe openExternal",
|
|
338
|
+
);
|
|
339
|
+
const url = boundedString(
|
|
340
|
+
value.url,
|
|
341
|
+
"Package iframe openExternal.url",
|
|
342
|
+
2_048,
|
|
343
|
+
);
|
|
344
|
+
let parsed: URL;
|
|
345
|
+
try {
|
|
346
|
+
parsed = new URL(url);
|
|
347
|
+
} catch {
|
|
348
|
+
throw new Error("Package iframe openExternal.url is invalid");
|
|
349
|
+
}
|
|
350
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
351
|
+
throw new Error("Package iframe openExternal.url is invalid");
|
|
352
|
+
}
|
|
353
|
+
return { schemaVersion: 2, type: "openExternal", url };
|
|
163
354
|
}
|
|
164
355
|
throw new Error("Package iframe message type is invalid");
|
|
165
356
|
}
|
|
166
357
|
|
|
358
|
+
/**
|
|
359
|
+
* The one origin a page may hand the host to open. A page is served from the
|
|
360
|
+
* anonymous artifact origin and has no business steering the User anywhere
|
|
361
|
+
* else, so anything other than that origin is refused rather than sanitized.
|
|
362
|
+
*/
|
|
363
|
+
export function packageIframeExternalUrlAllowedV2(
|
|
364
|
+
url: string,
|
|
365
|
+
artifactOrigin: string,
|
|
366
|
+
): boolean {
|
|
367
|
+
try {
|
|
368
|
+
return new URL(url).origin === new URL(artifactOrigin).origin;
|
|
369
|
+
} catch {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Whether a page of this Package may change the Session's focused Applet.
|
|
376
|
+
* Focus is an Applet capability, so it belongs to the Package that owns the
|
|
377
|
+
* Applet tools and to no other.
|
|
378
|
+
*/
|
|
379
|
+
export function packageIframeFocusAllowedV2(
|
|
380
|
+
contribution: Pick<PackageIframeContributionViewV1, "declaredTools">,
|
|
381
|
+
): boolean {
|
|
382
|
+
return contribution.declaredTools.includes(PACKAGE_IFRAME_FOCUS_TOOL_V2);
|
|
383
|
+
}
|
|
384
|
+
|
|
167
385
|
export function decodePackageIframeToolCommandV1(
|
|
168
386
|
input: unknown,
|
|
169
387
|
): PackageIframeToolCommandV1 {
|
|
@@ -259,84 +477,181 @@ export function decodePackageIframeCatalogV1(
|
|
|
259
477
|
"packageId",
|
|
260
478
|
"displayName",
|
|
261
479
|
"provenance",
|
|
262
|
-
"
|
|
263
|
-
"
|
|
480
|
+
"pages",
|
|
481
|
+
"entries",
|
|
264
482
|
"declaredTools",
|
|
265
483
|
],
|
|
266
484
|
label,
|
|
267
485
|
);
|
|
268
|
-
const artifact = record(contribution.artifact, `${label}.artifact`);
|
|
269
|
-
exact(
|
|
270
|
-
artifact,
|
|
271
|
-
["contentHash", "size", "mediaType", "bundlerVersion"],
|
|
272
|
-
`${label}.artifact`,
|
|
273
|
-
);
|
|
274
486
|
if (
|
|
275
|
-
|
|
276
|
-
|
|
487
|
+
!Array.isArray(contribution.declaredTools) ||
|
|
488
|
+
contribution.declaredTools.length > 64
|
|
277
489
|
) {
|
|
278
|
-
throw new Error(`${label}.
|
|
490
|
+
throw new Error(`${label}.declaredTools must be a bounded array`);
|
|
279
491
|
}
|
|
492
|
+
const declaredTools = contribution.declaredTools.map((tool, toolIndex) =>
|
|
493
|
+
boundedString(tool, `${label}.declaredTools[${toolIndex}]`, 64),
|
|
494
|
+
);
|
|
280
495
|
if (
|
|
281
|
-
!
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
artifact.mediaType !== "text/html"
|
|
496
|
+
!Array.isArray(contribution.pages) ||
|
|
497
|
+
contribution.pages.length === 0 ||
|
|
498
|
+
contribution.pages.length > PACKAGE_IFRAME_MAX_PAGES_V1
|
|
285
499
|
) {
|
|
286
|
-
throw new Error(`${label}.
|
|
500
|
+
throw new Error(`${label}.pages must be a non-empty bounded array`);
|
|
287
501
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
502
|
+
const pageIds = contribution.pages.map((candidatePage, pageIndex) => {
|
|
503
|
+
const page = record(candidatePage, `${label}.pages[${pageIndex}]`);
|
|
504
|
+
const id = boundedString(page.id, `${label}.pages[${pageIndex}].id`, 32);
|
|
505
|
+
if (!PACKAGE_IFRAME_ID_V1.test(id)) {
|
|
506
|
+
throw new Error(`${label}.pages[${pageIndex}].id is invalid`);
|
|
507
|
+
}
|
|
508
|
+
return id;
|
|
509
|
+
});
|
|
510
|
+
if (new Set(pageIds).size !== pageIds.length) {
|
|
511
|
+
throw new Error(`${label}.pages contains duplicate ids`);
|
|
294
512
|
}
|
|
295
|
-
const
|
|
296
|
-
const
|
|
297
|
-
const
|
|
513
|
+
const pages = contribution.pages.map((candidatePage, pageIndex) => {
|
|
514
|
+
const pageLabel = `${label}.pages[${pageIndex}]`;
|
|
515
|
+
const page = record(candidatePage, pageLabel);
|
|
516
|
+
exact(page, ["id", "artifact", "mounts"], pageLabel);
|
|
517
|
+
const artifact = record(page.artifact, `${pageLabel}.artifact`);
|
|
298
518
|
exact(
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
`${
|
|
519
|
+
artifact,
|
|
520
|
+
["contentHash", "size", "mediaType", "bundlerVersion"],
|
|
521
|
+
`${pageLabel}.artifact`,
|
|
302
522
|
);
|
|
303
523
|
if (
|
|
304
|
-
|
|
305
|
-
(
|
|
524
|
+
typeof artifact.contentHash !== "string" ||
|
|
525
|
+
!/^[0-9a-f]{64}$/.test(artifact.contentHash)
|
|
306
526
|
) {
|
|
307
|
-
throw new Error(`${
|
|
527
|
+
throw new Error(`${pageLabel}.artifact.contentHash is invalid`);
|
|
308
528
|
}
|
|
309
|
-
|
|
310
|
-
|
|
529
|
+
if (
|
|
530
|
+
!Number.isSafeInteger(artifact.size) ||
|
|
531
|
+
(artifact.size as number) < 0 ||
|
|
532
|
+
(artifact.size as number) > 256 * 1024 ||
|
|
533
|
+
artifact.mediaType !== "text/html"
|
|
534
|
+
) {
|
|
535
|
+
throw new Error(`${pageLabel}.artifact metadata is invalid`);
|
|
536
|
+
}
|
|
537
|
+
if (
|
|
538
|
+
!Array.isArray(page.mounts) ||
|
|
539
|
+
page.mounts.length === 0 ||
|
|
540
|
+
page.mounts.length > 64
|
|
541
|
+
) {
|
|
542
|
+
throw new Error(
|
|
543
|
+
`${pageLabel}.mounts must be a non-empty bounded array`,
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
const mounts = page.mounts.map((candidateMount, mountIndex) => {
|
|
547
|
+
const mount = record(
|
|
548
|
+
candidateMount,
|
|
549
|
+
`${pageLabel}.mounts[${mountIndex}]`,
|
|
550
|
+
);
|
|
551
|
+
const hasOrder = mount.order !== undefined;
|
|
552
|
+
exact(
|
|
553
|
+
mount,
|
|
554
|
+
hasOrder ? ["slot", "order"] : ["slot"],
|
|
555
|
+
`${pageLabel}.mounts[${mountIndex}]`,
|
|
556
|
+
);
|
|
557
|
+
if (
|
|
558
|
+
hasOrder &&
|
|
559
|
+
(typeof mount.order !== "number" || !Number.isFinite(mount.order))
|
|
560
|
+
) {
|
|
561
|
+
throw new Error(
|
|
562
|
+
`${pageLabel}.mounts[${mountIndex}].order is invalid`,
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
const slot = boundedString(
|
|
311
566
|
mount.slot,
|
|
312
|
-
`${
|
|
567
|
+
`${pageLabel}.mounts[${mountIndex}].slot`,
|
|
313
568
|
160,
|
|
314
|
-
)
|
|
315
|
-
|
|
569
|
+
);
|
|
570
|
+
if (!iframePageSlotAllowedV1(slot, { declaredTools, pageIds })) {
|
|
571
|
+
throw new Error(`${pageLabel}.mounts contains an unsafe slot`);
|
|
572
|
+
}
|
|
573
|
+
return { slot, ...(hasOrder ? { order: mount.order as number } : {}) };
|
|
574
|
+
});
|
|
575
|
+
return {
|
|
576
|
+
id: pageIds[pageIndex]!,
|
|
577
|
+
artifact: {
|
|
578
|
+
contentHash: artifact.contentHash,
|
|
579
|
+
size: artifact.size as number,
|
|
580
|
+
mediaType: "text/html" as const,
|
|
581
|
+
bundlerVersion: boundedString(
|
|
582
|
+
artifact.bundlerVersion,
|
|
583
|
+
`${pageLabel}.artifact.bundlerVersion`,
|
|
584
|
+
128,
|
|
585
|
+
),
|
|
586
|
+
},
|
|
587
|
+
mounts,
|
|
316
588
|
};
|
|
317
589
|
});
|
|
318
590
|
if (
|
|
319
|
-
!Array.isArray(contribution.
|
|
320
|
-
contribution.
|
|
591
|
+
!Array.isArray(contribution.entries) ||
|
|
592
|
+
contribution.entries.length > PACKAGE_IFRAME_MAX_ENTRIES_V1
|
|
321
593
|
) {
|
|
322
|
-
throw new Error(`${label}.
|
|
594
|
+
throw new Error(`${label}.entries must be a bounded array`);
|
|
323
595
|
}
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
596
|
+
const entries = contribution.entries.map((candidateEntry, entryIndex) => {
|
|
597
|
+
const entryLabel = `${label}.entries[${entryIndex}]`;
|
|
598
|
+
const entry = record(candidateEntry, entryLabel);
|
|
599
|
+
const hasOrder = entry.order !== undefined;
|
|
600
|
+
exact(
|
|
601
|
+
entry,
|
|
602
|
+
hasOrder
|
|
603
|
+
? ["id", "slot", "order", "label", "icon", "opens"]
|
|
604
|
+
: ["id", "slot", "label", "icon", "opens"],
|
|
605
|
+
entryLabel,
|
|
606
|
+
);
|
|
607
|
+
const id = boundedString(entry.id, `${entryLabel}.id`, 32);
|
|
608
|
+
if (!PACKAGE_IFRAME_ID_V1.test(id)) {
|
|
609
|
+
throw new Error(`${entryLabel}.id is invalid`);
|
|
610
|
+
}
|
|
611
|
+
if (entry.slot !== PACKAGE_IFRAME_ENTRY_SLOT_V1) {
|
|
612
|
+
throw new Error(`${entryLabel}.slot is invalid`);
|
|
613
|
+
}
|
|
614
|
+
if (
|
|
615
|
+
hasOrder &&
|
|
616
|
+
(typeof entry.order !== "number" || !Number.isFinite(entry.order))
|
|
617
|
+
) {
|
|
618
|
+
throw new Error(`${entryLabel}.order is invalid`);
|
|
619
|
+
}
|
|
620
|
+
const opens = record(entry.opens, `${entryLabel}.opens`);
|
|
621
|
+
exact(opens, ["kind", "page"], `${entryLabel}.opens`);
|
|
622
|
+
if (opens.kind !== "surface") {
|
|
623
|
+
throw new Error(`${entryLabel}.opens.kind is invalid`);
|
|
624
|
+
}
|
|
625
|
+
const page = boundedString(opens.page, `${entryLabel}.opens.page`, 32);
|
|
626
|
+
const target = pages.find((candidate) => candidate.id === page);
|
|
330
627
|
if (
|
|
331
|
-
!
|
|
332
|
-
!
|
|
628
|
+
!target ||
|
|
629
|
+
!target.mounts.some(
|
|
630
|
+
(mount) => mount.slot === `frockbot.surface:${page}`,
|
|
631
|
+
)
|
|
333
632
|
) {
|
|
334
|
-
throw new Error(`${
|
|
633
|
+
throw new Error(`${entryLabel}.opens.page names no surface page`);
|
|
335
634
|
}
|
|
635
|
+
return {
|
|
636
|
+
id,
|
|
637
|
+
slot: PACKAGE_IFRAME_ENTRY_SLOT_V1 as "frockbot.sidebar-actions",
|
|
638
|
+
...(hasOrder ? { order: entry.order as number } : {}),
|
|
639
|
+
label: boundedString(
|
|
640
|
+
entry.label,
|
|
641
|
+
`${entryLabel}.label`,
|
|
642
|
+
PACKAGE_IFRAME_ENTRY_LABEL_MAX_V1,
|
|
643
|
+
),
|
|
644
|
+
icon: boundedString(entry.icon, `${entryLabel}.icon`, 64),
|
|
645
|
+
opens: { kind: "surface" as const, page },
|
|
646
|
+
};
|
|
647
|
+
});
|
|
648
|
+
if (new Set(entries.map((entry) => entry.id)).size !== entries.length) {
|
|
649
|
+
throw new Error(`${label}.entries contains duplicate ids`);
|
|
336
650
|
}
|
|
337
651
|
if (
|
|
338
|
-
|
|
339
|
-
|
|
652
|
+
!PACKAGE_IFRAME_PROVENANCES_V1.includes(
|
|
653
|
+
contribution.provenance as PackageIframeProvenanceV1,
|
|
654
|
+
)
|
|
340
655
|
) {
|
|
341
656
|
throw new Error(`${label}.provenance is invalid`);
|
|
342
657
|
}
|
|
@@ -351,18 +666,9 @@ export function decodePackageIframeCatalogV1(
|
|
|
351
666
|
`${label}.displayName`,
|
|
352
667
|
128,
|
|
353
668
|
),
|
|
354
|
-
provenance: contribution.provenance as
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
size: artifact.size as number,
|
|
358
|
-
mediaType: "text/html" as const,
|
|
359
|
-
bundlerVersion: boundedString(
|
|
360
|
-
artifact.bundlerVersion,
|
|
361
|
-
`${label}.artifact.bundlerVersion`,
|
|
362
|
-
128,
|
|
363
|
-
),
|
|
364
|
-
},
|
|
365
|
-
mounts,
|
|
669
|
+
provenance: contribution.provenance as PackageIframeProvenanceV1,
|
|
670
|
+
pages,
|
|
671
|
+
entries,
|
|
366
672
|
declaredTools,
|
|
367
673
|
};
|
|
368
674
|
});
|
|
@@ -386,15 +692,50 @@ export function decodePackageIframeCatalogV1(
|
|
|
386
692
|
|
|
387
693
|
/** TypeScript contract shown by package_inspect_self. */
|
|
388
694
|
export const PACKAGE_IFRAME_BRIDGE_DTS_V1 = `
|
|
389
|
-
interface
|
|
390
|
-
|
|
695
|
+
interface FrockBotAppletSummary { appletId: string; displayName: string; status: "draft" | "published" | "deleted"; currentGenerationId?: string; tools: string[]; createdAt: string }
|
|
696
|
+
interface FrockBotAppletsState {
|
|
697
|
+
focused: FrockBotAppletSummary | null;
|
|
698
|
+
list: FrockBotAppletSummary[];
|
|
699
|
+
viewer: { token: string; socketUrl: string; uiUrl: string; generationId: string } | null;
|
|
700
|
+
build?: { status: "unknown" | "passed" | "failed"; command?: "check" | "build"; at?: string; summary?: string; diagnostics?: string[] };
|
|
701
|
+
}
|
|
702
|
+
interface FrockBotIframeBridgeV2 {
|
|
703
|
+
readonly ready: Promise<{ themeTokens: Record<string, string>; packageId: string; botId: string; slot: string; pageId?: string }>;
|
|
391
704
|
callTool(name: string, input: unknown): void;
|
|
392
705
|
subscribe(name: string, listener: (value: unknown) => void): () => void;
|
|
393
706
|
resize(height?: number): void;
|
|
707
|
+
/** v2. Focus one Applet for this Session, or clear it. Requires the applet_focus tool. */
|
|
708
|
+
focus(appletId: string | null): void;
|
|
709
|
+
/** v2. Open a URL on this Package's artifact origin in a new tab. */
|
|
710
|
+
openExternal(url: string): void;
|
|
394
711
|
}
|
|
395
|
-
declare global { interface Window { frockbot:
|
|
396
|
-
//
|
|
712
|
+
declare global { interface Window { frockbot: FrockBotIframeBridgeV2 } }
|
|
713
|
+
// The bridge is version 2. A page announces itself with hello and then
|
|
714
|
+
// receives schemaVersion: 2 messages; a page using the version 1 helper keeps
|
|
715
|
+
// receiving schemaVersion: 1 messages and works unchanged.
|
|
716
|
+
// Results arrive on state name tool:<name>. A page mounted in
|
|
717
|
+
// frockbot.right-panel or a surface also receives state name "applets" with
|
|
718
|
+
// FrockBotAppletsState.
|
|
719
|
+
//
|
|
720
|
+
// A Package declares 1..8 UI pages. Each page has an id (/^[a-z][a-z0-9-]{0,31}$/,
|
|
721
|
+
// unique in the Package), one inline ui.html, and 1..64 mounts. The slot of a
|
|
722
|
+
// mount must be one of:
|
|
723
|
+
// frockbot.bot-settings-sections the Bot's settings screen
|
|
724
|
+
// frockbot.tool-result:<tool> under a result of that declared tool
|
|
725
|
+
// frockbot.right-panel the right-hand panel body
|
|
726
|
+
// frockbot.surface:<pageId> an overlay surface named by a page id
|
|
727
|
+
// A Package may also declare 0..4 entries. An entry is
|
|
728
|
+
// { id, slot: "frockbot.sidebar-actions", order?, label (<= 32 chars), icon,
|
|
729
|
+
// opens: { kind: "surface", page } }; \`opens.page\` must name a page that
|
|
730
|
+
// mounts frockbot.surface:<that page id>. \`ready\` resolves with the id of the
|
|
731
|
+
// page this frame is showing.
|
|
397
732
|
`;
|
|
398
733
|
|
|
399
|
-
/**
|
|
400
|
-
|
|
734
|
+
/**
|
|
735
|
+
* Tiny inline helper authored pages may paste verbatim.
|
|
736
|
+
*
|
|
737
|
+
* It announces `hello` before anything else, so the host knows this page reads
|
|
738
|
+
* version 2 messages; a page carrying the older helper never announces, and the
|
|
739
|
+
* host keeps speaking version 1 to it.
|
|
740
|
+
*/
|
|
741
|
+
export const PACKAGE_IFRAME_HELPER_JS_V1 = `(()=>{const V=2,L=new Map(),obj=v=>v&&typeof v==='object'&&!Array.isArray(v),exact=(v,ks)=>obj(v)&&Object.keys(v).length===ks.length&&ks.every(k=>Object.prototype.hasOwnProperty.call(v,k)),str=(v,n)=>typeof v==='string'&&v.length>0&&v.length<=n,json=(v,d=0)=>d<=16&&(v===null||typeof v==='string'||typeof v==='boolean'||typeof v==='number'&&Number.isFinite(v)||Array.isArray(v)&&v.length<=256&&v.every(x=>json(x,d+1))||obj(v)&&Object.keys(v).length<=256&&Object.values(v).every(x=>json(x,d+1))),wire=v=>{try{return new TextEncoder().encode(JSON.stringify(v)).byteLength<=65536}catch{return false}};let ok,fail;const ready=new Promise((r,j)=>{ok=r;fail=j});addEventListener('message',e=>{if(e.source!==parent)return;const m=e.data;if(m?.schemaVersion!==1&&m?.schemaVersion!==2||!wire(m))return;if(m.type==='init'&&(exact(m,['schemaVersion','type','themeTokens','packageId','botId','slot'])||exact(m,['schemaVersion','type','themeTokens','packageId','botId','slot','pageId'])&&str(m.pageId,32))&&obj(m.themeTokens)&&Object.keys(m.themeTokens).length<=64&&Object.values(m.themeTokens).every(v=>typeof v==='string')&&str(m.packageId,64)&&str(m.botId,256)&&str(m.slot,160)){for(const [k,v] of Object.entries(m.themeTokens))document.documentElement.style.setProperty('--frockbot-'+k,v);ok({themeTokens:m.themeTokens,packageId:m.packageId,botId:m.botId,slot:m.slot,...(m.pageId===undefined?{}:{pageId:m.pageId})});return}if(m.type==='state'&&exact(m,['schemaVersion','type','name','value'])&&str(m.name,256)&&json(m.value))for(const fn of L.get(m.name)||[])fn(m.value)});window.frockbot={ready,callTool(name,input){parent.postMessage({schemaVersion:V,type:'callTool',name,input},'*')},subscribe(name,fn){const s=L.get(name)||new Set();s.add(fn);L.set(name,s);return()=>s.delete(fn)},resize(height=document.documentElement.scrollHeight){parent.postMessage({schemaVersion:V,type:'resize',height},'*')},focus(appletId){parent.postMessage({schemaVersion:2,type:'focus',appletId:appletId===undefined?null:appletId},'*')},openExternal(url){parent.postMessage({schemaVersion:2,type:'openExternal',url},'*')}};parent.postMessage({schemaVersion:2,type:'hello',bridgeVersion:V},'*');setTimeout(()=>fail(new Error('FrockBot iframe init timed out')),10000)})();`;
|