@botiverse/porthole 0.0.1 → 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 (152) hide show
  1. package/LICENSE +217 -0
  2. package/dist/bridge/bridge.d.ts +39 -0
  3. package/dist/bridge/bridge.js +198 -0
  4. package/dist/bridge/types.d.ts +83 -0
  5. package/dist/bridge/types.js +1 -0
  6. package/dist/compile/compile.d.ts +35 -0
  7. package/dist/compile/compile.js +30 -0
  8. package/dist/compile/policy.d.ts +55 -0
  9. package/dist/compile/policy.js +103 -0
  10. package/dist/config/capabilities.d.ts +154 -0
  11. package/dist/config/capabilities.js +63 -0
  12. package/dist/config/schema.d.ts +152 -0
  13. package/dist/config/schema.js +11 -0
  14. package/dist/config/validate.d.ts +22 -0
  15. package/dist/config/validate.js +261 -0
  16. package/dist/conformance/behavior.d.ts +2 -0
  17. package/dist/conformance/behavior.js +426 -0
  18. package/dist/conformance/index.d.ts +101 -0
  19. package/dist/conformance/index.js +4 -0
  20. package/dist/conformance/kit.d.ts +42 -0
  21. package/dist/conformance/kit.js +24 -0
  22. package/dist/conformance/oracles.d.ts +35 -0
  23. package/dist/conformance/oracles.js +77 -0
  24. package/dist/conformance/probes.d.ts +33 -0
  25. package/dist/conformance/probes.js +876 -0
  26. package/dist/conformance/served.d.ts +75 -0
  27. package/dist/conformance/served.js +335 -0
  28. package/dist/guide/examples.d.ts +70 -0
  29. package/dist/guide/examples.js +69 -0
  30. package/dist/guide/guide.d.ts +16 -0
  31. package/dist/guide/guide.js +265 -0
  32. package/dist/host/create-host.d.ts +8 -0
  33. package/dist/host/create-host.js +292 -0
  34. package/dist/host/locate.d.ts +8 -0
  35. package/dist/host/locate.js +16 -0
  36. package/dist/host/outcome.d.ts +24 -0
  37. package/dist/host/outcome.js +134 -0
  38. package/dist/host/types.d.ts +127 -0
  39. package/dist/host/types.js +1 -0
  40. package/dist/index.d.ts +25 -0
  41. package/dist/index.js +21 -0
  42. package/dist/isolation/served.d.ts +35 -0
  43. package/dist/isolation/served.js +117 -0
  44. package/dist/isolation/types.d.ts +96 -0
  45. package/dist/isolation/types.js +2 -0
  46. package/dist/mcp/adapter.d.ts +43 -0
  47. package/dist/mcp/adapter.js +327 -0
  48. package/dist/mcp/index.d.ts +27 -0
  49. package/dist/mcp/index.js +25 -0
  50. package/dist/mcp/proxy.d.ts +25 -0
  51. package/dist/mcp/proxy.js +106 -0
  52. package/dist/mcp/view.d.ts +31 -0
  53. package/dist/mcp/view.js +72 -0
  54. package/dist/platform/types.d.ts +51 -0
  55. package/dist/platform/types.js +1 -0
  56. package/dist/platform/web.d.ts +8 -0
  57. package/dist/platform/web.js +85 -0
  58. package/dist/presets/presets.d.ts +71 -0
  59. package/dist/presets/presets.js +140 -0
  60. package/dist/protocol/adapter.d.ts +73 -0
  61. package/dist/protocol/adapter.js +1 -0
  62. package/dist/protocol/jsonrpc.d.ts +43 -0
  63. package/dist/protocol/jsonrpc.js +50 -0
  64. package/dist/protocol/mcp-apps.d.ts +79 -0
  65. package/dist/protocol/mcp-apps.js +99 -0
  66. package/dist/protocol/shell-adapter.d.ts +5 -0
  67. package/dist/protocol/shell-adapter.js +228 -0
  68. package/dist/react/dialog.d.ts +20 -0
  69. package/dist/react/dialog.js +98 -0
  70. package/dist/react/frame.d.ts +91 -0
  71. package/dist/react/frame.js +255 -0
  72. package/dist/react/index.d.ts +13 -0
  73. package/dist/react/index.js +13 -0
  74. package/dist/react/live.d.ts +24 -0
  75. package/dist/react/live.js +57 -0
  76. package/dist/server/index.d.ts +27 -0
  77. package/dist/server/index.js +26 -0
  78. package/dist/server/keys.d.ts +19 -0
  79. package/dist/server/keys.js +78 -0
  80. package/dist/server/pages.d.ts +11 -0
  81. package/dist/server/pages.js +86 -0
  82. package/dist/server/proxy.d.ts +21 -0
  83. package/dist/server/proxy.js +107 -0
  84. package/dist/server/server.d.ts +7 -0
  85. package/dist/server/server.js +542 -0
  86. package/dist/server/tickets.d.ts +8 -0
  87. package/dist/server/tickets.js +28 -0
  88. package/dist/server/types.d.ts +97 -0
  89. package/dist/server/types.js +1 -0
  90. package/dist/shell/document.d.ts +29 -0
  91. package/dist/shell/document.js +85 -0
  92. package/dist/shell/plan.d.ts +37 -0
  93. package/dist/shell/plan.js +35 -0
  94. package/dist/shell/runtime.d.ts +28 -0
  95. package/dist/shell/runtime.js +848 -0
  96. package/dist/theme/variables.d.ts +73 -0
  97. package/dist/theme/variables.js +171 -0
  98. package/dist/transport/post-message.d.ts +9 -0
  99. package/dist/transport/post-message.js +38 -0
  100. package/dist/transport/types.d.ts +15 -0
  101. package/dist/transport/types.js +1 -0
  102. package/package.json +63 -1
  103. package/src/bridge/bridge.ts +278 -0
  104. package/src/bridge/types.ts +95 -0
  105. package/src/compile/compile.ts +68 -0
  106. package/src/compile/policy.ts +130 -0
  107. package/src/config/capabilities.ts +138 -0
  108. package/src/config/schema.ts +168 -0
  109. package/src/config/validate.ts +318 -0
  110. package/src/conformance/behavior.ts +485 -0
  111. package/src/conformance/index.ts +116 -0
  112. package/src/conformance/kit.ts +63 -0
  113. package/src/conformance/oracles.ts +126 -0
  114. package/src/conformance/probes.ts +1129 -0
  115. package/src/conformance/served.ts +501 -0
  116. package/src/guide/examples.ts +74 -0
  117. package/src/guide/guide.ts +430 -0
  118. package/src/host/create-host.ts +335 -0
  119. package/src/host/locate.ts +25 -0
  120. package/src/host/outcome.ts +159 -0
  121. package/src/host/types.ts +126 -0
  122. package/src/index.ts +128 -0
  123. package/src/isolation/served.ts +151 -0
  124. package/src/isolation/types.ts +91 -0
  125. package/src/mcp/adapter.ts +441 -0
  126. package/src/mcp/index.ts +50 -0
  127. package/src/mcp/proxy.ts +117 -0
  128. package/src/mcp/view.ts +97 -0
  129. package/src/platform/types.ts +55 -0
  130. package/src/platform/web.ts +91 -0
  131. package/src/presets/presets.ts +217 -0
  132. package/src/protocol/adapter.ts +67 -0
  133. package/src/protocol/jsonrpc.ts +83 -0
  134. package/src/protocol/mcp-apps.ts +138 -0
  135. package/src/protocol/shell-adapter.ts +261 -0
  136. package/src/react/dialog.tsx +158 -0
  137. package/src/react/frame.tsx +486 -0
  138. package/src/react/index.ts +20 -0
  139. package/src/react/live.ts +76 -0
  140. package/src/server/index.ts +37 -0
  141. package/src/server/keys.ts +100 -0
  142. package/src/server/pages.ts +117 -0
  143. package/src/server/proxy.ts +131 -0
  144. package/src/server/server.ts +657 -0
  145. package/src/server/tickets.ts +30 -0
  146. package/src/server/types.ts +104 -0
  147. package/src/shell/document.ts +121 -0
  148. package/src/shell/plan.ts +69 -0
  149. package/src/shell/runtime.ts +848 -0
  150. package/src/theme/variables.ts +190 -0
  151. package/src/transport/post-message.ts +45 -0
  152. package/src/transport/types.ts +16 -0
