@botiverse/porthole 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/annotate/types.d.ts +120 -0
  2. package/dist/annotate/types.js +42 -0
  3. package/dist/annotate/validate.d.ts +11 -0
  4. package/dist/annotate/validate.js +156 -0
  5. package/dist/bridge/bridge.d.ts +4 -0
  6. package/dist/bridge/bridge.js +12 -1
  7. package/dist/compile/policy.d.ts +10 -3
  8. package/dist/compile/policy.js +16 -5
  9. package/dist/config/capabilities.d.ts +37 -4
  10. package/dist/config/capabilities.js +20 -0
  11. package/dist/config/schema.d.ts +11 -0
  12. package/dist/config/validate.js +12 -0
  13. package/dist/conformance/clipboard.d.ts +2 -0
  14. package/dist/conformance/clipboard.js +167 -0
  15. package/dist/conformance/kit.d.ts +1 -1
  16. package/dist/conformance/probes.js +2 -0
  17. package/dist/conformance/served.d.ts +8 -1
  18. package/dist/conformance/served.js +12 -8
  19. package/dist/guide/guide.js +11 -5
  20. package/dist/host/clipboard.d.ts +11 -0
  21. package/dist/host/clipboard.js +43 -0
  22. package/dist/host/create-host.js +165 -7
  23. package/dist/host/types.d.ts +68 -2
  24. package/dist/index.d.ts +8 -5
  25. package/dist/index.js +7 -3
  26. package/dist/mcp/adapter.js +10 -1
  27. package/dist/presets/presets.d.ts +3 -1
  28. package/dist/presets/presets.js +1 -0
  29. package/dist/protocol/mcp-apps.d.ts +5 -0
  30. package/dist/protocol/mcp-apps.js +5 -0
  31. package/dist/protocol/shell-adapter.js +6 -2
  32. package/dist/react/dialog.js +2 -0
  33. package/dist/react/frame.d.ts +50 -2
  34. package/dist/react/frame.js +153 -11
  35. package/dist/react/index.d.ts +2 -1
  36. package/dist/react/index.js +2 -1
  37. package/dist/react/toast.d.ts +33 -0
  38. package/dist/react/toast.js +42 -0
  39. package/dist/server/naming.d.ts +6 -0
  40. package/dist/server/naming.js +38 -0
  41. package/dist/server/pages.js +2 -0
  42. package/dist/server/server.js +49 -17
  43. package/dist/server/types.d.ts +6 -3
  44. package/dist/shell/annotate.d.ts +20 -0
  45. package/dist/shell/annotate.js +700 -0
  46. package/dist/shell/document.d.ts +6 -0
  47. package/dist/shell/document.js +12 -3
  48. package/dist/shell/plan.d.ts +2 -0
  49. package/dist/shell/plan.js +2 -1
  50. package/dist/shell/runtime.d.ts +4 -1
  51. package/dist/shell/runtime.js +71 -3
  52. package/package.json +1 -1
  53. package/src/annotate/types.ts +126 -0
  54. package/src/annotate/validate.ts +186 -0
  55. package/src/bridge/bridge.ts +13 -0
  56. package/src/compile/policy.ts +17 -5
  57. package/src/config/capabilities.ts +33 -3
  58. package/src/config/schema.ts +12 -0
  59. package/src/config/validate.ts +15 -0
  60. package/src/conformance/clipboard.ts +184 -0
  61. package/src/conformance/kit.ts +1 -1
  62. package/src/conformance/probes.ts +2 -0
  63. package/src/conformance/served.ts +24 -9
  64. package/src/guide/guide.ts +22 -5
  65. package/src/host/clipboard.ts +41 -0
  66. package/src/host/create-host.ts +181 -7
  67. package/src/host/types.ts +64 -2
  68. package/src/index.ts +24 -0
  69. package/src/mcp/adapter.ts +10 -1
  70. package/src/presets/presets.ts +4 -0
  71. package/src/protocol/mcp-apps.ts +6 -0
  72. package/src/protocol/shell-adapter.ts +11 -2
  73. package/src/react/dialog.tsx +2 -0
  74. package/src/react/frame.tsx +231 -13
  75. package/src/react/index.ts +9 -1
  76. package/src/react/toast.tsx +96 -0
  77. package/src/server/naming.ts +41 -0
  78. package/src/server/pages.ts +2 -0
  79. package/src/server/server.ts +61 -20
  80. package/src/server/types.ts +6 -3
  81. package/src/shell/annotate.ts +700 -0
  82. package/src/shell/document.ts +21 -3
  83. package/src/shell/plan.ts +4 -1
  84. package/src/shell/runtime.ts +71 -3
