@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/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 = 1 as const;
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 PackageIframeHostMessageV1 =
21
+ export type PackageIframeHostMessageV2 =
5
22
  | {
6
- schemaVersion: 1;
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: 1;
33
+ schemaVersion: PackageIframeBridgeVersionV2;
15
34
  type: "state";
16
35
  name: string;
17
36
  value: unknown;
18
37
  };
19
38
 
20
- export type PackageIframePageMessageV1 =
39
+ export type PackageIframePageMessageV2 =
21
40
  | {
22
- schemaVersion: 1;
41
+ schemaVersion: PackageIframeBridgeVersionV2;
23
42
  type: "callTool";
24
43
  name: string;
25
44
  input: unknown;
26
45
  }
27
46
  | {
28
- schemaVersion: 1;
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
- provenance: "Bot-authored" | "User-installed";
37
- artifact: {
38
- contentHash: string;
39
- size: number;
40
- mediaType: "text/html";
41
- bundlerVersion: string;
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
- /** Exact host-side decoder; unknown message types and fields fail closed. */
133
- export function decodePackageIframePageMessageV1(
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
- ): PackageIframePageMessageV1 {
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: 1,
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: 1, type: "resize", height: value.height };
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
- "artifact",
263
- "mounts",
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
- typeof artifact.contentHash !== "string" ||
276
- !/^[0-9a-f]{64}$/.test(artifact.contentHash)
487
+ !Array.isArray(contribution.declaredTools) ||
488
+ contribution.declaredTools.length > 64
277
489
  ) {
278
- throw new Error(`${label}.artifact.contentHash is invalid`);
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
- !Number.isSafeInteger(artifact.size) ||
282
- (artifact.size as number) < 0 ||
283
- (artifact.size as number) > 256 * 1024 ||
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}.artifact metadata is invalid`);
500
+ throw new Error(`${label}.pages must be a non-empty bounded array`);
287
501
  }
288
- if (
289
- !Array.isArray(contribution.mounts) ||
290
- contribution.mounts.length === 0 ||
291
- contribution.mounts.length > 64
292
- ) {
293
- throw new Error(`${label}.mounts must be a non-empty bounded array`);
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 mounts = contribution.mounts.map((candidateMount, mountIndex) => {
296
- const mount = record(candidateMount, `${label}.mounts[${mountIndex}]`);
297
- const hasOrder = mount.order !== undefined;
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
- mount,
300
- hasOrder ? ["slot", "order"] : ["slot"],
301
- `${label}.mounts[${mountIndex}]`,
519
+ artifact,
520
+ ["contentHash", "size", "mediaType", "bundlerVersion"],
521
+ `${pageLabel}.artifact`,
302
522
  );
303
523
  if (
304
- hasOrder &&
305
- (typeof mount.order !== "number" || !Number.isFinite(mount.order))
524
+ typeof artifact.contentHash !== "string" ||
525
+ !/^[0-9a-f]{64}$/.test(artifact.contentHash)
306
526
  ) {
307
- throw new Error(`${label}.mounts[${mountIndex}].order is invalid`);
527
+ throw new Error(`${pageLabel}.artifact.contentHash is invalid`);
308
528
  }
309
- return {
310
- slot: boundedString(
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
- `${label}.mounts[${mountIndex}].slot`,
567
+ `${pageLabel}.mounts[${mountIndex}].slot`,
313
568
  160,
314
- ),
315
- ...(hasOrder ? { order: mount.order as number } : {}),
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.declaredTools) ||
320
- contribution.declaredTools.length > 64
591
+ !Array.isArray(contribution.entries) ||
592
+ contribution.entries.length > PACKAGE_IFRAME_MAX_ENTRIES_V1
321
593
  ) {
322
- throw new Error(`${label}.declaredTools must be a bounded array`);
594
+ throw new Error(`${label}.entries must be a bounded array`);
323
595
  }
324
- const declaredTools = contribution.declaredTools.map((tool, toolIndex) =>
325
- boundedString(tool, `${label}.declaredTools[${toolIndex}]`, 64),
326
- );
327
- for (const mount of mounts) {
328
- const prefix = "frockbot.tool-result:";
329
- if (mount.slot === "frockbot.bot-settings-sections") continue;
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
- !mount.slot.startsWith(prefix) ||
332
- !declaredTools.includes(mount.slot.slice(prefix.length))
628
+ !target ||
629
+ !target.mounts.some(
630
+ (mount) => mount.slot === `frockbot.surface:${page}`,
631
+ )
333
632
  ) {
334
- throw new Error(`${label}.mounts contains an unsafe slot`);
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
- contribution.provenance !== "Bot-authored" &&
339
- contribution.provenance !== "User-installed"
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 "Bot-authored" | "User-installed",
355
- artifact: {
356
- contentHash: artifact.contentHash,
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 FrockBotIframeBridgeV1 {
390
- readonly ready: Promise<{ themeTokens: Record<string, string>; packageId: string; botId: string; slot: string }>;
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: FrockBotIframeBridgeV1 } }
396
- // Messages are schemaVersion: 1. Results arrive on state name tool:<name>.
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
- /** Tiny inline helper authored pages may paste verbatim. */
400
- export const PACKAGE_IFRAME_HELPER_JS_V1 = `(()=>{const V=1,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!==V||!wire(m))return;if(m.type==='init'&&exact(m,['schemaVersion','type','themeTokens','packageId','botId','slot'])&&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});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},'*')}};setTimeout(()=>fail(new Error('FrockBot iframe init timed out')),10000)})();`;
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)})();`;
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
+ export * from "./applets.js";
1
2
  export * from "./authoring.js";
3
+ export * from "./contributions.js";
2
4
  export * from "./iframe-ui.js";
3
5
  export * from "./isolate.js";
4
6
  export * from "./isolate-context-catalog.generated.js";