@botiverse/porthole 0.0.1 → 0.1.1

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 (152) hide show
  1. package/LICENSE +217 -0
  2. package/dist/bridge/bridge.d.ts +39 -0
  3. package/dist/bridge/bridge.js +198 -0
  4. package/dist/bridge/types.d.ts +83 -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 +103 -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 +152 -0
  13. package/dist/config/schema.js +11 -0
  14. package/dist/config/validate.d.ts +22 -0
  15. package/dist/config/validate.js +261 -0
  16. package/dist/conformance/behavior.d.ts +2 -0
  17. package/dist/conformance/behavior.js +426 -0
  18. package/dist/conformance/index.d.ts +101 -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 +33 -0
  25. package/dist/conformance/probes.js +876 -0
  26. package/dist/conformance/served.d.ts +75 -0
  27. package/dist/conformance/served.js +335 -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 +265 -0
  32. package/dist/host/create-host.d.ts +8 -0
  33. package/dist/host/create-host.js +292 -0
  34. package/dist/host/locate.d.ts +8 -0
  35. package/dist/host/locate.js +16 -0
  36. package/dist/host/outcome.d.ts +24 -0
  37. package/dist/host/outcome.js +134 -0
  38. package/dist/host/types.d.ts +127 -0
  39. package/dist/host/types.js +1 -0
  40. package/dist/index.d.ts +25 -0
  41. package/dist/index.js +21 -0
  42. package/dist/isolation/served.d.ts +35 -0
  43. package/dist/isolation/served.js +117 -0
  44. package/dist/isolation/types.d.ts +96 -0
  45. package/dist/isolation/types.js +2 -0
  46. package/dist/mcp/adapter.d.ts +43 -0
  47. package/dist/mcp/adapter.js +327 -0
  48. package/dist/mcp/index.d.ts +27 -0
  49. package/dist/mcp/index.js +25 -0
  50. package/dist/mcp/proxy.d.ts +25 -0
  51. package/dist/mcp/proxy.js +106 -0
  52. package/dist/mcp/view.d.ts +31 -0
  53. package/dist/mcp/view.js +72 -0
  54. package/dist/platform/types.d.ts +51 -0
  55. package/dist/platform/types.js +1 -0
  56. package/dist/platform/web.d.ts +8 -0
  57. package/dist/platform/web.js +85 -0
  58. package/dist/presets/presets.d.ts +71 -0
  59. package/dist/presets/presets.js +140 -0
  60. package/dist/protocol/adapter.d.ts +73 -0
  61. package/dist/protocol/adapter.js +1 -0
  62. package/dist/protocol/jsonrpc.d.ts +43 -0
  63. package/dist/protocol/jsonrpc.js +50 -0
  64. package/dist/protocol/mcp-apps.d.ts +79 -0
  65. package/dist/protocol/mcp-apps.js +99 -0
  66. package/dist/protocol/shell-adapter.d.ts +5 -0
  67. package/dist/protocol/shell-adapter.js +228 -0
  68. package/dist/react/dialog.d.ts +20 -0
  69. package/dist/react/dialog.js +98 -0
  70. package/dist/react/frame.d.ts +91 -0
  71. package/dist/react/frame.js +255 -0
  72. package/dist/react/index.d.ts +13 -0
  73. package/dist/react/index.js +13 -0
  74. package/dist/react/live.d.ts +24 -0
  75. package/dist/react/live.js +57 -0
  76. package/dist/server/index.d.ts +27 -0
  77. package/dist/server/index.js +26 -0
  78. package/dist/server/keys.d.ts +19 -0
  79. package/dist/server/keys.js +78 -0
  80. package/dist/server/pages.d.ts +11 -0
  81. package/dist/server/pages.js +86 -0
  82. package/dist/server/proxy.d.ts +21 -0
  83. package/dist/server/proxy.js +107 -0
  84. package/dist/server/server.d.ts +7 -0
  85. package/dist/server/server.js +542 -0
  86. package/dist/server/tickets.d.ts +8 -0
  87. package/dist/server/tickets.js +28 -0
  88. package/dist/server/types.d.ts +97 -0
  89. package/dist/server/types.js +1 -0
  90. package/dist/shell/document.d.ts +29 -0
  91. package/dist/shell/document.js +85 -0
  92. package/dist/shell/plan.d.ts +37 -0
  93. package/dist/shell/plan.js +35 -0
  94. package/dist/shell/runtime.d.ts +28 -0
  95. package/dist/shell/runtime.js +848 -0
  96. package/dist/theme/variables.d.ts +73 -0
  97. package/dist/theme/variables.js +171 -0
  98. package/dist/transport/post-message.d.ts +9 -0
  99. package/dist/transport/post-message.js +38 -0
  100. package/dist/transport/types.d.ts +15 -0
  101. package/dist/transport/types.js +1 -0
  102. package/package.json +63 -1
  103. package/src/bridge/bridge.ts +278 -0
  104. package/src/bridge/types.ts +95 -0
  105. package/src/compile/compile.ts +68 -0
  106. package/src/compile/policy.ts +130 -0
  107. package/src/config/capabilities.ts +138 -0
  108. package/src/config/schema.ts +168 -0
  109. package/src/config/validate.ts +318 -0
  110. package/src/conformance/behavior.ts +485 -0
  111. package/src/conformance/index.ts +116 -0
  112. package/src/conformance/kit.ts +63 -0
  113. package/src/conformance/oracles.ts +126 -0
  114. package/src/conformance/probes.ts +1129 -0
  115. package/src/conformance/served.ts +501 -0
  116. package/src/guide/examples.ts +74 -0
  117. package/src/guide/guide.ts +430 -0
  118. package/src/host/create-host.ts +335 -0
  119. package/src/host/locate.ts +25 -0
  120. package/src/host/outcome.ts +159 -0
  121. package/src/host/types.ts +126 -0
  122. package/src/index.ts +128 -0
  123. package/src/isolation/served.ts +151 -0
  124. package/src/isolation/types.ts +91 -0
  125. package/src/mcp/adapter.ts +441 -0
  126. package/src/mcp/index.ts +50 -0
  127. package/src/mcp/proxy.ts +117 -0
  128. package/src/mcp/view.ts +97 -0
  129. package/src/platform/types.ts +55 -0
  130. package/src/platform/web.ts +91 -0
  131. package/src/presets/presets.ts +217 -0
  132. package/src/protocol/adapter.ts +67 -0
  133. package/src/protocol/jsonrpc.ts +83 -0
  134. package/src/protocol/mcp-apps.ts +138 -0
  135. package/src/protocol/shell-adapter.ts +261 -0
  136. package/src/react/dialog.tsx +158 -0
  137. package/src/react/frame.tsx +486 -0
  138. package/src/react/index.ts +20 -0
  139. package/src/react/live.ts +76 -0
  140. package/src/server/index.ts +37 -0
  141. package/src/server/keys.ts +100 -0
  142. package/src/server/pages.ts +117 -0
  143. package/src/server/proxy.ts +131 -0
  144. package/src/server/server.ts +657 -0
  145. package/src/server/tickets.ts +30 -0
  146. package/src/server/types.ts +104 -0
  147. package/src/shell/document.ts +121 -0
  148. package/src/shell/plan.ts +69 -0
  149. package/src/shell/runtime.ts +848 -0
  150. package/src/theme/variables.ts +190 -0
  151. package/src/transport/post-message.ts +45 -0
  152. package/src/transport/types.ts +16 -0
