@botiverse/porthole 0.1.3 → 0.1.4

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 (42) hide show
  1. package/dist/compile/policy.d.ts +6 -0
  2. package/dist/compile/policy.js +9 -1
  3. package/dist/config/schema.d.ts +11 -0
  4. package/dist/config/validate.js +12 -0
  5. package/dist/conformance/served.d.ts +8 -1
  6. package/dist/conformance/served.js +12 -8
  7. package/dist/guide/guide.js +3 -1
  8. package/dist/host/create-host.js +24 -7
  9. package/dist/host/types.d.ts +44 -2
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.js +1 -1
  12. package/dist/presets/presets.d.ts +3 -1
  13. package/dist/presets/presets.js +1 -0
  14. package/dist/react/frame.d.ts +34 -4
  15. package/dist/react/frame.js +80 -18
  16. package/dist/server/naming.d.ts +6 -0
  17. package/dist/server/naming.js +38 -0
  18. package/dist/server/pages.js +2 -0
  19. package/dist/server/server.js +49 -17
  20. package/dist/server/types.d.ts +6 -3
  21. package/dist/shell/annotate.js +31 -0
  22. package/dist/shell/document.d.ts +6 -0
  23. package/dist/shell/document.js +8 -2
  24. package/dist/shell/runtime.js +33 -1
  25. package/package.json +1 -1
  26. package/src/compile/policy.ts +10 -1
  27. package/src/config/schema.ts +12 -0
  28. package/src/config/validate.ts +15 -0
  29. package/src/conformance/served.ts +24 -9
  30. package/src/guide/guide.ts +3 -1
  31. package/src/host/create-host.ts +26 -7
  32. package/src/host/types.ts +39 -2
  33. package/src/index.ts +2 -0
  34. package/src/presets/presets.ts +4 -0
  35. package/src/react/frame.tsx +125 -23
  36. package/src/server/naming.ts +41 -0
  37. package/src/server/pages.ts +2 -0
  38. package/src/server/server.ts +61 -20
  39. package/src/server/types.ts +6 -3
  40. package/src/shell/annotate.ts +31 -0
  41. package/src/shell/document.ts +15 -2
  42. package/src/shell/runtime.ts +33 -1
@@ -39,15 +39,27 @@ export function PortholeProvider(props) {
39
39
  return copied;
40
40
  }, []);
41
41
  const closeNotice = useCallback(() => setNotice(null), []);
42
- const { hostInfo, locator } = props;
42
+ // Compared by value, and read when used: an inline hostInfo or locator doesn't start every
43
+ // frame again on each render.
44
+ const hostInfo = useByValue(props.hostInfo);
45
+ const ownLocator = useRef(props.locator);
46
+ useLayoutEffect(() => {
47
+ ownLocator.current = props.locator;
48
+ });
49
+ const locate = useCallback((instance, signal) => ownLocator.current === undefined
50
+ ? Promise.reject(new Error("No locator (PortholeProvider.locator)"))
51
+ : ownLocator.current.locate(instance, signal), []);
52
+ const locates = props.locator !== undefined;
53
+ const { nonce } = props;
43
54
  const value = useMemo(() => ({
44
55
  live,
45
56
  heights,
46
57
  confirm,
47
58
  copyText,
48
59
  ...(hostInfo === undefined ? {} : { hostInfo }),
49
- ...(locator === undefined ? {} : { locator }),
50
- }), [live, heights, confirm, copyText, hostInfo, locator]);
60
+ ...(locates ? { locate } : {}),
61
+ ...(nonce === undefined ? {} : { nonce }),
62
+ }), [live, heights, confirm, copyText, hostInfo, locates, locate, nonce]);
51
63
  const current = queue[0] ?? null;
