@botiverse/porthole 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/LICENSE +217 -0
  2. package/dist/bridge/bridge.d.ts +39 -0
  3. package/dist/bridge/bridge.js +184 -0
  4. package/dist/bridge/types.d.ts +67 -0
  5. package/dist/bridge/types.js +1 -0
  6. package/dist/compile/compile.d.ts +35 -0
  7. package/dist/compile/compile.js +30 -0
  8. package/dist/compile/policy.d.ts +55 -0
  9. package/dist/compile/policy.js +106 -0
  10. package/dist/config/capabilities.d.ts +154 -0
  11. package/dist/config/capabilities.js +63 -0
  12. package/dist/config/schema.d.ts +131 -0
  13. package/dist/config/schema.js +1 -0
  14. package/dist/config/validate.d.ts +20 -0
  15. package/dist/config/validate.js +230 -0
  16. package/dist/conformance/behavior.d.ts +2 -0
  17. package/dist/conformance/behavior.js +376 -0
  18. package/dist/conformance/index.d.ts +95 -0
  19. package/dist/conformance/index.js +4 -0
  20. package/dist/conformance/kit.d.ts +42 -0
  21. package/dist/conformance/kit.js +24 -0
  22. package/dist/conformance/oracles.d.ts +35 -0
  23. package/dist/conformance/oracles.js +77 -0
  24. package/dist/conformance/probes.d.ts +9 -0
  25. package/dist/conformance/probes.js +865 -0
  26. package/dist/conformance/served.d.ts +68 -0
  27. package/dist/conformance/served.js +311 -0
  28. package/dist/guide/examples.d.ts +70 -0
  29. package/dist/guide/examples.js +69 -0
  30. package/dist/guide/guide.d.ts +16 -0
  31. package/dist/guide/guide.js +261 -0
  32. package/dist/host/create-host.d.ts +8 -0
  33. package/dist/host/create-host.js +248 -0
  34. package/dist/host/locate.d.ts +8 -0
  35. package/dist/host/locate.js +11 -0
  36. package/dist/host/types.d.ts +95 -0
  37. package/dist/host/types.js +1 -0
  38. package/dist/index.d.ts +23 -0
  39. package/dist/index.js +19 -0
  40. package/dist/isolation/served.d.ts +32 -0
  41. package/dist/isolation/served.js +97 -0
  42. package/dist/isolation/types.d.ts +87 -0
  43. package/dist/isolation/types.js +1 -0
  44. package/dist/mcp/adapter.d.ts +43 -0
  45. package/dist/mcp/adapter.js +327 -0
  46. package/dist/mcp/index.d.ts +27 -0
  47. package/dist/mcp/index.js +25 -0
  48. package/dist/mcp/proxy.d.ts +25 -0
  49. package/dist/mcp/proxy.js +106 -0
  50. package/dist/mcp/view.d.ts +31 -0
  51. package/dist/mcp/view.js +72 -0
  52. package/dist/platform/types.d.ts +44 -0
  53. package/dist/platform/types.js +1 -0
  54. package/dist/platform/web.d.ts +8 -0
  55. package/dist/platform/web.js +72 -0
  56. package/dist/presets/presets.d.ts +65 -0
  57. package/dist/presets/presets.js +125 -0
  58. package/dist/protocol/adapter.d.ts +73 -0
  59. package/dist/protocol/adapter.js +1 -0
  60. package/dist/protocol/jsonrpc.d.ts +43 -0
  61. package/dist/protocol/jsonrpc.js +50 -0
  62. package/dist/protocol/mcp-apps.d.ts +79 -0
  63. package/dist/protocol/mcp-apps.js +99 -0
  64. package/dist/protocol/shell-adapter.d.ts +5 -0
  65. package/dist/protocol/shell-adapter.js +228 -0
  66. package/dist/react/dialog.d.ts +20 -0
  67. package/dist/react/dialog.js +98 -0
  68. package/dist/react/frame.d.ts +75 -0
  69. package/dist/react/frame.js +221 -0
  70. package/dist/react/index.d.ts +13 -0
  71. package/dist/react/index.js +13 -0
  72. package/dist/react/live.d.ts +24 -0
  73. package/dist/react/live.js +57 -0
  74. package/dist/server/index.d.ts +27 -0
  75. package/dist/server/index.js +26 -0
  76. package/dist/server/keys.d.ts +19 -0
  77. package/dist/server/keys.js +78 -0
  78. package/dist/server/pages.d.ts +6 -0
  79. package/dist/server/pages.js +20 -0
  80. package/dist/server/proxy.d.ts +21 -0
  81. package/dist/server/proxy.js +107 -0
  82. package/dist/server/server.d.ts +7 -0
  83. package/dist/server/server.js +518 -0
  84. package/dist/server/tickets.d.ts +8 -0
  85. package/dist/server/tickets.js +28 -0
  86. package/dist/server/types.d.ts +97 -0
  87. package/dist/server/types.js +1 -0
  88. package/dist/shell/document.d.ts +28 -0
  89. package/dist/shell/document.js +84 -0
  90. package/dist/shell/plan.d.ts +35 -0
  91. package/dist/shell/plan.js +34 -0
  92. package/dist/shell/runtime.d.ts +28 -0
  93. package/dist/shell/runtime.js +832 -0
  94. package/dist/theme/variables.d.ts +73 -0
  95. package/dist/theme/variables.js +171 -0
  96. package/dist/transport/post-message.d.ts +9 -0
  97. package/dist/transport/post-message.js +38 -0
  98. package/dist/transport/types.d.ts +15 -0
  99. package/dist/transport/types.js +1 -0
  100. package/package.json +71 -1
  101. package/src/bridge/bridge.ts +256 -0
  102. package/src/bridge/types.ts +77 -0
  103. package/src/compile/compile.ts +68 -0
  104. package/src/compile/policy.ts +133 -0
  105. package/src/config/capabilities.ts +138 -0
  106. package/src/config/schema.ts +140 -0
  107. package/src/config/validate.ts +284 -0
  108. package/src/conformance/behavior.ts +430 -0
  109. package/src/conformance/index.ts +102 -0
  110. package/src/conformance/kit.ts +63 -0
  111. package/src/conformance/oracles.ts +126 -0
  112. package/src/conformance/probes.ts +1093 -0
  113. package/src/conformance/served.ts +472 -0
  114. package/src/guide/examples.ts +74 -0
  115. package/src/guide/guide.ts +424 -0
  116. package/src/host/create-host.ts +290 -0
  117. package/src/host/locate.ts +20 -0
  118. package/src/host/types.ts +95 -0
  119. package/src/index.ts +118 -0
  120. package/src/isolation/served.ts +127 -0
  121. package/src/isolation/types.ts +77 -0
  122. package/src/mcp/adapter.ts +441 -0
  123. package/src/mcp/index.ts +50 -0
  124. package/src/mcp/proxy.ts +117 -0
  125. package/src/mcp/view.ts +97 -0
  126. package/src/platform/types.ts +48 -0
  127. package/src/platform/web.ts +78 -0
  128. package/src/presets/presets.ts +193 -0
  129. package/src/protocol/adapter.ts +67 -0
  130. package/src/protocol/jsonrpc.ts +83 -0
  131. package/src/protocol/mcp-apps.ts +138 -0
  132. package/src/protocol/shell-adapter.ts +261 -0
  133. package/src/react/dialog.tsx +158 -0
  134. package/src/react/frame.tsx +422 -0
  135. package/src/react/index.ts +20 -0
  136. package/src/react/live.ts +76 -0
  137. package/src/server/index.ts +37 -0
  138. package/src/server/keys.ts +100 -0
  139. package/src/server/pages.ts +31 -0
  140. package/src/server/proxy.ts +131 -0
  141. package/src/server/server.ts +617 -0
  142. package/src/server/tickets.ts +30 -0
  143. package/src/server/types.ts +104 -0
  144. package/src/shell/document.ts +120 -0
  145. package/src/shell/plan.ts +66 -0
  146. package/src/shell/runtime.ts +832 -0
  147. package/src/theme/variables.ts +190 -0
  148. package/src/transport/post-message.ts +45 -0
  149. package/src/transport/types.ts +16 -0
