@botiverse/porthole 0.1.0 → 0.1.2
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 +6 -5
- package/dist/compile/policy.js +10 -12
- package/dist/config/schema.d.ts +29 -0
- package/dist/config/schema.js +16 -1
- package/dist/config/validate.d.ts +3 -1
- package/dist/config/validate.js +55 -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 +25 -0
- package/dist/host/outcome.js +147 -0
- package/dist/host/types.d.ts +39 -2
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -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 +11 -3
- package/dist/presets/presets.js +17 -1
- package/dist/react/frame.d.ts +16 -0
- package/dist/react/frame.js +44 -10
- package/dist/server/crypto.d.ts +19 -0
- package/dist/server/crypto.js +144 -0
- package/dist/server/index.d.ts +12 -4
- package/dist/server/index.js +11 -3
- package/dist/server/keys.js +12 -9
- package/dist/server/naming.d.ts +17 -0
- package/dist/server/naming.js +34 -0
- package/dist/server/pages.d.ts +11 -6
- package/dist/server/pages.js +86 -12
- package/dist/server/server.js +191 -23
- package/dist/server/tickets.d.ts +24 -0
- package/dist/server/tickets.js +46 -0
- package/dist/server/types.d.ts +76 -5
- 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 +2 -9
- package/src/bridge/bridge.ts +28 -6
- package/src/bridge/types.ts +21 -3
- package/src/compile/policy.ts +10 -12
- package/src/config/schema.ts +38 -0
- package/src/config/validate.ts +63 -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 +177 -0
- package/src/host/types.ts +42 -2
- package/src/index.ts +19 -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 +31 -3
- package/src/react/frame.tsx +78 -14
- package/src/server/crypto.ts +166 -0
- package/src/server/index.ts +20 -3
- package/src/server/keys.ts +21 -13
- package/src/server/naming.ts +42 -0
- package/src/server/pages.ts +106 -11
- package/src/server/server.ts +233 -23
- package/src/server/tickets.ts +65 -0
- package/src/server/types.ts +84 -7
- package/src/shell/document.ts +7 -6
- package/src/shell/plan.ts +3 -0
- package/src/shell/runtime.ts +20 -4
package/dist/server/types.d.ts
CHANGED
|
@@ -10,11 +10,41 @@ export interface ContentDomain {
|
|
|
10
10
|
/** `http` only for `localhost`. */
|
|
11
11
|
readonly scheme: "https" | "http";
|
|
12
12
|
readonly port?: number;
|
|
13
|
+
/**
|
|
14
|
+
* The label under `domain` a storage scope is served on (P047). Default: 24 base32 characters
|
|
15
|
+
* keyed from the scope with the server's secret (stable, not guessable). A host's own (flat
|
|
16
|
+
* names like `a-<id>`) must return one lowercase DNS label, the same for the same scope, a
|
|
17
|
+
* different one for a different scope, and never a `reserved` one.
|
|
18
|
+
*/
|
|
19
|
+
readonly name?: (scope: string) => string;
|
|
20
|
+
/**
|
|
21
|
+
* Labels this server never serves or names (another server's namespace under the same domain,
|
|
22
|
+
* `f` for `<instance>.f.<domain>`, or `www`). Requests for them get null (the host's routes).
|
|
23
|
+
*/
|
|
24
|
+
readonly reserved?: readonly string[];
|
|
13
25
|
}
|
|
14
|
-
/**
|
|
15
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Who may open an instance, decided by the host before it calls `issue`: a viewer the host
|
|
28
|
+
* knows, or (a public link) nobody in particular. Porthole never interprets either: identity and
|
|
29
|
+
* authorization policy are the host's (P050).
|
|
30
|
+
*/
|
|
31
|
+
export type Grant = ({
|
|
16
32
|
/** The host's id for the viewer: recorded with the ticket, never shown to content. */
|
|
17
33
|
readonly viewer: string;
|
|
34
|
+
readonly anonymous?: never;
|
|
35
|
+
} | {
|
|
36
|
+
/**
|
|
37
|
+
* A public link: no viewer (P046). Only for documents (a shell, an artifact version, the
|
|
38
|
+
* sandbox proxy), never for a url-mode app.
|
|
39
|
+
*/
|
|
40
|
+
readonly anonymous: true;
|
|
41
|
+
readonly viewer?: never;
|
|
42
|
+
}) & {
|
|
43
|
+
/**
|
|
44
|
+
* The host's own opaque claim, carried in the ticket and the session cookie and handed back to
|
|
45
|
+
* `authorize` on every request (a session id, a share id). At most 512 characters.
|
|
46
|
+
*/
|
|
47
|
+
readonly subject?: string;
|
|
18
48
|
/** How long the ticket is good for, seconds (default 60). */
|
|
19
49
|
readonly ttlSeconds?: number;
|
|
20
50
|
/**
|
|
@@ -22,10 +52,13 @@ export interface Grant {
|
|
|
22
52
|
* url-mode app's every request, rides on.
|
|
23
53
|
*/
|
|
24
54
|
readonly sessionSeconds?: number;
|
|
25
|
-
}
|
|
55
|
+
};
|
|
26
56
|
export interface TicketRecord {
|
|
27
57
|
readonly instance: InstanceRef;
|
|
58
|
+
/** The grant's viewer; "" for an anonymous grant. */
|
|
28
59
|
readonly viewer: string;
|
|
60
|
+
readonly anonymous?: boolean;
|
|
61
|
+
readonly subject?: string;
|
|
29
62
|
/** Epoch milliseconds. */
|
|
30
63
|
readonly expiresAt: number;
|
|
31
64
|
/** The grant's `sessionSeconds`, if it set one. */
|
|
@@ -46,7 +79,11 @@ export interface ArtifactStore {
|
|
|
46
79
|
/** What a request's cookie lets in: one instance, for one viewer, on one origin, until a time. */
|
|
47
80
|
export interface Access {
|
|
48
81
|
readonly instance: InstanceRef;
|
|
82
|
+
/** The grant's viewer; "" for an anonymous one. */
|
|
49
83
|
readonly viewer: string;
|
|
84
|
+
readonly anonymous?: boolean;
|
|
85
|
+
/** The grant's opaque subject, if it had one. */
|
|
86
|
+
readonly subject?: string;
|
|
50
87
|
/** The content origin the access is for. */
|
|
51
88
|
readonly origin: string;
|
|
52
89
|
/** When the cookie stops being good, epoch milliseconds. */
|
|
@@ -58,6 +95,37 @@ export interface Access {
|
|
|
58
95
|
* Porthole's cookie; the answer gets `frame-ancestors` added, and loses any cookie named as ours.
|
|
59
96
|
*/
|
|
60
97
|
export type Upstream = (request: Request, access: Access) => Response | Promise<Response>;
|
|
98
|
+
/** What `authorize` is asked about: a request the cookie (or ticket) let in. */
|
|
99
|
+
export interface AuthorizeRequest {
|
|
100
|
+
readonly scope: string;
|
|
101
|
+
readonly instance: InstanceRef;
|
|
102
|
+
readonly viewer: string;
|
|
103
|
+
readonly anonymous: boolean;
|
|
104
|
+
readonly subject?: string;
|
|
105
|
+
/** The request itself (its path, method; never its Porthole cookie's value). */
|
|
106
|
+
readonly request: Request;
|
|
107
|
+
}
|
|
108
|
+
/** The host's answer: let it in, or not (with a short reason code for the host's own UI). */
|
|
109
|
+
export type AuthorizeAnswer = {
|
|
110
|
+
readonly allow: true;
|
|
111
|
+
} | {
|
|
112
|
+
readonly allow: false;
|
|
113
|
+
/** A short code (`revoked`, `left-workspace`): lowercase letters, digits and dashes. */
|
|
114
|
+
readonly code?: string;
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* The host's check on every request its cookie lets in (P050): revocation, membership,
|
|
118
|
+
* liveness. Porthole calls it after the cookie (or ticket) verifies and before anything of the
|
|
119
|
+
* content is served; a deny is a 403 page that tells the host why (`blocked: denied`).
|
|
120
|
+
*/
|
|
121
|
+
export interface Authorizer {
|
|
122
|
+
check(request: AuthorizeRequest): AuthorizeAnswer | Promise<AuthorizeAnswer>;
|
|
123
|
+
/**
|
|
124
|
+
* How long an answer may be reused for the same session and scope, seconds. Default 0: asked
|
|
125
|
+
* on every request. A host opts into a short time for apps with many requests.
|
|
126
|
+
*/
|
|
127
|
+
readonly ttlSeconds?: number;
|
|
128
|
+
}
|
|
61
129
|
export interface ServerOptions {
|
|
62
130
|
readonly content: ContentDomain;
|
|
63
131
|
/** At least 32 random bytes: keys the labels and signs tickets and cookies. */
|
|
@@ -73,6 +141,8 @@ export interface ServerOptions {
|
|
|
73
141
|
readonly tickets?: TicketStore;
|
|
74
142
|
/** Url mode: where an external instance's requests go once let in. Absent: nothing serves them. */
|
|
75
143
|
readonly upstream?: Upstream;
|
|
144
|
+
/** The host's per-request check (P050). Absent: a good cookie is enough until it ends. */
|
|
145
|
+
readonly authorize?: Authorizer;
|
|
76
146
|
}
|
|
77
147
|
export interface PortholeServer {
|
|
78
148
|
/**
|
|
@@ -88,8 +158,9 @@ export interface PortholeServer {
|
|
|
88
158
|
*/
|
|
89
159
|
issue(instance: InstanceRef, grant: Grant): Promise<FrameLocation>;
|
|
90
160
|
/**
|
|
91
|
-
* The access a request's cookie carries for a url-mode app on the request's origin, or null
|
|
92
|
-
* for a host's own WebSocket upgrade path,
|
|
161
|
+
* The access a request's cookie carries for a url-mode app on the request's origin, or null
|
|
162
|
+
* (no good cookie, or the host's check denies it): for a host's own WebSocket upgrade path,
|
|
163
|
+
* which a fetch-style handler can't take.
|
|
93
164
|
*/
|
|
94
165
|
access(request: Request): Promise<Access | null>;
|
|
95
166
|
/** The frame source every content origin matches (`https://*.example-usercontent.com`). */
|
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.2",
|
|
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": [
|
|
@@ -41,24 +41,17 @@
|
|
|
41
41
|
"@types/react": "19.3.0",
|
|
42
42
|
"@types/react-dom": "19.3.0",
|
|
43
43
|
"esbuild": "0.28.2",
|
|
44
|
+
"miniflare": "4.20260730.0",
|
|
44
45
|
"react": "19.3.0",
|
|
45
46
|
"react-dom": "19.3.0",
|
|
46
47
|
"typescript": "7.0.2"
|
|
47
48
|
},
|
|
48
49
|
"peerDependencies": {
|
|
49
|
-
"@modelcontextprotocol/ext-apps": ">=2.0.0",
|
|
50
|
-
"@playwright/test": ">=1.63.0",
|
|
51
50
|
"react": ">=19"
|
|
52
51
|
},
|
|
53
52
|
"peerDependenciesMeta": {
|
|
54
|
-
"@playwright/test": {
|
|
55
|
-
"optional": true
|
|
56
|
-
},
|
|
57
53
|
"react": {
|
|
58
54
|
"optional": true
|
|
59
|
-
},
|
|
60
|
-
"@modelcontextprotocol/ext-apps": {
|
|
61
|
-
"optional": true
|
|
62
55
|
}
|
|
63
56
|
},
|
|
64
57
|
"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,16 +93,18 @@ 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
|
|
|
100
101
|
/**
|
|
101
102
|
* The frame's `allow` attribute (Permissions Policy). Empty: no camera, microphone, geolocation,
|
|
102
|
-
* clipboard, fullscreen or other powerful feature is delegated to the frame
|
|
103
|
+
* clipboard, fullscreen or other powerful feature is delegated to the frame; a url-mode config
|
|
104
|
+
* may delegate clipboard writes and full screen to the app's own origin (P049).
|
|
103
105
|
*/
|
|
104
|
-
export function allowAttribute(
|
|
105
|
-
return "";
|
|
106
|
+
export function allowAttribute(config: PortholeConfig): string {
|
|
107
|
+
return config.mode === "url" ? (config.permissions ?? []).join("; ") : "";
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
/**
|
|
@@ -119,15 +121,11 @@ export function embedderFrameSources(config: PortholeConfig): string[] {
|
|
|
119
121
|
|
|
120
122
|
/**
|
|
121
123
|
* 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
|
-
*
|
|
124
|
+
* of display it shows. Frames only: no `default-src`, so the host's own scripts, styles and
|
|
125
|
+
* fetches stay its policy's; and no `child-src`, which the host's workers would fall back to (every
|
|
126
|
+
* engine Porthole supports reads frames from `frame-src`, P038).
|
|
125
127
|
*/
|
|
126
128
|
export function embedderCsp(configs: readonly PortholeConfig[]): string {
|
|
127
129
|
const sources = unique(configs.flatMap(embedderFrameSources));
|
|
128
|
-
|
|
129
|
-
return serializeCsp([
|
|
130
|
-
["frame-src", ...all],
|
|
131
|
-
["child-src", ...all],
|
|
132
|
-
]);
|
|
130
|
+
return serializeCsp([["frame-src", ...(sources.length === 0 ? ["'none'"] : sources)]]);
|
|
133
131
|
}
|
package/src/config/schema.ts
CHANGED
|
@@ -71,6 +71,13 @@ export interface ThemeConfig {
|
|
|
71
71
|
readonly variables: Readonly<Record<string, ThemeValue>>;
|
|
72
72
|
/** The window event the shell fires after the theme changed, for code that must redraw. */
|
|
73
73
|
readonly event: string;
|
|
74
|
+
/**
|
|
75
|
+
* Whether a whole document (an artifact page) takes the person's light or dark scheme:
|
|
76
|
+
* `declared` (default): only a page that declares it supports both
|
|
77
|
+
* (`<meta name="color-scheme" content="light dark">`); any other page shows as its author
|
|
78
|
+
* styled it, in light. `always`: every page. The variables are there either way.
|
|
79
|
+
*/
|
|
80
|
+
readonly documents?: "declared" | "always";
|
|
74
81
|
}
|
|
75
82
|
|
|
76
83
|
export interface Limits {
|
|
@@ -88,8 +95,35 @@ export interface Limits {
|
|
|
88
95
|
readonly maxPromptChars: number;
|
|
89
96
|
/** The most errors one run of a display reports. */
|
|
90
97
|
readonly maxErrorReports: number;
|
|
98
|
+
/**
|
|
99
|
+
* How long a display may take to load before its outcome is a timeout (ms). Optional; 15 s
|
|
100
|
+
* when absent.
|
|
101
|
+
*/
|
|
102
|
+
readonly loadTimeoutMs?: number;
|
|
91
103
|
}
|
|
92
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Sandbox tokens a url-mode frame may add to its baseline, by the host's choice (P037): a
|
|
107
|
+
* person's own running app (a dev server's preview) may need popups (an OAuth flow), modals and
|
|
108
|
+
* downloads. Never top navigation, and nothing for other modes.
|
|
109
|
+
*/
|
|
110
|
+
export const URL_SANDBOX_EXTRAS = [
|
|
111
|
+
"allow-popups",
|
|
112
|
+
"allow-popups-to-escape-sandbox",
|
|
113
|
+
"allow-modals",
|
|
114
|
+
"allow-downloads",
|
|
115
|
+
] as const;
|
|
116
|
+
|
|
117
|
+
export type UrlSandboxExtra = (typeof URL_SANDBOX_EXTRAS)[number];
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Powerful features a url-mode frame may be delegated, by the host's choice (P049): writing the
|
|
121
|
+
* clipboard (a "copy" button in the person's app) and going full screen. Nothing for other modes.
|
|
122
|
+
*/
|
|
123
|
+
export const URL_PERMISSIONS = ["clipboard-write", "fullscreen"] as const;
|
|
124
|
+
|
|
125
|
+
export type UrlPermission = (typeof URL_PERMISSIONS)[number];
|
|
126
|
+
|
|
93
127
|
/** Where the host shows the content, for the manual's size rules. */
|
|
94
128
|
export interface LayoutConfig {
|
|
95
129
|
/** The narrowest the frame ever is (a small phone). */
|
|
@@ -137,4 +171,8 @@ export interface PortholeConfig {
|
|
|
137
171
|
*/
|
|
138
172
|
readonly contentOrigins: readonly string[];
|
|
139
173
|
readonly embedder: EmbedderConfig;
|
|
174
|
+
/** Url mode only: sandbox tokens added to its baseline (`URL_SANDBOX_EXTRAS`). */
|
|
175
|
+
readonly sandbox?: readonly UrlSandboxExtra[];
|
|
176
|
+
/** Url mode only: features delegated to the frame (`URL_PERMISSIONS`); none by default. */
|
|
177
|
+
readonly permissions?: readonly UrlPermission[];
|
|
140
178
|
}
|
package/src/config/validate.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CAPABILITY_NAMES, isCapabilityName, type Gate } from "./capabilities.ts";
|
|
2
|
-
import type
|
|
2
|
+
import { URL_PERMISSIONS, URL_SANDBOX_EXTRAS, type PortholeConfig } from "./schema.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Checks a config before anything is derived from it. Every string that ends up in a CSP, an
|
|
@@ -30,24 +30,30 @@ const HOST =
|
|
|
30
30
|
* every subdomain) and an optional port, nothing else. Insecure schemes only for loopback names.
|
|
31
31
|
*/
|
|
32
32
|
export function isOriginSource(value: unknown): value is string {
|
|
33
|
+
return originSourceProblem(value) === null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Why `value` isn't an origin source Porthole accepts, in words to act on; null if it is. */
|
|
37
|
+
export function originSourceProblem(value: unknown): string | null {
|
|
38
|
+
const shape = "is not an origin (scheme://host[:port], lowercase, no path)";
|
|
33
39
|
if (typeof value !== "string" || value.length > 253 + 16) {
|
|
34
|
-
return
|
|
40
|
+
return `${JSON.stringify(value)} ${shape}`;
|
|
35
41
|
}
|
|
36
42
|
const match = /^(https|http|wss|ws):\/\/([^/:]+|\[::1\])(:(\d{1,5}))?$/u.exec(value);
|
|
37
43
|
if (match === null) {
|
|
38
|
-
return
|
|
44
|
+
return `${JSON.stringify(value)} ${shape}`;
|
|
39
45
|
}
|
|
40
46
|
const [, scheme, host = "", , port] = match;
|
|
41
47
|
if (port !== undefined && (Number(port) < 1 || Number(port) > 65_535)) {
|
|
42
|
-
return
|
|
48
|
+
return `${JSON.stringify(value)} has no valid port`;
|
|
43
49
|
}
|
|
44
50
|
if (host !== "[::1]" && !HOST.test(host)) {
|
|
45
|
-
return
|
|
51
|
+
return `${JSON.stringify(value)} ${shape}`;
|
|
46
52
|
}
|
|
47
53
|
if ((scheme === "http" || scheme === "ws") && !LOOPBACK.test(host.replace(/^\*\./u, "x."))) {
|
|
48
|
-
return
|
|
54
|
+
return `${JSON.stringify(value)} uses ${scheme} off loopback: ${scheme} is allowed only for localhost, *.localhost, 127.0.0.1 and [::1] (loopback development); use ${scheme === "http" ? "https" : "wss"}`;
|
|
49
55
|
}
|
|
50
|
-
return
|
|
56
|
+
return null;
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
const fail = (path: string, message: string): never => {
|
|
@@ -78,11 +84,9 @@ function sources(value: unknown, path: string): void {
|
|
|
78
84
|
return;
|
|
79
85
|
}
|
|
80
86
|
value.forEach((source, index) => {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
`${JSON.stringify(source)} is not an origin (scheme://host[:port], lowercase, no path)`,
|
|
85
|
-
);
|
|
87
|
+
const problem = originSourceProblem(source);
|
|
88
|
+
if (problem !== null) {
|
|
89
|
+
fail(`${path}[${index}]`, problem);
|
|
86
90
|
}
|
|
87
91
|
});
|
|
88
92
|
}
|
|
@@ -125,6 +129,8 @@ export function validateConfig(input: unknown): PortholeConfig {
|
|
|
125
129
|
"guide",
|
|
126
130
|
"contentOrigins",
|
|
127
131
|
"embedder",
|
|
132
|
+
"sandbox",
|
|
133
|
+
"permissions",
|
|
128
134
|
],
|
|
129
135
|
"config",
|
|
130
136
|
);
|
|
@@ -173,7 +179,14 @@ export function validateConfig(input: unknown): PortholeConfig {
|
|
|
173
179
|
}
|
|
174
180
|
|
|
175
181
|
const theme = object(config.theme, "config.theme");
|
|
176
|
-
only(theme, ["variables", "event"], "config.theme");
|
|
182
|
+
only(theme, ["variables", "event", "documents"], "config.theme");
|
|
183
|
+
if (
|
|
184
|
+
theme.documents !== undefined &&
|
|
185
|
+
theme.documents !== "declared" &&
|
|
186
|
+
theme.documents !== "always"
|
|
187
|
+
) {
|
|
188
|
+
fail("config.theme.documents", 'must be "declared" or "always"');
|
|
189
|
+
}
|
|
177
190
|
const variables = object(theme.variables, "config.theme.variables");
|
|
178
191
|
for (const [name, value] of Object.entries(variables)) {
|
|
179
192
|
const path = `config.theme.variables[${JSON.stringify(name)}]`;
|
|
@@ -212,10 +225,13 @@ export function validateConfig(input: unknown): PortholeConfig {
|
|
|
212
225
|
"maxPromptChars",
|
|
213
226
|
"maxErrorReports",
|
|
214
227
|
];
|
|
215
|
-
only(limits, limitNames, "config.limits");
|
|
228
|
+
only(limits, [...limitNames, "loadTimeoutMs"], "config.limits");
|
|
216
229
|
for (const name of limitNames) {
|
|
217
230
|
positive(limits[name], `config.limits.${name}`);
|
|
218
231
|
}
|
|
232
|
+
if (limits.loadTimeoutMs !== undefined) {
|
|
233
|
+
positive(limits.loadTimeoutMs, "config.limits.loadTimeoutMs", 600_000);
|
|
234
|
+
}
|
|
219
235
|
if ((limits.minHeightPx as number) > (limits.maxHeightPx as number)) {
|
|
220
236
|
fail("config.limits.minHeightPx", "must not exceed maxHeightPx");
|
|
221
237
|
}
|
|
@@ -280,5 +296,38 @@ export function validateConfig(input: unknown): PortholeConfig {
|
|
|
280
296
|
"config.embedder.frameSrc",
|
|
281
297
|
);
|
|
282
298
|
|
|
299
|
+
if (config.sandbox !== undefined) {
|
|
300
|
+
if (config.mode !== "url") {
|
|
301
|
+
fail("config.sandbox", "only url mode may add sandbox tokens");
|
|
302
|
+
}
|
|
303
|
+
if (!Array.isArray(config.sandbox)) {
|
|
304
|
+
fail("config.sandbox", "must be an array of sandbox tokens");
|
|
305
|
+
}
|
|
306
|
+
(config.sandbox as unknown[]).forEach((token, index) => {
|
|
307
|
+
if (!(URL_SANDBOX_EXTRAS as readonly unknown[]).includes(token)) {
|
|
308
|
+
fail(
|
|
309
|
+
`config.sandbox[${index}]`,
|
|
310
|
+
/top-navigation|same-origin/u.test(String(token))
|
|
311
|
+
? `${JSON.stringify(token)} is never granted (P037)`
|
|
312
|
+
: `must be one of ${URL_SANDBOX_EXTRAS.join(", ")}`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (config.permissions !== undefined) {
|
|
319
|
+
if (config.mode !== "url") {
|
|
320
|
+
fail("config.permissions", "only url mode may be delegated features");
|
|
321
|
+
}
|
|
322
|
+
if (!Array.isArray(config.permissions)) {
|
|
323
|
+
fail("config.permissions", "must be an array of features");
|
|
324
|
+
}
|
|
325
|
+
(config.permissions as unknown[]).forEach((feature, index) => {
|
|
326
|
+
if (!(URL_PERMISSIONS as readonly unknown[]).includes(feature)) {
|
|
327
|
+
fail(`config.permissions[${index}]`, `must be one of ${URL_PERMISSIONS.join(", ")}`);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
283
332
|
return input as PortholeConfig;
|
|
284
333
|
}
|