@botiverse/porthole 0.1.0 → 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 (68) hide show
  1. package/dist/bridge/bridge.js +20 -6
  2. package/dist/bridge/types.d.ts +19 -3
  3. package/dist/compile/policy.d.ts +3 -3
  4. package/dist/compile/policy.js +6 -9
  5. package/dist/config/schema.d.ts +21 -0
  6. package/dist/config/schema.js +11 -1
  7. package/dist/config/validate.d.ts +3 -1
  8. package/dist/config/validate.js +41 -10
  9. package/dist/conformance/behavior.js +50 -0
  10. package/dist/conformance/index.d.ts +11 -5
  11. package/dist/conformance/index.js +2 -2
  12. package/dist/conformance/probes.d.ts +26 -2
  13. package/dist/conformance/probes.js +51 -40
  14. package/dist/conformance/served.d.ts +7 -0
  15. package/dist/conformance/served.js +31 -7
  16. package/dist/guide/guide.js +6 -2
  17. package/dist/host/create-host.js +46 -2
  18. package/dist/host/locate.js +6 -1
  19. package/dist/host/outcome.d.ts +24 -0
  20. package/dist/host/outcome.js +134 -0
  21. package/dist/host/types.d.ts +34 -2
  22. package/dist/index.d.ts +4 -2
  23. package/dist/index.js +3 -1
  24. package/dist/isolation/served.d.ts +3 -0
  25. package/dist/isolation/served.js +20 -0
  26. package/dist/isolation/types.d.ts +9 -0
  27. package/dist/isolation/types.js +2 -1
  28. package/dist/platform/types.d.ts +7 -0
  29. package/dist/platform/web.js +15 -2
  30. package/dist/presets/presets.d.ts +9 -3
  31. package/dist/presets/presets.js +16 -1
  32. package/dist/react/frame.d.ts +16 -0
  33. package/dist/react/frame.js +44 -10
  34. package/dist/server/pages.d.ts +11 -6
  35. package/dist/server/pages.js +78 -12
  36. package/dist/server/server.js +34 -10
  37. package/dist/shell/document.d.ts +3 -2
  38. package/dist/shell/document.js +4 -3
  39. package/dist/shell/plan.d.ts +2 -0
  40. package/dist/shell/plan.js +1 -0
  41. package/dist/shell/runtime.js +20 -4
  42. package/package.json +1 -9
  43. package/src/bridge/bridge.ts +28 -6
  44. package/src/bridge/types.ts +21 -3
  45. package/src/compile/policy.ts +6 -9
  46. package/src/config/schema.ts +28 -0
  47. package/src/config/validate.ts +48 -14
  48. package/src/conformance/behavior.ts +55 -0
  49. package/src/conformance/index.ts +18 -4
  50. package/src/conformance/probes.ts +88 -52
  51. package/src/conformance/served.ts +37 -8
  52. package/src/guide/guide.ts +8 -2
  53. package/src/host/create-host.ts +47 -2
  54. package/src/host/locate.ts +6 -1
  55. package/src/host/outcome.ts +159 -0
  56. package/src/host/types.ts +33 -2
  57. package/src/index.ts +11 -1
  58. package/src/isolation/served.ts +24 -0
  59. package/src/isolation/types.ts +15 -1
  60. package/src/platform/types.ts +7 -0
  61. package/src/platform/web.ts +15 -2
  62. package/src/presets/presets.ts +27 -3
  63. package/src/react/frame.tsx +78 -14
  64. package/src/server/pages.ts +97 -11
  65. package/src/server/server.ts +48 -8
  66. package/src/shell/document.ts +7 -6
  67. package/src/shell/plan.ts +3 -0
  68. package/src/shell/runtime.ts +20 -4
