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