@botiverse/porthole 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/annotate/types.d.ts +120 -0
- package/dist/annotate/types.js +42 -0
- package/dist/annotate/validate.d.ts +11 -0
- package/dist/annotate/validate.js +156 -0
- package/dist/bridge/bridge.d.ts +4 -0
- package/dist/bridge/bridge.js +12 -1
- package/dist/compile/policy.d.ts +10 -3
- package/dist/compile/policy.js +16 -5
- package/dist/config/capabilities.d.ts +37 -4
- package/dist/config/capabilities.js +20 -0
- package/dist/config/schema.d.ts +11 -0
- package/dist/config/validate.js +12 -0
- package/dist/conformance/clipboard.d.ts +2 -0
- package/dist/conformance/clipboard.js +167 -0
- package/dist/conformance/kit.d.ts +1 -1
- package/dist/conformance/probes.js +2 -0
- package/dist/conformance/served.d.ts +8 -1
- package/dist/conformance/served.js +12 -8
- package/dist/guide/guide.js +11 -5
- package/dist/host/clipboard.d.ts +11 -0
- package/dist/host/clipboard.js +43 -0
- package/dist/host/create-host.js +165 -7
- package/dist/host/types.d.ts +68 -2
- package/dist/index.d.ts +8 -5
- package/dist/index.js +7 -3
- package/dist/mcp/adapter.js +10 -1
- package/dist/presets/presets.d.ts +3 -1
- package/dist/presets/presets.js +1 -0
- package/dist/protocol/mcp-apps.d.ts +5 -0
- package/dist/protocol/mcp-apps.js +5 -0
- package/dist/protocol/shell-adapter.js +6 -2
- package/dist/react/dialog.js +2 -0
- package/dist/react/frame.d.ts +50 -2
- package/dist/react/frame.js +153 -11
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.js +2 -1
- package/dist/react/toast.d.ts +33 -0
- package/dist/react/toast.js +42 -0
- 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 +49 -17
- package/dist/server/types.d.ts +6 -3
- package/dist/shell/annotate.d.ts +20 -0
- package/dist/shell/annotate.js +700 -0
- package/dist/shell/document.d.ts +6 -0
- package/dist/shell/document.js +12 -3
- package/dist/shell/plan.d.ts +2 -0
- package/dist/shell/plan.js +2 -1
- package/dist/shell/runtime.d.ts +4 -1
- package/dist/shell/runtime.js +71 -3
- package/package.json +1 -1
- package/src/annotate/types.ts +126 -0
- package/src/annotate/validate.ts +186 -0
- package/src/bridge/bridge.ts +13 -0
- package/src/compile/policy.ts +17 -5
- package/src/config/capabilities.ts +33 -3
- package/src/config/schema.ts +12 -0
- package/src/config/validate.ts +15 -0
- package/src/conformance/clipboard.ts +184 -0
- package/src/conformance/kit.ts +1 -1
- package/src/conformance/probes.ts +2 -0
- package/src/conformance/served.ts +24 -9
- package/src/guide/guide.ts +22 -5
- package/src/host/clipboard.ts +41 -0
- package/src/host/create-host.ts +181 -7
- package/src/host/types.ts +64 -2
- package/src/index.ts +24 -0
- package/src/mcp/adapter.ts +10 -1
- package/src/presets/presets.ts +4 -0
- package/src/protocol/mcp-apps.ts +6 -0
- package/src/protocol/shell-adapter.ts +11 -2
- package/src/react/dialog.tsx +2 -0
- package/src/react/frame.tsx +231 -13
- package/src/react/index.ts +9 -1
- package/src/react/toast.tsx +96 -0
- package/src/server/naming.ts +41 -0
- package/src/server/pages.ts +2 -0
- package/src/server/server.ts +61 -20
- package/src/server/types.ts +6 -3
- package/src/shell/annotate.ts +700 -0
- package/src/shell/document.ts +21 -3
- package/src/shell/plan.ts +4 -1
- package/src/shell/runtime.ts +71 -3
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,5 +1,8 @@
|
|
|
1
1
|
import { hostOriginProblem } from "../isolation/served.js";
|
|
2
|
+
import { ANNOTATE_RUNTIME } from "./annotate.js";
|
|
2
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;
|
|
3
6
|
const escapeAttribute = (value) => value.replace(/&/gu, "&").replace(/"/gu, """).replace(/</gu, "<");
|
|
4
7
|
const escapeText = (value) => value.replace(/&/gu, "&").replace(/</gu, "<").replace(/>/gu, ">");
|
|
5
8
|
/** JSON that is safe inside a `<script>`: no `</script>`, no `<!--`. */
|
|
@@ -52,7 +55,13 @@ export function shellDocument(plan, csp, options) {
|
|
|
52
55
|
throw new TypeError(`shellDocument: ${problem}`);
|
|
53
56
|
}
|
|
54
57
|
}
|
|
55
|
-
|
|
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}"`;
|
|
62
|
+
// The annotation runtime only where it is declared; spliced in before the plan, which may carry
|
|
63
|
+
// any text (a document) and must not be searched.
|
|
64
|
+
const runtime = SHELL_RUNTIME.replace("__ANNOTATE__", () => plan.capabilities.annotate === undefined ? "" : ANNOTATE_RUNTIME).replace("__PLAN__", () => scriptJson({
|
|
56
65
|
...plan,
|
|
57
66
|
origins: options.origins,
|
|
58
67
|
...(options.document === undefined ? {} : { document: options.document }),
|
|
@@ -72,13 +81,13 @@ export function shellDocument(plan, csp, options) {
|
|
|
72
81
|
${head.join("\n")}
|
|
73
82
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
74
83
|
<title>${escapeText(options.title ?? "Content")}</title>
|
|
75
|
-
<style>
|
|
84
|
+
<style${nonce}>
|
|
76
85
|
${BASE_STYLE}
|
|
77
86
|
</style>
|
|
78
87
|
</head>
|
|
79
88
|
<body>
|
|
80
89
|
<div id="porthole-root"></div>
|
|
81
|
-
<script>${runtime}</script>
|
|
90
|
+
<script${nonce}>${runtime}</script>
|
|
82
91
|
</body>
|
|
83
92
|
</html>
|
|
84
93
|
`;
|
package/dist/shell/plan.d.ts
CHANGED
|
@@ -32,6 +32,8 @@ export interface ShellPlan {
|
|
|
32
32
|
readonly storage: "memory" | "native";
|
|
33
33
|
readonly maxErrorReports: number;
|
|
34
34
|
readonly maxPromptChars: number;
|
|
35
|
+
/** The most text `copyText` carries. */
|
|
36
|
+
readonly maxCopyChars: number;
|
|
35
37
|
readonly protocolVersion: string;
|
|
36
38
|
}
|
|
37
39
|
export declare function shellPlan(config: PortholeConfig): ShellPlan | null;
|
package/dist/shell/plan.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { capabilitySpec } from "../config/capabilities.js";
|
|
1
|
+
import { MAX_COPY_CHARS, capabilitySpec } from "../config/capabilities.js";
|
|
2
2
|
import { declaredCapabilities, effectiveGates } from "../compile/policy.js";
|
|
3
3
|
import { MCP_APPS_PROTOCOL_VERSION } from "../protocol/mcp-apps.js";
|
|
4
4
|
export function shellPlan(config) {
|
|
@@ -30,6 +30,7 @@ export function shellPlan(config) {
|
|
|
30
30
|
storage: config.isolation === "opaque" ? "memory" : "native",
|
|
31
31
|
maxErrorReports: config.limits.maxErrorReports,
|
|
32
32
|
maxPromptChars: config.limits.maxPromptChars,
|
|
33
|
+
maxCopyChars: MAX_COPY_CHARS,
|
|
33
34
|
protocolVersion: MCP_APPS_PROTOCOL_VERSION,
|
|
34
35
|
};
|
|
35
36
|
}
|
package/dist/shell/runtime.d.ts
CHANGED
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
* engine enforces `frame-src 'none'` for `srcdoc`/`about:blank` children); and the resource
|
|
14
14
|
* hints and refreshes a CSP doesn't govern;
|
|
15
15
|
* 4. closes the parser entry points that could build a shadow root step 3 can't see (declarative
|
|
16
|
-
* shadow DOM through `setHTMLUnsafe`, `parseHTMLUnsafe`, `document.write`)
|
|
16
|
+
* shadow DOM through `setHTMLUnsafe`, `parseHTMLUnsafe`, `document.write`);
|
|
17
|
+
* 5. keeps content off the clipboard: the async Clipboard API refuses, `execCommand` won't copy or
|
|
18
|
+
* cut, and copy and cut events never reach content, so what a person copies is what they
|
|
19
|
+
* selected (P051). Copy buttons go through the host (`copyText`, P052).
|
|
17
20
|
* A whole document (an artifact page, an MCP Apps view) is never written in: it is parsed inert,
|
|
18
21
|
* imported, and its scripts run in order, as a fragment's are.
|
|
19
22
|
*
|
package/dist/shell/runtime.js
CHANGED
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
* engine enforces `frame-src 'none'` for `srcdoc`/`about:blank` children); and the resource
|
|
14
14
|
* hints and refreshes a CSP doesn't govern;
|
|
15
15
|
* 4. closes the parser entry points that could build a shadow root step 3 can't see (declarative
|
|
16
|
-
* shadow DOM through `setHTMLUnsafe`, `parseHTMLUnsafe`, `document.write`)
|
|
16
|
+
* shadow DOM through `setHTMLUnsafe`, `parseHTMLUnsafe`, `document.write`);
|
|
17
|
+
* 5. keeps content off the clipboard: the async Clipboard API refuses, `execCommand` won't copy or
|
|
18
|
+
* cut, and copy and cut events never reach content, so what a person copies is what they
|
|
19
|
+
* selected (P051). Copy buttons go through the host (`copyText`, P052).
|
|
17
20
|
* A whole document (an artifact page, an MCP Apps view) is never written in: it is parsed inert,
|
|
18
21
|
* imported, and its scripts run in order, as a fragment's are.
|
|
19
22
|
*
|
|
@@ -28,6 +31,9 @@
|
|
|
28
31
|
export const SHELL_RUNTIME = String.raw `(() => {
|
|
29
32
|
"use strict";
|
|
30
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 : "";
|
|
31
37
|
const declared = (name) => Object.prototype.hasOwnProperty.call(plan.capabilities, name);
|
|
32
38
|
// Fixed in place: content can neither restore what is removed nor replace what is wrapped.
|
|
33
39
|
const lock = (target, name, value) => {
|
|
@@ -263,13 +269,32 @@ export const SHELL_RUNTIME = String.raw `(() => {
|
|
|
263
269
|
this.innerHTML = String(html);
|
|
264
270
|
});
|
|
265
271
|
}
|
|
272
|
+
// insertHTML parses markup outside these guards; copy, cut and paste reach the clipboard (P051).
|
|
273
|
+
const REFUSED_COMMANDS = ["inserthtml", "copy", "cut", "paste"];
|
|
266
274
|
const execCommand = Document.prototype.execCommand;
|
|
267
275
|
if (typeof execCommand === "function") {
|
|
268
276
|
lock(Document.prototype, "execCommand", function (command, ...rest) {
|
|
269
|
-
return String(command).toLowerCase()
|
|
277
|
+
return REFUSED_COMMANDS.indexOf(String(command).toLowerCase()) !== -1 ? false : execCommand.call(this, command, ...rest);
|
|
270
278
|
});
|
|
271
279
|
}
|
|
272
280
|
|
|
281
|
+
// The clipboard is the host's to write (P051): content can neither write nor read it. A copy
|
|
282
|
+
// the person makes (their keys, their menu) copies their selection as the browser makes it: no
|
|
283
|
+
// copy or cut listener of the content's runs, so none can swap what goes on the clipboard.
|
|
284
|
+
const noClipboard = function () {
|
|
285
|
+
return Promise.reject(new DOMException("Content can't use the clipboard here" + (declared("copyText") ? ": call copyText(text) from a click" : ""), "NotAllowedError"));
|
|
286
|
+
};
|
|
287
|
+
if (typeof Clipboard === "function") {
|
|
288
|
+
for (const name of ["write", "writeText", "read", "readText"]) {
|
|
289
|
+
if (typeof Clipboard.prototype[name] === "function") {
|
|
290
|
+
lock(Clipboard.prototype, name, noClipboard);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
for (const type of ["copy", "cut"]) {
|
|
295
|
+
window.addEventListener(type, (event) => event.stopImmediatePropagation(), true);
|
|
296
|
+
}
|
|
297
|
+
|
|
273
298
|
// A connected link or meta can't become a guarded one: the change is tried on a detached copy
|
|
274
299
|
// first, and if the copy would be guarded, the element leaves the document before it changes.
|
|
275
300
|
const watched = (element) => element && element.nodeType === 1 && (element.localName === "link" || element.localName === "meta");
|
|
@@ -463,6 +488,9 @@ export const SHELL_RUNTIME = String.raw `(() => {
|
|
|
463
488
|
}
|
|
464
489
|
// A script made by a script is async unless told otherwise: keep the page's order.
|
|
465
490
|
live.async = script.hasAttribute("async");
|
|
491
|
+
if (nonce !== "") {
|
|
492
|
+
live.nonce = nonce;
|
|
493
|
+
}
|
|
466
494
|
live.textContent = script.textContent;
|
|
467
495
|
const external = live.hasAttribute("src") && !live.async && live.type !== "module";
|
|
468
496
|
const loaded = external
|
|
@@ -487,6 +515,27 @@ export const SHELL_RUNTIME = String.raw `(() => {
|
|
|
487
515
|
window.dispatchEvent(new Event("load"));
|
|
488
516
|
};
|
|
489
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
|
+
|
|
490
539
|
// A fragment takes the person's scheme; a whole document only if it declares that it supports
|
|
491
540
|
// both (or the config says always), else it shows as its author styled it (P039).
|
|
492
541
|
let lastContext = null;
|
|
@@ -522,6 +571,7 @@ export const SHELL_RUNTIME = String.raw `(() => {
|
|
|
522
571
|
schemeFollows();
|
|
523
572
|
await runScripts(scripts);
|
|
524
573
|
announceLoaded();
|
|
574
|
+
contentArrived();
|
|
525
575
|
};
|
|
526
576
|
|
|
527
577
|
// A document the plan carries (a served page, an MCP Apps view) is rendered once the shell's own
|
|
@@ -534,6 +584,8 @@ export const SHELL_RUNTIME = String.raw `(() => {
|
|
|
534
584
|
}
|
|
535
585
|
};
|
|
536
586
|
|
|
587
|
+
__ANNOTATE__
|
|
588
|
+
|
|
537
589
|
if (plan.protocol !== "shell") {
|
|
538
590
|
// A view that speaks for itself.
|
|
539
591
|
if (typeof plan.document === "string") {
|
|
@@ -624,6 +676,18 @@ export const SHELL_RUNTIME = String.raw `(() => {
|
|
|
624
676
|
);
|
|
625
677
|
});
|
|
626
678
|
}
|
|
679
|
+
if (declared("copyText")) {
|
|
680
|
+
lock(window, "copyText", (text) => {
|
|
681
|
+
if (!gestured("copyText")) {
|
|
682
|
+
return refuse("copyText needs a click: call it from a button's handler");
|
|
683
|
+
}
|
|
684
|
+
const words = String(text);
|
|
685
|
+
if (words === "" || words.length > plan.maxCopyChars) {
|
|
686
|
+
return refuse("copyText takes 1 to " + plan.maxCopyChars + " characters");
|
|
687
|
+
}
|
|
688
|
+
return request("porthole/copy-text", { text: words }).then((result) => !(result && result.isError));
|
|
689
|
+
});
|
|
690
|
+
}
|
|
627
691
|
if (declared("openLink")) {
|
|
628
692
|
lock(window, "openLink", (url) => {
|
|
629
693
|
if (!gestured("openLink")) {
|
|
@@ -636,7 +700,10 @@ export const SHELL_RUNTIME = String.raw `(() => {
|
|
|
636
700
|
window.addEventListener("error", (event) => {
|
|
637
701
|
const error = event.error;
|
|
638
702
|
const where = event.lineno ? " (line " + event.lineno + ")" : "";
|
|
639
|
-
|
|
703
|
+
// V8's stack starts with the message; WebKit's and Firefox's don't, so it goes first there.
|
|
704
|
+
const stack = error && error.stack ? String(error.stack).split("\n").slice(0, 3).join(" ") : "";
|
|
705
|
+
const said = error && error.message !== undefined ? String(error.name || "Error") + ": " + String(error.message) : event.message;
|
|
706
|
+
report(stack === "" ? said + where : stack.indexOf(String(error.message)) === -1 ? said + " " + stack : stack);
|
|
640
707
|
});
|
|
641
708
|
window.addEventListener("unhandledrejection", (event) => {
|
|
642
709
|
const reason = event.reason;
|
|
@@ -768,6 +835,7 @@ export const SHELL_RUNTIME = String.raw `(() => {
|
|
|
768
835
|
schedule();
|
|
769
836
|
await runScripts(scripts);
|
|
770
837
|
announceLoaded();
|
|
838
|
+
contentArrived();
|
|
771
839
|
schedule();
|
|
772
840
|
};
|
|
773
841
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botiverse/porthole",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "A host-side sandbox for agent-made HTML: isolation, egress hardening, a themed shell, gated host functions, and the agent manual, from one config.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"files": [
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Annotations (P053): a person points at part of a display (an element, or text they select) to
|
|
3
|
+
* comment on it, and the host marks the anchors already made. The host drives the shell with
|
|
4
|
+
* Porthole's own messages (`porthole/annotate.*`); the shell answers with anchors and where they
|
|
5
|
+
* are. An anchor is a set of W3C Web Annotation selectors, so it can be found again after the
|
|
6
|
+
* content changes; finding it is best-effort, and says so (`orphaned`).
|
|
7
|
+
*
|
|
8
|
+
* Everything the frame reports is the content's word: the shell shares its realm with the
|
|
9
|
+
* content, which can interfere or fake a report. The host checks each report's shape and size
|
|
10
|
+
* and takes reports only while it asked for them. Use them as UI hints (where to draw a comment,
|
|
11
|
+
* what to quote), never as permissions.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The element a CSS selector matches (W3C `CssSelector`). */
|
|
15
|
+
export interface CssSelector {
|
|
16
|
+
readonly type: "CssSelector";
|
|
17
|
+
readonly value: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Text, with a little of what comes before and after it (W3C `TextQuoteSelector`). */
|
|
21
|
+
export interface TextQuoteSelector {
|
|
22
|
+
readonly type: "TextQuoteSelector";
|
|
23
|
+
readonly exact: string;
|
|
24
|
+
readonly prefix?: string;
|
|
25
|
+
readonly suffix?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Offsets into the document's text: the text of the body's text nodes, in order, without
|
|
30
|
+
* scripts, styles and templates (W3C `TextPositionSelector`).
|
|
31
|
+
*/
|
|
32
|
+
export interface TextPositionSelector {
|
|
33
|
+
readonly type: "TextPositionSelector";
|
|
34
|
+
readonly start: number;
|
|
35
|
+
readonly end: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type AnnotationSelector = CssSelector | TextQuoteSelector | TextPositionSelector;
|
|
39
|
+
|
|
40
|
+
export interface AnnotationAnchor {
|
|
41
|
+
/** What the person pointed at: an element, or a text selection. */
|
|
42
|
+
readonly kind: "element" | "text";
|
|
43
|
+
/** At most one of each type; tried in turn when the anchor is found again. */
|
|
44
|
+
readonly selectors: readonly AnnotationSelector[];
|
|
45
|
+
/** A short description for the host to show ("h2#revenue Revenue grew 12%"); the content's. */
|
|
46
|
+
readonly label: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A box in the frame's viewport, CSS px: add the frame element's position to place it on the page. */
|
|
50
|
+
export interface AnnotationRect {
|
|
51
|
+
readonly x: number;
|
|
52
|
+
readonly y: number;
|
|
53
|
+
readonly width: number;
|
|
54
|
+
readonly height: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What the person may point at while the host is asking. */
|
|
58
|
+
export interface AnnotateMode {
|
|
59
|
+
/** Elements, picked with a click or tap (the one under the pointer is outlined); one pick. */
|
|
60
|
+
readonly elements?: boolean;
|
|
61
|
+
/** Text, by selecting it: each settled selection is reported, and its clearing. */
|
|
62
|
+
readonly text?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** An anchor the host marks in the frame, under its own id. */
|
|
66
|
+
export interface AnnotationMark {
|
|
67
|
+
readonly id: string;
|
|
68
|
+
readonly anchor: AnnotationAnchor;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether a mark's anchor was found in the content as it is now. */
|
|
72
|
+
export type AnchorState = "attached" | "orphaned";
|
|
73
|
+
|
|
74
|
+
export interface AnchorResult {
|
|
75
|
+
readonly id: string;
|
|
76
|
+
readonly state: AnchorState;
|
|
77
|
+
/** Where it is now, when attached. */
|
|
78
|
+
readonly rect?: AnnotationRect;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** What the frame reports, once the host has checked it. */
|
|
82
|
+
export type AnnotationReport =
|
|
83
|
+
/** The person pointed at an element or settled a text selection. */
|
|
84
|
+
| {
|
|
85
|
+
readonly type: "selected";
|
|
86
|
+
readonly anchor: AnnotationAnchor;
|
|
87
|
+
readonly rect: AnnotationRect;
|
|
88
|
+
}
|
|
89
|
+
/** The text selection went away. */
|
|
90
|
+
| { readonly type: "unselected" }
|
|
91
|
+
/** The person pressed Escape while picking an element. */
|
|
92
|
+
| { readonly type: "cancelled" }
|
|
93
|
+
/** Where the marked anchors are now, after `show`, `focus`, or a change of the content. */
|
|
94
|
+
| { readonly type: "anchored"; readonly results: readonly AnchorResult[] };
|
|
95
|
+
|
|
96
|
+
/** Porthole's own messages for annotations (own-prefixed, P002). */
|
|
97
|
+
export const ANNOTATE_METHODS = {
|
|
98
|
+
/** Host → frame: let the person point (`AnnotateMode`). */
|
|
99
|
+
start: "porthole/annotate.start",
|
|
100
|
+
/** Host → frame: stop. */
|
|
101
|
+
stop: "porthole/annotate.stop",
|
|
102
|
+
/** Host → frame: mark these anchors (replacing what was marked). */
|
|
103
|
+
show: "porthole/annotate.show",
|
|
104
|
+
/** Host → frame: bring one mark into view and make it the active one. */
|
|
105
|
+
focus: "porthole/annotate.focus",
|
|
106
|
+
/** Host → frame: remove every mark. */
|
|
107
|
+
clear: "porthole/annotate.clear",
|
|
108
|
+
/** Frame → host. */
|
|
109
|
+
selected: "porthole/annotate.selected",
|
|
110
|
+
unselected: "porthole/annotate.unselected",
|
|
111
|
+
cancelled: "porthole/annotate.cancelled",
|
|
112
|
+
anchored: "porthole/annotate.anchored",
|
|
113
|
+
} as const;
|
|
114
|
+
|
|
115
|
+
/** The most of each part of an anchor or report the host takes. */
|
|
116
|
+
export const ANNOTATION_LIMITS = {
|
|
117
|
+
label: 120,
|
|
118
|
+
cssSelector: 1_000,
|
|
119
|
+
exact: 4_000,
|
|
120
|
+
context: 64,
|
|
121
|
+
/** The most marks one `show` carries, and results one report. */
|
|
122
|
+
marks: 500,
|
|
123
|
+
id: 128,
|
|
124
|
+
position: 50_000_000,
|
|
125
|
+
coordinate: 10_000_000,
|
|
126
|
+
} as const;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ANNOTATION_LIMITS as LIMITS,
|
|
3
|
+
type AnchorResult,
|
|
4
|
+
type AnnotateMode,
|
|
5
|
+
type AnnotationAnchor,
|
|
6
|
+
type AnnotationMark,
|
|
7
|
+
type AnnotationRect,
|
|
8
|
+
type AnnotationReport,
|
|
9
|
+
type AnnotationSelector,
|
|
10
|
+
} from "./types.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The host's checks on what a frame says about annotations (P053), and on anchors a host stores
|
|
14
|
+
* and sends back. Each rebuilds what it accepts from known fields only, within `ANNOTATION_LIMITS`,
|
|
15
|
+
* and answers null for anything else: a report is dropped whole, never repaired.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
19
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
20
|
+
|
|
21
|
+
const text = (value: unknown, max: number, min = 0): value is string =>
|
|
22
|
+
typeof value === "string" && value.length >= min && value.length <= max;
|
|
23
|
+
|
|
24
|
+
const count = (value: unknown, max: number): value is number =>
|
|
25
|
+
typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= max;
|
|
26
|
+
|
|
27
|
+
function selector(value: unknown): AnnotationSelector | null {
|
|
28
|
+
if (!isRecord(value)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
switch (value.type) {
|
|
32
|
+
case "CssSelector":
|
|
33
|
+
return text(value.value, LIMITS.cssSelector, 1)
|
|
34
|
+
? { type: "CssSelector", value: value.value }
|
|
35
|
+
: null;
|
|
36
|
+
case "TextQuoteSelector": {
|
|
37
|
+
const { exact, prefix, suffix } = value;
|
|
38
|
+
if (
|
|
39
|
+
!text(exact, LIMITS.exact, 1) ||
|
|
40
|
+
(prefix !== undefined && !text(prefix, LIMITS.context)) ||
|
|
41
|
+
(suffix !== undefined && !text(suffix, LIMITS.context))
|
|
42
|
+
) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
type: "TextQuoteSelector",
|
|
47
|
+
exact,
|
|
48
|
+
...(prefix === undefined || prefix === "" ? {} : { prefix }),
|
|
49
|
+
...(suffix === undefined || suffix === "" ? {} : { suffix }),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
case "TextPositionSelector":
|
|
53
|
+
return count(value.start, LIMITS.position) &&
|
|
54
|
+
count(value.end, LIMITS.position) &&
|
|
55
|
+
value.start <= value.end
|
|
56
|
+
? { type: "TextPositionSelector", start: value.start, end: value.end }
|
|
57
|
+
: null;
|
|
58
|
+
default:
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** An anchor, rebuilt from its known fields; null if any part is malformed or too large. */
|
|
64
|
+
export function parseAnchor(value: unknown): AnnotationAnchor | null {
|
|
65
|
+
if (!isRecord(value) || (value.kind !== "element" && value.kind !== "text")) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
if (!Array.isArray(value.selectors) || value.selectors.length === 0) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
if (value.selectors.length > 3 || !text(value.label, LIMITS.label)) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const selectors: AnnotationSelector[] = [];
|
|
75
|
+
for (const each of value.selectors) {
|
|
76
|
+
const parsed = selector(each);
|
|
77
|
+
if (parsed === null || selectors.some((other) => other.type === parsed.type)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
selectors.push(parsed);
|
|
81
|
+
}
|
|
82
|
+
// A text anchor is found by its words; an element one needs a way to find the element.
|
|
83
|
+
const quote = selectors.some((each) => each.type === "TextQuoteSelector");
|
|
84
|
+
const css = selectors.some((each) => each.type === "CssSelector");
|
|
85
|
+
if (value.kind === "text" ? !quote : !css && !quote) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return { kind: value.kind, selectors, label: value.label };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const coordinate = (value: unknown): value is number =>
|
|
92
|
+
typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= LIMITS.coordinate;
|
|
93
|
+
|
|
94
|
+
/** A box in the frame's viewport; null unless every number is finite and sizes aren't negative. */
|
|
95
|
+
export function parseRect(value: unknown): AnnotationRect | null {
|
|
96
|
+
if (!isRecord(value)) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const { x, y, width, height } = value;
|
|
100
|
+
if (!coordinate(x) || !coordinate(y) || !coordinate(width) || !coordinate(height)) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
if (width < 0 || height < 0) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return { x, y, width, height };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const markId = (value: unknown): value is string => text(value, LIMITS.id, 1);
|
|
110
|
+
|
|
111
|
+
function results(value: unknown): AnchorResult[] | null {
|
|
112
|
+
if (!Array.isArray(value) || value.length > LIMITS.marks) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const out: AnchorResult[] = [];
|
|
116
|
+
for (const each of value) {
|
|
117
|
+
if (!isRecord(each) || !markId(each.id)) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
if (each.state === "orphaned") {
|
|
121
|
+
out.push({ id: each.id, state: "orphaned" });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (each.state !== "attached") {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const rect = each.rect === undefined ? undefined : parseRect(each.rect);
|
|
128
|
+
if (rect === null) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
out.push({ id: each.id, state: "attached", ...(rect === undefined ? {} : { rect }) });
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A report from the frame, checked; null if malformed (it is then dropped). */
|
|
137
|
+
export function parseReport(value: unknown): AnnotationReport | null {
|
|
138
|
+
if (!isRecord(value)) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
switch (value.type) {
|
|
142
|
+
case "selected": {
|
|
143
|
+
const anchor = parseAnchor(value.anchor);
|
|
144
|
+
const rect = parseRect(value.rect);
|
|
145
|
+
return anchor === null || rect === null ? null : { type: "selected", anchor, rect };
|
|
146
|
+
}
|
|
147
|
+
case "unselected":
|
|
148
|
+
return { type: "unselected" };
|
|
149
|
+
case "cancelled":
|
|
150
|
+
return { type: "cancelled" };
|
|
151
|
+
case "anchored": {
|
|
152
|
+
const checked = results(value.results);
|
|
153
|
+
return checked === null ? null : { type: "anchored", results: checked };
|
|
154
|
+
}
|
|
155
|
+
default:
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** What the host may start with: elements, text, or both (default both). */
|
|
161
|
+
export function annotateMode(mode: AnnotateMode | undefined): Required<AnnotateMode> {
|
|
162
|
+
const elements = mode?.elements ?? true;
|
|
163
|
+
const words = mode?.text ?? true;
|
|
164
|
+
if (typeof elements !== "boolean" || typeof words !== "boolean" || (!elements && !words)) {
|
|
165
|
+
throw new TypeError("annotations.start: elements, text, or both");
|
|
166
|
+
}
|
|
167
|
+
return { elements, text: words };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The host's marks, checked before they go to the frame; throws on the first bad one. */
|
|
171
|
+
export function checkMarks(marks: readonly AnnotationMark[]): AnnotationMark[] {
|
|
172
|
+
if (!Array.isArray(marks) || marks.length > LIMITS.marks) {
|
|
173
|
+
throw new TypeError(`annotations.show: at most ${LIMITS.marks} marks`);
|
|
174
|
+
}
|
|
175
|
+
const seen = new Set<string>();
|
|
176
|
+
return marks.map((mark, index) => {
|
|
177
|
+
const anchor = parseAnchor(mark?.anchor);
|
|
178
|
+
if (!markId(mark?.id) || seen.has(mark.id) || anchor === null) {
|
|
179
|
+
throw new TypeError(
|
|
180
|
+
`annotations.show: mark ${index} needs a unique id (1 to ${LIMITS.id} characters) and a valid anchor`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
seen.add(mark.id);
|
|
184
|
+
return { id: mark.id, anchor };
|
|
185
|
+
});
|
|
186
|
+
}
|
package/src/bridge/bridge.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
|
+
MAX_COPY_CHARS,
|
|
2
3
|
capabilitySpec,
|
|
3
4
|
type CapabilityArgs,
|
|
4
5
|
type CapabilityName,
|
|
5
6
|
type CapabilityResults,
|
|
6
7
|
} from "../config/capabilities.ts";
|
|
8
|
+
import type { AnnotationReport } from "../annotate/types.ts";
|
|
9
|
+
import { parseReport } from "../annotate/validate.ts";
|
|
7
10
|
import type { PortholeConfig } from "../config/schema.ts";
|
|
8
11
|
import { effectiveGates } from "../compile/policy.ts";
|
|
9
12
|
import type {
|
|
@@ -26,6 +29,7 @@ import type {
|
|
|
26
29
|
export type BridgeEvent =
|
|
27
30
|
| { readonly type: "resize"; readonly height: number; readonly width?: number }
|
|
28
31
|
| { readonly type: "error"; readonly message: string }
|
|
32
|
+
| { readonly type: "annotation"; readonly report: AnnotationReport }
|
|
29
33
|
| { readonly type: "refused"; readonly capability: CapabilityName; readonly refusal: Refusal }
|
|
30
34
|
| { readonly type: "muted" };
|
|
31
35
|
|
|
@@ -129,6 +133,13 @@ const VALIDATORS: { readonly [N in CapabilityName]: Validator<N> } = {
|
|
|
129
133
|
? { message: message.slice(0, 2_000) }
|
|
130
134
|
: null;
|
|
131
135
|
},
|
|
136
|
+
copyText: (args) => {
|
|
137
|
+
const text = isRecord(args) ? args.text : undefined;
|
|
138
|
+
return typeof text === "string" && text !== "" && text.length <= MAX_COPY_CHARS
|
|
139
|
+
? { text }
|
|
140
|
+
: null;
|
|
141
|
+
},
|
|
142
|
+
annotate: (args) => parseReport(args),
|
|
132
143
|
resize: (args) => {
|
|
133
144
|
if (
|
|
134
145
|
!isRecord(args) ||
|
|
@@ -250,6 +261,8 @@ export function createBridge(options: BridgeOptions): HostFunctionBridge {
|
|
|
250
261
|
if (capability === "resize") {
|
|
251
262
|
const size = args as CapabilityArgs["resize"];
|
|
252
263
|
onEvent({ type: "resize", ...size });
|
|
264
|
+
} else if (capability === "annotate") {
|
|
265
|
+
onEvent({ type: "annotation", report: args as CapabilityArgs["annotate"] });
|
|
253
266
|
} else {
|
|
254
267
|
onEvent({ type: "error", message: (args as CapabilityArgs["reportError"]).message });
|
|
255
268
|
}
|
package/src/compile/policy.ts
CHANGED
|
@@ -99,12 +99,15 @@ export function sandboxTokens(config: PortholeConfig): string[] {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
/**
|
|
102
|
-
* The frame's `allow` attribute (Permissions Policy).
|
|
103
|
-
*
|
|
104
|
-
*
|
|
102
|
+
* The frame's `allow` attribute (Permissions Policy). No camera, microphone, geolocation,
|
|
103
|
+
* fullscreen or other powerful feature is delegated to the frame, and the clipboard is denied by
|
|
104
|
+
* name (P051): content never writes it, the host does (`copyText`). A url-mode config may
|
|
105
|
+
* delegate clipboard writes and full screen to the app's own origin (P049).
|
|
105
106
|
*/
|
|
106
107
|
export function allowAttribute(config: PortholeConfig): string {
|
|
107
|
-
return config.mode === "url"
|
|
108
|
+
return config.mode === "url"
|
|
109
|
+
? (config.permissions ?? []).join("; ")
|
|
110
|
+
: "clipboard-read 'none'; clipboard-write 'none'";
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
/**
|
|
@@ -126,6 +129,15 @@ export function embedderFrameSources(config: PortholeConfig): string[] {
|
|
|
126
129
|
* engine Porthole supports reads frames from `frame-src`, P038).
|
|
127
130
|
*/
|
|
128
131
|
export function embedderCsp(configs: readonly PortholeConfig[]): string {
|
|
129
|
-
const sources =
|
|
132
|
+
const sources = embedderFrameSourcesOf(configs);
|
|
130
133
|
return serializeCsp([["frame-src", ...(sources.length === 0 ? ["'none'"] : sources)]]);
|
|
131
134
|
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The `frame-src` sources the embedding page must allow for every kind of display it shows, as a
|
|
138
|
+
* list, for a host that writes its own policy around them (empty: it frames nothing of
|
|
139
|
+
* Porthole's but `srcdoc`, which needs none).
|
|
140
|
+
*/
|
|
141
|
+
export function embedderFrameSourcesOf(configs: readonly PortholeConfig[]): string[] {
|
|
142
|
+
return unique(configs.flatMap(embedderFrameSources));
|
|
143
|
+
}
|