@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.
Files changed (68) 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 +3 -3
  4. package/dist/compile/policy.js +6 -9
  5. package/dist/config/schema.d.ts +21 -0
  6. package/dist/config/schema.js +11 -1
  7. package/dist/config/validate.d.ts +3 -1
  8. package/dist/config/validate.js +41 -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 +24 -0
  20. package/dist/host/outcome.js +134 -0
  21. package/dist/host/types.d.ts +34 -2
  22. package/dist/index.d.ts +4 -2
  23. package/dist/index.js +3 -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 +9 -3
  31. package/dist/presets/presets.js +16 -1
  32. package/dist/react/frame.d.ts +16 -0
  33. package/dist/react/frame.js +44 -10
  34. package/dist/server/pages.d.ts +11 -6
  35. package/dist/server/pages.js +78 -12
  36. package/dist/server/server.js +34 -10
  37. package/dist/shell/document.d.ts +3 -2
  38. package/dist/shell/document.js +4 -3
  39. package/dist/shell/plan.d.ts +2 -0
  40. package/dist/shell/plan.js +1 -0
  41. package/dist/shell/runtime.js +20 -4
  42. package/package.json +1 -9
  43. package/src/bridge/bridge.ts +28 -6
  44. package/src/bridge/types.ts +21 -3
  45. package/src/compile/policy.ts +6 -9
  46. package/src/config/schema.ts +28 -0
  47. package/src/config/validate.ts +48 -14
  48. package/src/conformance/behavior.ts +55 -0
  49. package/src/conformance/index.ts +18 -4
  50. package/src/conformance/probes.ts +88 -52
  51. package/src/conformance/served.ts +37 -8
  52. package/src/guide/guide.ts +8 -2
  53. package/src/host/create-host.ts +47 -2
  54. package/src/host/locate.ts +6 -1
  55. package/src/host/outcome.ts +159 -0
  56. package/src/host/types.ts +33 -2
  57. package/src/index.ts +11 -1
  58. package/src/isolation/served.ts +24 -0
  59. package/src/isolation/types.ts +15 -1
  60. package/src/platform/types.ts +7 -0
  61. package/src/platform/web.ts +15 -2
  62. package/src/presets/presets.ts +27 -3
  63. package/src/react/frame.tsx +78 -14
  64. package/src/server/pages.ts +97 -11
  65. package/src/server/server.ts +48 -8
  66. package/src/shell/document.ts +7 -6
  67. package/src/shell/plan.ts +3 -0
  68. package/src/shell/runtime.ts +20 -4
@@ -1,7 +1,12 @@
1
+ import { FRAME_STATUS_METHOD } from "../isolation/types.ts";
2
+ import type { FrameBlock } from "../host/types.ts";
3
+
1
4
  /**
2
- * The server's own pages on a content origin: why it shows nothing of the content. Plain, no
3
- * script, framable only by the host origins, never cached. Their words are the host-agnostic
4
- * mechanism's; a host that wants its own wording serves its own page in front.
5
+ * The server's own pages on a content origin: why it shows nothing of the content, and the page a
6
+ * framed app is entered through. Plain, framable only by the host origins, never cached. Each tells
7
+ * the page that frames it how loading went (`porthole/notifications/frame-status`, P040), posted to
8
+ * the host origins only, so a host can show its own fallback ("Open in a new tab"). Their words are
9
+ * the host-agnostic mechanism's; a host that wants its own wording serves its own page in front.
5
10
  */
6
11
 
7
12
  const escape = (text: string): string =>
@@ -10,22 +15,103 @@ const escape = (text: string): string =>
10
15
  const STYLE =
11
16
  ":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}";
12
17
 