52
64
  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) => {
53
65
  // A yes runs the effect inside this very click (a window it opens is the person's).
@@ -134,6 +146,8 @@ export function PortholeFrame(props) {
134
146
  return (_jsx("div", { ref: box, className: props.className, "data-porthole-display": props.displayId, "data-porthole-state": state, style: {
135
147
  position: "relative",
136
148
  width: "100%",
149
+ // A display that fills takes its parent's height (the host sizes the parent, or this box).
150
+ ...(props.fill === true || props.config.mode === "url" ? { height: "100%" } : {}),
137
151
  ...(state === "running" ? {} : { minHeight: Math.max(height, PLACEHOLDER_HEIGHT_PX) }),
138
152
  ...props.style,
139
153
  }, children: state === "running" ? (_jsx(Mount, { frame: props, page: page, initialHeightPx: height, placeholder: placeholder, run: run }, String(props.generation ?? 0))) : (placeholder({ state, title: props.title, run })) }));
@@ -145,8 +159,11 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
145
159
  const [ready, setReady] = useState(false);
146
160
  // The height this display started at: later reports don't make it start again.
147
161
  const [startHeight] = useState(initialHeightPx);
148
- const { config, document: view, instance, content, partial, scheme } = frame;
149
- const fills = config.mode === "url";
162
+ const { document: view, content, partial, scheme } = frame;
163
+ // By value: a config or instance made inline (each render) is the same display.
164
+ const config = useByValue(frame.config);
165
+ const instance = useByValue(frame.instance);
166
+ const fills = config.mode === "url" || frame.fill === true;
150
167
  // The latest props, read when the display calls back (never during render).
151
168
  const latest = useEffectEvent(() => frame);
152
169
  const notify = useEffectEvent((event, detail) => {
@@ -161,9 +178,17 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
161
178
  frame.onResize?.(size);
162
179
  return;
163
180
  }
164
- case "error":
165
- frame.onError?.(detail.message);
181
+ case "contentError":
182
+ frame.onContentError?.(detail.message);
183
+ return;
184
+ case "hostError":
185
+ frame.onHostError?.(detail);
166
186
  return;
187
+ case "error": {
188
+ const { message, source } = detail;
189
+ frame.onError?.(message, source);
190
+ return;
191
+ }
167
192
  case "navigated":
168
193
  frame.onNavigated?.();
169
194
  return;
@@ -220,7 +245,8 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
220
245
  : {}),
221
246
  theme: (each) => latest().theme?.(each) ?? {},
222
247
  ...(page.hostInfo === undefined ? {} : { hostInfo: page.hostInfo }),
223
- ...(page.locator === undefined ? {} : { locator: page.locator }),
248
+ ...(page.locate === undefined ? {} : { locator: { locate: page.locate } }),
249
+ ...(page.nonce === undefined ? {} : { nonce: page.nonce }),
224
250
  }).mount(container, {
225
251
  displayId: first.displayId,
226
252
  title: first.title,
@@ -228,10 +254,11 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
228
254
  initialHeightPx: startHeight,
229
255
  ...(view === undefined ? {} : { document: view }),
230
256
  ...(instance === undefined ? {} : { instance }),
257
+ ...(fills ? { fill: true } : {}),
231
258
  });
232
259
  }
233
260
  catch (error) {
234
- first.onError?.(error instanceof Error ? error.message : String(error));
261
+ hostFailed(first, error, "mount");
235
262
  return;
236
263
  }
237
264
  handle.current = display;
@@ -244,6 +271,8 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
244
271
  "muted",
245
272
  "outcome",
246
273
  "annotation",
274
+ "contentError",
275
+ "hostError",
247
276
  ];
248
277
  for (const event of events) {
249
278
  display.on(event, (detail) => notify(event, detail));
@@ -260,7 +289,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
260
289
  };
261
290
  // A new display only for a new config, document, instance or page (or generation, by key);
262
291
  // content, theme, handlers, id and title are fed to the running one.
263
- }, [config, view, instance, page, startHeight]);
292
+ }, [config, view, instance, page, startHeight, fills]);
264
293
  useEffect(() => {
265
294
  if (content !== undefined) {
266
295
  handle.current?.setContent(content, { partial: partial === true });
@@ -268,7 +297,15 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
268
297
  }, [content, partial]);
269
298
  // Annotations follow their props (a new display gets them all when it is made, above).
270
299
  const annotates = config.capabilities.annotate !== undefined;
271
- const { annotating, annotations, focusedAnnotation } = frame;
300
+ const { annotating } = frame;
301
+ const focusedAnnotation = focusOf(frame.focusedAnnotation);
302
+ const focusToken = focusedAnnotation === null
303
+ ? ""
304
+ : typeof frame.focusedAnnotation === "object" && frame.focusedAnnotation !== null
305
+ ? JSON.stringify([frame.focusedAnnotation.id, frame.focusedAnnotation.key])
306
+ : JSON.stringify([focusedAnnotation]);
307
+ // By value: the same marks given again don't mark them again.
308
+ const annotations = useByValue(frame.annotations);
272
309
  const mode = annotating === undefined || annotating === null
273
310
  ? ""
274
311
  : JSON.stringify([annotating.elements ?? true, annotating.text ?? true]);
@@ -290,10 +327,11 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
290
327
  }, [annotates, annotations]);