@@ -9,7 +9,7 @@ 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 { isHostLabel } from "./naming.ts";
12
+ import { isHostLabel, reservedLabels } from "./naming.ts";
13
13
  import { enterPage, pageScript, SESSION_PATH, statusPage } from "./pages.ts";
14
14
  import type { FrameBlock } from "../host/types.ts";
15
15
  import { memoryTicketStore } from "./tickets.ts";
@@ -46,6 +46,18 @@ const MAX_SESSION_SECONDS = 30 * 24 * 3_600;
46
46
  /** Browsers keep a cookie of up to 4 KB, name and attributes included. */
47
47
  const MAX_COOKIE_BYTES = 3_800;
48
48
 
49
+ /** A host (and port) as it compares: lowercase, no trailing dot, no default port. */
50
+ function normalHost(value: string, protocol: string): string {
51
+ const host = value
52
+ .trim()
53
+ .toLowerCase()
54
+ .replace(/\.(?=:\d+$|$)/u, "");
55
+ const defaultPort = protocol === "https:" ? ":443" : protocol === "http:" ? ":80" : "";
56
+ return defaultPort !== "" && host.endsWith(defaultPort)
57
+ ? host.slice(0, -defaultPort.length)
58
+ : host;
59
+ }
60
+
49
61
  /**
50
62
  * What a session cookie carries, signed: the instance, its viewer (or anonymous), the host's
51
63
  * opaque subject, and when it stops.
@@ -190,21 +202,14 @@ export function createServer(options: ServerOptions): PortholeServer {
190
202
  throw new TypeError("content.port must be a port number");
191
203
  }
192
204
  const keys = createKeys(options.secret);
193
- const reserved = new Set(content.reserved ?? []);
194
- for (const label of reserved) {
195
- if (!isHostLabel(label)) {
196
- throw new TypeError(
197
- `content.reserved: ${JSON.stringify(label)} is not a lowercase DNS label`,
198
- );
199
- }
200
- }
205
+ const isReserved = reservedLabels(content.reserved);
201
206
  /** The label a scope is served on: the host's naming, checked, or the keyed default. */
202
207
  const nameOf = (scope: string): string => {
203
208
  if (content.name === undefined) {
204
209
  return keys.label(scope);
205
210
  }
206
211
  const label = content.name(scope);
207
- if (!isHostLabel(label) || reserved.has(label)) {
212
+ if (!isHostLabel(label) || isReserved(label)) {
208
213
  throw new TypeError(
209
214
  `content.name gave ${JSON.stringify(label)} for a scope: it must be one lowercase DNS label (a-z 0-9 -, at most 63, not xn--), not reserved`,
210
215
  );
@@ -213,7 +218,7 @@ export function createServer(options: ServerOptions): PortholeServer {
213
218
  };
214
219
  /** Whether a request's label may be one this server named. */
215
220
  const ours = (label: string): boolean =>
216
- !reserved.has(label) && (content.name === undefined ? LABEL.test(label) : isHostLabel(label));
221
+ !isReserved(label) && (content.name === undefined ? LABEL.test(label) : isHostLabel(label));
217
222
  const secure = content.scheme === "https";
218
223
  const defaultPort = secure ? 443 : 80;
219
224
  const portPart =
@@ -520,6 +525,7 @@ export function createServer(options: ServerOptions): PortholeServer {
520
525
  "cache-control": "no-store",
521
526
  "referrer-policy": "no-referrer",
522
527
  "x-content-type-options": "nosniff",
528
+ "x-robots-tag": "noindex, nofollow",
523
529
  });
524
530
  }
525
531
 
@@ -686,8 +692,20 @@ export function createServer(options: ServerOptions): PortholeServer {
686
692
  }
687
693
  }
688
694
 