18
+ /** Where the pages' scripts are served, on every content origin. */
19
+ export const STATUS_SCRIPT_PATH = "/__porthole/status.js";
20
+ export const ENTER_SCRIPT_PATH = "/__porthole/enter.js";
21
+ /** Answers 204 when the request carries a good session for its origin's app, 401 otherwise. */
22
+ export const SESSION_PATH = "/__porthole/session";
23
+
24
+ const say = (): string =>
25
+ `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); };`;
26
+
27
+ /** Tells the host the page is blocked, and why (from the page's data attributes). */
28
+ const STATUS_SCRIPT = `(() => {
29
+ const main = document.querySelector("[data-porthole-status]");
30
+ if (main === null) return;
31
+ const hosts = JSON.parse(main.dataset.portholeHosts || "[]");
32
+ ${say()}
33
+ say({ state: "blocked", reason: main.dataset.portholeReason || "error", status: Number(main.dataset.portholeStatus) });
34
+ })();`;
35
+
36
+ /**
37
+ * Enters a framed app: says so, checks that the browser kept the session cookie this page's own
38
+ * answer set (WebKit keeps none in a cross-site frame, P025), then goes on to the app, or says it
39
+ * is blocked and why.
40
+ */
41
+ const ENTER_SCRIPT = `(async () => {
42
+ const main = document.querySelector("[data-porthole-enter]");
43
+ if (main === null) return;
44
+ const hosts = JSON.parse(main.dataset.portholeHosts || "[]");
45
+ ${say()}
46
+ say({ state: "entering" });
47
+ let kept = false;
48
+ try {
49
+ kept = (await fetch(${JSON.stringify(SESSION_PATH)}, { credentials: "same-origin", cache: "no-store" })).ok;
50
+ } catch (_) {}
51
+ if (kept) {
52
+ location.replace(main.dataset.portholeTarget || "/");
53
+ return;
54
+ }
55
+ say({ state: "blocked", reason: "cookies", status: 401 });
56
+ main.querySelector("h1").textContent = "Open this app in a new tab";
57
+ main.querySelector("p").textContent = "This browser keeps no sign-in for it inside a frame.";
58
+ })();`;
59
+
60
+ /** The pages' scripts, by path: served with the pages' own policy in mind (`script-src 'self'`). */
61
+ export function pageScript(pathname: string): Response | null {
62
+ const body =
63
+ pathname === STATUS_SCRIPT_PATH
64
+ ? STATUS_SCRIPT
65
+ : pathname === ENTER_SCRIPT_PATH
66
+ ? ENTER_SCRIPT
67
+ : null;
68
+ return body === null
69
+ ? null
70
+ : new Response(body, {
71
+ headers: {
72
+ "content-type": "text/javascript; charset=utf-8",
73
+ "cache-control": "no-store",
74
+ "x-content-type-options": "nosniff",
75
+ },
76
+ });
77
+ }
78
+
79
+ const policy = (frameAncestors: string, connect: boolean): string =>
80
+ `default-src 'none'; style-src 'unsafe-inline'; script-src 'self'; ${connect ? "connect-src 'self'; " : ""}base-uri 'none'; form-action 'none'; frame-ancestors ${frameAncestors}`;
81
+
82
+ const pageHeaders = (frameAncestors: string, connect: boolean) => ({
83
+ "content-type": "text/html; charset=utf-8",
84
+ "cache-control": "no-store",
85
+ "content-security-policy": policy(frameAncestors, connect),
86
+ "referrer-policy": "no-referrer",
87
+ "x-content-type-options": "nosniff",
88
+ });
89
+
13
90
  export function statusPage(
14
91
  status: number,
15
92
  title: string,
16
93
  detail: string,
17
94
  frameAncestors: string,
18
95
  method = "GET",
96
+ reason: FrameBlock = "error",
97
+ hostOrigins: readonly string[] = [],
19
98
  ): Response {
20
- 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}"><h1>${escape(title)}</h1><p>${escape(detail)}</p></main></body></html>`;
99
+ 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>`;
21
100
  return new Response(method === "HEAD" ? null : body, {
22
101
  status,
23
- headers: {
24
- "content-type": "text/html; charset=utf-8",
25
- "cache-control": "no-store",
26
- "content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors ${frameAncestors}`,
27
- "referrer-policy": "no-referrer",
28
- "x-content-type-options": "nosniff",
29
- },
102
+ headers: pageHeaders(frameAncestors, false),
30
103
  });
31
104
  }