291
328
  useEffect(() => {
292
329
  const display = handle.current;
293
- if (annotates && display !== null && typeof focusedAnnotation === "string") {
294
- attempt(() => display.annotations.focus(focusedAnnotation), latest());
330
+ const id = focusOf(latest().focusedAnnotation);
331
+ if (annotates && display !== null && focusToken !== "" && id !== null) {
332
+ attempt(() => display.annotations.focus(id), latest());
295
333
  }
296
- }, [annotates, focusedAnnotation]);
334
+ }, [annotates, focusToken]);
297
335
  useEffect(() => {
298
336
  handle.current?.setTheme(scheme);
299
337
  }, [scheme]);
@@ -310,13 +348,19 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
310
348
  ...(ready ? {} : { minHeight: Math.max(startHeight, PLACEHOLDER_HEIGHT_PX) }),
311
349
  }, 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 })] }));
312
350
  }
313
- /** Runs an annotation call, telling the frame's `onError` what it threw (a bad anchor, say). */
351
+ /** Tells the frame's props what failed on the host's side. */
352
+ function hostFailed(frame, error, kind) {
353
+ const message = error instanceof Error ? error.message : String(error);
354
+ frame.onHostError?.({ message, kind });
355
+ frame.onError?.(message, "host");
356
+ }
357
+ /** Runs an annotation call, telling the frame's props what it threw (a bad anchor, say). */
314
358
  function attempt(run, frame) {
315
359
  try {
316
360
  run();
317
361
  }
318
362
  catch (error) {
319
- frame.onError?.(error instanceof Error ? error.message : String(error));
363
+ hostFailed(frame, error, "annotations");
320
364
  }
321
365
  }
322
366
  /** A new display's annotations, from the props as they are. */
@@ -325,11 +369,29 @@ function applyAnnotations(display, frame) {
325
369
  if (frame.annotations !== undefined && frame.annotations.length > 0) {
326
370
  display.annotations.show(frame.annotations);
327
371
  }
328
- if (typeof frame.focusedAnnotation === "string") {
329
- display.annotations.focus(frame.focusedAnnotation);
372
+ const focused = focusOf(frame.focusedAnnotation);
373
+ if (focused !== null) {
374
+ display.annotations.focus(focused);
330
375
  }
331
376
  if (frame.annotating !== undefined && frame.annotating !== null) {
332
377
  display.annotations.start(frame.annotating);
333
378
  }
334
379
  }, frame);
335
380
  }
381
+ /**
382
+ * `value` as long as it is equal by value (as JSON): plain data a host builds inline on each
383
+ * render (a config, an instance, marks, hostInfo) keeps one identity, so effects keyed on it run
384
+ * only when it really changes.
385
+ */
386
+ function useByValue(value) {
387
+ const key = value === undefined ? "" : JSON.stringify(value);
388
+ return useMemo(() => (key === "" ? undefined : JSON.parse(key)), [key]);
389
+ }
390
+ /** The id `focusedAnnotation` names, if any. */
391
+ function focusOf(value) {
392
+ return typeof value === "string"
393
+ ? value
394
+ : value === null || value === undefined
395
+ ? null
396
+ : value.id;
397
+ }
@@ -15,3 +15,9 @@ export declare function isHostLabel(label: unknown): label is string;
15
15
  * than being folded into another scope's name.
16
16
  */
17
17
  export declare function prefixedName(prefix: string): (scope: string) => string;
18
+ /**
19
+ * Whether a label is reserved (`ContentDomain.reserved`): a whole label, a prefix ending in `*`
20
+ * (`p-*`), or the host's own function. Entries are checked once, here; a function that throws
21
+ * reserves the label (fail closed: the request goes to the host's routes, never served as ours).
22
+ */
23
+ export declare function reservedLabels(reserved: readonly string[] | ((label: string) => boolean) | undefined): (label: string) => boolean;
@@ -32,3 +32,41 @@ export function prefixedName(prefix) {
32
32
  return label;
33
33
  };
34
34
  }
