@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/dist/server/server.js
CHANGED
|
@@ -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 = {
|
|
@@ -44,6 +55,10 @@ function mergeEgress(base, extra) {
|
|
|
44
55
|
merged[kind] = origins;
|
|
45
56
|
}
|
|
46
57
|
}
|
|
58
|
+
// What the host's config opens to any https site stays; a view can't ask for it (P059).
|
|
59
|
+
if (base.anyHttps !== undefined) {
|
|
60
|
+
merged.anyHttps = base.anyHttps;
|
|
61
|
+
}
|
|
47
62
|
return merged;
|
|
48
63
|
}
|
|
49
64
|
/** `instance` with only the fields Porthole reads, each checked: what a ticket and cookie carry. */
|
|
@@ -148,25 +163,20 @@ export function createServer(options) {
|
|
|
148
163
|
throw new TypeError("content.port must be a port number");
|
|
149
164
|
}
|
|
150
165
|
const keys = createKeys(options.secret);
|
|
151
|
-
const
|
|
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
|
-
}
|
|
166
|
+
const isReserved = reservedLabels(content.reserved);
|
|
157
167
|
/** The label a scope is served on: the host's naming, checked, or the keyed default. */
|
|
158
168
|
const nameOf = (scope) => {
|
|
159
169
|
if (content.name === undefined) {
|
|
160
170
|
return keys.label(scope);
|
|
161
171
|
}
|
|
162
172
|
const label = content.name(scope);
|
|
163
|
-
if (!isHostLabel(label) ||
|
|
173
|
+
if (!isHostLabel(label) || isReserved(label)) {
|
|
164
174
|
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
175
|
}
|
|
166
176
|
return label;
|
|
167
177
|
};
|
|
168
178
|
/** Whether a request's label may be one this server named. */
|
|
169
|
-
const ours = (label) => !
|
|
179
|
+
const ours = (label) => !isReserved(label) && (content.name === undefined ? LABEL.test(label) : isHostLabel(label));
|
|
170
180
|
const secure = content.scheme === "https";
|
|
171
181
|
const defaultPort = secure ? 443 : 80;
|
|
172
182
|
const portPart = content.port === undefined || content.port === defaultPort ? "" : `:${content.port}`;
|
|
@@ -431,6 +441,7 @@ export function createServer(options) {
|
|
|
431
441
|
"cache-control": "no-store",
|
|
432
442
|
"referrer-policy": "no-referrer",
|
|
433
443
|
"x-content-type-options": "nosniff",
|
|
444
|
+
"x-robots-tag": "noindex, nofollow",
|
|
434
445
|
});
|
|
435
446
|
}
|
|
436
447
|
async function serveDocument(request, url, label, key) {
|
|
@@ -563,7 +574,16 @@ export function createServer(options) {
|
|
|
563
574
|
return page(502, "The app didn't answer", "Its server couldn't be reached.", method);
|
|
564
575
|
}
|
|
565
576
|
}
|
|
566
|
-
|
|
577
|
+
const underDomain = (host) => host === domain || host.endsWith(`.${domain}`);
|
|
578
|
+
/**
|
|
579
|
+
* The label a request is for: null when it isn't under the content domain at all (or is in a
|
|
580
|
+
* reserved namespace), "mismatch" when its Host header names another host than its URL does
|
|
581
|
+
* and either is under the content domain. A Node adapter may build the URL from an
|
|
582
|
+
* absolute-form request target (`GET http://other/…`) while a host routes by the Host header
|
|
583
|
+
* (and a cache keys on it): the two must agree before anything is served. A Host that is a
|
|
584
|
+
* local server address (an IP literal, `localhost`), as a test runner or a dev proxy sends, is
|
|
585
|
+
* not a name and isn't compared.
|
|
586
|
+
*/
|
|
567
587
|
function labelOf(request) {
|
|
568
588
|
let url;
|
|
569
589
|
try {
|
|
@@ -573,13 +593,25 @@ export function createServer(options) {
|
|
|
573
593
|
return null;
|
|
574
594
|
}
|
|
575
595
|
const host = url.hostname.toLowerCase().replace(/\.$/u, "");
|
|
576
|
-
|
|
596
|
+
const header = request.headers.get("host");
|
|
597
|
+
if (header !== null) {
|
|
598
|
+
const named = normalHost(header, url.protocol);
|
|
599
|
+
const name = named.replace(/:\d+$/u, "");
|
|
600
|
+
const local = name === "localhost" || /^[\d.]+$/u.test(name) || name.startsWith("[");
|
|
601
|
+
if (!local &&
|
|
602
|
+
named !== normalHost(url.host, url.protocol) &&
|
|
603
|
+
(underDomain(host) || underDomain(name))) {
|
|
604
|
+
return "mismatch";
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (!underDomain(host)) {
|
|
577
608
|
return null;
|
|
578
609
|
}
|
|
579
610
|
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>`)
|
|
581
|
-
// under it go to the host's own routes. Any other deeper name is ours
|
|
582
|
-
|
|
611
|
+
// A reserved label is another server's namespace (`f` for `<x>.f.<domain>`), or the host's
|
|
612
|
+
// own: it and the names under it go to the host's own routes. Any other deeper name is ours
|
|
613
|
+
// to refuse.
|
|
614
|
+
if (isReserved(label.split(".").at(-1) ?? "")) {
|
|
583
615
|
return null;
|
|
584
616
|
}
|
|
585
617
|
return { url, label };
|
|
@@ -639,7 +671,7 @@ export function createServer(options) {
|
|
|
639
671
|
},
|
|
640
672
|
async access(request) {
|
|
641
673
|
const at = labelOf(request);
|
|
642
|
-
if (at === null || !ours(at.label)) {
|
|
674
|
+
if (at === null || at === "mismatch" || !ours(at.label)) {
|
|
643
675
|
return null;
|
|
644
676
|
}
|
|
645
677
|
const access = accessOf(request, at.label, null);
|
|
@@ -650,8 +682,11 @@ export function createServer(options) {
|
|
|
650
682
|
if (at === null) {
|
|
651
683
|
return null;
|
|
652
684
|
}
|
|
653
|
-
const { url, label } = at;
|
|
654
685
|
const method = request.method.toUpperCase();
|
|
686
|
+
if (at === "mismatch") {
|
|
687
|
+
return page(400, "Bad request", "The request's Host header and its URL name different hosts.", method);
|
|
688
|
+
}
|
|
689
|
+
const { url, label } = at;
|
|
655
690
|
try {
|
|
656
691
|
if (!ours(label)) {
|
|
657
692
|
return page(404, "Nothing here", "This address names no content.", method);
|
|
@@ -673,7 +708,8 @@ export function createServer(options) {
|
|
|
673
708
|
if (document?.[1] !== undefined) {
|
|
674
709
|
return await serveDocument(request, url, label, document[1]);
|
|
675
710
|
}
|
|
676
|
-
|
|
711
|
+
// Without an upstream, only Porthole's own paths are served here: any other is nothing.
|
|
712
|
+
if (url.pathname.startsWith(PREFIX) || options.upstream === undefined) {
|
|
677
713
|
return page(404, "Nothing here", "This address names no content.", method);
|
|
678
714
|
}
|
|
679
715
|
return await serveApp(request, label);
|
package/dist/server/types.d.ts
CHANGED
|
@@ -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
|
|
22
|
-
* `f` for `<instance>.f.<domain>`,
|
|
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
|
package/dist/shell/annotate.js
CHANGED
|
@@ -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();
|
package/dist/shell/document.d.ts
CHANGED
|
@@ -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.
|
package/dist/shell/document.js
CHANGED
|
@@ -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, "&").replace(/"/gu, """).replace(/</gu, "<");
|
|
5
7
|
const escapeText = (value) => value.replace(/&/gu, "&").replace(/</gu, "<").replace(/>/gu, ">");
|
|
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
|
`;
|
package/dist/shell/runtime.js
CHANGED
|
@@ -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
|
|
@@ -579,12 +607,16 @@ export const SHELL_RUNTIME = String.raw `(() => {
|
|
|
579
607
|
const notify = (method, params) => send({ method, params });
|
|
580
608
|
|
|
581
609
|
const themeNames = new Set(plan.themeVariables);
|
|
610
|
+
// What the content was last told of (its scheme and values): the theme it starts with is no
|
|
611
|
+
// change, and a host context that changes neither (a host re-sending it) is none either.
|
|
612
|
+
let announced = null;
|
|
582
613
|
const applyTheme = (context) => {
|
|
583
614
|
if (context === null || typeof context !== "object") {
|
|
584
615
|
return;
|
|
585
616
|
}
|
|
586
617
|
// The spec's variables, and Porthole's own beside them (its own-prefixed field).
|
|
587
618
|
const own = context["porthole/styles"];
|
|
619
|
+
const applied = [];
|
|
588
620
|
for (const variables of [context.styles && context.styles.variables, own && own.variables]) {
|
|
589
621
|
if (variables === null || typeof variables !== "object") {
|
|
590
622
|
continue;
|
|
@@ -593,12 +625,21 @@ export const SHELL_RUNTIME = String.raw `(() => {
|
|
|
593
625
|
const value = variables[name];
|
|
594
626
|
if (themeNames.has(name) && typeof value === "string") {
|
|
595
627
|
document.documentElement.style.setProperty(name, value);
|
|
628
|
+
applied.push(name + ":" + value);
|
|
596
629
|
}
|
|
597
630
|
}
|
|
598
631
|
}
|
|
599
632
|
lastContext = context;
|
|
600
633
|
schemeFollows();
|
|
601
|
-
|
|
634
|
+
const now = JSON.stringify([context.theme, applied.sort()]);
|
|
635
|
+
if (announced === null) {
|
|
636
|
+
announced = now;
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (now !== announced) {
|
|
640
|
+
announced = now;
|
|
641
|
+
window.dispatchEvent(new CustomEvent(plan.themeEvent, { detail: { theme: context.theme } }));
|
|
642
|
+
}
|
|
602
643
|
};
|
|
603
644
|
|
|
604
645
|
// No size before there is something to size: an empty shell's height would make the host
|
|
@@ -672,7 +713,10 @@ export const SHELL_RUNTIME = String.raw `(() => {
|
|
|
672
713
|
window.addEventListener("error", (event) => {
|
|
673
714
|
const error = event.error;
|
|
674
715
|
const where = event.lineno ? " (line " + event.lineno + ")" : "";
|
|
675
|
-
|
|
716
|
+
// V8's stack starts with the message; WebKit's and Firefox's don't, so it goes first there.
|
|
717
|
+
const stack = error && error.stack ? String(error.stack).split("\n").slice(0, 3).join(" ") : "";
|
|
718
|
+
const said = error && error.message !== undefined ? String(error.name || "Error") + ": " + String(error.message) : event.message;
|
|
719
|
+
report(stack === "" ? said + where : stack.indexOf(String(error.message)) === -1 ? said + " " + stack : stack);
|
|
676
720
|
});
|
|
677
721
|
window.addEventListener("unhandledrejection", (event) => {
|
|
678
722
|
const reason = event.reason;
|
|
@@ -804,6 +848,7 @@ export const SHELL_RUNTIME = String.raw `(() => {
|
|
|
804
848
|
schedule();
|
|
805
849
|
await runScripts(scripts);
|
|
806
850
|
announceLoaded();
|
|
851
|
+
contentArrived();
|
|
807
852
|
schedule();
|
|
808
853
|
};
|
|
809
854
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botiverse/porthole",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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": [
|
package/src/compile/policy.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CAPABILITIES, type CapabilityName, type Gate } from "../config/capabilities.ts";
|
|
2
|
-
import type {
|
|
2
|
+
import type { PortholeConfig } from "../config/schema.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* The frame's policies, derived from the config: its CSP (in the shell's `<meta>` for opaque
|
|
@@ -10,7 +10,12 @@ import type { EgressConfig, PortholeConfig } from "../config/schema.ts";
|
|
|
10
10
|
/** A CSP directive: its name, then its sources. */
|
|
11
11
|
export type CspDirective = readonly [name: string, ...sources: string[]];
|
|
12
12
|
|
|
13
|
-
export type EgressKind =
|
|
13
|
+
export type EgressKind = "connect" | "script" | "style" | "img" | "font" | "media";
|
|
14
|
+
|
|
15
|
+
/** Whether `kind` loads from any https site (`EgressConfig.anyHttps`, P059): passive kinds only. */
|
|
16
|
+
export function anyHttps(config: PortholeConfig, kind: EgressKind): boolean {
|
|
17
|
+
return (config.egress.anyHttps ?? []).some((each) => each === kind);
|
|
18
|
+
}
|
|
14
19
|
|
|
15
20
|
/** The capabilities a config declares, in vocabulary order. */
|
|
16
21
|
export function declaredCapabilities(config: PortholeConfig): CapabilityName[] {
|
|
@@ -56,13 +61,15 @@ export function frameCspDirectives(config: PortholeConfig): CspDirective[] {
|
|
|
56
61
|
return [];
|
|
57
62
|
}
|
|
58
63
|
const origins = (kind: EgressKind) => egressOrigins(config, kind);
|
|
64
|
+
// Any https site, for passive kinds a host opened so (P059); never for what runs or talks.
|
|
65
|
+
const https = (kind: "img" | "media" | "font") => (anyHttps(config, kind) ? ["https:"] : []);
|
|
59
66
|
return [
|
|
60
67
|
["default-src", "'none'"],
|
|
61
68
|
["script-src", "'unsafe-inline'", "'unsafe-eval'", "blob:", ...origins("script")],
|
|
62
69
|
["style-src", "'unsafe-inline'", ...origins("style")],
|
|
63
|
-
["font-src", "data:", ...origins("font")],
|
|
64
|
-
["img-src", "data:", "blob:", ...origins("img")],
|
|
65
|
-
["media-src", "data:", "blob:", ...origins("media")],
|
|
70
|
+
["font-src", "data:", ...https("font"), ...origins("font")],
|
|
71
|
+
["img-src", "data:", "blob:", ...https("img"), ...origins("img")],
|
|
72
|
+
["media-src", "data:", "blob:", ...https("media"), ...origins("media")],
|
|
66
73
|
["connect-src", ...list(origins("connect"))],
|
|
67
74
|
["worker-src", "blob:"],
|
|
68
75
|
["child-src", "blob:"],
|
|
@@ -129,6 +136,15 @@ export function embedderFrameSources(config: PortholeConfig): string[] {
|
|
|
129
136
|
* engine Porthole supports reads frames from `frame-src`, P038).
|
|
130
137
|
*/
|
|
131
138
|
export function embedderCsp(configs: readonly PortholeConfig[]): string {
|
|
132
|
-
const sources =
|
|
139
|
+
const sources = embedderFrameSourcesOf(configs);
|
|
133
140
|
return serializeCsp([["frame-src", ...(sources.length === 0 ? ["'none'"] : sources)]]);
|
|
134
141
|
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The `frame-src` sources the embedding page must allow for every kind of display it shows, as a
|
|
145
|
+
* list, for a host that writes its own policy around them (empty: it frames nothing of
|
|
146
|
+
* Porthole's but `srcdoc`, which needs none).
|
|
147
|
+
*/
|
|
148
|
+
export function embedderFrameSourcesOf(configs: readonly PortholeConfig[]): string[] {
|
|
149
|
+
return unique(configs.flatMap(embedderFrameSources));
|
|
150
|
+
}
|
package/src/config/schema.ts
CHANGED
|
@@ -61,8 +61,19 @@ export interface EgressConfig {
|
|
|
61
61
|
readonly img?: readonly string[];
|
|
62
62
|
readonly font?: readonly string[];
|
|
63
63
|
readonly media?: readonly string[];
|
|
64
|
+
/**
|
|
65
|
+
* Passive content that may load from any https site (P059): images, audio and video, fonts, for
|
|
66
|
+
* pages that hotlink them. Never scripts, styles or data. It opens a channel out: a viewer's
|
|
67
|
+
* address and a visit signal reach any site, and data can leave in the URL.
|
|
68
|
+
*/
|
|
69
|
+
readonly anyHttps?: readonly PassiveKind[];
|
|
64
70
|
}
|
|
65
71
|
|
|
72
|
+
/** What `EgressConfig.anyHttps` may open: content that runs nothing (P059). */
|
|
73
|
+
export const PASSIVE_KINDS = ["img", "media", "font"] as const;
|
|
74
|
+
|
|
75
|
+
export type PassiveKind = (typeof PASSIVE_KINDS)[number];
|
|
76
|
+
|
|
66
77
|
export interface ThemeConfig {
|
|
67
78
|
/**
|
|
68
79
|
* Every CSS variable the content may read, with its default per scheme. The shell injects
|
|
@@ -175,4 +186,16 @@ export interface PortholeConfig {
|
|
|
175
186
|
readonly sandbox?: readonly UrlSandboxExtra[];
|
|
176
187
|
/** Url mode only: features delegated to the frame (`URL_PERMISSIONS`); none by default. */
|
|
177
188
|
readonly permissions?: readonly UrlPermission[];
|
|
189
|
+
/**
|
|
190
|
+
* Domain isolation: how far a display's storage reaches, for the manual. Storage is per origin,
|
|
191
|
+
* and the host picks each display's origin (`InstanceRef.scope`); a host that gives several
|
|
192
|
+
* displays one scope (all of a conversation's widgets) names it here (`{ shared:
|
|
193
|
+
* "conversation" }`), so the manual says storage is shared across it and keys need a prefix.
|
|
194
|
+
*/
|
|
195
|
+
readonly storage?: StorageConfig;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface StorageConfig {
|
|
199
|
+
/** What the displays sharing one origin have in common, as a noun ("conversation"). */
|
|
200
|
+
readonly shared?: string;
|
|
178
201
|
}
|