@@ -25,9 +25,9 @@ export function PortholeProvider(props) {
25
25
  useLayoutEffect(() => {
26
26
  own.current = props.confirm;
27
27
  });
28
- const confirm = useCallback((request) => own.current !== undefined
29
- ? own.current(request)
30
- : new Promise((resolve) => setQueue((waiting) => [...waiting, { request, resolve }])), []);
28
+ const confirm = useCallback((request, proceed) => own.current !== undefined
29
+ ? own.current(request, proceed)
30
+ : new Promise((resolve) => setQueue((waiting) => [...waiting, { request, resolve, proceed }])), []);
31
31
  const { hostInfo, locator } = props;
32
32
  const value = useMemo(() => ({
33
33
  live,
@@ -38,7 +38,11 @@ export function PortholeProvider(props) {
38
38
  }), [live, heights, confirm, hostInfo, locator]);
39
39
  const current = queue[0] ?? null;
40
40
  return (_jsxs(Page.Provider, { value: value, children: [props.children, props.confirm === undefined ? (_jsx(ConfirmDialog, { request: current?.request ?? null, ...(props.dialogClassName === undefined ? {} : { className: props.dialogClassName }), onAnswer: (accepted) => {
41
- current?.resolve(accepted);
41
+ // A yes runs the effect inside this very click (a window it opens is the person's).
42
+ if (accepted) {
43
+ current?.proceed();
44
+ }
45
+ current?.resolve(accepted ? "accepted" : "declined");
42
46
  setQueue((waiting) => waiting.slice(1));
43
47
  } })) : null] }));
44
48
  }
@@ -56,6 +60,16 @@ const PLACEHOLDER_STYLE = {
56
60
  function DefaultPlaceholder({ state, title, run }) {
57
61
  return (_jsxs("div", { "data-porthole-placeholder": state, style: { ...PLACEHOLDER_STYLE, pointerEvents: state === "stopped" ? "auto" : "none" }, children: [_jsx("span", { children: state === "stopped" ? "Stopped while others run" : title }), state === "stopped" ? (_jsx("button", { type: "button", "data-porthole-run": "", onClick: run, children: "Run again" })) : null] }));
58
62
  }
63
+ /** A display's remembered height: under its id, else under the first of its other ids that has one. */
64
+ function rememberedHeight(heights, displayId, aliases) {
65
+ for (const id of [displayId, ...(aliases ?? [])]) {
66
+ const height = heights.get(id);
67
+ if (height !== undefined) {
68
+ return height;
69
+ }
70
+ }
71
+ return 0;
72
+ }
59
73
  export function PortholeFrame(props) {
60
74
  const page = useContext(Page);
61
75
  const key = useId();
@@ -89,7 +103,17 @@ export function PortholeFrame(props) {
89
103
  page.live.release(key);
90
104
  };
91
105
  }, [page, key, max, eager]);
92
- const height = page.heights.get(props.displayId) ?? 0;
106
+ const height = rememberedHeight(page.heights, props.displayId, props.aliases);
107
+ // A display renamed while it runs (its id settled) keeps its height under its new id.
108
+ const previousId = useRef(props.displayId);
109
+ useLayoutEffect(() => {
110
+ const before = previousId.current;
111
+ previousId.current = props.displayId;
112
+ const kept = page.heights.get(before);
113
+ if (before !== props.displayId && kept !== undefined && !page.heights.has(props.displayId)) {
114
+ page.heights.set(props.displayId, kept);
115
+ }
116
+ }, [page, props.displayId]);
93
117
  const run = () => page.live.admit(key, max);
94
118
  const placeholder = props.renderPlaceholder ?? DefaultPlaceholder;
95
119
  return (_jsx("div", { ref: box, className: props.className, "data-porthole-display": props.displayId, "data-porthole-state": state, style: {
@@ -107,6 +131,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
107
131
  // The height this display started at: later reports don't make it start again.
108
132
  const [startHeight] = useState(initialHeightPx);
109
133
  const { config, document: view, instance, content, partial, scheme } = frame;
134
+ const fills = config.mode === "url";
110
135
  // The latest props, read when the display calls back (never during render).
111
136
  const latest = useEffectEvent(() => frame);
112
137
  const notify = useEffectEvent((event, detail) => {
@@ -135,6 +160,9 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
135
160
  case "muted":
136
161
  frame.onMuted?.();
137
162
  return;
163
+ case "outcome":
164
+ frame.onOutcome?.(detail);
165
+ return;
138
166
  }
139
167
  });
140
168
  useEffect(() => {
@@ -162,10 +190,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
162
190
  handlers,
163
191
  ...(asks
164
192
  ? {
165
- confirm: (request) => (latest().confirm ?? page.confirm ?? (() => Promise.resolve(false)))({
166
- ...request,
167
- display: { id: latest().displayId, title: latest().title },
168
- }),
193
+ confirm: (request, proceed) => (latest().confirm ?? page.confirm ?? (() => Promise.resolve(false)))({ ...request, display: { id: latest().displayId, title: latest().title } }, proceed),
169
194
  }
170
195
  : {}),
171
196
  theme: (each) => latest().theme?.(each) ?? {},
@@ -192,6 +217,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
192
217
  "navigated",
193
218
  "refused",
194
219
  "muted",
220
+ "outcome",
195
221
  ];
196
222
  for (const event of events) {
197
223
  display.on(event, (detail) => notify(event, detail));
@@ -214,8 +240,16 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
214
240
  useEffect(() => {
215
241
  handle.current?.setTheme(scheme);
216
242
  }, [scheme]);
243
+ const { themeKey } = frame;
244
+ useEffect(() => {
245
+ if (themeKey !== undefined) {
246
+ handle.current?.refreshTheme();
247
+ }
248
+ }, [themeKey]);
217
249
  return (_jsxs("div", { "data-porthole-ready": String(ready), style: {
218
250
  position: "relative",
251
+ // A url-mode app fills the frame's box (give PortholeFrame a height); the rest size to content.
252
+ ...(fills ? { height: "100%" } : {}),
219
253
  ...(ready ? {} : { minHeight: Math.max(startHeight, PLACEHOLDER_HEIGHT_PX) }),
220
- }, children: [_jsx("div", { ref: holder, "data-porthole-mount": "", style: { opacity: ready ? 1 : 0 } }), ready ? null : placeholder({ state: "loading", title: frame.title, run })] }));
254
+ }, children: [_jsx("div", { ref: holder, "data-porthole-mount": "", style: { opacity: ready ? 1 : 0, ...(fills ? { height: "100%" } : {}) } }), ready ? null : placeholder({ state: "loading", title: frame.title, run })] }));
221
255
  }
@@ -1,6 +1,11 @@
1
- /**
2
- * The server's own pages on a content origin: why it shows nothing of the content. Plain, no
3
- * script, framable only by the host origins, never cached. Their words are the host-agnostic
4
- * mechanism's; a host that wants its own wording serves its own page in front.
5
- */
6
- export declare function statusPage(status: number, title: string, detail: string, frameAncestors: string, method?: string): Response;
1
+ import type { FrameBlock } from "../host/types.ts";
2
+ /** Where the pages' scripts are served, on every content origin. */
3
+ export declare const STATUS_SCRIPT_PATH = "/__porthole/status.js";
4
+ export declare const ENTER_SCRIPT_PATH = "/__porthole/enter.js";
5
+ /** Answers 204 when the request carries a good session for its origin's app, 401 otherwise. */
6
+ export declare const SESSION_PATH = "/__porthole/session";
7
+ /** The pages' scripts, by path: served with the pages' own policy in mind (`script-src 'self'`). */
8
+ export declare function pageScript(pathname: string): Response | null;
9
+ export declare function statusPage(status: number, title: string, detail: string, frameAncestors: string, method?: string, reason?: FrameBlock, hostOrigins?: readonly string[]): Response;
10
+ /** The page a framed app is entered through (its answer also sets the session cookie). */
11
+ export declare function enterPage(target: string, frameAncestors: string, hostOrigins: readonly string[], cookie: string): Response;
@@ -1,20 +1,86 @@
1
+ import { FRAME_STATUS_METHOD } from "../isolation/types.js";
1
2
  /**
2
- * The server's own pages on a content origin: why it shows nothing of the content. Plain, no
3
- * script, framable only by the host origins, never cached. Their words are the host-agnostic
4
- * mechanism's; a host that wants its own wording serves its own page in front.
3
+ * The server's own pages on a content origin: why it shows nothing of the content, and the page a
4
+ * framed app is entered through. Plain, framable only by the host origins, never cached. Each tells
5
+ * the page that frames it how loading went (`porthole/notifications/frame-status`, P040), posted to
6
+ * the host origins only, so a host can show its own fallback ("Open in a new tab"). Their words are
7
+ * the host-agnostic mechanism's; a host that wants its own wording serves its own page in front.
5
8
  */
6
9
  const escape = (text) => text.replace(/[&<>"']/gu, (char) => `&#${char.charCodeAt(0)};`);
7
10
  const STYLE = ":root{color-scheme:light dark;font-family:system-ui,sans-serif}body{margin:0;min-height:100vh;display:grid;place-items:center;background:Canvas;color:CanvasText}main{max-width:420px;padding:24px}h1{font-size:17px;margin:0 0 8px}p{margin:0;opacity:.75;line-height:1.5}";
8
- export function statusPage(status, title, detail, frameAncestors, method = "GET") {
9
- const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)}</title><style>${STYLE}</style></head><body><main data-porthole-status="${status}"><h1>${escape(title)}</h1><p>${escape(detail)}</p></main></body></html>`;
11
+ /** Where the pages' scripts are served, on every content origin. */
12
+ export const STATUS_SCRIPT_PATH = "/__porthole/status.js";
13
+ export const ENTER_SCRIPT_PATH = "/__porthole/enter.js";
14
+ /** Answers 204 when the request carries a good session for its origin's app, 401 otherwise. */
15
+ export const SESSION_PATH = "/__porthole/session";
16
+ const say = () => `const say = (params) => { if (window.parent === window) return; for (const origin of hosts) window.parent.postMessage({ jsonrpc: "2.0", method: ${JSON.stringify(FRAME_STATUS_METHOD)}, params }, origin); };`;
17
+ /** Tells the host the page is blocked, and why (from the page's data attributes). */
18
+ const STATUS_SCRIPT = `(() => {
19
+ const main = document.querySelector("[data-porthole-status]");
20
+ if (main === null) return;
21
+ const hosts = JSON.parse(main.dataset.portholeHosts || "[]");
22
+ ${say()}
23
+ say({ state: "blocked", reason: main.dataset.portholeReason || "error", status: Number(main.dataset.portholeStatus) });
24
+ })();`;
25
+ /**
26
+ * Enters a framed app: says so, checks that the browser kept the session cookie this page's own
27
+ * answer set (WebKit keeps none in a cross-site frame, P025), then goes on to the app, or says it
28
+ * is blocked and why.
29
+ */
30
+ const ENTER_SCRIPT = `(async () => {
31
+ const main = document.querySelector("[data-porthole-enter]");
32
+ if (main === null) return;
33
+ const hosts = JSON.parse(main.dataset.portholeHosts || "[]");
34
+ ${say()}
35
+ say({ state: "entering" });
36
+ let kept = false;
37
+ try {
38
+ kept = (await fetch(${JSON.stringify(SESSION_PATH)}, { credentials: "same-origin", cache: "no-store" })).ok;
39
+ } catch (_) {}
40
+ if (kept) {
41
+ location.replace(main.dataset.portholeTarget || "/");
42
+ return;
43
+ }
44
+ say({ state: "blocked", reason: "cookies", status: 401 });
45
+ main.querySelector("h1").textContent = "Open this app in a new tab";
46
+ main.querySelector("p").textContent = "This browser keeps no sign-in for it inside a frame.";
47
+ })();`;
48
+ /** The pages' scripts, by path: served with the pages' own policy in mind (`script-src 'self'`). */
49
+ export function pageScript(pathname) {
50
+ const body = pathname === STATUS_SCRIPT_PATH
51
+ ? STATUS_SCRIPT
52
+ : pathname === ENTER_SCRIPT_PATH
53
+ ? ENTER_SCRIPT
54
+ : null;
55
+ return body === null
56
+ ? null
57
+ : new Response(body, {
58
+ headers: {
59
+ "content-type": "text/javascript; charset=utf-8",
60
+ "cache-control": "no-store",
61
+ "x-content-type-options": "nosniff",
62
+ },
63
+ });
64
+ }
65
+ const policy = (frameAncestors, connect) => `default-src 'none'; style-src 'unsafe-inline'; script-src 'self'; ${connect ? "connect-src 'self'; " : ""}base-uri 'none'; form-action 'none'; frame-ancestors ${frameAncestors}`;
66
+ const pageHeaders = (frameAncestors, connect) => ({
67
+ "content-type": "text/html; charset=utf-8",
68
+ "cache-control": "no-store",
69
+ "content-security-policy": policy(frameAncestors, connect),
70
+ "referrer-policy": "no-referrer",
71
+ "x-content-type-options": "nosniff",
72
+ });
73
+ export function statusPage(status, title, detail, frameAncestors, method = "GET", reason = "error", hostOrigins = []) {
74
+ const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)}</title><style>${STYLE}</style></head><body><main data-porthole-status="${status}" data-porthole-reason="${escape(reason)}" data-porthole-hosts="${escape(JSON.stringify(hostOrigins))}"><h1>${escape(title)}</h1><p>${escape(detail)}</p></main><script src="${STATUS_SCRIPT_PATH}"></script></body></html>`;
10
75
  return new Response(method === "HEAD" ? null : body, {
11
76
  status,
12
- headers: {
13
- "content-type": "text/html; charset=utf-8",
14
- "cache-control": "no-store",
15
- "content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors ${frameAncestors}`,
16
- "referrer-policy": "no-referrer",
17
- "x-content-type-options": "nosniff",
18
- },
77
+ headers: pageHeaders(frameAncestors, false),
19
78
  });
20
79
  }
80
+ /** The page a framed app is entered through (its answer also sets the session cookie). */
81
+ export function enterPage(target, frameAncestors, hostOrigins, cookie) {
82
+ const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Opening the app</title><style>${STYLE}</style></head><body><main data-porthole-enter="" data-porthole-target="${escape(target)}" data-porthole-hosts="${escape(JSON.stringify(hostOrigins))}"><h1>Opening the app…</h1><p></p></main><script src="${ENTER_SCRIPT_PATH}"></script></body></html>`;
83
+ const headers = new Headers(pageHeaders(frameAncestors, true));
84
+ headers.set("set-cookie", cookie);
85
+ return new Response(body, { status: 200, headers });
86
+ }
@@ -1,8 +1,8 @@
1
1
  import { compile } from "../compile/compile.js";
2
- import { isExactOrigin, maybeSameSite, originMatchesSource, parseOrigin, } from "../isolation/served.js";
2
+ import { hostOriginProblem, maybeSameSite, originMatchesSource, parseOrigin, } from "../isolation/served.js";
3
3
  import { sandboxProxyDocument } from "../mcp/proxy.js";
4
4
  import { createKeys, newTicket, ticketId } from "./keys.js";
5
- import { statusPage } from "./pages.js";
5
+ import { enterPage, pageScript, SESSION_PATH, statusPage } from "./pages.js";
6
6
  import { memoryTicketStore } from "./tickets.js";
7
7
  /**
8
8
  * Paths on a content origin. Porthole's own live under `/__porthole/`; on a url-mode app's origin
@@ -155,9 +155,14 @@ export function createServer(options) {
155
155
  throw new TypeError("hostOrigins must name the origins that embed content");
156
156
  }
157
157
  for (const origin of options.hostOrigins) {
158
- const parsed = isExactOrigin(origin) ? parseOrigin(origin) : null;
158
+ const problem = hostOriginProblem(origin);
159
+ if (problem !== null) {
160
+ throw new TypeError(`hostOrigins: ${problem}`);
161
+ }
162
+ // A desktop app's own scheme (ferry://app) is never the same site as web content.
163
+ const parsed = parseOrigin(origin);
159
164
  if (parsed === null) {
160
- throw new TypeError(`hostOrigins: ${JSON.stringify(origin)} is not an exact origin`);
165
+ continue;
161
166
  }
162
167
  if (parsed.host === domain ||
163
168
  parsed.host.endsWith(`.${domain}`) ||
@@ -171,7 +176,8 @@ export function createServer(options) {
171
176
  compiled.set(name, compile(config));
172
177
  }
173
178
  const tickets = options.tickets ?? memoryTicketStore();
174
- const page = (status, title, detail, method) => statusPage(status, title, detail, frameAncestors, method);
179
+ /** A page saying why there is nothing to show, told to the host too (its reason, P040). */
180
+ const page = (status, title, detail, method, reason = status === 404 ? "not-found" : status === 502 ? "upstream" : "error") => statusPage(status, title, detail, frameAncestors, method, reason, options.hostOrigins);
175
181
  /** The config a served document is served under, checked to fit it and this server. */
176
182
  function configFor(name, kind) {
177
183
  const each = compiled.get(name);
@@ -357,7 +363,7 @@ export function createServer(options) {
357
363
  }
358
364
  access ??= accessOf(request, label, key);
359
365
  if (access === null) {
360
- return page(401, "This link has been used", "Content here opens once per link. Open it again from the app that showed it.", method);
366
+ return page(401, "This link has been used", "Content here opens once per link. Open it again from the app that showed it.", method, "session");
361
367
  }
362
368
  const served = await documentOf(access.instance);
363
369
  if (served === null) {
@@ -376,13 +382,20 @@ export function createServer(options) {
376
382
  }
377
383
  const record = await take(url.searchParams.get("ticket"), label);
378
384
  if (record === null || record.instance.document.kind !== "external") {
379
- return page(403, "This link has been used", "A link into an app works once, for a short time. Open it again from the app that showed it.");
385
+ return page(403, "This link has been used", "A link into an app works once, for a short time. Open it again from the app that showed it.", method, "session");
380
386
  }
381
387
  const target = new URL(record.instance.document.url);
388
+ const landing = `${target.pathname}${target.search}${target.hash}`;
389
+ // In a frame, a page enters the app: it can tell whether the browser kept the cookie (and tell
390
+ // the host), where a redirect would leave a frame without one showing a refusal. On a tab of
391
+ // its own, a redirect.
392
+ if (request.headers.get("sec-fetch-dest") !== "document") {
393
+ return enterPage(landing, frameAncestors, options.hostOrigins, sessionCookie(request, record));
394
+ }
382
395
  return new Response(null, {
383
396
  status: 303,
384
397
  headers: {
385
- location: `${target.pathname}${target.search}${target.hash}`,
398
+ location: landing,
386
399
  "set-cookie": sessionCookie(request, record),
387
400
  "cache-control": "no-store",
388
401
  "referrer-policy": "no-referrer",
@@ -425,7 +438,7 @@ export function createServer(options) {
425
438
  const method = request.method.toUpperCase();
426
439
  const access = accessOf(request, label, null);
427
440
  if (access === null) {
428
- return page(401, "Open this from the app", "This address shows an app only to the people the app opens it for.", method);
441
+ return page(401, "Open this from the app", "This address shows an app only to the people the app opens it for.", method, "session");
429
442
  }
430
443
  if (options.upstream === undefined) {
431
444
  return page(502, "Nothing serves this app", "The server has no upstream for apps.", method);
@@ -481,7 +494,7 @@ export function createServer(options) {
481
494
  const path = clean.document.kind === "external"
482
495
  ? `${ENTER_PATH}?ticket=${ticket}`
483
496
  : `${PREFIX}d/${key}?ticket=${ticket}`;
484
- return { url: `${origin}${path}`, origin };
497
+ return { url: `${origin}${path}`, origin, reports: true };
485
498
  },
486
499
  async access(request) {
487
500
  const at = labelOf(request);
@@ -501,6 +514,17 @@ export function createServer(options) {
501
514
  if (url.pathname === ENTER_PATH) {
502
515
  return await enter(request, url, label);
503
516
  }
517
+ const script = pageScript(url.pathname);
518
+ if (script !== null) {
519
+ return script;
520
+ }
521
+ if (url.pathname === SESSION_PATH) {
522
+ // Whether this request's cookie opens its origin's app: the entry page asks.
523
+ return new Response(null, {
524
+ status: accessOf(request, label, null) === null ? 401 : 204,
525
+ headers: { "cache-control": "no-store" },
526
+ });
527
+ }
504
528
  const document = DOCUMENT_PATH.exec(url.pathname);
505
529
  if (document?.[1] !== undefined) {
506
530
  return await serveDocument(request, url, label, document[1]);
@@ -2,8 +2,9 @@ import type { ShellPlan } from "./plan.ts";
2
2
  export interface ShellDocumentOptions {
3
3
  /**
4
4
  * The origins the shell takes messages from: the embedding page's own (`location.origin`), or
5
- * every host origin a served document may be embedded by. Exact origins, no wildcards. The
6
- * sender must also be the frame's parent.
5
+ * every host origin a served document may be embedded by. Exact origins of any scheme (a
6
+ * desktop app's own, `ferry://app`), no wildcards, never "null". The sender must also be the
7
+ * frame's parent.
7
8
  */
8
9
  readonly origins: readonly string[];
9
10
  /** The frame document's title (for assistive technology inside the frame). */
@@ -1,5 +1,5 @@
1
+ import { hostOriginProblem } from "../isolation/served.js";
1
2
  import { SHELL_RUNTIME } from "./runtime.js";
2
- const EXACT_ORIGIN = /^(https?:\/\/[a-z0-9.-]+(:\d{1,5})?|https?:\/\/\[::1\](:\d{1,5})?)$/u;
3
3
  const escapeAttribute = (value) => value.replace(/&/gu, "&amp;").replace(/"/gu, "&quot;").replace(/</gu, "&lt;");
4
4
  const escapeText = (value) => value.replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
5
5
  /** JSON that is safe inside a `<script>`: no `</script>`, no `<!--`. */
@@ -47,8 +47,9 @@ body {
47
47
  */
48
48
  export function shellDocument(plan, csp, options) {
49
49
  for (const origin of options.origins) {
50
- if (!EXACT_ORIGIN.test(origin)) {
51
- throw new TypeError(`shellDocument: ${JSON.stringify(origin)} is not an exact origin`);
50
+ const problem = hostOriginProblem(origin);
51
+ if (problem !== null) {
52
+ throw new TypeError(`shellDocument: ${problem}`);
52
53
  }
53
54
  }
54
55
  const runtime = SHELL_RUNTIME.replace("__PLAN__", () => scriptJson({
@@ -26,6 +26,8 @@ export interface ShellPlan {
26
26
  readonly themeVariables: readonly string[];
27
27
  /** The window event fired after a theme change. */
28
28
  readonly themeEvent: string;
29
+ /** Whether a whole document takes the person's scheme: when it declares it, or always. */
30
+ readonly themeDocuments: "declared" | "always";
29
31
  /** `memory`: an opaque origin has no storage, so a map stands in for the frame's life. */
30
32
  readonly storage: "memory" | "native";
31
33
  readonly maxErrorReports: number;
@@ -26,6 +26,7 @@ export function shellPlan(config) {
26
26
  globals,
27
27
  themeVariables: Object.keys(config.theme.variables),
28
28
  themeEvent: config.theme.event,
29
+ themeDocuments: config.theme.documents ?? "declared",
29
30
  storage: config.isolation === "opaque" ? "memory" : "native",
30
31
  maxErrorReports: config.limits.maxErrorReports,
31
32
  maxPromptChars: config.limits.maxPromptChars,
@@ -487,6 +487,21 @@ export const SHELL_RUNTIME = String.raw `(() => {
487
487
  window.dispatchEvent(new Event("load"));
488
488
  };
489
489
 
490
+ // A fragment takes the person's scheme; a whole document only if it declares that it supports
491
+ // both (or the config says always), else it shows as its author styled it (P039).
492
+ let lastContext = null;
493
+ let themed = plan.content !== "document";
494
+ const schemeFollows = () => {
495
+ const root = document.documentElement;
496
+ const theme = lastContext && lastContext.theme;
497
+ if (themed && (theme === "light" || theme === "dark")) {
498
+ root.style.colorScheme = theme;
499
+ root.setAttribute("data-theme", theme);
500
+ } else {
501
+ root.style.removeProperty("color-scheme");
502
+ root.removeAttribute("data-theme");
503
+ }
504
+ };
490
505
  // A whole document, parsed inert (no scripts run, no declarative shadow roots built), guarded,
491
506
  // imported in place of the shell's, then its scripts run in order. The theme on the root stays.
492
507
  const renderDocument = async (html) => {
@@ -502,6 +517,9 @@ export const SHELL_RUNTIME = String.raw `(() => {
502
517
  document.head.replaceChildren(...Array.from(parsed.head.childNodes, (node) => document.importNode(node, true)));
503
518
  document.body.replaceWith(document.importNode(parsed.body, true));
504
519
  root = null;
520
+ const declaredScheme = parsed.querySelector('meta[name="color-scheme" i]');
521
+ themed = plan.themeDocuments === "always" || (declaredScheme !== null && /\bdark\b/i.test(declaredScheme.getAttribute("content") || ""));
522
+ schemeFollows();
505
523
  await runScripts(scripts);
506
524
  announceLoaded();
507
525
  };
@@ -554,10 +572,8 @@ export const SHELL_RUNTIME = String.raw `(() => {
554
572
  }
555
573
  }
556
574
  }
557
- if (context.theme === "light" || context.theme === "dark") {
558
- document.documentElement.style.colorScheme = context.theme;
559
- document.documentElement.setAttribute("data-theme", context.theme);
560
- }
575
+ lastContext = context;
576
+ schemeFollows();
561
577
  window.dispatchEvent(new CustomEvent(plan.themeEvent, { detail: { theme: context.theme } }));
562
578
  };
563
579
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botiverse/porthole",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A host-side sandbox for agent-made HTML: isolation, egress hardening, a themed shell, gated host functions, and the agent manual, from one config.",
5
5
  "license": "Apache-2.0",
6
6
  "files": [
@@ -46,19 +46,11 @@
46
46
  "typescript": "7.0.2"
47
47
  },
48
48
  "peerDependencies": {
49
- "@modelcontextprotocol/ext-apps": ">=2.0.0",
50
- "@playwright/test": ">=1.63.0",
51
49
  "react": ">=19"
52
50
  },
53
51
  "peerDependenciesMeta": {
54
- "@playwright/test": {
55
- "optional": true
56
- },
57
52
  "react": {
58
53
  "optional": true
59
- },
60
- "@modelcontextprotocol/ext-apps": {
61
- "optional": true
62
54
  }
63
55
  },
64
56
  "engines": {
@@ -11,6 +11,7 @@ import type {
11
11
  CapabilityHandler,
12
12
  CapabilityHandlers,
13
13
  Confirm,
14
+ ConfirmAnswer,
14
15
  ConfirmRequest,
15
16
  HostFunctionBridge,
16
17
  Refusal,
@@ -204,17 +205,28 @@ export function createBridge(options: BridgeOptions): HostFunctionBridge {
204
205
  return refuse<Result>(capability, "no-user-activation");
205
206
  }
206
207
  }
208
+ // The effect, run once: when the person's "yes" calls `proceed` (inside its own click, so an
209
+ // effect that needs a gesture gets one), or after `confirm` resolves accepted.
210
+ let effect: Promise<CallOutcome<Result>> | null = null;
211
+ const run = (): Promise<CallOutcome<Result>> => (effect ??= perform(capability, args));
207
212
  if (confirms) {
208
213
  if (options.confirm === undefined) {
209
214
  return refuse<Result>(capability, "no-confirm");
210
215
  }
211
- let accepted = false;
216
+ let answer: ConfirmAnswer = false;
212
217
  try {
213
- accepted = await options.confirm({ capability, args, display } as ConfirmRequest);
218
+ answer = await options.confirm(
219
+ { capability, args, display } as ConfirmRequest,
220
+ () => void run(),
221
+ );
214
222
  } catch {
215
- accepted = false;
223
+ answer = effect === null ? false : "accepted";
216
224
  }
217
- if (!accepted) {
225
+ if (effect === null && answer !== true && answer !== "accepted") {
226
+ if (answer === "redirected") {
227
+ return refuse<Result>(capability, "redirected");
228
+ }
229
+ // Declines in a row mute the display; a yes starts the count again.
218
230
  declines += 1;
219
231
  if (declines >= config.limits.declinesBeforeMute && !muted) {
220
232
  muted = true;
@@ -222,8 +234,18 @@ export function createBridge(options: BridgeOptions): HostFunctionBridge {
222
234
  }
223
235
  return refuse<Result>(capability, "declined");
224
236
  }
237
+ declines = 0;
225
238
  }
239
+ return run();
240
+ },
241
+ };
226
242
 
243
+ async function perform<N extends CapabilityName>(
244
+ capability: N,
245
+ args: CapabilityArgs[N],
246
+ ): Promise<CallOutcome<CapabilityResults[N]>> {
247
+ type Result = CapabilityResults[N];
248
+ {
227
249
  if (capabilitySpec(capability).served === "event") {
228
250
  if (capability === "resize") {
229
251
  const size = args as CapabilityArgs["resize"];
@@ -251,6 +273,6 @@ export function createBridge(options: BridgeOptions): HostFunctionBridge {
251
273
  error instanceof Error ? error.message : String(error),
252
274
  );
253
275
  }
254
- },
255
- };
276
+ }
277
+ }
256
278
  }
@@ -38,10 +38,26 @@ export type ConfirmRequest = {
38
38
  }[CapabilityName];
39
39
 
40
40
  /**
41
- * Asks a person. Resolves true only if they accept. The host's UI, or the React binding's default
42
- * dialog; drawn outside the frame, never inside it.
41
+ * What the person chose. `true`/`"accepted"`: the call goes. `false`/`"declined"`: it doesn't,
42
+ * and the decline counts toward muting the display. `"redirected"`: it doesn't go, but the person
43
+ * took it elsewhere (edited the prompt first, say), so it neither counts as a decline nor resets
44
+ * the count. The host decides which its UI's choices are.
43
45
  */
44
- export type Confirm = (request: ConfirmRequest) => Promise<boolean>;
46
+ export type ConfirmAnswer = boolean | "accepted" | "declined" | "redirected";
47
+
48
+ /**
49
+ * Runs the call's effect now, inside the caller's own gesture, and counts as accepted. A host
50
+ * whose effect needs the person's gesture (opening a window) calls it from the click handler of
51
+ * its own "yes" button, rather than resolving the promise and letting the effect run after.
52
+ */
53
+ export type Proceed = () => void;
54
+
55
+ /**
56
+ * Asks a person. Resolves with their answer. The host's UI, or the React binding's default
57
+ * dialog; drawn outside the frame, never inside it. `proceed` (optional to use) runs the effect
58
+ * within the "yes" click itself.
59
+ */
60
+ export type Confirm = (request: ConfirmRequest, proceed: Proceed) => Promise<ConfirmAnswer>;
45
61
 
46
62
  /** Why a call didn't run. */
47
63
  export type Refusal =
@@ -55,6 +71,8 @@ export type Refusal =
55
71
  | "no-confirm"
56
72
  /** The person declined. */
57
73
  | "declined"
74
+ /** The person took it elsewhere (edited it first): not sent, not counted as a decline. */
75
+ | "redirected"
58
76
  /** The display was muted after too many declines (until it runs again). */
59
77
  | "muted"
60
78
  | "rate-limited"
@@ -93,7 +93,8 @@ export function sandboxTokens(config: PortholeConfig): string[] {
93
93
  case "domain":
94
94
  return ["allow-scripts", "allow-same-origin"];
95
95
  case "external":
96
- return ["allow-scripts", "allow-same-origin", "allow-forms"];
96
+ // The host may add a documented few (popups, modals, downloads), never top navigation.
97
+ return ["allow-scripts", "allow-same-origin", "allow-forms", ...(config.sandbox ?? [])];
97
98
  }
98
99
  }
99
100
 
@@ -119,15 +120,11 @@ export function embedderFrameSources(config: PortholeConfig): string[] {
119
120
 
120
121
  /**
121
122
  * The CSP the embedding page sends (a header, or the host's own document's policy), for every kind
122
- * of display it shows. It sets no `default-src`: it restricts frames only, leaving the host's own
123
- * scripts, styles and fetches to the host's policy. `child-src` repeats `frame-src` for engines
124
- * that read frames from it.
123
+ * of display it shows. Frames only: no `default-src`, so the host's own scripts, styles and
124
+ * fetches stay its policy's; and no `child-src`, which the host's workers would fall back to (every
125
+ * engine Porthole supports reads frames from `frame-src`, P038).
125
126
  */
126
127
  export function embedderCsp(configs: readonly PortholeConfig[]): string {
127
128
  const sources = unique(configs.flatMap(embedderFrameSources));
128
- const all = sources.length === 0 ? ["'none'"] : sources;
129
- return serializeCsp([
130
- ["frame-src", ...all],
131
- ["child-src", ...all],
132
- ]);
129
+ return serializeCsp([["frame-src", ...(sources.length === 0 ? ["'none'"] : sources)]]);
133
130
  }