35
+ const PREFIX_ENTRY = /^[a-z0-9]([a-z0-9-]{0,61})?\*$/u;
36
+ /**
37
+ * Whether a label is reserved (`ContentDomain.reserved`): a whole label, a prefix ending in `*`
38
+ * (`p-*`), or the host's own function. Entries are checked once, here; a function that throws
39
+ * reserves the label (fail closed: the request goes to the host's routes, never served as ours).
40
+ */
41
+ export function reservedLabels(reserved) {
42
+ if (reserved === undefined) {
43
+ return () => false;
44
+ }
45
+ if (typeof reserved === "function") {
46
+ return (label) => {
47
+ try {
48
+ return reserved(label);
49
+ }
50
+ catch {
51
+ return true;
52
+ }
53
+ };
54
+ }
55
+ if (!Array.isArray(reserved)) {
56
+ throw new TypeError("content.reserved: a list of labels and prefixes, or a function");
57
+ }
58
+ const whole = new Set();
59
+ const prefixes = [];
60
+ for (const entry of reserved) {
61
+ if (typeof entry === "string" && PREFIX_ENTRY.test(entry)) {
62
+ prefixes.push(entry.slice(0, -1));
63
+ }
64
+ else if (isHostLabel(entry)) {
65
+ whole.add(entry);
66
+ }
67
+ else {
68
+ throw new TypeError(`content.reserved: ${JSON.stringify(entry)} is not a lowercase DNS label or a prefix ending in *`);
69
+ }
70
+ }
71
+ return (label) => whole.has(label) || prefixes.some((prefix) => label.startsWith(prefix));
72
+ }
@@ -77,6 +77,8 @@ const pageHeaders = (frameAncestors, connect) => ({
77
77
  "content-security-policy": policy(frameAncestors, connect),
78
78
  "referrer-policy": "no-referrer",
79
79
  "x-content-type-options": "nosniff",
80
+ // Content origins are behind tickets: nothing on them is for search engines.
81
+ "x-robots-tag": "noindex, nofollow",
80
82
  });
81
83
  export function statusPage(status, title, detail, frameAncestors, method = "GET", reason = "error", hostOrigins = [], code) {
82
84
  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)}"${code === undefined ? "" : ` data-porthole-code="${escape(code)}"`} data-porthole-hosts="${escape(JSON.stringify(hostOrigins))}"><h1>${escape(title)}</h1><p>${escape(detail)}</p></main><script src="${STATUS_SCRIPT_PATH}"></script></body></html>`;
@@ -2,7 +2,7 @@ import { compile } from "../compile/compile.js";
2
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 { isHostLabel } from "./naming.js";
5
+ import { isHostLabel, reservedLabels } from "./naming.js";
6
6
  import { enterPage, pageScript, SESSION_PATH, statusPage } from "./pages.js";
7
7
  import { memoryTicketStore } from "./tickets.js";
8
8
  /**
@@ -26,6 +26,17 @@ const DEFAULT_SESSION_SECONDS = 3_600;
26
26
  const MAX_SESSION_SECONDS = 30 * 24 * 3_600;
27
27
  /** Browsers keep a cookie of up to 4 KB, name and attributes included. */
28
28
  const MAX_COOKIE_BYTES = 3_800;
29
+ /** A host (and port) as it compares: lowercase, no trailing dot, no default port. */
30
+ function normalHost(value, protocol) {
31
+ const host = value
32
+ .trim()
33
+ .toLowerCase()
34
+ .replace(/\.(?=:\d+$|$)/u, "");
35
+ const defaultPort = protocol === "https:" ? ":443" : protocol === "http:" ? ":80" : "";
36
+ return defaultPort !== "" && host.endsWith(defaultPort)
37
+ ? host.slice(0, -defaultPort.length)
38
+ : host;
39
+ }
29
40
  const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
30
41
  /** Which configs a kind of served document may be served under. */