@@ -0,0 +1,107 @@
1
+ const HOP_BY_HOP = new Set([
2
+ "connection",
3
+ "keep-alive",
4
+ "proxy-authenticate",
5
+ "proxy-authorization",
6
+ "proxy-connection",
7
+ "te",
8
+ "trailer",
9
+ "transfer-encoding",
10
+ "upgrade",
11
+ ]);
12
+ /** What a proxy in front of the host's server added: the app is told nothing of it. */
13
+ const FORWARDING = /^(?:x-forwarded-.*|forwarded|x-real-ip)$/u;
14
+ function connectionTokens(headers) {
15
+ return new Set((headers.get("connection") ?? "")
16
+ .split(",")
17
+ .map((token) => token.trim().toLowerCase())
18
+ .filter((token) => token !== ""));
19
+ }
20
+ /** The browser's request headers as the app at `target` gets them. */
21
+ export function upstreamRequestHeaders(headers, origin, target) {
22
+ const named = connectionTokens(headers);
23
+ const out = new Headers();
24
+ for (const [name, value] of headers) {
25
+ const key = name.toLowerCase();
26
+ if (key === "host" || HOP_BY_HOP.has(key) || named.has(key) || FORWARDING.test(key)) {
27
+ continue;
28
+ }
29
+ if (key === "origin" && value === origin) {
30
+ out.set(name, target);
31
+ }
32
+ else if (key === "referer" && (value === origin || value.startsWith(`${origin}/`))) {
33
+ out.set(name, `${target}${value.slice(origin.length)}`);
34
+ }
35
+ else {
36
+ out.append(name, value);
37
+ }
38
+ }
39
+ // The answer comes back decoded by fetch: ask for it plain rather than pay to decode it.
40
+ out.set("accept-encoding", "identity");
41
+ return out;
42
+ }
43
+ /** The app's answer headers as the browser on `origin` gets them. */
44
+ export function upstreamResponseHeaders(headers, origin, target) {
45
+ const named = connectionTokens(headers);
46
+ const out = new Headers();
47
+ for (const [name, value] of headers) {
48
+ const key = name.toLowerCase();
49
+ if (HOP_BY_HOP.has(key) ||
50
+ named.has(key) ||
51
+ key === "set-cookie" ||
52
+ // fetch decoded the body, whatever the app said it encoded.
53
+ key === "content-encoding" ||
54
+ key === "content-length") {
55
+ continue;
56
+ }
57
+ if (key === "location") {
58
+ out.set(name, onOrigin(value, origin, target));
59
+ }
60
+ else {
61
+ out.append(name, value);
62
+ }
63
+ }
64
+ for (const cookie of headers.getSetCookie()) {
65
+ // Host-only on the content origin: an app's cookie never reaches the content domain's others.
66
+ const [pair = "", ...attributes] = cookie.split(";");
67
+ const kept = attributes.filter((attribute) => !/^\s*domain\s*=/iu.test(attribute));
68
+ out.append("set-cookie", [pair, ...kept].join(";"));
69
+ }
70
+ return out;
71
+ }
72
+ /** `<target>/x` → `<origin>/x`; any other URL as it is. */
73
+ function onOrigin(location, origin, target) {
74
+ try {
75
+ const url = new URL(location, target);
76
+ return url.origin === target ? `${origin}${url.pathname}${url.search}${url.hash}` : location;
77
+ }
78
+ catch {
79
+ return location;
80
+ }
81
+ }
82
+ /** An `Upstream` that relays to the app by HTTP. */
83
+ export function proxyTo(options = {}) {
84
+ const relay = options.fetch ?? fetch;
85
+ return async (request, access) => {
86
+ const where = options.target?.(access) ??
87
+ (access.instance.document.kind === "external" ? access.instance.document.url : null);
88
+ if (where === null) {
89
+ return new Response("Not an app", { status: 502 });
90
+ }
91
+ const target = new URL(where).origin;
92
+ const url = new URL(request.url);
93
+ const method = request.method.toUpperCase();
94
+ const hasBody = method !== "GET" && method !== "HEAD";
95
+ const answer = await relay(`${target}${url.pathname}${url.search}`, {
96
+ method,
97
+ headers: upstreamRequestHeaders(request.headers, access.origin, target),
98
+ redirect: "manual",
99
+ ...(hasBody ? { body: request.body, duplex: "half" } : {}),
100
+ });
101
+ return new Response(method === "HEAD" ? null : answer.body, {
102
+ status: answer.status,
103
+ statusText: answer.statusText,
104
+ headers: upstreamResponseHeaders(answer.headers, access.origin, target),
105
+ });
106
+ };
107
+ }
@@ -0,0 +1,7 @@
1
+ import type { PortholeServer, ServerOptions } from "./types.ts";
2
+ /**
3
+ * Serving for domain, artifact and url modes, and the MCP Apps sandbox proxy (P016, P020): a
4
+ * fetch-style handler a host's backend mounts in front of its own routes, plus `issue`, which the
5
+ * host's authenticated API calls once it has decided a viewer may see an instance.
6
+ */
7
+ export declare function createServer(options: ServerOptions): PortholeServer;
@@ -0,0 +1,542 @@
1
+ import { compile } from "../compile/compile.js";
2
+ import { hostOriginProblem, maybeSameSite, originMatchesSource, parseOrigin, } from "../isolation/served.js";
3
+ import { sandboxProxyDocument } from "../mcp/proxy.js";
4
+ import { createKeys, newTicket, ticketId } from "./keys.js";
5
+ import { enterPage, pageScript, SESSION_PATH, statusPage } from "./pages.js";
6
+ import { memoryTicketStore } from "./tickets.js";
7
+ /**
8
+ * Paths on a content origin. Porthole's own live under `/__porthole/`; on a url-mode app's origin
9
+ * every other path is the app's.
10
+ */
11
+ const PREFIX = "/__porthole/";
12
+ /** A served document (shell, artifact page, sandbox proxy), by its instance's key. */
13
+ const DOCUMENT_PATH = /^\/__porthole\/d\/([a-z2-7]{16})$/u;
14
+ /** Where a url-mode app's ticket is taken, before the app's own landing path. */
15
+ const ENTER_PATH = "/__porthole/enter";
16
+ const LABEL = /^[a-z2-7]{24}$/u;
17
+ const DNS_NAME = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/u;
18
+ const NAME = /^[A-Za-z0-9._:-]{1,200}$/u;
19
+ const DEFAULT_TICKET_SECONDS = 60;
20
+ const MAX_TICKET_SECONDS = 600;
21
+ const DEFAULT_SESSION_SECONDS = 3_600;
22
+ const MAX_SESSION_SECONDS = 30 * 24 * 3_600;
23
+ /** Browsers keep a cookie of up to 4 KB, name and attributes included. */
24
+ const MAX_COOKIE_BYTES = 3_800;
25
+ const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
26
+ /** Which configs a kind of served document may be served under. */
27
+ const MODES = {
28
+ shell: ["inline", "artifact"],
29
+ artifact: ["artifact"],
30
+ "mcp-proxy": ["mcp-app"],
31
+ };
32
+ function mergeEgress(base, extra) {
33
+ if (extra === undefined) {
34
+ return base;
35
+ }
36
+ const merged = {};
37
+ for (const kind of ["connect", "script", "style", "img", "font", "media"]) {
38
+ const origins = [...new Set([...(base[kind] ?? []), ...(extra[kind] ?? [])])];
39
+ if (origins.length > 0) {
40
+ merged[kind] = origins;
41
+ }
42
+ }
43
+ return merged;
44
+ }
45
+ /** `instance` with only the fields Porthole reads, each checked: what a ticket and cookie carry. */
46
+ function cleanInstance(instance) {
47
+ if (!isRecord(instance) || typeof instance.scope !== "string") {
48
+ throw new TypeError("An instance needs a scope (a string)");
49
+ }
50
+ if (instance.scope.length === 0 || instance.scope.length > 512) {
51
+ throw new TypeError("An instance's scope is 1 to 512 characters");
52
+ }
53
+ const document = instance.document;
54
+ if (!isRecord(document)) {
55
+ throw new TypeError("An instance needs a document");
56
+ }
57
+ const name = (value, what) => {
58
+ if (typeof value !== "string" || !NAME.test(value)) {
59
+ throw new TypeError(`${what} must be 1 to 200 of A-Z a-z 0-9 . _ : -`);
60
+ }
61
+ return value;
62
+ };
63
+ const optionalName = (value, what) => value === undefined ? {} : { preset: name(value, what) };
64
+ let clean;
65
+ switch (document.kind) {
66
+ case "shell":
67
+ clean = { kind: "shell", preset: name(document.preset, "A shell's preset") };
68
+ break;
69
+ case "artifact":
70
+ clean = {
71
+ kind: "artifact",
72
+ id: name(document.id, "An artifact's id"),
73
+ version: name(document.version, "An artifact's version"),
74
+ ...optionalName(document.preset, "An artifact's preset"),
75
+ };
76
+ break;
77
+ case "mcp-proxy":
78
+ clean = {
79
+ kind: "mcp-proxy",
80
+ ...optionalName(document.preset, "The proxy's preset"),
81
+ ...(document.egress === undefined ? {} : { egress: document.egress }),
82
+ };
83
+ break;
84
+ case "external": {
85
+ let url;
86
+ try {
87
+ url = new URL(String(document.url));
88
+ }
89
+ catch {
90
+ throw new TypeError("An external instance's url must be a URL");
91
+ }
92
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
93
+ throw new TypeError("An external instance's url must be http(s)");
94
+ }
95
+ clean = { kind: "external", url: url.href };
96
+ break;
97
+ }
98
+ default:
99
+ throw new TypeError(`Unknown served document kind ${JSON.stringify(document.kind)}`);
100
+ }
101
+ return { scope: instance.scope, document: clean };
102
+ }
103
+ function seconds(value, fallback, max, what) {
104
+ if (value === undefined) {
105
+ return fallback;
106
+ }
107
+ if (!Number.isInteger(value) || value < 1 || value > max) {
108
+ throw new TypeError(`${what} must be an integer from 1 to ${max}`);
109
+ }
110
+ return value;
111
+ }
112
+ /** The cookies a request carries, as name → values (a name may come twice: partitioned or not). */
113
+ function cookiesOf(request) {
114
+ const header = request.headers.get("cookie") ?? "";
115
+ const pairs = [];
116
+ for (const part of header.split(";")) {
117
+ const at = part.indexOf("=");
118
+ if (at > 0) {
119
+ pairs.push([part.slice(0, at).trim(), part.slice(at + 1).trim()]);
120
+ }
121
+ }
122
+ return pairs;
123
+ }
124
+ /**
125
+ * Serving for domain, artifact and url modes, and the MCP Apps sandbox proxy (P016, P020): a
126
+ * fetch-style handler a host's backend mounts in front of its own routes, plus `issue`, which the
127
+ * host's authenticated API calls once it has decided a viewer may see an instance.
128
+ */
129
+ export function createServer(options) {
130
+ const { content } = options;
131
+ const domain = content.domain;
132
+ if (!DNS_NAME.test(domain)) {
133
+ throw new TypeError(`content.domain ${JSON.stringify(domain)} is not a lowercase DNS name`);
134
+ }
135
+ const loopback = domain === "localhost" || domain.endsWith(".localhost");
136
+ if (content.scheme === "http" && !loopback) {
137
+ throw new TypeError("content.scheme http is for localhost only: serve content over https");
138
+ }
139
+ if (content.scheme !== "http" && content.scheme !== "https") {
140
+ throw new TypeError('content.scheme must be "https" or "http"');
141
+ }
142
+ if (content.port !== undefined &&
143
+ (!Number.isInteger(content.port) || content.port < 1 || content.port > 65_535)) {
144
+ throw new TypeError("content.port must be a port number");
145
+ }
146
+ const keys = createKeys(options.secret);
147
+ const secure = content.scheme === "https";
148
+ const defaultPort = secure ? 443 : 80;
149
+ const portPart = content.port === undefined || content.port === defaultPort ? "" : `:${content.port}`;
150
+ const originOfLabel = (label) => `${content.scheme}://${label}.${domain}${portPart}`;
151
+ const contentSource = `${content.scheme}://*.${domain}${portPart}`;
152
+ const sampleHost = `${keys.label("")}.${domain}`;
153
+ const sampleOrigin = originOfLabel(keys.label(""));
154
+ if (options.hostOrigins.length === 0) {
155
+ throw new TypeError("hostOrigins must name the origins that embed content");
156
+ }
157
+ for (const origin of options.hostOrigins) {
158
+ const problem = hostOriginProblem(origin);
159
+ if (problem !== null) {
160
+ throw new TypeError(`hostOrigins: ${problem}`);
161
+ }
162
+ // A desktop app's own scheme (ferry://app) is never the same site as web content.
163
+ const parsed = parseOrigin(origin);
164
+ if (parsed === null) {
165
+ continue;
166
+ }
167
+ if (parsed.host === domain ||
168
+ parsed.host.endsWith(`.${domain}`) ||
169
+ maybeSameSite(parsed.host, sampleHost)) {
170
+ throw new TypeError(`hostOrigins: ${origin} may be the same site as the content domain ${domain}: serve content from a registrable domain the host uses for nothing else`);
171
+ }
172
+ }
173
+ const frameAncestors = options.hostOrigins.join(" ");
174
+ const compiled = new Map();
175
+ for (const [name, config] of Object.entries(options.configs)) {
176
+ compiled.set(name, compile(config));
177
+ }
178
+ const tickets = options.tickets ?? memoryTicketStore();
179
+ /** A page saying why there is nothing to show, told to the host too (its reason, P040). */
180
+ const page = (status, title, detail, method, reason = status === 404 ? "not-found" : status === 502 ? "upstream" : "error") => statusPage(status, title, detail, frameAncestors, method, reason, options.hostOrigins);
181
+ /** The config a served document is served under, checked to fit it and this server. */
182
+ function configFor(name, kind) {
183
+ const each = compiled.get(name);
184
+ if (each === undefined) {
185
+ throw new TypeError(`No config named ${JSON.stringify(name)} (ServerOptions.configs)`);
186
+ }
187
+ const { config } = each;
188
+ if (config.isolation !== "domain" || !MODES[kind].includes(config.mode)) {
189
+ throw new TypeError(`The config ${JSON.stringify(name)} (${config.mode}, ${config.isolation}) can't serve a ${kind}: it needs ${MODES[kind].join(" or ")} mode with domain isolation`);
190
+ }
191
+ if (!config.contentOrigins.some((source) => originMatchesSource(sampleOrigin, source))) {
192
+ throw new TypeError(`The config ${JSON.stringify(name)}'s contentOrigins don't include this server's (${contentSource})`);
193
+ }
194
+ return each;
195
+ }
196
+ /** The compiled config an instance's document goes out under: its CSP, its shell. */
197
+ function compiledFor(instance) {
198
+ const { document } = instance;
199
+ switch (document.kind) {
200
+ case "shell":
201
+ return configFor(document.preset, "shell");
202
+ case "artifact":
203
+ return configFor(document.preset ?? "artifact", "artifact");
204
+ case "mcp-proxy": {
205
+ const { config } = configFor(document.preset ?? "mcpApp", "mcp-proxy");
206
+ return compile({ ...config, egress: mergeEgress(config.egress, document.egress) });
207
+ }
208
+ case "external":
209
+ return null;
210
+ }
211
+ }
212
+ function check(instance) {
213
+ const { document } = instance;
214
+ switch (document.kind) {
215
+ case "shell":
216
+ compiledFor(instance);
217
+ return;
218
+ case "artifact":
219
+ compiledFor(instance);
220
+ if (options.artifacts === undefined) {
221
+ throw new TypeError("Artifact pages need ServerOptions.artifacts");
222
+ }
223
+ return;
224
+ case "mcp-proxy":
225
+ // Compiling validates the view's egress as a config would be.
226
+ compiledFor(instance);
227
+ return;
228
+ case "external":
229
+ if (options.upstream === undefined) {
230
+ throw new TypeError("Url mode needs ServerOptions.upstream: nothing would serve the app");
231
+ }
232
+ }
233
+ }
234
+ async function documentOf(instance) {
235
+ const { document } = instance;
236
+ const config = compiledFor(instance);
237
+ if (config === null) {
238
+ return null;
239
+ }
240
+ const origins = options.hostOrigins;
241
+ switch (document.kind) {
242
+ case "shell":
243
+ return { html: config.shellDocument({ origins, cspMeta: false }), csp: config.csp };
244
+ case "artifact": {
245
+ const stored = await options.artifacts?.get(document.id, document.version);
246
+ if (stored === null || stored === undefined || typeof stored.html !== "string") {
247
+ return null;
248
+ }
249
+ return {
250
+ html: config.shellDocument({ origins, document: stored.html, cspMeta: false }),
251
+ csp: config.csp,
252
+ };
253
+ }
254
+ case "mcp-proxy":
255
+ return { html: sandboxProxyDocument(config, origins), csp: config.csp };
256
+ case "external":
257
+ return null;
258
+ }
259
+ }
260
+ const cookieName = (key) => secure ? `__Host-porthole-${key}` : `porthole-${key}`;
261
+ const ourCookie = (name) => name.startsWith("porthole-") || name.startsWith("__Host-porthole-");
262
+ /**
263
+ * The cookie a taken ticket buys: this origin only (`__Host-` over https), never readable by the
264
+ * content (`HttpOnly`). In a frame it is a third party's: `SameSite=None`, partitioned by the
265
+ * page that frames it; on a tab of its own, `Lax`. (Engines that keep no cookie in a cross-site
266
+ * frame, measured: WebKit, still get the document, which comes with the ticket's answer.)
267
+ */
268
+ function sessionCookie(request, record) {
269
+ const sessionSeconds = record.sessionSeconds ?? DEFAULT_SESSION_SECONDS;
270
+ const session = {
271
+ i: record.instance,
272
+ w: record.viewer,
273
+ x: Date.now() + sessionSeconds * 1_000,
274
+ };
275
+ const framed = request.headers.get("sec-fetch-dest") !== "document";
276
+ return [
277
+ `${cookieName(keys.instanceKey(record.instance))}=${keys.sign(session)}`,
278
+ "Path=/",
279
+ "HttpOnly",
280
+ `Max-Age=${sessionSeconds}`,
281
+ ...(framed
282
+ ? ["Secure", "SameSite=None", "Partitioned"]
283
+ : [...(secure ? ["Secure"] : []), "SameSite=Lax"]),
284
+ ].join("; ");
285
+ }
286
+ /** The access a request's own cookies give on `label`'s origin, for `key`'s instance or the app. */
287
+ function accessOf(request, label, key) {
288
+ let best = null;
289
+ for (const [name, value] of cookiesOf(request)) {
290
+ if (!(key === null ? ourCookie(name) : name === cookieName(key))) {
291
+ continue;
292
+ }
293
+ const session = keys.verify(value);
294
+ if (!isRecord(session) ||
295
+ typeof session.w !== "string" ||
296
+ typeof session.x !== "number" ||
297
+ session.x <= Date.now()) {
298
+ continue;
299
+ }
300
+ let instance;
301
+ try {
302
+ instance = cleanInstance(session.i);
303
+ }
304
+ catch {
305
+ continue;
306
+ }
307
+ if (keys.label(instance.scope) !== label ||
308
+ (key === null ? instance.document.kind !== "external" : keys.instanceKey(instance) !== key)) {
309
+ continue;
310
+ }
311
+ if (best === null || session.x > best.expiresAt) {
312
+ best = { instance, viewer: session.w, origin: originOfLabel(label), expiresAt: session.x };
313
+ }
314
+ }
315
+ return best;
316
+ }
317
+ /** A taken ticket, if it is good for `label`'s origin. */
318
+ async function take(ticket, label) {
319
+ if (ticket === null || ticket.length === 0 || ticket.length > 128) {
320
+ return null;
321
+ }
322
+ const record = await tickets.take(ticketId(ticket));
323
+ if (record === null || record.expiresAt <= Date.now()) {
324
+ return null;
325
+ }
326
+ try {
327
+ const instance = cleanInstance(record.instance);
328
+ return keys.label(instance.scope) === label ? { ...record, instance } : null;
329
+ }
330
+ catch {
331
+ return null;
332
+ }
333
+ }
334
+ function documentHeaders(csp) {
335
+ return new Headers({
336
+ "content-type": "text/html; charset=utf-8",
337
+ "content-security-policy": `${csp}; frame-ancestors ${frameAncestors}`,
338
+ "cache-control": "no-store",
339
+ "referrer-policy": "no-referrer",
340
+ "x-content-type-options": "nosniff",
341
+ });
342
+ }
343
+ async function serveDocument(request, url, label, key) {
344
+ const method = request.method.toUpperCase();
345
+ if (method !== "GET" && method !== "HEAD") {
346
+ return page(405, "Not allowed", "Content here is only read.", method);
347
+ }
348
+ let access = null;
349
+ let cookie = null;
350
+ // The ticket's own answer carries the document: an engine that keeps no cookie in a frame
351
+ // still shows it. The cookie serves what follows (a reload), where the engine keeps it.
352
+ const record = method === "GET" ? await take(url.searchParams.get("ticket"), label) : null;
353
+ if (record !== null &&
354
+ record.instance.document.kind !== "external" &&
355
+ keys.instanceKey(record.instance) === key) {
356
+ cookie = sessionCookie(request, record);
357
+ access = {
358
+ instance: record.instance,
359
+ viewer: record.viewer,
360
+ origin: originOfLabel(label),
361
+ expiresAt: Date.now() + (record.sessionSeconds ?? DEFAULT_SESSION_SECONDS) * 1_000,
362
+ };
363
+ }
364
+ access ??= accessOf(request, label, key);
365
+ if (access === null) {
366
+ return page(401, "This link has been used", "Content here opens once per link. Open it again from the app that showed it.", method, "session");
367
+ }
368
+ const served = await documentOf(access.instance);
369
+ if (served === null) {
370
+ return page(404, "Not found", "This page, or this version of it, doesn't exist.", method);
371
+ }
372
+ const headers = documentHeaders(served.csp);
373
+ if (cookie !== null) {
374
+ headers.set("set-cookie", cookie);
375
+ }
376
+ return new Response(method === "HEAD" ? null : served.html, { status: 200, headers });
377
+ }
378
+ async function enter(request, url, label) {
379
+ const method = request.method.toUpperCase();
380
+ if (method !== "GET") {
381
+ return page(405, "Not allowed", "Open this link in a browser.", method);
382
+ }
383
+ const record = await take(url.searchParams.get("ticket"), label);
384
+ if (record === null || record.instance.document.kind !== "external") {
385
+ return page(403, "This link has been used", "A link into an app works once, for a short time. Open it again from the app that showed it.", method, "session");
386
+ }
387
+ const target = new URL(record.instance.document.url);
388
+ const landing = `${target.pathname}${target.search}${target.hash}`;
389
+ // In a frame, a page enters the app: it can tell whether the browser kept the cookie (and tell
390
+ // the host), where a redirect would leave a frame without one showing a refusal. On a tab of
391
+ // its own, a redirect.
392
+ if (request.headers.get("sec-fetch-dest") !== "document") {
393
+ return enterPage(landing, frameAncestors, options.hostOrigins, sessionCookie(request, record));
394
+ }
395
+ return new Response(null, {
396
+ status: 303,
397
+ headers: {
398
+ location: landing,
399
+ "set-cookie": sessionCookie(request, record),
400
+ "cache-control": "no-store",
401
+ "referrer-policy": "no-referrer",
402
+ },
403
+ });
404
+ }
405
+ /** The request as the upstream gets it: without Porthole's cookies. */
406
+ function forUpstream(request) {
407
+ const headers = new Headers(request.headers);
408
+ const kept = cookiesOf(request)
409
+ .filter(([name]) => !ourCookie(name))
410
+ .map(([name, value]) => `${name}=${value}`);
411
+ if (kept.length > 0) {
412
+ headers.set("cookie", kept.join("; "));
413
+ }
414
+ else {
415
+ headers.delete("cookie");
416
+ }
417
+ return new Request(request, { headers });
418
+ }
419
+ /** The upstream's answer as the browser gets it: framable by the host only, our cookies kept ours. */
420
+ function fromUpstream(response) {
421
+ const headers = new Headers(response.headers);
422
+ headers.delete("set-cookie");
423
+ for (const cookie of response.headers.getSetCookie()) {
424
+ const name = cookie.split("=")[0]?.trim() ?? "";
425
+ if (!ourCookie(name)) {
426
+ headers.append("set-cookie", cookie);
427
+ }
428
+ }
429
+ // Only the host may frame the app; any policy of the app's own still applies too.
430
+ headers.append("content-security-policy", `frame-ancestors ${frameAncestors}`);
431
+ return new Response(response.body, {
432
+ status: response.status,
433
+ statusText: response.statusText,
434
+ headers,
435
+ });
436
+ }
437
+ async function serveApp(request, label) {
438
+ const method = request.method.toUpperCase();
439
+ const access = accessOf(request, label, null);
440
+ if (access === null) {
441
+ return page(401, "Open this from the app", "This address shows an app only to the people the app opens it for.", method, "session");
442
+ }
443
+ if (options.upstream === undefined) {
444
+ return page(502, "Nothing serves this app", "The server has no upstream for apps.", method);
445
+ }
446
+ try {
447
+ return fromUpstream(await options.upstream(forUpstream(request), access));
448
+ }
449
+ catch {
450
+ return page(502, "The app didn't answer", "Its server couldn't be reached.", method);
451
+ }
452
+ }
453
+ /** The label a request is for: null when it isn't under the content domain at all. */
454
+ function labelOf(request) {
455
+ let url;
456
+ try {
457
+ url = new URL(request.url);
458
+ }
459
+ catch {
460
+ return null;
461
+ }
462
+ const host = url.hostname.toLowerCase().replace(/\.$/u, "");
463
+ if (host !== domain && !host.endsWith(`.${domain}`)) {
464
+ return null;
465
+ }
466
+ return { url, label: host.slice(0, Math.max(0, host.length - domain.length - 1)) };
467
+ }
468
+ return {
469
+ contentSource,
470
+ originFor: (scope) => originOfLabel(keys.label(scope)),
471
+ async issue(instance, grant) {
472
+ const clean = cleanInstance(instance);
473
+ check(clean);
474
+ if (typeof grant.viewer !== "string" ||
475
+ grant.viewer.length === 0 ||
476
+ grant.viewer.length > 512) {
477
+ throw new TypeError("A grant's viewer is 1 to 512 characters");
478
+ }
479
+ const ttl = seconds(grant.ttlSeconds, DEFAULT_TICKET_SECONDS, MAX_TICKET_SECONDS, "ttlSeconds");
480
+ const sessionSeconds = seconds(grant.sessionSeconds, DEFAULT_SESSION_SECONDS, MAX_SESSION_SECONDS, "sessionSeconds");
481
+ const key = keys.instanceKey(clean);
482
+ const probe = keys.sign({ i: clean, w: grant.viewer, x: Number.MAX_SAFE_INTEGER });
483
+ if (cookieName(key).length + probe.length + 120 > MAX_COOKIE_BYTES) {
484
+ throw new TypeError("This instance is too large to carry in a cookie (fewer origins?)");
485
+ }
486
+ const ticket = newTicket();
487
+ await tickets.put(ticketId(ticket), {
488
+ instance: clean,
489
+ viewer: grant.viewer,
490
+ expiresAt: Date.now() + ttl * 1_000,
491
+ ...(grant.sessionSeconds === undefined ? {} : { sessionSeconds }),
492
+ });
493
+ const origin = originOfLabel(keys.label(clean.scope));
494
+ const path = clean.document.kind === "external"
495
+ ? `${ENTER_PATH}?ticket=${ticket}`
496
+ : `${PREFIX}d/${key}?ticket=${ticket}`;
497
+ return { url: `${origin}${path}`, origin, reports: true };
498
+ },
499
+ async access(request) {
500
+ const at = labelOf(request);
501
+ return at === null || !LABEL.test(at.label) ? null : accessOf(request, at.label, null);
502
+ },
503
+ async handle(request) {
504
+ const at = labelOf(request);
505
+ if (at === null) {
506
+ return null;
507
+ }
508
+ const { url, label } = at;
509
+ const method = request.method.toUpperCase();
510
+ try {
511
+ if (!LABEL.test(label)) {
512
+ return page(404, "Nothing here", "This address names no content.", method);
513
+ }
514
+ if (url.pathname === ENTER_PATH) {
515
+ return await enter(request, url, label);
516
+ }
517
+ const script = pageScript(url.pathname);
518
+ if (script !== null) {
519
+ return script;
520
+ }
521
+ if (url.pathname === SESSION_PATH) {
522
+ // Whether this request's cookie opens its origin's app: the entry page asks.
523
+ return new Response(null, {
524
+ status: accessOf(request, label, null) === null ? 401 : 204,
525
+ headers: { "cache-control": "no-store" },
526
+ });
527
+ }
528
+ const document = DOCUMENT_PATH.exec(url.pathname);
529
+ if (document?.[1] !== undefined) {
530
+ return await serveDocument(request, url, label, document[1]);
531
+ }
532
+ if (url.pathname.startsWith(PREFIX)) {
533
+ return page(404, "Nothing here", "This address names no content.", method);
534
+ }
535
+ return await serveApp(request, label);
536
+ }
537
+ catch {
538
+ return page(500, "Something went wrong", "This content couldn't be served.", method);
539
+ }
540
+ },
541
+ };
542
+ }
@@ -0,0 +1,8 @@
1
+ import type { TicketStore } from "./types.ts";
2
+ /**
3
+ * Tickets in this process's memory: right for one server process. A host that runs several
4
+ * behind a load balancer passes a shared store (its database) whose `take` is atomic.
5
+ */
6
+ export declare function memoryTicketStore(options?: {
7
+ readonly now?: () => number;
8
+ }): TicketStore;