@botiverse/porthole 0.1.3 → 0.1.5

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 (51) hide show
  1. package/dist/compile/policy.d.ts +10 -2
  2. package/dist/compile/policy.js +18 -4
  3. package/dist/config/schema.d.ts +20 -0
  4. package/dist/config/schema.js +2 -0
  5. package/dist/config/validate.js +174 -2
  6. package/dist/conformance/behavior.js +14 -1
  7. package/dist/conformance/kit.d.ts +5 -0
  8. package/dist/conformance/kit.js +11 -0
  9. package/dist/conformance/probes.js +19 -2
  10. package/dist/conformance/served.d.ts +8 -1
  11. package/dist/conformance/served.js +12 -8
  12. package/dist/guide/guide.d.ts +2 -0
  13. package/dist/guide/guide.js +22 -5
  14. package/dist/host/create-host.js +24 -7
  15. package/dist/host/types.d.ts +44 -2
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +2 -1
  18. package/dist/presets/presets.d.ts +3 -1
  19. package/dist/presets/presets.js +5 -0
  20. package/dist/react/frame.d.ts +34 -4
  21. package/dist/react/frame.js +91 -22
  22. package/dist/server/naming.d.ts +6 -0
  23. package/dist/server/naming.js +38 -0
  24. package/dist/server/pages.js +2 -0
  25. package/dist/server/server.js +53 -17
  26. package/dist/server/types.d.ts +6 -3
  27. package/dist/shell/annotate.js +31 -0
  28. package/dist/shell/document.d.ts +6 -0
  29. package/dist/shell/document.js +8 -2
  30. package/dist/shell/runtime.js +47 -2
  31. package/package.json +1 -1
  32. package/src/compile/policy.ts +22 -6
  33. package/src/config/schema.ts +23 -0
  34. package/src/config/validate.ts +193 -2
  35. package/src/conformance/behavior.ts +20 -1
  36. package/src/conformance/kit.ts +16 -0
  37. package/src/conformance/probes.ts +21 -2
  38. package/src/conformance/served.ts +24 -9
  39. package/src/guide/guide.ts +25 -4
  40. package/src/host/create-host.ts +26 -7
  41. package/src/host/types.ts +39 -2
  42. package/src/index.ts +4 -0
  43. package/src/presets/presets.ts +8 -0
  44. package/src/react/frame.tsx +136 -27
  45. package/src/server/naming.ts +41 -0
  46. package/src/server/pages.ts +2 -0
  47. package/src/server/server.ts +65 -20
  48. package/src/server/types.ts +6 -3
  49. package/src/shell/annotate.ts +31 -0
  50. package/src/shell/document.ts +15 -2
  51. package/src/shell/runtime.ts +47 -2
package/src/host/types.ts CHANGED
@@ -34,6 +34,13 @@ export interface HostOptions<C extends AdapterConnection = ShellConnection> {
34
34
  readonly platform?: PlatformHost;
35
35
  /** Required for domain and external isolation: where an instance's frame loads from. */
36
36
  readonly locator?: InstanceLocator;
37
+ /**
38
+ * The embedding page's CSP nonce, where its `script-src` is nonce-based (P054): an opaque frame
39
+ * (`srcdoc`) inherits the page's policy, so the shell and the content's scripts carry it. A
40
+ * function is read each time a frame starts (a page whose nonce changes). Served frames inherit
41
+ * nothing and need none.
42
+ */
43
+ readonly nonce?: string | (() => string | undefined);
37
44
  }
38
45
 