31
42
  const MODES = {
@@ -148,25 +159,20 @@ export function createServer(options) {
148
159
  throw new TypeError("content.port must be a port number");
149
160
  }
150
161
  const keys = createKeys(options.secret);
151
- const reserved = new Set(content.reserved ?? []);
152
- for (const label of reserved) {
153
- if (!isHostLabel(label)) {
154
- throw new TypeError(`content.reserved: ${JSON.stringify(label)} is not a lowercase DNS label`);
155
- }
156
- }
162
+ const isReserved = reservedLabels(content.reserved);
157
163
  /** The label a scope is served on: the host's naming, checked, or the keyed default. */
158
164
  const nameOf = (scope) => {
159
165
  if (content.name === undefined) {
160
166
  return keys.label(scope);
161
167
  }
162
168
  const label = content.name(scope);
163
- if (!isHostLabel(label) || reserved.has(label)) {
169
+ if (!isHostLabel(label) || isReserved(label)) {
164
170
  throw new TypeError(`content.name gave ${JSON.stringify(label)} for a scope: it must be one lowercase DNS label (a-z 0-9 -, at most 63, not xn--), not reserved`);
165
171
  }
166
172
  return label;
167
173
  };
168
174
  /** Whether a request's label may be one this server named. */
169
- const ours = (label) => !reserved.has(label) && (content.name === undefined ? LABEL.test(label) : isHostLabel(label));
175
+ const ours = (label) => !isReserved(label) && (content.name === undefined ? LABEL.test(label) : isHostLabel(label));
170
176
  const secure = content.scheme === "https";
171
177
  const defaultPort = secure ? 443 : 80;
172
178
  const portPart = content.port === undefined || content.port === defaultPort ? "" : `:${content.port}`;
@@ -431,6 +437,7 @@ export function createServer(options) {
431
437
  "cache-control": "no-store",
432
438
  "referrer-policy": "no-referrer",
433
439
  "x-content-type-options": "nosniff",
440
+ "x-robots-tag": "noindex, nofollow",
434
441
  });
435
442
  }
436
443
  async function serveDocument(request, url, label, key) {
@@ -563,7 +570,16 @@ export function createServer(options) {
563
570
  return page(502, "The app didn't answer", "Its server couldn't be reached.", method);
564
571
  }
565
572
  }