@@ -0,0 +1,318 @@
1
+ import { CAPABILITY_NAMES, isCapabilityName, type Gate } from "./capabilities.ts";
2
+ import { URL_SANDBOX_EXTRAS, 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
+ return originSourceProblem(value) === null;
34
+ }
35
+
36
+ /** Why `value` isn't an origin source Porthole accepts, in words to act on; null if it is. */
37
+ export function originSourceProblem(value: unknown): string | null {
38
+ const shape = "is not an origin (scheme://host[:port], lowercase, no path)";
39
+ if (typeof value !== "string" || value.length > 253 + 16) {
40
+ return `${JSON.stringify(value)} ${shape}`;
41
+ }
42
+ const match = /^(https|http|wss|ws):\/\/([^/:]+|\[::1\])(:(\d{1,5}))?$/u.exec(value);
43
+ if (match === null) {
44
+ return `${JSON.stringify(value)} ${shape}`;
45
+ }
46
+ const [, scheme, host = "", , port] = match;
47
+ if (port !== undefined && (Number(port) < 1 || Number(port) > 65_535)) {
48
+ return `${JSON.stringify(value)} has no valid port`;
49
+ }
50
+ if (host !== "[::1]" && !HOST.test(host)) {
51
+ return `${JSON.stringify(value)} ${shape}`;
52
+ }
53
+ if ((scheme === "http" || scheme === "ws") && !LOOPBACK.test(host.replace(/^\*\./u, "x."))) {
54
+ return `${JSON.stringify(value)} uses ${scheme} off loopback: ${scheme} is allowed only for localhost, *.localhost, 127.0.0.1 and [::1] (loopback development); use ${scheme === "http" ? "https" : "wss"}`;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ const fail = (path: string, message: string): never => {
60
+ throw new ConfigError(path, message);
61
+ };
62
+
63
+ const isObject = (value: unknown): value is Record<string, unknown> =>
64
+ value !== null && typeof value === "object" && !Array.isArray(value);
65
+
66
+ function object(value: unknown, path: string): Record<string, unknown> {
67
+ return isObject(value) ? value : fail(path, "must be an object");
68
+ }
69
+
70
+ function only(value: Record<string, unknown>, keys: readonly string[], path: string): void {
71
+ for (const key of Object.keys(value)) {
72
+ if (!keys.includes(key)) {
73
+ fail(`${path}.${key}`, "is not a known field");
74
+ }
75
+ }
76
+ }
77
+
78
+ function sources(value: unknown, path: string): void {
79
+ if (value === undefined) {
80
+ return;
81
+ }
82
+ if (!Array.isArray(value)) {
83
+ fail(path, "must be an array of origins");
84
+ return;
85
+ }
86
+ value.forEach((source, index) => {
87
+ const problem = originSourceProblem(source);
88
+ if (problem !== null) {
89
+ fail(`${path}[${index}]`, problem);
90
+ }
91
+ });
92
+ }
93
+
94
+ function positive(value: unknown, path: string, max = Number.MAX_SAFE_INTEGER): void {
95
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > max) {
96
+ fail(path, `must be an integer from 1 to ${max}`);
97
+ }
98
+ }
99
+
100
+ function gate(value: unknown, path: string): asserts value is Gate {
101
+ if (value === "user-activation" || value === "confirm") {
102
+ return;
103
+ }
104
+ const limit = isObject(value) && isObject(value.rateLimit) ? value.rateLimit : null;
105
+ if (limit === null || Object.keys(value as object).length !== 1) {
106
+ fail(path, 'must be "user-activation", "confirm" or { rateLimit: { perMinute } }');
107
+ return;
108
+ }
109
+ only(limit, ["perMinute"], `${path}.rateLimit`);
110
+ positive(limit.perMinute, `${path}.rateLimit.perMinute`, 10_000);
111
+ }
112
+
113
+ const MODES = ["inline", "artifact", "mcp-app", "url"];
114
+ const ISOLATIONS = ["opaque", "domain", "external"];
115
+
116
+ /** Returns `input` as a config once every field checks out; throws a `ConfigError` otherwise. */
117
+ export function validateConfig(input: unknown): PortholeConfig {
118
+ const config = object(input, "config");
119
+ only(
120
+ config,
121
+ [
122
+ "mode",
123
+ "isolation",
124
+ "capabilities",
125
+ "egress",
126
+ "theme",
127
+ "limits",
128
+ "layout",
129
+ "guide",
130
+ "contentOrigins",
131
+ "embedder",
132
+ "sandbox",
133
+ ],
134
+ "config",
135
+ );
136
+
137
+ if (!MODES.includes(config.mode as string)) {
138
+ fail("config.mode", `must be one of ${MODES.join(", ")}`);
139
+ }
140
+ if (!ISOLATIONS.includes(config.isolation as string)) {
141
+ fail("config.isolation", `must be one of ${ISOLATIONS.join(", ")}`);
142
+ }
143
+ if ((config.mode === "url") !== (config.isolation === "external")) {
144
+ fail("config.isolation", 'url mode, and only url mode, is "external"');
145
+ }
146
+
147
+ const capabilities = object(config.capabilities, "config.capabilities");
148
+ for (const [name, value] of Object.entries(capabilities)) {
149
+ const path = `config.capabilities.${name}`;
150
+ if (!isCapabilityName(name)) {
151
+ fail(path, `is not a capability (${CAPABILITY_NAMES.join(", ")})`);
152
+ }
153
+ const capability = object(value, path);
154
+ only(capability, ["gates", "connect", "resource", "guide"], path);
155
+ if (capability.gates !== undefined) {
156
+ if (!Array.isArray(capability.gates)) {
157
+ fail(`${path}.gates`, "must be an array");
158
+ }
159
+ (capability.gates as unknown[]).forEach((each, index) =>
160
+ gate(each, `${path}.gates[${index}]`),
161
+ );
162
+ }
163
+ sources(capability.connect, `${path}.connect`);
164
+ sources(capability.resource, `${path}.resource`);
165
+ if (capability.guide !== undefined && typeof capability.guide !== "string") {
166
+ fail(`${path}.guide`, "must be a string");
167
+ }
168
+ }
169
+ if (config.mode === "url" && Object.keys(capabilities).length > 0) {
170
+ fail("config.capabilities", "url mode has no shell, so it can declare no capabilities");
171
+ }
172
+
173
+ const egress = object(config.egress, "config.egress");
174
+ const kinds = ["connect", "script", "style", "img", "font", "media"];
175
+ only(egress, kinds, "config.egress");
176
+ for (const kind of kinds) {
177
+ sources(egress[kind], `config.egress.${kind}`);
178
+ }
179
+
180
+ const theme = object(config.theme, "config.theme");
181
+ only(theme, ["variables", "event", "documents"], "config.theme");
182
+ if (
183
+ theme.documents !== undefined &&
184
+ theme.documents !== "declared" &&
185
+ theme.documents !== "always"
186
+ ) {
187
+ fail("config.theme.documents", 'must be "declared" or "always"');
188
+ }
189
+ const variables = object(theme.variables, "config.theme.variables");
190
+ for (const [name, value] of Object.entries(variables)) {
191
+ const path = `config.theme.variables[${JSON.stringify(name)}]`;
192
+ if (!/^--[a-z0-9]+(-[a-z0-9]+)*$/u.test(name)) {
193
+ fail(path, "must be a CSS custom property name (--lowercase-words)");
194
+ }
195
+ const values = isObject(value) ? [value.light, value.dark] : [value];
196
+ if (isObject(value)) {
197
+ only(value, ["light", "dark"], path);
198
+ }
199
+ for (const each of values) {
200
+ if (
201
+ typeof each !== "string" ||
202
+ each.length === 0 ||
203
+ each.length > 500 ||
204
+ /[;{}<>]/u.test(each)
205
+ ) {
206
+ fail(
207
+ path,
208
+ "must be a CSS value (1–500 characters, no ; { } < >), or { light, dark } of them",
209
+ );
210
+ }
211
+ }
212
+ }
213
+ if (typeof theme.event !== "string" || !/^[a-z][a-z0-9]*([:.-][a-z0-9]+)*$/u.test(theme.event)) {
214
+ fail("config.theme.event", 'must be an event name such as "porthole:theme"');
215
+ }
216
+
217
+ const limits = object(config.limits, "config.limits");
218
+ const limitNames = [
219
+ "maxHeightPx",
220
+ "minHeightPx",
221
+ "maxBytes",
222
+ "maxLiveFrames",
223
+ "declinesBeforeMute",
224
+ "maxPromptChars",
225
+ "maxErrorReports",
226
+ ];
227
+ only(limits, [...limitNames, "loadTimeoutMs"], "config.limits");
228
+ for (const name of limitNames) {
229
+ positive(limits[name], `config.limits.${name}`);
230
+ }
231
+ if (limits.loadTimeoutMs !== undefined) {
232
+ positive(limits.loadTimeoutMs, "config.limits.loadTimeoutMs", 600_000);
233
+ }
234
+ if ((limits.minHeightPx as number) > (limits.maxHeightPx as number)) {
235
+ fail("config.limits.minHeightPx", "must not exceed maxHeightPx");
236
+ }
237
+
238
+ const layout = object(config.layout, "config.layout");
239
+ only(layout, ["minWidthPx", "narrowWidthPx", "wideWidthPx"], "config.layout");
240
+ for (const name of ["minWidthPx", "narrowWidthPx", "wideWidthPx"]) {
241
+ positive(layout[name], `config.layout.${name}`, 10_000);
242
+ }
243
+ if (
244
+ (layout.minWidthPx as number) > (layout.narrowWidthPx as number) ||
245
+ (layout.narrowWidthPx as number) > (layout.wideWidthPx as number)
246
+ ) {
247
+ fail("config.layout", "must hold minWidthPx ≤ narrowWidthPx ≤ wideWidthPx");
248
+ }
249
+
250
+ const guide = object(config.guide, "config.guide");
251
+ only(guide, ["toolName", "guideToolName", "topics"], "config.guide");
252
+ for (const name of ["toolName", "guideToolName"]) {
253
+ const value = guide[name];
254
+ if (
255
+ value !== undefined &&
256
+ (typeof value !== "string" || !/^[A-Za-z0-9_.-]{1,64}$/u.test(value))
257
+ ) {
258
+ fail(`config.guide.${name}`, "must be a tool name (letters, digits, _ . -)");
259
+ }
260
+ }
261
+ if (guide.topics !== undefined) {
262
+ for (const [name, text] of Object.entries(object(guide.topics, "config.guide.topics"))) {
263
+ const path = `config.guide.topics.${name}`;
264
+ if (
265
+ !/^[a-z][a-z0-9-]{0,31}$/u.test(name) ||
266
+ (BUILT_IN_TOPICS as readonly string[]).includes(name)
267
+ ) {
268
+ fail(path, "must be a new topic name (lowercase), not a built-in one");
269
+ }
270
+ if (typeof text !== "string" || text.trim() === "") {
271
+ fail(path, "must be the topic's text");
272
+ }
273
+ }
274
+ }
275
+
276
+ sources(config.contentOrigins, "config.contentOrigins");
277
+ const served = config.isolation === "domain" || config.isolation === "external";
278
+ const contentOrigins = config.contentOrigins as readonly string[];
279
+ if (served !== contentOrigins.length > 0) {
280
+ fail(
281
+ "config.contentOrigins",
282
+ served
283
+ ? `${String(config.isolation)} isolation needs the origins its content is served from`
284
+ : "opaque isolation serves nothing: leave it empty",
285
+ );
286
+ }
287
+
288
+ const embedder = object(config.embedder, "config.embedder");
289
+ only(embedder, ["frameSrc"], "config.embedder");
290
+ // The host's own frames may include its own origin, by name.
291
+ sources(
292
+ Array.isArray(embedder.frameSrc)
293
+ ? embedder.frameSrc.filter((source) => source !== "'self'")
294
+ : embedder.frameSrc,
295
+ "config.embedder.frameSrc",
296
+ );
297
+
298
+ if (config.sandbox !== undefined) {
299
+ if (config.mode !== "url") {
300
+ fail("config.sandbox", "only url mode may add sandbox tokens");
301
+ }
302
+ if (!Array.isArray(config.sandbox)) {
303
+ fail("config.sandbox", "must be an array of sandbox tokens");
304
+ }
305
+ (config.sandbox as unknown[]).forEach((token, index) => {
306
+ if (!(URL_SANDBOX_EXTRAS as readonly unknown[]).includes(token)) {
307
+ fail(
308
+ `config.sandbox[${index}]`,
309
+ /top-navigation|same-origin/u.test(String(token))
310
+ ? `${JSON.stringify(token)} is never granted (P037)`
311
+ : `must be one of ${URL_SANDBOX_EXTRAS.join(", ")}`,
312
+ );
313
+ }
314
+ });
315
+ }
316
+
317
+ return input as PortholeConfig;
318
+ }