689
- /** The label a request is for: null when it isn't under the content domain at all. */
690
- function labelOf(request: Request): { readonly url: URL; readonly label: string } | null {
695
+ const underDomain = (host: string): boolean => host === domain || host.endsWith(`.${domain}`);
696
+
697
+ /**
698
+ * The label a request is for: null when it isn't under the content domain at all (or is in a
699
+ * reserved namespace), "mismatch" when its Host header names another host than its URL does
700
+ * and either is under the content domain. A Node adapter may build the URL from an
701
+ * absolute-form request target (`GET http://other/…`) while a host routes by the Host header
702
+ * (and a cache keys on it): the two must agree before anything is served. A Host that is a
703
+ * local server address (an IP literal, `localhost`), as a test runner or a dev proxy sends, is
704
+ * not a name and isn't compared.
705
+ */
706
+ function labelOf(
707
+ request: Request,
708
+ ): { readonly url: URL; readonly label: string } | "mismatch" | null {
691
709
  let url: URL;
692
710
  try {
693
711
  url = new URL(request.url);
@@ -695,13 +713,27 @@ export function createServer(options: ServerOptions): PortholeServer {
695
713
  return null;
696
714
  }
697
715
  const host = url.hostname.toLowerCase().replace(/\.$/u, "");
698
- if (host !== domain && !host.endsWith(`.${domain}`)) {
716
+ const header = request.headers.get("host");
717
+ if (header !== null) {
718
+ const named = normalHost(header, url.protocol);
719
+ const name = named.replace(/:\d+$/u, "");
720
+ const local = name === "localhost" || /^[\d.]+$/u.test(name) || name.startsWith("[");
721
+ if (
722
+ !local &&
723
+ named !== normalHost(url.host, url.protocol) &&
724
+ (underDomain(host) || underDomain(name))
725
+ ) {
726
+ return "mismatch";
727
+ }
728
+ }
729
+ if (!underDomain(host)) {
699
730
  return null;
700
731
  }
701
732
  const label = host.slice(0, Math.max(0, host.length - domain.length - 1));
702
- // A reserved label is another server's namespace (`f` for `<x>.f.<domain>`): it and the names
703
- // under it go to the host's own routes. Any other deeper name is ours to refuse.
704
- if (reserved.has(label.split(".").at(-1) ?? "")) {
733
+ // A reserved label is another server's namespace (`f` for `<x>.f.<domain>`), or the host's
734
+ // own: it and the names under it go to the host's own routes. Any other deeper name is ours
735
+ // to refuse.
736
+ if (isReserved(label.split(".").at(-1) ?? "")) {
705
737
  return null;
706
738
  }
707
739
  return { url, label };
@@ -779,7 +811,7 @@ export function createServer(options: ServerOptions): PortholeServer {
779
811
 
780
812
  async access(request) {
781
813
  const at = labelOf(request);
782
- if (at === null || !ours(at.label)) {
814
+ if (at === null || at === "mismatch" || !ours(at.label)) {
783
815
  return null;
784
816
  }
785
817
  const access = accessOf(request, at.label, null);
@@ -791,8 +823,16 @@ export function createServer(options: ServerOptions): PortholeServer {
791
823
  if (at === null) {
792
824
  return null;
793
825
  }
794
- const { url, label } = at;
795
826
  const method = request.method.toUpperCase();
827
+ if (at === "mismatch") {
828
+ return page(
829
+ 400,
830
+ "Bad request",
831
+ "The request's Host header and its URL name different hosts.",
832
+ method,
833
+ );
834
+ }
835
+ const { url, label } = at;
796
836
  try {
797
837
  if (!ours(label)) {
798
838
  return page(404, "Nothing here", "This address names no content.", method);
@@ -815,7 +855,8 @@ export function createServer(options: ServerOptions): PortholeServer {
815
855
  if (document?.[1] !== undefined) {
816
856
  return await serveDocument(request, url, label, document[1]);
817
857
  }
818
- if (url.pathname.startsWith(PREFIX)) {
858
+ // Without an upstream, only Porthole's own paths are served here: any other is nothing.
859
+ if (url.pathname.startsWith(PREFIX) || options.upstream === undefined) {
819
860
  return page(404, "Nothing here", "This address names no content.", method);
820
861
  }
821
862
  return await serveApp(request, label);
@@ -19,10 +19,13 @@ export interface ContentDomain {
19
19
  */
20
20
  readonly name?: (scope: string) => string;
21
21
  /**
22
- * Labels this server never serves or names (another server's namespace under the same domain,
23
- * `f` for `<instance>.f.<domain>`, or `www`). Requests for them get null (the host's routes).
22
+ * Labels this server never serves or names: another server's namespace under the same domain
23
+ * (`f` for `<instance>.f.<domain>`, `www`), or the host's own (`p-*`: every label starting
24
+ * `p-`). Each entry is a whole label, or a prefix ending in `*`; or a function that says
25
+ * whether a label is reserved. Requests for them, and for the names under them, get null (the
26
+ * host's routes), so a host routes them without wrapping `handle`.
24
27
  */
25
- readonly reserved?: readonly string[];
28
+ readonly reserved?: readonly string[] | ((label: string) => boolean);
26
29
  }
27
30
 
28
31
  /**