@@ -0,0 +1,284 @@
1
+ import { CAPABILITY_NAMES, isCapabilityName, type Gate } from "./capabilities.ts";
2
+ import type { PortholeConfig } from "./schema.ts";
3
+
4
+ /**
5
+ * Checks a config before anything is derived from it. Every string that ends up in a CSP, an
6
+ * attribute or the shell is checked here, so a malformed origin can't inject a directive
7
+ * (`https://a.com; script-src *`) and a typo can't widen a policy. A config that fails is refused
8
+ * whole, never repaired.
9
+ */
10
+
11
+ export class ConfigError extends Error {
12
+ readonly path: string;
13
+
14
+ constructor(path: string, message: string) {
15
+ super(`${path}: ${message}`);
16
+ this.name = "ConfigError";
17
+ this.path = path;
18
+ }
19
+ }
20
+
21
+ /** The built-in manual topics; a host's own topics may not reuse these names. */
22
+ export const BUILT_IN_TOPICS = ["widget", "chart", "interactive"] as const;
23
+
24
+ const LOOPBACK = /^(localhost|127\.0\.0\.1|\[::1\])$|\.localhost$/u;
25
+ const HOST =
26
+ /^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/u;
27
+
28
+ /**
29
+ * Whether `value` is a CSP host source Porthole accepts: a scheme, a host (optionally `*.` for
30
+ * every subdomain) and an optional port, nothing else. Insecure schemes only for loopback names.
31
+ */
32
+ export function isOriginSource(value: unknown): value is string {
33
+ if (typeof value !== "string" || value.length > 253 + 16) {
34
+ return false;
35
+ }
36
+ const match = /^(https|http|wss|ws):\/\/([^/:]+|\[::1\])(:(\d{1,5}))?$/u.exec(value);
37
+ if (match === null) {
38
+ return false;
39
+ }
40
+ const [, scheme, host = "", , port] = match;
41
+ if (port !== undefined && (Number(port) < 1 || Number(port) > 65_535)) {
42
+ return false;
43
+ }
44
+ if (host !== "[::1]" && !HOST.test(host)) {
45
+ return false;
46
+ }
47
+ if ((scheme === "http" || scheme === "ws") && !LOOPBACK.test(host.replace(/^\*\./u, "x."))) {
48
+ return false;
49
+ }
50
+ return true;
51
+ }
52
+
53
+ const fail = (path: string, message: string): never => {
54
+ throw new ConfigError(path, message);
55
+ };
56
+
57
+ const isObject = (value: unknown): value is Record<string, unknown> =>
58
+ value !== null && typeof value === "object" && !Array.isArray(value);
59
+
60
+ function object(value: unknown, path: string): Record<string, unknown> {
61
+ return isObject(value) ? value : fail(path, "must be an object");
62
+ }
63
+
64
+ function only(value: Record<string, unknown>, keys: readonly string[], path: string): void {
65
+ for (const key of Object.keys(value)) {
66
+ if (!keys.includes(key)) {
67
+ fail(`${path}.${key}`, "is not a known field");
68
+ }
69
+ }
70
+ }
71
+
72
+ function sources(value: unknown, path: string): void {
73
+ if (value === undefined) {
74
+ return;
75
+ }
76
+ if (!Array.isArray(value)) {
77
+ fail(path, "must be an array of origins");
78
+ return;
79
+ }
80
+ value.forEach((source, index) => {
81
+ if (!isOriginSource(source)) {
82
+ fail(
83
+ `${path}[${index}]`,
84
+ `${JSON.stringify(source)} is not an origin (scheme://host[:port], lowercase, no path)`,
85
+ );
86
+ }
87
+ });
88
+ }
89
+
90
+ function positive(value: unknown, path: string, max = Number.MAX_SAFE_INTEGER): void {
91
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > max) {
92
+ fail(path, `must be an integer from 1 to ${max}`);
93
+ }
94
+ }
95
+
96
+ function gate(value: unknown, path: string): asserts value is Gate {
97
+ if (value === "user-activation" || value === "confirm") {
98
+ return;
99
+ }
100
+ const limit = isObject(value) && isObject(value.rateLimit) ? value.rateLimit : null;
101
+ if (limit === null || Object.keys(value as object).length !== 1) {
102
+ fail(path, 'must be "user-activation", "confirm" or { rateLimit: { perMinute } }');
103
+ return;
104
+ }
105
+ only(limit, ["perMinute"], `${path}.rateLimit`);
106
+ positive(limit.perMinute, `${path}.rateLimit.perMinute`, 10_000);
107
+ }
108
+
109
+ const MODES = ["inline", "artifact", "mcp-app", "url"];
110
+ const ISOLATIONS = ["opaque", "domain", "external"];
111
+
112
+ /** Returns `input` as a config once every field checks out; throws a `ConfigError` otherwise. */
113
+ export function validateConfig(input: unknown): PortholeConfig {
114
+ const config = object(input, "config");
115
+ only(
116
+ config,
117
+ [
118
+ "mode",
119
+ "isolation",
120
+ "capabilities",
121
+ "egress",
122
+ "theme",
123
+ "limits",
124
+ "layout",
125
+ "guide",
126
+ "contentOrigins",
127
+ "embedder",
128
+ ],
129
+ "config",
130
+ );
131
+
132
+ if (!MODES.includes(config.mode as string)) {
133
+ fail("config.mode", `must be one of ${MODES.join(", ")}`);
134
+ }
135
+ if (!ISOLATIONS.includes(config.isolation as string)) {
136
+ fail("config.isolation", `must be one of ${ISOLATIONS.join(", ")}`);
137
+ }
138
+ if ((config.mode === "url") !== (config.isolation === "external")) {
139
+ fail("config.isolation", 'url mode, and only url mode, is "external"');
140
+ }
141
+
142
+ const capabilities = object(config.capabilities, "config.capabilities");
143
+ for (const [name, value] of Object.entries(capabilities)) {
144
+ const path = `config.capabilities.${name}`;
145
+ if (!isCapabilityName(name)) {
146
+ fail(path, `is not a capability (${CAPABILITY_NAMES.join(", ")})`);
147
+ }
148
+ const capability = object(value, path);
149
+ only(capability, ["gates", "connect", "resource", "guide"], path);
150
+ if (capability.gates !== undefined) {
151
+ if (!Array.isArray(capability.gates)) {
152
+ fail(`${path}.gates`, "must be an array");
153
+ }
154
+ (capability.gates as unknown[]).forEach((each, index) =>
155
+ gate(each, `${path}.gates[${index}]`),
156
+ );
157
+ }
158
+ sources(capability.connect, `${path}.connect`);
159
+ sources(capability.resource, `${path}.resource`);
160
+ if (capability.guide !== undefined && typeof capability.guide !== "string") {
161
+ fail(`${path}.guide`, "must be a string");
162
+ }
163
+ }
164
+ if (config.mode === "url" && Object.keys(capabilities).length > 0) {
165
+ fail("config.capabilities", "url mode has no shell, so it can declare no capabilities");
166
+ }
167
+
168
+ const egress = object(config.egress, "config.egress");
169
+ const kinds = ["connect", "script", "style", "img", "font", "media"];
170
+ only(egress, kinds, "config.egress");
171
+ for (const kind of kinds) {
172
+ sources(egress[kind], `config.egress.${kind}`);
173
+ }
174
+
175
+ const theme = object(config.theme, "config.theme");
176
+ only(theme, ["variables", "event"], "config.theme");
177
+ const variables = object(theme.variables, "config.theme.variables");
178
+ for (const [name, value] of Object.entries(variables)) {
179
+ const path = `config.theme.variables[${JSON.stringify(name)}]`;
180
+ if (!/^--[a-z0-9]+(-[a-z0-9]+)*$/u.test(name)) {
181
+ fail(path, "must be a CSS custom property name (--lowercase-words)");
182
+ }
183
+ const values = isObject(value) ? [value.light, value.dark] : [value];
184
+ if (isObject(value)) {
185
+ only(value, ["light", "dark"], path);
186
+ }
187
+ for (const each of values) {
188
+ if (
189
+ typeof each !== "string" ||
190
+ each.length === 0 ||
191
+ each.length > 500 ||
192
+ /[;{}<>]/u.test(each)
193
+ ) {
194
+ fail(
195
+ path,
196
+ "must be a CSS value (1–500 characters, no ; { } < >), or { light, dark } of them",
197
+ );
198
+ }
199
+ }
200
+ }
201
+ if (typeof theme.event !== "string" || !/^[a-z][a-z0-9]*([:.-][a-z0-9]+)*$/u.test(theme.event)) {
202
+ fail("config.theme.event", 'must be an event name such as "porthole:theme"');
203
+ }
204
+
205
+ const limits = object(config.limits, "config.limits");
206
+ const limitNames = [
207
+ "maxHeightPx",
208
+ "minHeightPx",
209
+ "maxBytes",
210
+ "maxLiveFrames",
211
+ "declinesBeforeMute",
212
+ "maxPromptChars",
213
+ "maxErrorReports",
214
+ ];
215
+ only(limits, limitNames, "config.limits");
216
+ for (const name of limitNames) {
217
+ positive(limits[name], `config.limits.${name}`);
218
+ }
219
+ if ((limits.minHeightPx as number) > (limits.maxHeightPx as number)) {
220
+ fail("config.limits.minHeightPx", "must not exceed maxHeightPx");
221
+ }
222
+
223
+ const layout = object(config.layout, "config.layout");
224
+ only(layout, ["minWidthPx", "narrowWidthPx", "wideWidthPx"], "config.layout");
225
+ for (const name of ["minWidthPx", "narrowWidthPx", "wideWidthPx"]) {
226
+ positive(layout[name], `config.layout.${name}`, 10_000);
227
+ }
228
+ if (
229
+ (layout.minWidthPx as number) > (layout.narrowWidthPx as number) ||
230
+ (layout.narrowWidthPx as number) > (layout.wideWidthPx as number)
231
+ ) {
232
+ fail("config.layout", "must hold minWidthPx ≤ narrowWidthPx ≤ wideWidthPx");
233
+ }
234
+
235
+ const guide = object(config.guide, "config.guide");
236
+ only(guide, ["toolName", "guideToolName", "topics"], "config.guide");
237
+ for (const name of ["toolName", "guideToolName"]) {
238
+ const value = guide[name];
239
+ if (
240
+ value !== undefined &&
241
+ (typeof value !== "string" || !/^[A-Za-z0-9_.-]{1,64}$/u.test(value))
242
+ ) {
243
+ fail(`config.guide.${name}`, "must be a tool name (letters, digits, _ . -)");
244
+ }
245
+ }
246
+ if (guide.topics !== undefined) {
247
+ for (const [name, text] of Object.entries(object(guide.topics, "config.guide.topics"))) {
248
+ const path = `config.guide.topics.${name}`;
249
+ if (
250
+ !/^[a-z][a-z0-9-]{0,31}$/u.test(name) ||
251
+ (BUILT_IN_TOPICS as readonly string[]).includes(name)
252
+ ) {
253
+ fail(path, "must be a new topic name (lowercase), not a built-in one");
254
+ }
255
+ if (typeof text !== "string" || text.trim() === "") {
256
+ fail(path, "must be the topic's text");
257
+ }
258
+ }
259
+ }
260
+
261
+ sources(config.contentOrigins, "config.contentOrigins");
262
+ const served = config.isolation === "domain" || config.isolation === "external";
263
+ const contentOrigins = config.contentOrigins as readonly string[];
264
+ if (served !== contentOrigins.length > 0) {
265
+ fail(
266
+ "config.contentOrigins",
267
+ served
268
+ ? `${String(config.isolation)} isolation needs the origins its content is served from`
269
+ : "opaque isolation serves nothing: leave it empty",
270
+ );
271
+ }
272
+
273
+ const embedder = object(config.embedder, "config.embedder");
274
+ only(embedder, ["frameSrc"], "config.embedder");
275
+ // The host's own frames may include its own origin, by name.
276
+ sources(
277
+ Array.isArray(embedder.frameSrc)
278
+ ? embedder.frameSrc.filter((source) => source !== "'self'")
279
+ : embedder.frameSrc,
280
+ "config.embedder.frameSrc",
281
+ );
282
+
283
+ return input as PortholeConfig;
284
+ }