@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.
Files changed (84) hide show
  1. package/dist/bridge/bridge.js +20 -6
  2. package/dist/bridge/types.d.ts +19 -3
  3. package/dist/compile/policy.d.ts +6 -5
  4. package/dist/compile/policy.js +10 -12
  5. package/dist/config/schema.d.ts +29 -0
  6. package/dist/config/schema.js +16 -1
  7. package/dist/config/validate.d.ts +3 -1
  8. package/dist/config/validate.js +55 -10
  9. package/dist/conformance/behavior.js +50 -0
  10. package/dist/conformance/index.d.ts +11 -5
  11. package/dist/conformance/index.js +2 -2
  12. package/dist/conformance/probes.d.ts +26 -2
  13. package/dist/conformance/probes.js +51 -40
  14. package/dist/conformance/served.d.ts +7 -0
  15. package/dist/conformance/served.js +31 -7
  16. package/dist/guide/guide.js +6 -2
  17. package/dist/host/create-host.js +46 -2
  18. package/dist/host/locate.js +6 -1
  19. package/dist/host/outcome.d.ts +25 -0
  20. package/dist/host/outcome.js +147 -0
  21. package/dist/host/types.d.ts +39 -2
  22. package/dist/index.d.ts +6 -3
  23. package/dist/index.js +4 -1
  24. package/dist/isolation/served.d.ts +3 -0
  25. package/dist/isolation/served.js +20 -0
  26. package/dist/isolation/types.d.ts +9 -0
  27. package/dist/isolation/types.js +2 -1
  28. package/dist/platform/types.d.ts +7 -0
  29. package/dist/platform/web.js +15 -2
  30. package/dist/presets/presets.d.ts +11 -3
  31. package/dist/presets/presets.js +17 -1
  32. package/dist/react/frame.d.ts +16 -0
  33. package/dist/react/frame.js +44 -10
  34. package/dist/server/crypto.d.ts +19 -0
  35. package/dist/server/crypto.js +144 -0
  36. package/dist/server/index.d.ts +12 -4
  37. package/dist/server/index.js +11 -3
  38. package/dist/server/keys.js +12 -9
  39. package/dist/server/naming.d.ts +17 -0
  40. package/dist/server/naming.js +34 -0
  41. package/dist/server/pages.d.ts +11 -6
  42. package/dist/server/pages.js +86 -12
  43. package/dist/server/server.js +191 -23
  44. package/dist/server/tickets.d.ts +24 -0
  45. package/dist/server/tickets.js +46 -0
  46. package/dist/server/types.d.ts +76 -5
  47. package/dist/shell/document.d.ts +3 -2
  48. package/dist/shell/document.js +4 -3
  49. package/dist/shell/plan.d.ts +2 -0
  50. package/dist/shell/plan.js +1 -0
  51. package/dist/shell/runtime.js +20 -4
  52. package/package.json +2 -9
  53. package/src/bridge/bridge.ts +28 -6
  54. package/src/bridge/types.ts +21 -3
  55. package/src/compile/policy.ts +10 -12
  56. package/src/config/schema.ts +38 -0
  57. package/src/config/validate.ts +63 -14
  58. package/src/conformance/behavior.ts +55 -0
  59. package/src/conformance/index.ts +18 -4
  60. package/src/conformance/probes.ts +88 -52
  61. package/src/conformance/served.ts +37 -8
  62. package/src/guide/guide.ts +8 -2
  63. package/src/host/create-host.ts +47 -2
  64. package/src/host/locate.ts +6 -1
  65. package/src/host/outcome.ts +177 -0
  66. package/src/host/types.ts +42 -2
  67. package/src/index.ts +19 -1
  68. package/src/isolation/served.ts +24 -0
  69. package/src/isolation/types.ts +15 -1
  70. package/src/platform/types.ts +7 -0
  71. package/src/platform/web.ts +15 -2
  72. package/src/presets/presets.ts +31 -3
  73. package/src/react/frame.tsx +78 -14
  74. package/src/server/crypto.ts +166 -0
  75. package/src/server/index.ts +20 -3
  76. package/src/server/keys.ts +21 -13
  77. package/src/server/naming.ts +42 -0
  78. package/src/server/pages.ts +106 -11
  79. package/src/server/server.ts +233 -23
  80. package/src/server/tickets.ts +65 -0
  81. package/src/server/types.ts +84 -7
  82. package/src/shell/document.ts +7 -6
  83. package/src/shell/plan.ts +3 -0
  84. package/src/shell/runtime.ts +20 -4
@@ -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
- /** Who may open an instance, decided by the host before it calls `issue`. */
15
- export interface Grant {
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, which a fetch-style handler can't take.
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`). */
@@ -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, no wildcards. The
6
- * sender must also be the frame's parent.
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). */
@@ -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, "&amp;").replace(/"/gu, "&quot;").replace(/</gu, "&lt;");
4
4
  const escapeText = (value) => value.replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
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
- if (!EXACT_ORIGIN.test(origin)) {
51
- throw new TypeError(`shellDocument: ${JSON.stringify(origin)} is not an exact origin`);
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({
@@ -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;
@@ -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,
@@ -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
- if (context.theme === "light" || context.theme === "dark") {
558
- document.documentElement.style.colorScheme = context.theme;
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.0",
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": {
@@ -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 accepted = false;
216
+ let answer: ConfirmAnswer = false;
212
217
  try {
213
- accepted = await options.confirm({ capability, args, display } as ConfirmRequest);
218
+ answer = await options.confirm(
219
+ { capability, args, display } as ConfirmRequest,
220
+ () => void run(),
221
+ );
214
222
  } catch {
215
- accepted = false;
223
+ answer = effect === null ? false : "accepted";
216
224
  }
217
- if (!accepted) {
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
  }
@@ -38,10 +38,26 @@ export type ConfirmRequest = {
38
38
  }[CapabilityName];
39
39
 
40
40
  /**
41
- * Asks a person. Resolves true only if they accept. The host's UI, or the React binding's default
42
- * dialog; drawn outside the frame, never inside it.
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 Confirm = (request: ConfirmRequest) => Promise<boolean>;
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"
@@ -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
- return ["allow-scripts", "allow-same-origin", "allow-forms"];
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(_config: PortholeConfig): string {
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. It sets no `default-src`: it restricts frames only, leaving the host's own
123
- * scripts, styles and fetches to the host's policy. `child-src` repeats `frame-src` for engines
124
- * that read frames from it.
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
- const all = sources.length === 0 ? ["'none'"] : sources;
129
- return serializeCsp([
130
- ["frame-src", ...all],
131
- ["child-src", ...all],
132
- ]);
130
+ return serializeCsp([["frame-src", ...(sources.length === 0 ? ["'none'"] : sources)]]);
133
131
  }
@@ -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
  }
@@ -1,5 +1,5 @@
1
1
  import { CAPABILITY_NAMES, isCapabilityName, type Gate } from "./capabilities.ts";
2
- import type { PortholeConfig } from "./schema.ts";
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 false;
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 false;
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 false;
48
+ return `${JSON.stringify(value)} has no valid port`;
43
49
  }
44
50
  if (host !== "[::1]" && !HOST.test(host)) {
45
- return false;
51
+ return `${JSON.stringify(value)} ${shape}`;
46
52
  }
47
53
  if ((scheme === "http" || scheme === "ws") && !LOOPBACK.test(host.replace(/^\*\./u, "x."))) {
48
- return false;
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 true;
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
- if (!isOriginSource(source)) {
82
- fail(
83
- `${path}[${index}]`,
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
  }