@botiverse/porthole 0.1.0 → 0.1.1
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/bridge/bridge.js +20 -6
- package/dist/bridge/types.d.ts +19 -3
- package/dist/compile/policy.d.ts +3 -3
- package/dist/compile/policy.js +6 -9
- package/dist/config/schema.d.ts +21 -0
- package/dist/config/schema.js +11 -1
- package/dist/config/validate.d.ts +3 -1
- package/dist/config/validate.js +41 -10
- package/dist/conformance/behavior.js +50 -0
- package/dist/conformance/index.d.ts +11 -5
- package/dist/conformance/index.js +2 -2
- package/dist/conformance/probes.d.ts +26 -2
- package/dist/conformance/probes.js +51 -40
- package/dist/conformance/served.d.ts +7 -0
- package/dist/conformance/served.js +31 -7
- package/dist/guide/guide.js +6 -2
- package/dist/host/create-host.js +46 -2
- package/dist/host/locate.js +6 -1
- package/dist/host/outcome.d.ts +24 -0
- package/dist/host/outcome.js +134 -0
- package/dist/host/types.d.ts +34 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/isolation/served.d.ts +3 -0
- package/dist/isolation/served.js +20 -0
- package/dist/isolation/types.d.ts +9 -0
- package/dist/isolation/types.js +2 -1
- package/dist/platform/types.d.ts +7 -0
- package/dist/platform/web.js +15 -2
- package/dist/presets/presets.d.ts +9 -3
- package/dist/presets/presets.js +16 -1
- package/dist/react/frame.d.ts +16 -0
- package/dist/react/frame.js +44 -10
- package/dist/server/pages.d.ts +11 -6
- package/dist/server/pages.js +78 -12
- package/dist/server/server.js +34 -10
- package/dist/shell/document.d.ts +3 -2
- package/dist/shell/document.js +4 -3
- package/dist/shell/plan.d.ts +2 -0
- package/dist/shell/plan.js +1 -0
- package/dist/shell/runtime.js +20 -4
- package/package.json +1 -9
- package/src/bridge/bridge.ts +28 -6
- package/src/bridge/types.ts +21 -3
- package/src/compile/policy.ts +6 -9
- package/src/config/schema.ts +28 -0
- package/src/config/validate.ts +48 -14
- package/src/conformance/behavior.ts +55 -0
- package/src/conformance/index.ts +18 -4
- package/src/conformance/probes.ts +88 -52
- package/src/conformance/served.ts +37 -8
- package/src/guide/guide.ts +8 -2
- package/src/host/create-host.ts +47 -2
- package/src/host/locate.ts +6 -1
- package/src/host/outcome.ts +159 -0
- package/src/host/types.ts +33 -2
- package/src/index.ts +11 -1
- package/src/isolation/served.ts +24 -0
- package/src/isolation/types.ts +15 -1
- package/src/platform/types.ts +7 -0
- package/src/platform/web.ts +15 -2
- package/src/presets/presets.ts +27 -3
- package/src/react/frame.tsx +78 -14
- package/src/server/pages.ts +97 -11
- package/src/server/server.ts +48 -8
- package/src/shell/document.ts +7 -6
- package/src/shell/plan.ts +3 -0
- package/src/shell/runtime.ts +20 -4
package/dist/react/frame.js
CHANGED
|
@@ -25,9 +25,9 @@ export function PortholeProvider(props) {
|
|
|
25
25
|
useLayoutEffect(() => {
|
|
26
26
|
own.current = props.confirm;
|
|
27
27
|
});
|
|
28
|
-
const confirm = useCallback((request) => own.current !== undefined
|
|
29
|
-
? own.current(request)
|
|
30
|
-
: new Promise((resolve) => setQueue((waiting) => [...waiting, { request, resolve }])), []);
|
|
28
|
+
const confirm = useCallback((request, proceed) => own.current !== undefined
|
|
29
|
+
? own.current(request, proceed)
|
|
30
|
+
: new Promise((resolve) => setQueue((waiting) => [...waiting, { request, resolve, proceed }])), []);
|
|
31
31
|
const { hostInfo, locator } = props;
|
|
32
32
|
const value = useMemo(() => ({
|
|
33
33
|
live,
|
|
@@ -38,7 +38,11 @@ export function PortholeProvider(props) {
|
|
|
38
38
|
}), [live, heights, confirm, hostInfo, locator]);
|
|
39
39
|
const current = queue[0] ?? null;
|
|
40
40
|
return (_jsxs(Page.Provider, { value: value, children: [props.children, props.confirm === undefined ? (_jsx(ConfirmDialog, { request: current?.request ?? null, ...(props.dialogClassName === undefined ? {} : { className: props.dialogClassName }), onAnswer: (accepted) => {
|
|
41
|
-
|
|
41
|
+
// A yes runs the effect inside this very click (a window it opens is the person's).
|
|
42
|
+
if (accepted) {
|
|
43
|
+
current?.proceed();
|
|
44
|
+
}
|
|
45
|
+
current?.resolve(accepted ? "accepted" : "declined");
|
|
42
46
|
setQueue((waiting) => waiting.slice(1));
|
|
43
47
|
} })) : null] }));
|
|
44
48
|
}
|
|
@@ -56,6 +60,16 @@ const PLACEHOLDER_STYLE = {
|
|
|
56
60
|
function DefaultPlaceholder({ state, title, run }) {
|
|
57
61
|
return (_jsxs("div", { "data-porthole-placeholder": state, style: { ...PLACEHOLDER_STYLE, pointerEvents: state === "stopped" ? "auto" : "none" }, children: [_jsx("span", { children: state === "stopped" ? "Stopped while others run" : title }), state === "stopped" ? (_jsx("button", { type: "button", "data-porthole-run": "", onClick: run, children: "Run again" })) : null] }));
|
|
58
62
|
}
|
|
63
|
+
/** A display's remembered height: under its id, else under the first of its other ids that has one. */
|
|
64
|
+
function rememberedHeight(heights, displayId, aliases) {
|
|
65
|
+
for (const id of [displayId, ...(aliases ?? [])]) {
|
|
66
|
+
const height = heights.get(id);
|
|
67
|
+
if (height !== undefined) {
|
|
68
|
+
return height;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
59
73
|
export function PortholeFrame(props) {
|
|
60
74
|
const page = useContext(Page);
|
|
61
75
|
const key = useId();
|
|
@@ -89,7 +103,17 @@ export function PortholeFrame(props) {
|
|
|
89
103
|
page.live.release(key);
|
|
90
104
|
};
|
|
91
105
|
}, [page, key, max, eager]);
|
|
92
|
-
const height = page.heights.
|
|
106
|
+
const height = rememberedHeight(page.heights, props.displayId, props.aliases);
|
|
107
|
+
// A display renamed while it runs (its id settled) keeps its height under its new id.
|
|
108
|
+
const previousId = useRef(props.displayId);
|
|
109
|
+
useLayoutEffect(() => {
|
|
110
|
+
const before = previousId.current;
|
|
111
|
+
previousId.current = props.displayId;
|
|
112
|
+
const kept = page.heights.get(before);
|
|
113
|
+
if (before !== props.displayId && kept !== undefined && !page.heights.has(props.displayId)) {
|
|
114
|
+
page.heights.set(props.displayId, kept);
|
|
115
|
+
}
|
|
116
|
+
}, [page, props.displayId]);
|
|
93
117
|
const run = () => page.live.admit(key, max);
|
|
94
118
|
const placeholder = props.renderPlaceholder ?? DefaultPlaceholder;
|
|
95
119
|
return (_jsx("div", { ref: box, className: props.className, "data-porthole-display": props.displayId, "data-porthole-state": state, style: {
|
|
@@ -107,6 +131,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
107
131
|
// The height this display started at: later reports don't make it start again.
|
|
108
132
|
const [startHeight] = useState(initialHeightPx);
|
|
109
133
|
const { config, document: view, instance, content, partial, scheme } = frame;
|
|
134
|
+
const fills = config.mode === "url";
|
|
110
135
|
// The latest props, read when the display calls back (never during render).
|
|
111
136
|
const latest = useEffectEvent(() => frame);
|
|
112
137
|
const notify = useEffectEvent((event, detail) => {
|
|
@@ -135,6 +160,9 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
135
160
|
case "muted":
|
|
136
161
|
frame.onMuted?.();
|
|
137
162
|
return;
|
|
163
|
+
case "outcome":
|
|
164
|
+
frame.onOutcome?.(detail);
|
|
165
|
+
return;
|
|
138
166
|
}
|
|
139
167
|
});
|
|
140
168
|
useEffect(() => {
|
|
@@ -162,10 +190,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
162
190
|
handlers,
|
|
163
191
|
...(asks
|
|
164
192
|
? {
|
|
165
|
-
confirm: (request) => (latest().confirm ?? page.confirm ?? (() => Promise.resolve(false)))({
|
|
166
|
-
...request,
|
|
167
|
-
display: { id: latest().displayId, title: latest().title },
|
|
168
|
-
}),
|
|
193
|
+
confirm: (request, proceed) => (latest().confirm ?? page.confirm ?? (() => Promise.resolve(false)))({ ...request, display: { id: latest().displayId, title: latest().title } }, proceed),
|
|
169
194
|
}
|
|
170
195
|
: {}),
|
|
171
196
|
theme: (each) => latest().theme?.(each) ?? {},
|
|
@@ -192,6 +217,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
192
217
|
"navigated",
|
|
193
218
|
"refused",
|
|
194
219
|
"muted",
|
|
220
|
+
"outcome",
|
|
195
221
|
];
|
|
196
222
|
for (const event of events) {
|
|
197
223
|
display.on(event, (detail) => notify(event, detail));
|
|
@@ -214,8 +240,16 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
214
240
|
useEffect(() => {
|
|
215
241
|
handle.current?.setTheme(scheme);
|
|
216
242
|
}, [scheme]);
|
|
243
|
+
const { themeKey } = frame;
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (themeKey !== undefined) {
|
|
246
|
+
handle.current?.refreshTheme();
|
|
247
|
+
}
|
|
248
|
+
}, [themeKey]);
|
|
217
249
|
return (_jsxs("div", { "data-porthole-ready": String(ready), style: {
|
|
218
250
|
position: "relative",
|
|
251
|
+
// A url-mode app fills the frame's box (give PortholeFrame a height); the rest size to content.
|
|
252
|
+
...(fills ? { height: "100%" } : {}),
|
|
219
253
|
...(ready ? {} : { minHeight: Math.max(startHeight, PLACEHOLDER_HEIGHT_PX) }),
|
|
220
|
-
}, children: [_jsx("div", { ref: holder, "data-porthole-mount": "", style: { opacity: ready ? 1 : 0 } }), ready ? null : placeholder({ state: "loading", title: frame.title, run })] }));
|
|
254
|
+
}, children: [_jsx("div", { ref: holder, "data-porthole-mount": "", style: { opacity: ready ? 1 : 0, ...(fills ? { height: "100%" } : {}) } }), ready ? null : placeholder({ state: "loading", title: frame.title, run })] }));
|
|
221
255
|
}
|
package/dist/server/pages.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*/
|
|
6
|
-
export declare
|
|
1
|
+
import type { FrameBlock } from "../host/types.ts";
|
|
2
|
+
/** Where the pages' scripts are served, on every content origin. */
|
|
3
|
+
export declare const STATUS_SCRIPT_PATH = "/__porthole/status.js";
|
|
4
|
+
export declare const ENTER_SCRIPT_PATH = "/__porthole/enter.js";
|
|
5
|
+
/** Answers 204 when the request carries a good session for its origin's app, 401 otherwise. */
|
|
6
|
+
export declare const SESSION_PATH = "/__porthole/session";
|
|
7
|
+
/** The pages' scripts, by path: served with the pages' own policy in mind (`script-src 'self'`). */
|
|
8
|
+
export declare function pageScript(pathname: string): Response | null;
|
|
9
|
+
export declare function statusPage(status: number, title: string, detail: string, frameAncestors: string, method?: string, reason?: FrameBlock, hostOrigins?: readonly string[]): Response;
|
|
10
|
+
/** The page a framed app is entered through (its answer also sets the session cookie). */
|
|
11
|
+
export declare function enterPage(target: string, frameAncestors: string, hostOrigins: readonly string[], cookie: string): Response;
|
package/dist/server/pages.js
CHANGED
|
@@ -1,20 +1,86 @@
|
|
|
1
|
+
import { FRAME_STATUS_METHOD } from "../isolation/types.js";
|
|
1
2
|
/**
|
|
2
|
-
* The server's own pages on a content origin: why it shows nothing of the content
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* The server's own pages on a content origin: why it shows nothing of the content, and the page a
|
|
4
|
+
* framed app is entered through. Plain, framable only by the host origins, never cached. Each tells
|
|
5
|
+
* the page that frames it how loading went (`porthole/notifications/frame-status`, P040), posted to
|
|
6
|
+
* the host origins only, so a host can show its own fallback ("Open in a new tab"). Their words are
|
|
7
|
+
* the host-agnostic mechanism's; a host that wants its own wording serves its own page in front.
|
|
5
8
|
*/
|
|
6
9
|
const escape = (text) => text.replace(/[&<>"']/gu, (char) => `&#${char.charCodeAt(0)};`);
|
|
7
10
|
const STYLE = ":root{color-scheme:light dark;font-family:system-ui,sans-serif}body{margin:0;min-height:100vh;display:grid;place-items:center;background:Canvas;color:CanvasText}main{max-width:420px;padding:24px}h1{font-size:17px;margin:0 0 8px}p{margin:0;opacity:.75;line-height:1.5}";
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
/** Where the pages' scripts are served, on every content origin. */
|
|
12
|
+
export const STATUS_SCRIPT_PATH = "/__porthole/status.js";
|
|
13
|
+
export const ENTER_SCRIPT_PATH = "/__porthole/enter.js";
|
|
14
|
+
/** Answers 204 when the request carries a good session for its origin's app, 401 otherwise. */
|
|
15
|
+
export const SESSION_PATH = "/__porthole/session";
|
|
16
|
+
const say = () => `const say = (params) => { if (window.parent === window) return; for (const origin of hosts) window.parent.postMessage({ jsonrpc: "2.0", method: ${JSON.stringify(FRAME_STATUS_METHOD)}, params }, origin); };`;
|
|
17
|
+
/** Tells the host the page is blocked, and why (from the page's data attributes). */
|
|
18
|
+
const STATUS_SCRIPT = `(() => {
|
|
19
|
+
const main = document.querySelector("[data-porthole-status]");
|
|
20
|
+
if (main === null) return;
|
|
21
|
+
const hosts = JSON.parse(main.dataset.portholeHosts || "[]");
|
|
22
|
+
${say()}
|
|
23
|
+
say({ state: "blocked", reason: main.dataset.portholeReason || "error", status: Number(main.dataset.portholeStatus) });
|
|
24
|
+
})();`;
|
|
25
|
+
/**
|
|
26
|
+
* Enters a framed app: says so, checks that the browser kept the session cookie this page's own
|
|
27
|
+
* answer set (WebKit keeps none in a cross-site frame, P025), then goes on to the app, or says it
|
|
28
|
+
* is blocked and why.
|
|
29
|
+
*/
|
|
30
|
+
const ENTER_SCRIPT = `(async () => {
|
|
31
|
+
const main = document.querySelector("[data-porthole-enter]");
|
|
32
|
+
if (main === null) return;
|
|
33
|
+
const hosts = JSON.parse(main.dataset.portholeHosts || "[]");
|
|
34
|
+
${say()}
|
|
35
|
+
say({ state: "entering" });
|
|
36
|
+
let kept = false;
|
|
37
|
+
try {
|
|
38
|
+
kept = (await fetch(${JSON.stringify(SESSION_PATH)}, { credentials: "same-origin", cache: "no-store" })).ok;
|
|
39
|
+
} catch (_) {}
|
|
40
|
+
if (kept) {
|
|
41
|
+
location.replace(main.dataset.portholeTarget || "/");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
say({ state: "blocked", reason: "cookies", status: 401 });
|
|
45
|
+
main.querySelector("h1").textContent = "Open this app in a new tab";
|
|
46
|
+
main.querySelector("p").textContent = "This browser keeps no sign-in for it inside a frame.";
|
|
47
|
+
})();`;
|
|
48
|
+
/** The pages' scripts, by path: served with the pages' own policy in mind (`script-src 'self'`). */
|
|
49
|
+
export function pageScript(pathname) {
|
|
50
|
+
const body = pathname === STATUS_SCRIPT_PATH
|
|
51
|
+
? STATUS_SCRIPT
|
|
52
|
+
: pathname === ENTER_SCRIPT_PATH
|
|
53
|
+
? ENTER_SCRIPT
|
|
54
|
+
: null;
|
|
55
|
+
return body === null
|
|
56
|
+
? null
|
|
57
|
+
: new Response(body, {
|
|
58
|
+
headers: {
|
|
59
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
60
|
+
"cache-control": "no-store",
|
|
61
|
+
"x-content-type-options": "nosniff",
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const policy = (frameAncestors, connect) => `default-src 'none'; style-src 'unsafe-inline'; script-src 'self'; ${connect ? "connect-src 'self'; " : ""}base-uri 'none'; form-action 'none'; frame-ancestors ${frameAncestors}`;
|
|
66
|
+
const pageHeaders = (frameAncestors, connect) => ({
|
|
67
|
+
"content-type": "text/html; charset=utf-8",
|
|
68
|
+
"cache-control": "no-store",
|
|
69
|
+
"content-security-policy": policy(frameAncestors, connect),
|
|
70
|
+
"referrer-policy": "no-referrer",
|
|
71
|
+
"x-content-type-options": "nosniff",
|
|
72
|
+
});
|
|
73
|
+
export function statusPage(status, title, detail, frameAncestors, method = "GET", reason = "error", hostOrigins = []) {
|
|
74
|
+
const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)}</title><style>${STYLE}</style></head><body><main data-porthole-status="${status}" data-porthole-reason="${escape(reason)}" data-porthole-hosts="${escape(JSON.stringify(hostOrigins))}"><h1>${escape(title)}</h1><p>${escape(detail)}</p></main><script src="${STATUS_SCRIPT_PATH}"></script></body></html>`;
|
|
10
75
|
return new Response(method === "HEAD" ? null : body, {
|
|
11
76
|
status,
|
|
12
|
-
headers:
|
|
13
|
-
"content-type": "text/html; charset=utf-8",
|
|
14
|
-
"cache-control": "no-store",
|
|
15
|
-
"content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors ${frameAncestors}`,
|
|
16
|
-
"referrer-policy": "no-referrer",
|
|
17
|
-
"x-content-type-options": "nosniff",
|
|
18
|
-
},
|
|
77
|
+
headers: pageHeaders(frameAncestors, false),
|
|
19
78
|
});
|
|
20
79
|
}
|
|
80
|
+
/** The page a framed app is entered through (its answer also sets the session cookie). */
|
|
81
|
+
export function enterPage(target, frameAncestors, hostOrigins, cookie) {
|
|
82
|
+
const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Opening the app</title><style>${STYLE}</style></head><body><main data-porthole-enter="" data-porthole-target="${escape(target)}" data-porthole-hosts="${escape(JSON.stringify(hostOrigins))}"><h1>Opening the app…</h1><p></p></main><script src="${ENTER_SCRIPT_PATH}"></script></body></html>`;
|
|
83
|
+
const headers = new Headers(pageHeaders(frameAncestors, true));
|
|
84
|
+
headers.set("set-cookie", cookie);
|
|
85
|
+
return new Response(body, { status: 200, headers });
|
|
86
|
+
}
|
package/dist/server/server.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { compile } from "../compile/compile.js";
|
|
2
|
-
import {
|
|
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 { statusPage } from "./pages.js";
|
|
5
|
+
import { enterPage, pageScript, SESSION_PATH, statusPage } from "./pages.js";
|
|
6
6
|
import { memoryTicketStore } from "./tickets.js";
|
|
7
7
|
/**
|
|
8
8
|
* Paths on a content origin. Porthole's own live under `/__porthole/`; on a url-mode app's origin
|
|
@@ -155,9 +155,14 @@ export function createServer(options) {
|
|
|
155
155
|
throw new TypeError("hostOrigins must name the origins that embed content");
|
|
156
156
|
}
|
|
157
157
|
for (const origin of options.hostOrigins) {
|
|
158
|
-
const
|
|
158
|
+
const problem = hostOriginProblem(origin);
|
|
159
|
+
if (problem !== null) {
|
|
160
|
+
throw new TypeError(`hostOrigins: ${problem}`);
|
|
161
|
+
}
|
|
162
|
+
// A desktop app's own scheme (ferry://app) is never the same site as web content.
|
|
163
|
+
const parsed = parseOrigin(origin);
|
|
159
164
|
if (parsed === null) {
|
|
160
|
-
|
|
165
|
+
continue;
|
|
161
166
|
}
|
|
162
167
|
if (parsed.host === domain ||
|
|
163
168
|
parsed.host.endsWith(`.${domain}`) ||
|
|
@@ -171,7 +176,8 @@ export function createServer(options) {
|
|
|
171
176
|
compiled.set(name, compile(config));
|
|
172
177
|
}
|
|
173
178
|
const tickets = options.tickets ?? memoryTicketStore();
|
|
174
|
-
|
|
179
|
+
/** A page saying why there is nothing to show, told to the host too (its reason, P040). */
|
|
180
|
+
const page = (status, title, detail, method, reason = status === 404 ? "not-found" : status === 502 ? "upstream" : "error") => statusPage(status, title, detail, frameAncestors, method, reason, options.hostOrigins);
|
|
175
181
|
/** The config a served document is served under, checked to fit it and this server. */
|
|
176
182
|
function configFor(name, kind) {
|
|
177
183
|
const each = compiled.get(name);
|
|
@@ -357,7 +363,7 @@ export function createServer(options) {
|
|
|
357
363
|
}
|
|
358
364
|
access ??= accessOf(request, label, key);
|
|
359
365
|
if (access === null) {
|
|
360
|
-
return page(401, "This link has been used", "Content here opens once per link. Open it again from the app that showed it.", method);
|
|
366
|
+
return page(401, "This link has been used", "Content here opens once per link. Open it again from the app that showed it.", method, "session");
|
|
361
367
|
}
|
|
362
368
|
const served = await documentOf(access.instance);
|
|
363
369
|
if (served === null) {
|
|
@@ -376,13 +382,20 @@ export function createServer(options) {
|
|
|
376
382
|
}
|
|
377
383
|
const record = await take(url.searchParams.get("ticket"), label);
|
|
378
384
|
if (record === null || record.instance.document.kind !== "external") {
|
|
379
|
-
return page(403, "This link has been used", "A link into an app works once, for a short time. Open it again from the app that showed it.");
|
|
385
|
+
return page(403, "This link has been used", "A link into an app works once, for a short time. Open it again from the app that showed it.", method, "session");
|
|
380
386
|
}
|
|
381
387
|
const target = new URL(record.instance.document.url);
|
|
388
|
+
const landing = `${target.pathname}${target.search}${target.hash}`;
|
|
389
|
+
// In a frame, a page enters the app: it can tell whether the browser kept the cookie (and tell
|
|
390
|
+
// the host), where a redirect would leave a frame without one showing a refusal. On a tab of
|
|
391
|
+
// its own, a redirect.
|
|
392
|
+
if (request.headers.get("sec-fetch-dest") !== "document") {
|
|
393
|
+
return enterPage(landing, frameAncestors, options.hostOrigins, sessionCookie(request, record));
|
|
394
|
+
}
|
|
382
395
|
return new Response(null, {
|
|
383
396
|
status: 303,
|
|
384
397
|
headers: {
|
|
385
|
-
location:
|
|
398
|
+
location: landing,
|
|
386
399
|
"set-cookie": sessionCookie(request, record),
|
|
387
400
|
"cache-control": "no-store",
|
|
388
401
|
"referrer-policy": "no-referrer",
|
|
@@ -425,7 +438,7 @@ export function createServer(options) {
|
|
|
425
438
|
const method = request.method.toUpperCase();
|
|
426
439
|
const access = accessOf(request, label, null);
|
|
427
440
|
if (access === null) {
|
|
428
|
-
return page(401, "Open this from the app", "This address shows an app only to the people the app opens it for.", method);
|
|
441
|
+
return page(401, "Open this from the app", "This address shows an app only to the people the app opens it for.", method, "session");
|
|
429
442
|
}
|
|
430
443
|
if (options.upstream === undefined) {
|
|
431
444
|
return page(502, "Nothing serves this app", "The server has no upstream for apps.", method);
|
|
@@ -481,7 +494,7 @@ export function createServer(options) {
|
|
|
481
494
|
const path = clean.document.kind === "external"
|
|
482
495
|
? `${ENTER_PATH}?ticket=${ticket}`
|
|
483
496
|
: `${PREFIX}d/${key}?ticket=${ticket}`;
|
|
484
|
-
return { url: `${origin}${path}`, origin };
|
|
497
|
+
return { url: `${origin}${path}`, origin, reports: true };
|
|
485
498
|
},
|
|
486
499
|
async access(request) {
|
|
487
500
|
const at = labelOf(request);
|
|
@@ -501,6 +514,17 @@ export function createServer(options) {
|
|
|
501
514
|
if (url.pathname === ENTER_PATH) {
|
|
502
515
|
return await enter(request, url, label);
|
|
503
516
|
}
|
|
517
|
+
const script = pageScript(url.pathname);
|
|
518
|
+
if (script !== null) {
|
|
519
|
+
return script;
|
|
520
|
+
}
|
|
521
|
+
if (url.pathname === SESSION_PATH) {
|
|
522
|
+
// Whether this request's cookie opens its origin's app: the entry page asks.
|
|
523
|
+
return new Response(null, {
|
|
524
|
+
status: accessOf(request, label, null) === null ? 401 : 204,
|
|
525
|
+
headers: { "cache-control": "no-store" },
|
|
526
|
+
});
|
|
527
|
+
}
|
|
504
528
|
const document = DOCUMENT_PATH.exec(url.pathname);
|
|
505
529
|
if (document?.[1] !== undefined) {
|
|
506
530
|
return await serveDocument(request, url, label, document[1]);
|
package/dist/shell/document.d.ts
CHANGED
|
@@ -2,8 +2,9 @@ import type { ShellPlan } from "./plan.ts";
|
|
|
2
2
|
export interface ShellDocumentOptions {
|
|
3
3
|
/**
|
|
4
4
|
* The origins the shell takes messages from: the embedding page's own (`location.origin`), or
|
|
5
|
-
* every host origin a served document may be embedded by. Exact origins
|
|
6
|
-
* sender must also be the
|
|
5
|
+
* every host origin a served document may be embedded by. Exact origins of any scheme (a
|
|
6
|
+
* desktop app's own, `ferry://app`), no wildcards, never "null". The sender must also be the
|
|
7
|
+
* frame's parent.
|
|
7
8
|
*/
|
|
8
9
|
readonly origins: readonly string[];
|
|
9
10
|
/** The frame document's title (for assistive technology inside the frame). */
|
package/dist/shell/document.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { hostOriginProblem } from "../isolation/served.js";
|
|
1
2
|
import { SHELL_RUNTIME } from "./runtime.js";
|
|
2
|
-
const EXACT_ORIGIN = /^(https?:\/\/[a-z0-9.-]+(:\d{1,5})?|https?:\/\/\[::1\](:\d{1,5})?)$/u;
|
|
3
3
|
const escapeAttribute = (value) => value.replace(/&/gu, "&").replace(/"/gu, """).replace(/</gu, "<");
|
|
4
4
|
const escapeText = (value) => value.replace(/&/gu, "&").replace(/</gu, "<").replace(/>/gu, ">");
|
|
5
5
|
/** JSON that is safe inside a `<script>`: no `</script>`, no `<!--`. */
|
|
@@ -47,8 +47,9 @@ body {
|
|
|
47
47
|
*/
|
|
48
48
|
export function shellDocument(plan, csp, options) {
|
|
49
49
|
for (const origin of options.origins) {
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
const problem = hostOriginProblem(origin);
|
|
51
|
+
if (problem !== null) {
|
|
52
|
+
throw new TypeError(`shellDocument: ${problem}`);
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
55
|
const runtime = SHELL_RUNTIME.replace("__PLAN__", () => scriptJson({
|
package/dist/shell/plan.d.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface ShellPlan {
|
|
|
26
26
|
readonly themeVariables: readonly string[];
|
|
27
27
|
/** The window event fired after a theme change. */
|
|
28
28
|
readonly themeEvent: string;
|
|
29
|
+
/** Whether a whole document takes the person's scheme: when it declares it, or always. */
|
|
30
|
+
readonly themeDocuments: "declared" | "always";
|
|
29
31
|
/** `memory`: an opaque origin has no storage, so a map stands in for the frame's life. */
|
|
30
32
|
readonly storage: "memory" | "native";
|
|
31
33
|
readonly maxErrorReports: number;
|
package/dist/shell/plan.js
CHANGED
|
@@ -26,6 +26,7 @@ export function shellPlan(config) {
|
|
|
26
26
|
globals,
|
|
27
27
|
themeVariables: Object.keys(config.theme.variables),
|
|
28
28
|
themeEvent: config.theme.event,
|
|
29
|
+
themeDocuments: config.theme.documents ?? "declared",
|
|
29
30
|
storage: config.isolation === "opaque" ? "memory" : "native",
|
|
30
31
|
maxErrorReports: config.limits.maxErrorReports,
|
|
31
32
|
maxPromptChars: config.limits.maxPromptChars,
|
package/dist/shell/runtime.js
CHANGED
|
@@ -487,6 +487,21 @@ export const SHELL_RUNTIME = String.raw `(() => {
|
|
|
487
487
|
window.dispatchEvent(new Event("load"));
|
|
488
488
|
};
|
|
489
489
|
|
|
490
|
+
// A fragment takes the person's scheme; a whole document only if it declares that it supports
|
|
491
|
+
// both (or the config says always), else it shows as its author styled it (P039).
|
|
492
|
+
let lastContext = null;
|
|
493
|
+
let themed = plan.content !== "document";
|
|
494
|
+
const schemeFollows = () => {
|
|
495
|
+
const root = document.documentElement;
|
|
496
|
+
const theme = lastContext && lastContext.theme;
|
|
497
|
+
if (themed && (theme === "light" || theme === "dark")) {
|
|
498
|
+
root.style.colorScheme = theme;
|
|
499
|
+
root.setAttribute("data-theme", theme);
|
|
500
|
+
} else {
|
|
501
|
+
root.style.removeProperty("color-scheme");
|
|
502
|
+
root.removeAttribute("data-theme");
|
|
503
|
+
}
|
|
504
|
+
};
|
|
490
505
|
// A whole document, parsed inert (no scripts run, no declarative shadow roots built), guarded,
|
|
491
506
|
// imported in place of the shell's, then its scripts run in order. The theme on the root stays.
|
|
492
507
|
const renderDocument = async (html) => {
|
|
@@ -502,6 +517,9 @@ export const SHELL_RUNTIME = String.raw `(() => {
|
|
|
502
517
|
document.head.replaceChildren(...Array.from(parsed.head.childNodes, (node) => document.importNode(node, true)));
|
|
503
518
|
document.body.replaceWith(document.importNode(parsed.body, true));
|
|
504
519
|
root = null;
|
|
520
|
+
const declaredScheme = parsed.querySelector('meta[name="color-scheme" i]');
|
|
521
|
+
themed = plan.themeDocuments === "always" || (declaredScheme !== null && /\bdark\b/i.test(declaredScheme.getAttribute("content") || ""));
|
|
522
|
+
schemeFollows();
|
|
505
523
|
await runScripts(scripts);
|
|
506
524
|
announceLoaded();
|
|
507
525
|
};
|
|
@@ -554,10 +572,8 @@ export const SHELL_RUNTIME = String.raw `(() => {
|
|
|
554
572
|
}
|
|
555
573
|
}
|
|
556
574
|
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
document.documentElement.setAttribute("data-theme", context.theme);
|
|
560
|
-
}
|
|
575
|
+
lastContext = context;
|
|
576
|
+
schemeFollows();
|
|
561
577
|
window.dispatchEvent(new CustomEvent(plan.themeEvent, { detail: { theme: context.theme } }));
|
|
562
578
|
};
|
|
563
579
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botiverse/porthole",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
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": [
|
|
@@ -46,19 +46,11 @@
|
|
|
46
46
|
"typescript": "7.0.2"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@modelcontextprotocol/ext-apps": ">=2.0.0",
|
|
50
|
-
"@playwright/test": ">=1.63.0",
|
|
51
49
|
"react": ">=19"
|
|
52
50
|
},
|
|
53
51
|
"peerDependenciesMeta": {
|
|
54
|
-
"@playwright/test": {
|
|
55
|
-
"optional": true
|
|
56
|
-
},
|
|
57
52
|
"react": {
|
|
58
53
|
"optional": true
|
|
59
|
-
},
|
|
60
|
-
"@modelcontextprotocol/ext-apps": {
|
|
61
|
-
"optional": true
|
|
62
54
|
}
|
|
63
55
|
},
|
|
64
56
|
"engines": {
|
package/src/bridge/bridge.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
CapabilityHandler,
|
|
12
12
|
CapabilityHandlers,
|
|
13
13
|
Confirm,
|
|
14
|
+
ConfirmAnswer,
|
|
14
15
|
ConfirmRequest,
|
|
15
16
|
HostFunctionBridge,
|
|
16
17
|
Refusal,
|
|
@@ -204,17 +205,28 @@ export function createBridge(options: BridgeOptions): HostFunctionBridge {
|
|
|
204
205
|
return refuse<Result>(capability, "no-user-activation");
|
|
205
206
|
}
|
|
206
207
|
}
|
|
208
|
+
// The effect, run once: when the person's "yes" calls `proceed` (inside its own click, so an
|
|
209
|
+
// effect that needs a gesture gets one), or after `confirm` resolves accepted.
|
|
210
|
+
let effect: Promise<CallOutcome<Result>> | null = null;
|
|
211
|
+
const run = (): Promise<CallOutcome<Result>> => (effect ??= perform(capability, args));
|
|
207
212
|
if (confirms) {
|
|
208
213
|
if (options.confirm === undefined) {
|
|
209
214
|
return refuse<Result>(capability, "no-confirm");
|
|
210
215
|
}
|
|
211
|
-
let
|
|
216
|
+
let answer: ConfirmAnswer = false;
|
|
212
217
|
try {
|
|
213
|
-
|
|
218
|
+
answer = await options.confirm(
|
|
219
|
+
{ capability, args, display } as ConfirmRequest,
|
|
220
|
+
() => void run(),
|
|
221
|
+
);
|
|
214
222
|
} catch {
|
|
215
|
-
|
|
223
|
+
answer = effect === null ? false : "accepted";
|
|
216
224
|
}
|
|
217
|
-
if (
|
|
225
|
+
if (effect === null && answer !== true && answer !== "accepted") {
|
|
226
|
+
if (answer === "redirected") {
|
|
227
|
+
return refuse<Result>(capability, "redirected");
|
|
228
|
+
}
|
|
229
|
+
// Declines in a row mute the display; a yes starts the count again.
|
|
218
230
|
declines += 1;
|
|
219
231
|
if (declines >= config.limits.declinesBeforeMute && !muted) {
|
|
220
232
|
muted = true;
|
|
@@ -222,8 +234,18 @@ export function createBridge(options: BridgeOptions): HostFunctionBridge {
|
|
|
222
234
|
}
|
|
223
235
|
return refuse<Result>(capability, "declined");
|
|
224
236
|
}
|
|
237
|
+
declines = 0;
|
|
225
238
|
}
|
|
239
|
+
return run();
|
|
240
|
+
},
|
|
241
|
+
};
|
|
226
242
|
|
|
243
|
+
async function perform<N extends CapabilityName>(
|
|
244
|
+
capability: N,
|
|
245
|
+
args: CapabilityArgs[N],
|
|
246
|
+
): Promise<CallOutcome<CapabilityResults[N]>> {
|
|
247
|
+
type Result = CapabilityResults[N];
|
|
248
|
+
{
|
|
227
249
|
if (capabilitySpec(capability).served === "event") {
|
|
228
250
|
if (capability === "resize") {
|
|
229
251
|
const size = args as CapabilityArgs["resize"];
|
|
@@ -251,6 +273,6 @@ export function createBridge(options: BridgeOptions): HostFunctionBridge {
|
|
|
251
273
|
error instanceof Error ? error.message : String(error),
|
|
252
274
|
);
|
|
253
275
|
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
256
278
|
}
|
package/src/bridge/types.ts
CHANGED
|
@@ -38,10 +38,26 @@ export type ConfirmRequest = {
|
|
|
38
38
|
}[CapabilityName];
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
41
|
+
* What the person chose. `true`/`"accepted"`: the call goes. `false`/`"declined"`: it doesn't,
|
|
42
|
+
* and the decline counts toward muting the display. `"redirected"`: it doesn't go, but the person
|
|
43
|
+
* took it elsewhere (edited the prompt first, say), so it neither counts as a decline nor resets
|
|
44
|
+
* the count. The host decides which its UI's choices are.
|
|
43
45
|
*/
|
|
44
|
-
export type
|
|
46
|
+
export type ConfirmAnswer = boolean | "accepted" | "declined" | "redirected";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Runs the call's effect now, inside the caller's own gesture, and counts as accepted. A host
|
|
50
|
+
* whose effect needs the person's gesture (opening a window) calls it from the click handler of
|
|
51
|
+
* its own "yes" button, rather than resolving the promise and letting the effect run after.
|
|
52
|
+
*/
|
|
53
|
+
export type Proceed = () => void;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Asks a person. Resolves with their answer. The host's UI, or the React binding's default
|
|
57
|
+
* dialog; drawn outside the frame, never inside it. `proceed` (optional to use) runs the effect
|
|
58
|
+
* within the "yes" click itself.
|
|
59
|
+
*/
|
|
60
|
+
export type Confirm = (request: ConfirmRequest, proceed: Proceed) => Promise<ConfirmAnswer>;
|
|
45
61
|
|
|
46
62
|
/** Why a call didn't run. */
|
|
47
63
|
export type Refusal =
|
|
@@ -55,6 +71,8 @@ export type Refusal =
|
|
|
55
71
|
| "no-confirm"
|
|
56
72
|
/** The person declined. */
|
|
57
73
|
| "declined"
|
|
74
|
+
/** The person took it elsewhere (edited it first): not sent, not counted as a decline. */
|
|
75
|
+
| "redirected"
|
|
58
76
|
/** The display was muted after too many declines (until it runs again). */
|
|
59
77
|
| "muted"
|
|
60
78
|
| "rate-limited"
|
package/src/compile/policy.ts
CHANGED
|
@@ -93,7 +93,8 @@ export function sandboxTokens(config: PortholeConfig): string[] {
|
|
|
93
93
|
case "domain":
|
|
94
94
|
return ["allow-scripts", "allow-same-origin"];
|
|
95
95
|
case "external":
|
|
96
|
-
|
|
96
|
+
// The host may add a documented few (popups, modals, downloads), never top navigation.
|
|
97
|
+
return ["allow-scripts", "allow-same-origin", "allow-forms", ...(config.sandbox ?? [])];
|
|
97
98
|
}
|
|
98
99
|
}
|
|
99
100
|
|
|
@@ -119,15 +120,11 @@ export function embedderFrameSources(config: PortholeConfig): string[] {
|
|
|
119
120
|
|
|
120
121
|
/**
|
|
121
122
|
* The CSP the embedding page sends (a header, or the host's own document's policy), for every kind
|
|
122
|
-
* of display it shows.
|
|
123
|
-
*
|
|
124
|
-
*
|
|
123
|
+
* of display it shows. Frames only: no `default-src`, so the host's own scripts, styles and
|
|
124
|
+
* fetches stay its policy's; and no `child-src`, which the host's workers would fall back to (every
|
|
125
|
+
* engine Porthole supports reads frames from `frame-src`, P038).
|
|
125
126
|
*/
|
|
126
127
|
export function embedderCsp(configs: readonly PortholeConfig[]): string {
|
|
127
128
|
const sources = unique(configs.flatMap(embedderFrameSources));
|
|
128
|
-
|
|
129
|
-
return serializeCsp([
|
|
130
|
-
["frame-src", ...all],
|
|
131
|
-
["child-src", ...all],
|
|
132
|
-
]);
|
|
129
|
+
return serializeCsp([["frame-src", ...(sources.length === 0 ? ["'none'"] : sources)]]);
|
|
133
130
|
}
|