39
46
  export interface MountOptions {
@@ -50,8 +57,23 @@ export interface MountOptions {
50
57
  readonly document?: string;
51
58
  /** The height to start at, before the content reports its own. */
52
59
  readonly initialHeightPx?: number;
60
+ /**
61
+ * Fill the container (give it a height) instead of taking the content's height: the content
62
+ * scrolls inside its frame (an artifact viewer's page). The content's sizes are still reported
63
+ * (`resize`). A url-mode app always fills.
64
+ */
65
+ readonly fill?: boolean;
53
66
  }
54
67
 
68
+ /** What failed on the host's side (`hostError`). */
69
+ export type HostErrorKind =
70
+ /** The locator rejected: the host's backend didn't give a location. */
71
+ | "locate"
72
+ /** A display couldn't be made (the React binding: a bad config, a missing handler). */
73
+ | "mount"
74
+ /** An annotation call was refused (an invalid mark, an unknown id). */
75
+ | "annotations";
76
+
55
77
  /** What a display reports, by event name. */
56
78
  /** Why a display's frame showed nothing of its content (`FrameOutcome`). */
57
79
  export type FrameBlock =
@@ -65,6 +87,8 @@ export type FrameBlock =
65
87
  | "upstream"
66
88
  /** The host's own check refused it (revoked, no longer a member): `code` says which. */
67
89
  | "denied"
90
+ /** The host's locator failed (its backend down, access gone): see the `hostError` event. */
91
+ | "locate"
68
92
  | "not-found"
69
93
  | "error";
70
94
 
@@ -93,8 +117,21 @@ export interface DisplayEvents {
93
117
  readonly outcome: FrameOutcome;
94
118
  /** The content's size, after the config's limits. */
95
119
  readonly resize: { readonly height: number; readonly width?: number };
96
- /** What the content threw, or what the sandbox blocked (its own words: untrusted). */
97
- readonly error: { readonly message: string };
120
+ /**
121
+ * What the content did wrong: what it threw, what the sandbox blocked (its own words:
122
+ * untrusted), or that it kept navigating its frame away. The agent's to hear and fix.
123
+ */
124
+ readonly contentError: { readonly message: string };
125
+ /**
126
+ * What failed on the host's side (`kind`): its locator, for now. The host's to handle; never
127
+ * the agent's. The display's `outcome` says `blocked: locate` too.
128
+ */
129
+ readonly hostError: { readonly message: string; readonly kind: HostErrorKind };
130
+ /**
131
+ * Both of the above, with their `source`: for a host that listened here before they were
132
+ * split (0.1.3). Prefer `contentError` and `hostError`.
133
+ */
134
+ readonly error: { readonly message: string; readonly source: "content" | "host" };
98
135
  /** The content navigated its frame away; the frame was started again from its content. */
99
136
  readonly navigated: undefined;
100
137
  /** A call didn't run. */
package/src/index.ts CHANGED
@@ -29,9 +29,11 @@ export type {
29
29
  LayoutConfig,
30
30
  Limits,
31
31
  PortholeConfig,
32
+ StorageConfig,
32
33
  ThemeConfig,
33
34
  ThemeValue,
34
35
  } from "./config/schema.ts";
36
+ export { PASSIVE_KINDS, type PassiveKind } from "./config/schema.ts";
35
37
  export {
36
38
  BUILT_IN_TOPICS,
37
39
  ConfigError,
@@ -61,10 +63,12 @@ export {
61
63
  export { compile, type CompiledConfig } from "./compile/compile.ts";
62
64
  export {
63
65
  allEgressOrigins,
66
+ anyHttps,
64
67
  declaredCapabilities,
65
68
  effectiveGates,
66
69
  egressOrigins,
67
70
  embedderCsp,
71
+ embedderFrameSourcesOf,
68
72
  type CspDirective,
69
73
  type EgressKind,
70
74
  } from "./compile/policy.ts";
@@ -9,6 +9,7 @@ import type {
9
9
  LayoutConfig,
10
10
  Limits,
11
11
  PortholeConfig,
12
+ StorageConfig,
12
13
  ThemeValue,
13
14
  UrlPermission,
14
15
  UrlSandboxExtra,
@@ -45,6 +46,8 @@ export interface PresetOptions {
45
46
  readonly isolation?: Exclude<IsolationKind, "external">;
46
47
  readonly contentOrigins?: readonly string[];
47
48
  readonly embedder?: EmbedderConfig;
49
+ /** Domain isolation: what displays sharing an origin share (`StorageConfig`), for the manual. */
50
+ readonly storage?: StorageConfig;
48
51
  }
49
52
 
50
53
  /** The url preset's options: where the app is served, and the embedding page's extra frames. */
@@ -123,6 +126,10 @@ const mergeEgress = (base: EgressConfig, extra: EgressConfig | undefined): Egres
123
126
  merged[kind] = origins;
124
127
  }
125
128
  }
129
+ const passive = [...new Set([...(base.anyHttps ?? []), ...(extra?.anyHttps ?? [])])];
130
+ if (passive.length > 0) {
131
+ merged.anyHttps = passive;
132
+ }
126
133
  return merged;
127
134
  };
128
135
 
@@ -174,6 +181,7 @@ function build(
174
181
  guide: options.guide ?? {},
175
182
  contentOrigins: options.contentOrigins ?? [],
176
183
  embedder: options.embedder ?? {},
184
+ ...(options.storage === undefined ? {} : { storage: options.storage }),
177
185
  });
178
186
  }