566
- /** The label a request is for: null when it isn't under the content domain at all. */
573
+ const underDomain = (host) => host === domain || host.endsWith(`.${domain}`);
574
+ /**
575
+ * The label a request is for: null when it isn't under the content domain at all (or is in a
576
+ * reserved namespace), "mismatch" when its Host header names another host than its URL does
577
+ * and either is under the content domain. A Node adapter may build the URL from an
578
+ * absolute-form request target (`GET http://other/…`) while a host routes by the Host header
579
+ * (and a cache keys on it): the two must agree before anything is served. A Host that is a
580
+ * local server address (an IP literal, `localhost`), as a test runner or a dev proxy sends, is
581
+ * not a name and isn't compared.
582
+ */
567
583
  function labelOf(request) {
568
584
  let url;
569
585
  try {
@@ -573,13 +589,25 @@ export function createServer(options) {
573
589
  return null;
574
590
  }
575
591
  const host = url.hostname.toLowerCase().replace(/\.$/u, "");
576
- if (host !== domain && !host.endsWith(`.${domain}`)) {
592
+ const header = request.headers.get("host");
593
+ if (header !== null) {
594
+ const named = normalHost(header, url.protocol);
595
+ const name = named.replace(/:\d+$/u, "");
596
+ const local = name === "localhost" || /^[\d.]+$/u.test(name) || name.startsWith("[");
597
+ if (!local &&
598
+ named !== normalHost(url.host, url.protocol) &&
599
+ (underDomain(host) || underDomain(name))) {
600
+ return "mismatch";
601
+ }
602
+ }
603
+ if (!underDomain(host)) {
577
604
  return null;
578
605
  }
579
606
  const label = host.slice(0, Math.max(0, host.length - domain.length - 1));
580
- // A reserved label is another server's namespace (`f` for `<x>.f.<domain>`): it and the names
581
- // under it go to the host's own routes. Any other deeper name is ours to refuse.
582
- if (reserved.has(label.split(".").at(-1) ?? "")) {
607
+ // A reserved label is another server's namespace (`f` for `<x>.f.<domain>`), or the host's
608
+ // own: it and the names under it go to the host's own routes. Any other deeper name is ours
609
+ // to refuse.
610
+ if (isReserved(label.split(".").at(-1) ?? "")) {
583
611
  return null;
584
612
  }
585
613
  return { url, label };
@@ -639,7 +667,7 @@ export function createServer(options) {
639
667
  },
640
668
  async access(request) {
641
669
  const at = labelOf(request);
642
- if (at === null || !ours(at.label)) {
670
+ if (at === null || at === "mismatch" || !ours(at.label)) {
643
671
  return null;
644
672
  }
645
673
  const access = accessOf(request, at.label, null);
@@ -650,8 +678,11 @@ export function createServer(options) {
650
678
  if (at === null) {
651
679
  return null;
652
680
  }
653
- const { url, label } = at;
654
681
  const method = request.method.toUpperCase();
682
+ if (at === "mismatch") {
683
+ return page(400, "Bad request", "The request's Host header and its URL name different hosts.", method);
684
+ }
685
+ const { url, label } = at;
655
686
  try {
656
687
  if (!ours(label)) {
657
688
  return page(404, "Nothing here", "This address names no content.", method);
@@ -673,7 +704,8 @@ export function createServer(options) {
673
704
  if (document?.[1] !== undefined) {
674
705
  return await serveDocument(request, url, label, document[1]);
675
706
  }
676
- if (url.pathname.startsWith(PREFIX)) {
707
+ // Without an upstream, only Porthole's own paths are served here: any other is nothing.
708
+ if (url.pathname.startsWith(PREFIX) || options.upstream === undefined) {
677
709
  return page(404, "Nothing here", "This address names no content.", method);
678
710
  }
679
711
  return await serveApp(request, label);
@@ -18,10 +18,13 @@ export interface ContentDomain {
18
18
  */
19
19
  readonly name?: (scope: string) => string;
20
20
  /**
21
- * Labels this server never serves or names (another server's namespace under the same domain,
22
- * `f` for `<instance>.f.<domain>`, or `www`). Requests for them get null (the host's routes).
21
+ * Labels this server never serves or names: another server's namespace under the same domain
22
+ * (`f` for `<instance>.f.<domain>`, `www`), or the host's own (`p-*`: every label starting
23
+ * `p-`). Each entry is a whole label, or a prefix ending in `*`; or a function that says
24
+ * whether a label is reserved. Requests for them, and for the names under them, get null (the
25
+ * host's routes), so a host routes them without wrapping `handle`.
23
26
  */
24
- readonly reserved?: readonly string[];
27
+ readonly reserved?: readonly string[] | ((label: string) => boolean);
25
28
  }
26
29
  /**
27
30
  * Who may open an instance, decided by the host before it calls `issue`: a viewer the host
@@ -369,6 +369,9 @@ export const ANNOTATE_RUNTIME = String.raw `if (declared("annotate")) {
369
369
  const root = ensureLayer();
370
370
  const parts = [];
371
371
  const style = createElement.call(document, "style");
372
+ if (nonce !== "") {
373
+ style.nonce = nonce;
374
+ }
372
375
  style.textContent = STYLE;
373
376
  parts.push(style);
374
377
  const flashOn = Date.now() < flashUntil;
@@ -439,20 +442,41 @@ export const ANNOTATE_RUNTIME = String.raw `if (declared("annotate")) {
439
442
  let anchorTimer = 0;
440
443
  let deadline = 0;
441
444
  let reportAll = false;
445
+ // Nothing is reported before the content is in place (an empty shell would orphan every
446
+ // mark), nor before a run of changes settles; a focus asked for meanwhile waits for it.
447
+ let waitingForContent = false;
448
+ let pendingFocus = null;
449
+ const settling = () => anchorTimer !== 0 || waitingForContent || marks.some((mark) => mark.state === null);
442
450
  const scheduleAnchor = (all) => {
443
451
  if (all) {
444
452
  reportAll = true;
445
453
  }
454
+ if (!contentIsIn()) {
455
+ if (!waitingForContent) {
456
+ waitingForContent = true;
457
+ whenContentIsIn(() => {
458
+ waitingForContent = false;
459
+ scheduleAnchor(false);
460
+ });
461
+ }
462
+ return;
463
+ }
446
464
  const now = Date.now();
447
465
  if (deadline === 0) {
448
466
  deadline = now + 1000;
449
467
  }
450
468
  clearTimeout(anchorTimer);
451
469
  anchorTimer = setTimeout(() => {
470
+ anchorTimer = 0;
452
471
  deadline = 0;
453
472
  const every = reportAll;
454
473
  reportAll = false;
455
474
  anchorAll(every);
475
+ if (pendingFocus !== null) {
476
+ const id = pendingFocus;
477
+ pendingFocus = null;
478
+ focus(id);
479
+ }
456
480
  }, Math.max(0, Math.min(150, deadline - now)));
457
481
  };
458
482
  new MutationObserver((records) => {
@@ -475,6 +499,12 @@ export const ANNOTATE_RUNTIME = String.raw `if (declared("annotate")) {
475
499
  if (!mark) {
476
500
  return;
477
501
  }
502
+ if (settling()) {
503
+ // Where it is isn't known yet: focus it once anchoring has settled, then say where.
504
+ pendingFocus = id;
505
+ active = id;
506
+ return;
507
+ }
478
508
  active = id;
479
509
  if (mark.found !== null) {
480
510
  const rect = rectOf(mark.found);
@@ -657,6 +687,7 @@ export const ANNOTATE_RUNTIME = String.raw `if (declared("annotate")) {
657
687
  focus(String(params.id));
658
688
  break;
659
689
  case M.clear:
690
+ pendingFocus = null;
660
691
  marks = [];
661
692
  active = null;
662
693
  draw();
@@ -21,6 +21,12 @@ export interface ShellDocumentOptions {
21
21
  * both apply, and the stricter wins.
22
22
  */
23
23
  readonly cspMeta?: boolean;
24
+ /**
25
+ * The embedding page's CSP nonce, where its `script-src` is nonce-based (P054): a `srcdoc` frame
26
+ * inherits the embedding page's policy, so the shell's script and style carry it, and so does
27
+ * every script the shell runs for the content. The frame can read it.
28
+ */
29
+ readonly nonce?: string;
24
30
  }
25
31
  /**
26
32
  * The frame's document: its policy first, then the base styles, an empty root, and the runtime.
@@ -1,6 +1,8 @@
1
1
  import { hostOriginProblem } from "../isolation/served.js";
2
2
  import { ANNOTATE_RUNTIME } from "./annotate.js";
3
3
  import { SHELL_RUNTIME } from "./runtime.js";
4
+ /** A CSP nonce: base64 or base64url characters, 8 to 256 of them. */
5
+ const NONCE = /^[A-Za-z0-9+/_=-]{8,256}$/u;
4
6
  const escapeAttribute = (value) => value.replace(/&/gu, "&amp;").replace(/"/gu, "&quot;").replace(/</gu, "&lt;");
5
7
  const escapeText = (value) => value.replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
6
8
  /** JSON that is safe inside a `<script>`: no `</script>`, no `<!--`. */
@@ -53,6 +55,10 @@ export function shellDocument(plan, csp, options) {
53
55
  throw new TypeError(`shellDocument: ${problem}`);
54
56
  }
55
57
  }
58
+ if (options.nonce !== undefined && !NONCE.test(options.nonce)) {
59
+ throw new TypeError("shellDocument: a nonce is 8 to 256 base64 characters");
60
+ }
61
+ const nonce = options.nonce === undefined ? "" : ` nonce="${options.nonce}"`;
56
62
  // The annotation runtime only where it is declared; spliced in before the plan, which may carry
57
63
  // any text (a document) and must not be searched.
58
64
  const runtime = SHELL_RUNTIME.replace("__ANNOTATE__", () => plan.capabilities.annotate === undefined ? "" : ANNOTATE_RUNTIME).replace("__PLAN__", () => scriptJson({
@@ -75,13 +81,13 @@ export function shellDocument(plan, csp, options) {
75
81
  ${head.join("\n")}
76
82
  <meta name="viewport" content="width=device-width, initial-scale=1">
77
83
  <title>${escapeText(options.title ?? "Content")}</title>
78
- <style>
84
+ <style${nonce}>
79
85
  ${BASE_STYLE}
80
86
  </style>
81
87
  </head>
82
88
  <body>
83
89
  <div id="porthole-root"></div>
84
- <script>${runtime}</script>
90
+ <script${nonce}>${runtime}</script>
85
91
  </body>
86
92
  </html>
87
93
  `;
@@ -31,6 +31,9 @@
31
31
  export const SHELL_RUNTIME = String.raw `(() => {
32
32
  "use strict";
33
33
  const plan = __PLAN__;
34
+ // The embedding page's nonce, where it gave one (a srcdoc frame inherits its policy): every
35
+ // script the shell runs for the content carries it.
36
+ const nonce = document.currentScript && document.currentScript.nonce ? document.currentScript.nonce : "";
34
37
  const declared = (name) => Object.prototype.hasOwnProperty.call(plan.capabilities, name);
35
38
  // Fixed in place: content can neither restore what is removed nor replace what is wrapped.
36
39
  const lock = (target, name, value) => {
@@ -485,6 +488,9 @@ export const SHELL_RUNTIME = String.raw `(() => {
485
488
  }
486
489
  // A script made by a script is async unless told otherwise: keep the page's order.
487
490
  live.async = script.hasAttribute("async");
491
+ if (nonce !== "") {
492
+ live.nonce = nonce;
493
+ }
488
494
  live.textContent = script.textContent;
489
495
  const external = live.hasAttribute("src") && !live.async && live.type !== "module";
490
496
  const loaded = external
@@ -509,6 +515,27 @@ export const SHELL_RUNTIME = String.raw `(() => {
509
515
  window.dispatchEvent(new Event("load"));
510
516
  };
511
517
 
518
+ // Whether the content is in place: a fragment or page the shell draws, once drawn whole; a
519
+ // view the plan carries, once rendered; a view that is the document itself, from the start.
520
+ const contentHooks = [];
521
+ let contentIn = typeof plan.document !== "string" && plan.protocol !== "shell";
522
+ const contentIsIn = () => contentIn;
523
+ const whenContentIsIn = (run) => {
524
+ if (contentIn) {
525
+ run();
526
+ } else {
527
+ contentHooks.push(run);
528
+ }
529
+ };
530
+ const contentArrived = () => {
531
+ if (!contentIn) {
532
+ contentIn = true;
533
+ for (const run of contentHooks.splice(0)) {
534
+ run();
535
+ }
536
+ }
537
+ };
538
+
512
539
  // A fragment takes the person's scheme; a whole document only if it declares that it supports
513
540
  // both (or the config says always), else it shows as its author styled it (P039).
514
541
  let lastContext = null;
@@ -544,6 +571,7 @@ export const SHELL_RUNTIME = String.raw `(() => {
544
571
  schemeFollows();
545
572
  await runScripts(scripts);
546
573
  announceLoaded();
574
+ contentArrived();
547
575
  };
548
576
 
549
577
  // A document the plan carries (a served page, an MCP Apps view) is rendered once the shell's own
@@ -672,7 +700,10 @@ export const SHELL_RUNTIME = String.raw `(() => {
672
700
  window.addEventListener("error", (event) => {
673
701
  const error = event.error;
674
702
  const where = event.lineno ? " (line " + event.lineno + ")" : "";
675
- report(error && error.stack ? String(error.stack).split("\n").slice(0, 3).join(" ") : event.message + where);
703
+ // V8's stack starts with the message; WebKit's and Firefox's don't, so it goes first there.
704
+ const stack = error && error.stack ? String(error.stack).split("\n").slice(0, 3).join(" ") : "";
705
+ const said = error && error.message !== undefined ? String(error.name || "Error") + ": " + String(error.message) : event.message;
706
+ report(stack === "" ? said + where : stack.indexOf(String(error.message)) === -1 ? said + " " + stack : stack);
676
707
  });
677
708
  window.addEventListener("unhandledrejection", (event) => {
678
709
  const reason = event.reason;
@@ -804,6 +835,7 @@ export const SHELL_RUNTIME = String.raw `(() => {
804
835
  schedule();
805
836
  await runScripts(scripts);
806
837
  announceLoaded();
838
+ contentArrived();
807
839
  schedule();
808
840
  };
809
841
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botiverse/porthole",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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": [
@@ -129,6 +129,15 @@ export function embedderFrameSources(config: PortholeConfig): string[] {
129
129
  * engine Porthole supports reads frames from `frame-src`, P038).
130
130
  */
131
131
  export function embedderCsp(configs: readonly PortholeConfig[]): string {
132
- const sources = unique(configs.flatMap(embedderFrameSources));
132
+ const sources = embedderFrameSourcesOf(configs);
133
133
  return serializeCsp([["frame-src", ...(sources.length === 0 ? ["'none'"] : sources)]]);
134
134
  }
135
+
136
+ /**
137
+ * The `frame-src` sources the embedding page must allow for every kind of display it shows, as a
138
+ * list, for a host that writes its own policy around them (empty: it frames nothing of
139
+ * Porthole's but `srcdoc`, which needs none).
140
+ */
141
+ export function embedderFrameSourcesOf(configs: readonly PortholeConfig[]): string[] {
142
+ return unique(configs.flatMap(embedderFrameSources));
143
+ }