@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.
- package/dist/compile/policy.d.ts +10 -2
- package/dist/compile/policy.js +18 -4
- package/dist/config/schema.d.ts +20 -0
- package/dist/config/schema.js +2 -0
- package/dist/config/validate.js +174 -2
- package/dist/conformance/behavior.js +14 -1
- package/dist/conformance/kit.d.ts +5 -0
- package/dist/conformance/kit.js +11 -0
- package/dist/conformance/probes.js +19 -2
- package/dist/conformance/served.d.ts +8 -1
- package/dist/conformance/served.js +12 -8
- package/dist/guide/guide.d.ts +2 -0
- package/dist/guide/guide.js +22 -5
- package/dist/host/create-host.js +24 -7
- package/dist/host/types.d.ts +44 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/presets/presets.d.ts +3 -1
- package/dist/presets/presets.js +5 -0
- package/dist/react/frame.d.ts +34 -4
- package/dist/react/frame.js +91 -22
- package/dist/server/naming.d.ts +6 -0
- package/dist/server/naming.js +38 -0
- package/dist/server/pages.js +2 -0
- package/dist/server/server.js +53 -17
- package/dist/server/types.d.ts +6 -3
- package/dist/shell/annotate.js +31 -0
- package/dist/shell/document.d.ts +6 -0
- package/dist/shell/document.js +8 -2
- package/dist/shell/runtime.js +47 -2
- package/package.json +1 -1
- package/src/compile/policy.ts +22 -6
- package/src/config/schema.ts +23 -0
- package/src/config/validate.ts +193 -2
- package/src/conformance/behavior.ts +20 -1
- package/src/conformance/kit.ts +16 -0
- package/src/conformance/probes.ts +21 -2
- package/src/conformance/served.ts +24 -9
- package/src/guide/guide.ts +25 -4
- package/src/host/create-host.ts +26 -7
- package/src/host/types.ts +39 -2
- package/src/index.ts +4 -0
- package/src/presets/presets.ts +8 -0
- package/src/react/frame.tsx +136 -27
- package/src/server/naming.ts +41 -0
- package/src/server/pages.ts +2 -0
- package/src/server/server.ts +65 -20
- package/src/server/types.ts +6 -3
- package/src/shell/annotate.ts +31 -0
- package/src/shell/document.ts +15 -2
- 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
|
-
/**
|
|
97
|
-
|
|
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";
|
package/src/presets/presets.ts
CHANGED
|
@@ -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
|
|
package/src/react/frame.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
...(
|
|
146
|
+
...(locates ? { locate } : {}),
|
|
147
|
+
...(nonce === undefined ? {} : { nonce }),
|
|
125
148
|
}),
|
|
126
|
-
[live, heights, confirm, copyText, hostInfo,
|
|
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
|
-
|
|
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
|
-
/**
|
|
227
|
-
|
|
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 {
|
|
384
|
-
|
|
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 "
|
|
402
|
-
frame.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
547
|
-
|
|
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,
|
|
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
|
-
|
|
552
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
596
|
-
|
|
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
|
+
}
|
package/src/server/naming.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/server/pages.ts
CHANGED
|
@@ -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(
|
package/src/server/server.ts
CHANGED
|
@@ -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
|
|
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) ||
|
|
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
|
-
!
|
|
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
|
-
|
|
690
|
-
|
|
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
|
-
|
|
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>`)
|
|
703
|
-
// under it go to the host's own routes. Any other deeper name is ours
|
|
704
|
-
|
|
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
|
-
|
|
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);
|