179
187
 
@@ -29,7 +29,7 @@ import type { CapabilityName } from "../config/capabilities.ts";
29
29
  import type { ColorScheme, PortholeConfig } from "../config/schema.ts";
30
30
  import { writeClipboard } from "../host/clipboard.ts";
31
31
  import { createHost } from "../host/create-host.ts";
32
- import type { DisplayEvents, DisplayHandle, FrameOutcome } from "../host/types.ts";
32
+ import type { DisplayEvents, DisplayHandle, FrameOutcome, HostErrorKind } from "../host/types.ts";
33
33
  import type { InstanceLocator, InstanceRef } from "../isolation/types.ts";
34
34
  import { ConfirmDialog } from "./dialog.tsx";
35
35
  import { createLiveFrames, type FrameState, type LiveFrames } from "./live.ts";
@@ -53,7 +53,9 @@ interface PageState {
53
53
  readonly heights: Map<string, number>;
54
54
  readonly confirm?: Confirm;
55
55
  readonly hostInfo?: { readonly name: string; readonly version: string };
56
- readonly locator?: InstanceLocator;
56
+ readonly nonce?: string;
57
+ /** The provider's locator, read when a frame locates (so an inline one restarts nothing). */
58
+ readonly locate?: InstanceLocator["locate"];
57
59
  /** The default `copyText`: copies on the host's page and shows the toast (P052). */
58
60
  readonly copyText?: CapabilityHandler<"copyText">;
59
61
  }
@@ -77,6 +79,11 @@ export interface PortholeProviderProps {
77
79
  readonly dialogClassName?: string;
78
80
  /** A class for the default copy toast, to restyle it. */
79
81
  readonly toastClassName?: string;
82
+ /**
83
+ * The page's CSP nonce, where its `script-src` is nonce-based: opaque frames inherit the page's
84
+ * policy, so their shell carries it (P054).
85
+ */
86
+ readonly nonce?: string;
80
87
  readonly children?: ReactNode;
81
88
  }
82
89
 
@@ -113,7 +120,22 @@ export function PortholeProvider(props: PortholeProviderProps): ReactElement {
113
120
  return copied;
114
121
  }, []);
115
122
  const closeNotice = useCallback(() => setNotice(null), []);