105
+
106
+ /** The page a framed app is entered through (its answer also sets the session cookie). */
107
+ export function enterPage(
108
+ target: string,
109
+ frameAncestors: string,
110
+ hostOrigins: readonly string[],
111
+ cookie: string,
112
+ ): Response {
113
+ 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>`;
114
+ const headers = new Headers(pageHeaders(frameAncestors, true));
115
+ headers.set("set-cookie", cookie);
116
+ return new Response(body, { status: 200, headers });
117
+ }
@@ -1,7 +1,7 @@
1
1
  import { compile, type CompiledConfig } from "../compile/compile.ts";
2
2
  import type { DisplayMode, EgressConfig } from "../config/schema.ts";
3
3
  import {
4
- isExactOrigin,
4
+ hostOriginProblem,
5
5
  maybeSameSite,
6
6
  originMatchesSource,
7
7
  parseOrigin,
@@ -9,7 +9,8 @@ import {
9
9
  import type { FrameLocation, InstanceRef, ServedDocument } from "../isolation/types.ts";
10
10
  import { sandboxProxyDocument } from "../mcp/proxy.ts";
11
11
  import { createKeys, newTicket, ticketId } from "./keys.ts";
12
- import { statusPage } from "./pages.ts";
12
+ import { enterPage, pageScript, SESSION_PATH, statusPage } from "./pages.ts";
13
+ import type { FrameBlock } from "../host/types.ts";
13
14
  import { memoryTicketStore } from "./tickets.ts";
14
15
  import type { Access, PortholeServer, ServerOptions, TicketRecord, TicketStore } from "./types.ts";
15
16
 
@@ -187,9 +188,14 @@ export function createServer(options: ServerOptions): PortholeServer {
187
188
  throw new TypeError("hostOrigins must name the origins that embed content");
188
189
  }
189
190
  for (const origin of options.hostOrigins) {
190
- const parsed = isExactOrigin(origin) ? parseOrigin(origin) : null;
191
+ const problem = hostOriginProblem(origin);
192
+ if (problem !== null) {
193
+ throw new TypeError(`hostOrigins: ${problem}`);
194
+ }
195
+ // A desktop app's own scheme (ferry://app) is never the same site as web content.
196
+ const parsed = parseOrigin(origin);
191
197
  if (parsed === null) {
192
- throw new TypeError(`hostOrigins: ${JSON.stringify(origin)} is not an exact origin`);
198
+ continue;
193
199
  }
194
200
  if (
195
201
  parsed.host === domain ||
@@ -209,8 +215,15 @@ export function createServer(options: ServerOptions): PortholeServer {
209
215
  }
210
216
  const tickets: TicketStore = options.tickets ?? memoryTicketStore();
211
217
 
212
- const page = (status: number, title: string, detail: string, method?: string): Response =>
213
- statusPage(status, title, detail, frameAncestors, method);
218
+ /** A page saying why there is nothing to show, told to the host too (its reason, P040). */
219
+ const page = (
220
+ status: number,
221
+ title: string,
222
+ detail: string,
223
+ method?: string,
224
+ reason: FrameBlock = status === 404 ? "not-found" : status === 502 ? "upstream" : "error",
225
+ ): Response =>
226
+ statusPage(status, title, detail, frameAncestors, method, reason, options.hostOrigins);
214
227
 
215
228
  /** The config a served document is served under, checked to fit it and this server. */
216
229
  function configFor(name: string, kind: keyof typeof MODES): CompiledConfig {
@@ -428,6 +441,7 @@ export function createServer(options: ServerOptions): PortholeServer {
428
441
  "This link has been used",
429
442
  "Content here opens once per link. Open it again from the app that showed it.",
430
443
  method,
444
+ "session",
431
445
  );
432
446
  }
433
447
  const served = await documentOf(access.instance);
@@ -452,13 +466,27 @@ export function createServer(options: ServerOptions): PortholeServer {
452
466
  403,
453
467
  "This link has been used",
454
468
  "A link into an app works once, for a short time. Open it again from the app that showed it.",
469
+ method,
470
+ "session",
455
471
  );
456
472
  }
457
473
  const target = new URL(record.instance.document.url);
474
+ const landing = `${target.pathname}${target.search}${target.hash}`;
475
+ // In a frame, a page enters the app: it can tell whether the browser kept the cookie (and tell
476
+ // the host), where a redirect would leave a frame without one showing a refusal. On a tab of
477
+ // its own, a redirect.
478
+ if (request.headers.get("sec-fetch-dest") !== "document") {
479
+ return enterPage(
480
+ landing,
481
+ frameAncestors,
482
+ options.hostOrigins,
483
+ sessionCookie(request, record),
484
+ );
485
+ }
458
486
  return new Response(null, {
459
487
  status: 303,
460
488
  headers: {
461
- location: `${target.pathname}${target.search}${target.hash}`,
489
+ location: landing,
462
490
  "set-cookie": sessionCookie(request, record),
463
491
  "cache-control": "no-store",
464
492
  "referrer-policy": "no-referrer",
@@ -508,6 +536,7 @@ export function createServer(options: ServerOptions): PortholeServer {
508
536
  "Open this from the app",
509
537
  "This address shows an app only to the people the app opens it for.",
510
538
  method,
539
+ "session",
511
540
  );
512
541
  }
513
542
  if (options.upstream === undefined) {
@@ -579,7 +608,7 @@ export function createServer(options: ServerOptions): PortholeServer {
579
608
  clean.document.kind === "external"
580
609
  ? `${ENTER_PATH}?ticket=${ticket}`
581
610
  : `${PREFIX}d/${key}?ticket=${ticket}`;
582
- return { url: `${origin}${path}`, origin };
611
+ return { url: `${origin}${path}`, origin, reports: true };
583
612
  },
584
613
 
585
614
  async access(request) {
@@ -601,6 +630,17 @@ export function createServer(options: ServerOptions): PortholeServer {
601
630
  if (url.pathname === ENTER_PATH) {
602
631
  return await enter(request, url, label);
603
632
  }
633
+ const script = pageScript(url.pathname);
634
+ if (script !== null) {
635
+ return script;
636
+ }
637
+ if (url.pathname === SESSION_PATH) {
638
+ // Whether this request's cookie opens its origin's app: the entry page asks.
639
+ return new Response(null, {
640
+ status: accessOf(request, label, null) === null ? 401 : 204,
641
+ headers: { "cache-control": "no-store" },
642
+ });
643
+ }
604
644
  const document = DOCUMENT_PATH.exec(url.pathname);
605
645
  if (document?.[1] !== undefined) {
606
646
  return await serveDocument(request, url, label, document[1]);
@@ -1,11 +1,13 @@
1
+ import { hostOriginProblem } from "../isolation/served.ts";
1
2
  import { SHELL_RUNTIME } from "./runtime.ts";
2
3
  import type { ShellPlan } from "./plan.ts";
3
4
 
4
5
  export interface ShellDocumentOptions {
5
6
  /**
6
7
  * The origins the shell takes messages from: the embedding page's own (`location.origin`), or
7
- * every host origin a served document may be embedded by. Exact origins, no wildcards. The
8
- * sender must also be the frame's parent.
8
+ * every host origin a served document may be embedded by. Exact origins of any scheme (a
9
+ * desktop app's own, `ferry://app`), no wildcards, never "null". The sender must also be the
10
+ * frame's parent.
9
11
  */
10
12
  readonly origins: readonly string[];
11
13
  /** The frame document's title (for assistive technology inside the frame). */
@@ -24,8 +26,6 @@ export interface ShellDocumentOptions {
24
26
  readonly cspMeta?: boolean;
25
27
  }
26
28
 
27
- const EXACT_ORIGIN = /^(https?:\/\/[a-z0-9.-]+(:\d{1,5})?|https?:\/\/\[::1\](:\d{1,5})?)$/u;
28
-
29
29
  const escapeAttribute = (value: string): string =>
30
30
  value.replace(/&/gu, "&amp;").replace(/"/gu, "&quot;").replace(/</gu, "&lt;");
31
31
 
@@ -80,8 +80,9 @@ body {
80
80
  */
81
81
  export function shellDocument(plan: ShellPlan, csp: string, options: ShellDocumentOptions): string {
82
82
  for (const origin of options.origins) {
83
- if (!EXACT_ORIGIN.test(origin)) {
84
- throw new TypeError(`shellDocument: ${JSON.stringify(origin)} is not an exact origin`);
83
+ const problem = hostOriginProblem(origin);
84
+ if (problem !== null) {
85
+ throw new TypeError(`shellDocument: ${problem}`);
85
86
  }
86
87
  }
87
88
  const runtime = SHELL_RUNTIME.replace("__PLAN__", () =>
package/src/shell/plan.ts CHANGED
@@ -25,6 +25,8 @@ export interface ShellPlan {
25
25
  readonly themeVariables: readonly string[];
26
26
  /** The window event fired after a theme change. */
27
27
  readonly themeEvent: string;
28
+ /** Whether a whole document takes the person's scheme: when it declares it, or always. */
29
+ readonly themeDocuments: "declared" | "always";
28
30
  /** `memory`: an opaque origin has no storage, so a map stands in for the frame's life. */
29
31
  readonly storage: "memory" | "native";
30
32
  readonly maxErrorReports: number;
@@ -58,6 +60,7 @@ export function shellPlan(config: PortholeConfig): ShellPlan | null {
58
60
  globals,
59
61
  themeVariables: Object.keys(config.theme.variables),
60
62
  themeEvent: config.theme.event,
63
+ themeDocuments: config.theme.documents ?? "declared",
61
64
  storage: config.isolation === "opaque" ? "memory" : "native",
62
65
  maxErrorReports: config.limits.maxErrorReports,
63
66
  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