116
- const { hostInfo, locator } = props;
123
+ // Compared by value, and read when used: an inline hostInfo or locator doesn't start every
124
+ // frame again on each render.
125
+ const hostInfo = useByValue(props.hostInfo);
126
+ const ownLocator = useRef(props.locator);
127
+ useLayoutEffect(() => {
128
+ ownLocator.current = props.locator;
129
+ });
130
+ const locate = useCallback<InstanceLocator["locate"]>(
131
+ (instance, signal) =>
132
+ ownLocator.current === undefined
133
+ ? Promise.reject(new Error("No locator (PortholeProvider.locator)"))
134
+ : ownLocator.current.locate(instance, signal),
135
+ [],
136
+ );
137
+ const locates = props.locator !== undefined;
138
+ const { nonce } = props;
117
139
  const value = useMemo<PageState>(
118
140
  () => ({
119
141
  live,
@@ -121,9 +143,10 @@ export function PortholeProvider(props: PortholeProviderProps): ReactElement {
121
143
  confirm,
122
144
  copyText,
123
145
  ...(hostInfo === undefined ? {} : { hostInfo }),
124
- ...(locator === undefined ? {} : { locator }),
146
+ ...(locates ? { locate } : {}),
147
+ ...(nonce === undefined ? {} : { nonce }),
125
148
  }),
126
- [live, heights, confirm, copyText, hostInfo, locator],
149
+ [live, heights, confirm, copyText, hostInfo, locates, locate, nonce],
127
150
  );
128
151
  const current = queue[0] ?? null;
129
152
  return (
@@ -192,9 +215,28 @@ export interface PortholeFrameProps {
192
215
  readonly generation?: number;
193
216
  /** Run at once, without waiting to near the screen (it still counts toward the page's most). */
194
217
  readonly eager?: boolean;
218
+ /**
219
+ * Fill the box (give it, or its parent, a height) and let the content scroll inside, instead of
220
+ * growing with the content: an artifact viewer's page. A url-mode app always fills.
221
+ */
222
+ readonly fill?: boolean;
195
223
  readonly onInitialized?: () => void;
196
224
  readonly onResize?: (size: { readonly height: number; readonly width?: number }) => void;
197
- readonly onError?: (message: string) => void;
225
+ /**
226
+ * What the content did wrong (what it threw, what the sandbox blocked, that it kept navigating
227
+ * away): the agent's to hear and fix.
228
+ */
229
+ readonly onContentError?: (message: string) => void;
230
+ /**
231
+ * What failed on the host's side: its locator (`locate`), a display that couldn't be made
232
+ * (`mount`), an annotation call refused (`annotations`). Never the agent's.
233
+ */
234
+ readonly onHostError?: (error: {
235
+ readonly message: string;
236
+ readonly kind: HostErrorKind;
237
+ }) => void;
238
+ /** Both, with their source (as before 0.1.4). Prefer `onContentError` and `onHostError`. */
239
+ readonly onError?: (message: string, source: "content" | "host") => void;
198
240
  /** The content navigated its frame away; it was started again (or stopped, after a few). */
199
241
  readonly onNavigated?: () => void;
200
242
  readonly onRefused?: (capability: CapabilityName, refusal: Refusal) => void;
@@ -223,8 +265,14 @@ export interface PortholeFrameProps {
223
265
  readonly annotating?: AnnotateMode | null;
224
266
  /** The anchors to mark in the frame (the host's comments). Keep it stable (memoized). */
225
267
  readonly annotations?: readonly AnnotationMark[];
226
- /** The mark to bring into view and make the active one; each new id focuses it. */
227
- readonly focusedAnnotation?: string | null;
268
+ /**
269
+ * The mark to bring into view and make the active one; each new id focuses it. To focus the
270
+ * same mark again (a second click on its comment), give `{ id, key }` with a new key.
271
+ */
272
+ readonly focusedAnnotation?:
273
+ | string
274
+ | { readonly id: string; readonly key: string | number }
275
+ | null;
228
276
  /**
229
277
  * What the frame reported: a selection (an anchor and where it is), its clearing, a cancelled
230
278
  * pick, where marks are. The content's word: UI hints, never permissions.
@@ -343,6 +391,8 @@ export function PortholeFrame(props: PortholeFrameProps): ReactElement {
343
391
  style={{
344
392
  position: "relative",
345
393
  width: "100%",
394
+ // A display that fills takes its parent's height (the host sizes the parent, or this box).
395
+ ...(props.fill === true || props.config.mode === "url" ? { height: "100%" } : {}),
346
396
  ...(state === "running" ? {} : { minHeight: Math.max(height, PLACEHOLDER_HEIGHT_PX) }),
347
397
  ...props.style,
348
398
  }}
@@ -380,8 +430,11 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
380
430
  const [ready, setReady] = useState(false);
381
431
  // The height this display started at: later reports don't make it start again.
382
432
  const [startHeight] = useState(initialHeightPx);
383
- const { config, document: view, instance, content, partial, scheme } = frame;
384
- const fills = config.mode === "url";
433
+ const { document: view, content, partial, scheme } = frame;
434
+ // By value: a config or instance made inline (each render) is the same display.
435
+ const config = useByValue(frame.config);
436
+ const instance = useByValue(frame.instance);
437
+ const fills = config.mode === "url" || frame.fill === true;
385
438
 
386
439
  // The latest props, read when the display calls back (never during render).
387
440
  const latest = useEffectEvent(() => frame);
@@ -398,9 +451,17 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
398
451
  frame.onResize?.(size);
399
452
  return;
400
453
  }
401
- case "error":
402
- frame.onError?.((detail as DisplayEvents["error"]).message);
454
+ case "contentError":
455
+ frame.onContentError?.((detail as DisplayEvents["contentError"]).message);
456
+ return;
457
+ case "hostError":
458
+ frame.onHostError?.(detail as DisplayEvents["hostError"]);
403
459
  return;
460
+ case "error": {
461
+ const { message, source } = detail as DisplayEvents["error"];
462
+ frame.onError?.(message, source);
463
+ return;
464
+ }
404
465
  case "navigated":
405
466
  frame.onNavigated?.();
406
467
  return;
@@ -468,7 +529,8 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
468
529
  : {}),
469
530
  theme: (each) => latest().theme?.(each) ?? {},
470
531
  ...(page.hostInfo === undefined ? {} : { hostInfo: page.hostInfo }),
471
- ...(page.locator === undefined ? {} : { locator: page.locator }),
532
+ ...(page.locate === undefined ? {} : { locator: { locate: page.locate } }),
533
+ ...(page.nonce === undefined ? {} : { nonce: page.nonce }),
472
534
  }).mount(container, {
473
535
  displayId: first.displayId,
474
536
  title: first.title,
@@ -476,9 +538,10 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
476
538
  initialHeightPx: startHeight,
477
539
  ...(view === undefined ? {} : { document: view }),
478
540
  ...(instance === undefined ? {} : { instance }),
541
+ ...(fills ? { fill: true } : {}),
479
542
  });
480
543
  } catch (error) {
481
- first.onError?.(error instanceof Error ? error.message : String(error));
544
+ hostFailed(first, error, "mount");
482
545
  return;
483
546
  }
484
547
  handle.current = display;
@@ -491,6 +554,8 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
491
554
  "muted",
492
555
  "outcome",
493
556
  "annotation",
557
+ "contentError",
558
+ "hostError",
494
559
  ];
495
560
  for (const event of events) {
496
561
  display.on(event, (detail) => notify(event, detail));
@@ -507,7 +572,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
507
572
  };
508
573
  // A new display only for a new config, document, instance or page (or generation, by key);
509
574
  // content, theme, handlers, id and title are fed to the running one.
510
- }, [config, view, instance, page, startHeight]);
575
+ }, [config, view, instance, page, startHeight, fills]);
511
576
 
512
577
  useEffect(() => {
513
578
  if (content !== undefined) {
@@ -516,7 +581,16 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
516
581
  }, [content, partial]);
517
582
  // Annotations follow their props (a new display gets them all when it is made, above).
518
583
  const annotates = config.capabilities.annotate !== undefined;
519
- const { annotating, annotations, focusedAnnotation } = frame;
584
+ const { annotating } = frame;
585
+ const focusedAnnotation = focusOf(frame.focusedAnnotation);
586
+ const focusToken =
587
+ focusedAnnotation === null
588
+ ? ""
589
+ : typeof frame.focusedAnnotation === "object" && frame.focusedAnnotation !== null
590
+ ? JSON.stringify([frame.focusedAnnotation.id, frame.focusedAnnotation.key])
591
+ : JSON.stringify([focusedAnnotation]);
592
+ // By value: the same marks given again don't mark them again.
593
+ const annotations = useByValue(frame.annotations);
520
594
  const mode =
521
595
  annotating === undefined || annotating === null
522
596
  ? ""
@@ -543,19 +617,27 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
543
617
  }, [annotates, annotations]);
544
618
  useEffect(() => {
545
619
  const display = handle.current;
546
- if (annotates && display !== null && typeof focusedAnnotation === "string") {
547
- attempt(() => display.annotations.focus(focusedAnnotation), latest());
620
+ const id = focusOf(latest().focusedAnnotation);
621
+ if (annotates && display !== null && focusToken !== "" && id !== null) {
622
+ attempt(() => display.annotations.focus(id), latest());
548
623
  }
549
- }, [annotates, focusedAnnotation]);
624
+ }, [annotates, focusToken]);
625
+ // A flip or a restyle after the display was made: its first scheme and values went with it.
626
+ const [mountScheme] = useState(scheme);
627
+ const flipped = useRef(false);
550
628
  useEffect(() => {
551
- handle.current?.setTheme(scheme);
552
- }, [scheme]);
629
+ if (flipped.current || scheme !== mountScheme) {
630
+ flipped.current = true;
631
+ handle.current?.setTheme(scheme);
632
+ }
633
+ }, [scheme, mountScheme]);
553
634
  const { themeKey } = frame;
635
+ const [mountThemeKey] = useState(themeKey);
554
636
  useEffect(() => {
555
- if (themeKey !== undefined) {
637
+ if (themeKey !== undefined && themeKey !== mountThemeKey) {
556
638
  handle.current?.refreshTheme();
557
639
  }
558
- }, [themeKey]);
640
+ }, [themeKey, mountThemeKey]);
559
641
 
560
642
  return (
561
643
  <div
@@ -577,12 +659,19 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
577
659
  );
578
660
  }
579
661
 
580
- /** Runs an annotation call, telling the frame's `onError` what it threw (a bad anchor, say). */
662
+ /** Tells the frame's props what failed on the host's side. */
663
+ function hostFailed(frame: PortholeFrameProps, error: unknown, kind: HostErrorKind): void {
664
+ const message = error instanceof Error ? error.message : String(error);
665
+ frame.onHostError?.({ message, kind });
666
+ frame.onError?.(message, "host");
667
+ }
668
+
669
+ /** Runs an annotation call, telling the frame's props what it threw (a bad anchor, say). */
581
670
  function attempt(run: () => void, frame: PortholeFrameProps): void {
582
671
  try {
583
672
  run();
584
673
  } catch (error) {
585
- frame.onError?.(error instanceof Error ? error.message : String(error));
674
+ hostFailed(frame, error, "annotations");
586
675
  }
587
676
  }
588
677
 
@@ -592,11 +681,31 @@ function applyAnnotations(display: DisplayHandle, frame: PortholeFrameProps): vo
592
681
  if (frame.annotations !== undefined && frame.annotations.length > 0) {
593
682
  display.annotations.show(frame.annotations);
594
683
  }
595
- if (typeof frame.focusedAnnotation === "string") {
596
- display.annotations.focus(frame.focusedAnnotation);
684
+ const focused = focusOf(frame.focusedAnnotation);
685
+ if (focused !== null) {
686
+ display.annotations.focus(focused);
597
687
  }
598
688
  if (frame.annotating !== undefined && frame.annotating !== null) {
599
689
  display.annotations.start(frame.annotating);
600
690
  }
601
691
  }, frame);
602
692
  }
693
+
694
+ /**
695
+ * `value` as long as it is equal by value (as JSON): plain data a host builds inline on each
696
+ * render (a config, an instance, marks, hostInfo) keeps one identity, so effects keyed on it run
697
+ * only when it really changes.
698
+ */
699
+ function useByValue<T>(value: T): T {
700
+ const key = value === undefined ? "" : JSON.stringify(value);
701
+ return useMemo(() => (key === "" ? undefined : JSON.parse(key)) as T, [key]);
702
+ }
703
+
704
+ /** The id `focusedAnnotation` names, if any. */
705
+ function focusOf(value: PortholeFrameProps["focusedAnnotation"]): string | null {
706
+ return typeof value === "string"
707
+ ? value
708
+ : value === null || value === undefined
709
+ ? null
710
+ : value.id;
711
+ }
@@ -40,3 +40,44 @@ export function prefixedName(prefix: string): (scope: string) => string {
40
40
  return label;
41
41
  };
42
42
  }
43
+
44
+ const PREFIX_ENTRY = /^[a-z0-9]([a-z0-9-]{0,61})?\*$/u;
45
+
46
+ /**
47
+ * Whether a label is reserved (`ContentDomain.reserved`): a whole label, a prefix ending in `*`
48
+ * (`p-*`), or the host's own function. Entries are checked once, here; a function that throws
49
+ * reserves the label (fail closed: the request goes to the host's routes, never served as ours).
50
+ */
51
+ export function reservedLabels(
52
+ reserved: readonly string[] | ((label: string) => boolean) | undefined,
53
+ ): (label: string) => boolean {
54
+ if (reserved === undefined) {
55
+ return () => false;
56
+ }
57
+ if (typeof reserved === "function") {
58
+ return (label) => {
59
+ try {
60
+ return reserved(label);
61
+ } catch {
62
+ return true;
63
+ }
64
+ };
65
+ }
66
+ if (!Array.isArray(reserved)) {
67
+ throw new TypeError("content.reserved: a list of labels and prefixes, or a function");
68
+ }
69
+ const whole = new Set<string>();
70
+ const prefixes: string[] = [];
71
+ for (const entry of reserved) {
72
+ if (typeof entry === "string" && PREFIX_ENTRY.test(entry)) {
73
+ prefixes.push(entry.slice(0, -1));
74
+ } else if (isHostLabel(entry)) {
75
+ whole.add(entry);
76
+ } else {
77
+ throw new TypeError(
78
+ `content.reserved: ${JSON.stringify(entry)} is not a lowercase DNS label or a prefix ending in *`,
79
+ );
80
+ }
81
+ }
82
+ return (label) => whole.has(label) || prefixes.some((prefix) => label.startsWith(prefix));
83
+ }
@@ -93,6 +93,8 @@ const pageHeaders = (frameAncestors: string, connect: boolean) => ({
93
93
  "content-security-policy": policy(frameAncestors, connect),
94
94
  "referrer-policy": "no-referrer",
95
95
  "x-content-type-options": "nosniff",
96
+ // Content origins are behind tickets: nothing on them is for search engines.
97
+ "x-robots-tag": "noindex, nofollow",
96
98
  });
97
99
 
98
100
  export function statusPage(
@@ -9,7 +9,7 @@ import {
9
9
  import type { FrameLocation, InstanceRef, ServedDocument } from "../isolation/types.ts";
10
10
  import { sandboxProxyDocument } from "../mcp/proxy.ts";
11
11
  import { createKeys, newTicket, ticketId } from "./keys.ts";
12
- import { isHostLabel } from "./naming.ts";
12
+ import { isHostLabel, reservedLabels } from "./naming.ts";
13
13
  import { enterPage, pageScript, SESSION_PATH, statusPage } from "./pages.ts";
14
14
  import type { FrameBlock } from "../host/types.ts";
15
15
  import { memoryTicketStore } from "./tickets.ts";
@@ -46,6 +46,18 @@ const MAX_SESSION_SECONDS = 30 * 24 * 3_600;
46
46
  /** Browsers keep a cookie of up to 4 KB, name and attributes included. */
47
47
  const MAX_COOKIE_BYTES = 3_800;
48
48
 
49
+ /** A host (and port) as it compares: lowercase, no trailing dot, no default port. */
50
+ function normalHost(value: string, protocol: string): string {
51
+ const host = value
52
+ .trim()
53
+ .toLowerCase()
54
+ .replace(/\.(?=:\d+$|$)/u, "");
55
+ const defaultPort = protocol === "https:" ? ":443" : protocol === "http:" ? ":80" : "";
56
+ return defaultPort !== "" && host.endsWith(defaultPort)
57
+ ? host.slice(0, -defaultPort.length)
58
+ : host;
59
+ }
60
+
49
61
  /**
50
62
  * What a session cookie carries, signed: the instance, its viewer (or anonymous), the host's
51
63
  * opaque subject, and when it stops.
@@ -80,6 +92,10 @@ function mergeEgress(base: EgressConfig, extra: EgressConfig | undefined): Egres
80
92
  merged[kind] = origins;
81
93
  }
82
94
  }
95
+ // What the host's config opens to any https site stays; a view can't ask for it (P059).
96
+ if (base.anyHttps !== undefined) {
97
+ merged.anyHttps = base.anyHttps;
98
+ }
83
99
  return merged;
84
100
  }
85
101
 
@@ -190,21 +206,14 @@ export function createServer(options: ServerOptions): PortholeServer {
190
206
  throw new TypeError("content.port must be a port number");
191
207
  }
192
208
  const keys = createKeys(options.secret);
193
- const reserved = new Set(content.reserved ?? []);
194
- for (const label of reserved) {
195
- if (!isHostLabel(label)) {
196
- throw new TypeError(
197
- `content.reserved: ${JSON.stringify(label)} is not a lowercase DNS label`,
198
- );
199
- }
200
- }
209
+ const isReserved = reservedLabels(content.reserved);
201
210
  /** The label a scope is served on: the host's naming, checked, or the keyed default. */
202
211
  const nameOf = (scope: string): string => {
203
212
  if (content.name === undefined) {
204
213
  return keys.label(scope);
205
214
  }
206
215
  const label = content.name(scope);
207
- if (!isHostLabel(label) || reserved.has(label)) {
216
+ if (!isHostLabel(label) || isReserved(label)) {
208
217
  throw new TypeError(
209
218
  `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`,
210
219
  );
@@ -213,7 +222,7 @@ export function createServer(options: ServerOptions): PortholeServer {
213
222
  };
214
223
  /** Whether a request's label may be one this server named. */
215
224
  const ours = (label: string): boolean =>
216
- !reserved.has(label) && (content.name === undefined ? LABEL.test(label) : isHostLabel(label));
225
+ !isReserved(label) && (content.name === undefined ? LABEL.test(label) : isHostLabel(label));
217
226
  const secure = content.scheme === "https";
218
227
  const defaultPort = secure ? 443 : 80;
219
228
  const portPart =
@@ -520,6 +529,7 @@ export function createServer(options: ServerOptions): PortholeServer {
520
529
  "cache-control": "no-store",
521
530
  "referrer-policy": "no-referrer",
522
531
  "x-content-type-options": "nosniff",
532
+ "x-robots-tag": "noindex, nofollow",
523
533
  });
524
534
  }
525
535
 
@@ -686,8 +696,20 @@ export function createServer(options: ServerOptions): PortholeServer {
686
696
  }
687
697
  }
688
698
 
689
- /** The label a request is for: null when it isn't under the content domain at all. */
690
- function labelOf(request: Request): { readonly url: URL; readonly label: string } | null {
699
+ const underDomain = (host: string): boolean => host === domain || host.endsWith(`.${domain}`);
700
+
701
+ /**
702
+ * The label a request is for: null when it isn't under the content domain at all (or is in a
703
+ * reserved namespace), "mismatch" when its Host header names another host than its URL does
704
+ * and either is under the content domain. A Node adapter may build the URL from an
705
+ * absolute-form request target (`GET http://other/…`) while a host routes by the Host header
706
+ * (and a cache keys on it): the two must agree before anything is served. A Host that is a
707
+ * local server address (an IP literal, `localhost`), as a test runner or a dev proxy sends, is
708
+ * not a name and isn't compared.
709
+ */
710
+ function labelOf(
711
+ request: Request,
712
+ ): { readonly url: URL; readonly label: string } | "mismatch" | null {
691
713
  let url: URL;
692
714
  try {
693
715
  url = new URL(request.url);
@@ -695,13 +717,27 @@ export function createServer(options: ServerOptions): PortholeServer {
695
717
  return null;
696
718
  }
697
719
  const host = url.hostname.toLowerCase().replace(/\.$/u, "");
698
- if (host !== domain && !host.endsWith(`.${domain}`)) {
720
+ const header = request.headers.get("host");
721
+ if (header !== null) {
722
+ const named = normalHost(header, url.protocol);
723
+ const name = named.replace(/:\d+$/u, "");
724
+ const local = name === "localhost" || /^[\d.]+$/u.test(name) || name.startsWith("[");
725
+ if (
726
+ !local &&
727
+ named !== normalHost(url.host, url.protocol) &&
728
+ (underDomain(host) || underDomain(name))
729
+ ) {
730
+ return "mismatch";
731
+ }
732
+ }
733
+ if (!underDomain(host)) {
699
734
  return null;
700
735
  }
701
736
  const label = host.slice(0, Math.max(0, host.length - domain.length - 1));
702
- // A reserved label is another server's namespace (`f` for `<x>.f.<domain>`): it and the names
703
- // under it go to the host's own routes. Any other deeper name is ours to refuse.
704
- if (reserved.has(label.split(".").at(-1) ?? "")) {
737
+ // A reserved label is another server's namespace (`f` for `<x>.f.<domain>`), or the host's
738
+ // own: it and the names under it go to the host's own routes. Any other deeper name is ours
739
+ // to refuse.
740
+ if (isReserved(label.split(".").at(-1) ?? "")) {
705
741
  return null;
706
742
  }
707
743
  return { url, label };
@@ -779,7 +815,7 @@ export function createServer(options: ServerOptions): PortholeServer {
779
815
 
780
816
  async access(request) {
781
817
  const at = labelOf(request);
782
- if (at === null || !ours(at.label)) {
818
+ if (at === null || at === "mismatch" || !ours(at.label)) {
783
819
  return null;
784
820
  }
785
821
  const access = accessOf(request, at.label, null);
@@ -791,8 +827,16 @@ export function createServer(options: ServerOptions): PortholeServer {
791
827
  if (at === null) {
792
828
  return null;
793
829
  }
794
- const { url, label } = at;
795
830
  const method = request.method.toUpperCase();
831
+ if (at === "mismatch") {
832
+ return page(
833
+ 400,
834
+ "Bad request",
835
+ "The request's Host header and its URL name different hosts.",
836
+ method,
837
+ );
838
+ }
839
+ const { url, label } = at;
796
840
  try {
797
841
  if (!ours(label)) {
798
842
  return page(404, "Nothing here", "This address names no content.", method);
@@ -815,7 +859,8 @@ export function createServer(options: ServerOptions): PortholeServer {
815
859
  if (document?.[1] !== undefined) {
816
860
  return await serveDocument(request, url, label, document[1]);
817
861
  }
818
- if (url.pathname.startsWith(PREFIX)) {
862
+ // Without an upstream, only Porthole's own paths are served here: any other is nothing.
863
+ if (url.pathname.startsWith(PREFIX) || options.upstream === undefined) {
819
864
  return page(404, "Nothing here", "This address names no content.", method);
820
865
  }
821
866
  return await serveApp(request, label);