@botiverse/porthole 0.1.8 → 0.2.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/dist/conformance/clipboard.js +5 -2
- package/dist/conformance/entry.d.ts +37 -0
- package/dist/conformance/entry.js +120 -0
- package/dist/conformance/harness/page.d.ts +32 -0
- package/dist/conformance/harness/page.js +215 -0
- package/dist/conformance/harness.js +1662 -0
- package/dist/conformance/host-kit.d.ts +85 -0
- package/dist/conformance/host-kit.js +341 -0
- package/dist/conformance/index.d.ts +2 -0
- package/dist/conformance/index.js +4 -0
- package/dist/conformance/served.js +13 -3
- package/dist/host/locator.d.ts +39 -0
- package/dist/host/locator.js +72 -0
- package/dist/host/outcome.js +1 -0
- package/dist/host/types.d.ts +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/node/index.d.ts +41 -0
- package/dist/node/index.js +160 -0
- package/dist/platform/web.js +12 -1
- package/dist/server/domain.d.ts +43 -0
- package/dist/server/domain.js +100 -0
- package/dist/server/host.d.ts +158 -0
- package/dist/server/host.js +416 -0
- package/dist/server/index.d.ts +9 -2
- package/dist/server/index.js +7 -0
- package/dist/server/naming.d.ts +12 -1
- package/dist/server/naming.js +9 -8
- package/dist/server/pages.d.ts +9 -0
- package/dist/server/pages.js +32 -0
- package/dist/server/server.js +287 -50
- package/dist/server/types.d.ts +37 -0
- package/dist/transport/post-message.d.ts +9 -1
- package/dist/transport/post-message.js +11 -2
- package/package.json +6 -2
- package/src/conformance/clipboard.ts +5 -2
- package/src/conformance/entry.ts +206 -0
- package/src/conformance/harness/page.ts +267 -0
- package/src/conformance/host-kit.ts +466 -0
- package/src/conformance/index.ts +12 -0
- package/src/conformance/served.ts +14 -3
- package/src/host/locator.ts +111 -0
- package/src/host/outcome.ts +1 -0
- package/src/host/types.ts +2 -0
- package/src/index.ts +7 -0
- package/src/node/index.ts +221 -0
- package/src/platform/web.ts +19 -1
- package/src/server/domain.ts +158 -0
- package/src/server/host.ts +706 -0
- package/src/server/index.ts +27 -1
- package/src/server/naming.ts +22 -2
- package/src/server/pages.ts +44 -0
- package/src/server/server.ts +355 -63
- package/src/server/types.ts +33 -0
- package/src/transport/post-message.ts +12 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type Page, type TestType } from "@playwright/test";
|
|
2
|
+
import type { PortholeConfig } from "../config/schema.ts";
|
|
3
|
+
import type { FrameLocation, InstanceRef } from "../isolation/types.ts";
|
|
4
|
+
import { type EntryConformanceHost } from "./entry.ts";
|
|
5
|
+
import type { ConformanceHost } from "./index.ts";
|
|
6
|
+
import { type ServedConformanceHost } from "./served.ts";
|
|
7
|
+
/**
|
|
8
|
+
* The conformance runner (P066): an app points it at its real page (its CSP, its nonce, its
|
|
9
|
+
* session) and its issue endpoint; Porthole loads its own harness there (framework-free, on the
|
|
10
|
+
* core's `createHost`), routes the canaries and a foreign origin, and registers the leak and
|
|
11
|
+
* served suites in the app's Playwright project:
|
|
12
|
+
*
|
|
13
|
+
* defineHostConformance(test, {
|
|
14
|
+
* name: "my-app",
|
|
15
|
+
* open: async (page) => { await signIn(page); await page.goto("/viewer"); },
|
|
16
|
+
* issue: { url: "/api/frames" }, // the app's ContentHost.issue route
|
|
17
|
+
* config: (instance) => servedConfigs[presetOf(instance)],
|
|
18
|
+
* instances: { shell: (scope) => ({ scope, document: { kind: "shell", preset: "widget" } }) },
|
|
19
|
+
* });
|
|
20
|
+
*/
|
|
21
|
+
export interface HostConformanceOptions {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
/** Opens the app's real page in `page` as a person sees it (signed in); the harness runs there. */
|
|
24
|
+
readonly open: (page: Page) => Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* The page's script nonce, where its CSP is nonce-based. Default: the first `script[nonce]`'s.
|
|
27
|
+
* The harness loads as a script from the page's own origin, so `'self'` policies need none.
|
|
28
|
+
*/
|
|
29
|
+
readonly nonce?: (page: Page) => Promise<string | undefined>;
|
|
30
|
+
/**
|
|
31
|
+
* The app's issue endpoint (`ContentHost.issue`), relative to the page: the harness asks it from
|
|
32
|
+
* the page with the page's own cookies, as the app's client does (`httpLocator`).
|
|
33
|
+
*/
|
|
34
|
+
readonly issue?: {
|
|
35
|
+
readonly url: string;
|
|
36
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
37
|
+
};
|
|
38
|
+
/** Or the location for an instance, got the test's own way. */
|
|
39
|
+
readonly locate?: (instance: InstanceRef, page: Page) => Promise<FrameLocation>;
|
|
40
|
+
/** The config a served instance is framed under (the app's served config for its preset). */
|
|
41
|
+
readonly config: (instance: InstanceRef) => PortholeConfig;
|
|
42
|
+
/**
|
|
43
|
+
* The instances the served cases ask for, each for the scope a case names. `shell` runs the
|
|
44
|
+
* domain-mode cases; `artifact` (versions of one stored page), `app` (url mode) and `proxy` (the
|
|
45
|
+
* MCP Apps sandbox proxy) run theirs where given, and are skipped (with the reason) where not.
|
|
46
|
+
*/
|
|
47
|
+
readonly instances: Pick<ServedConformanceHost, "shell"> & Partial<Pick<ServedConformanceHost, "artifact" | "app" | "proxy">>;
|
|
48
|
+
/**
|
|
49
|
+
* Redirect entry (P068), where the app serves clean addresses: the cases open them directly,
|
|
50
|
+
* signed in and out, and in a frame.
|
|
51
|
+
*/
|
|
52
|
+
readonly entry?: {
|
|
53
|
+
/** The clean address of `instances.artifact(scope, "1")` (`ContentHost.urlOf`). */
|
|
54
|
+
readonly url: (scope: string) => string;
|
|
55
|
+
/** A clean address the signed-in viewer may not open. */
|
|
56
|
+
readonly denied: (scope: string) => string;
|
|
57
|
+
/** Whether `page` shows the app's own sign-in. */
|
|
58
|
+
readonly atSignIn: (page: Page) => Promise<boolean>;
|
|
59
|
+
/** On the app's sign-in page: signs in, as a person does. */
|
|
60
|
+
readonly completeSignIn: (page: Page) => Promise<void>;
|
|
61
|
+
};
|
|
62
|
+
/** Case ids to skip, each with its reason (shown in the report). */
|
|
63
|
+
readonly skip?: Readonly<Record<string, string>>;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The harness as one script: from the package (`dist/conformance/harness.js`), or, run from the
|
|
67
|
+
* sources, bundled now (esbuild, a development dependency of Porthole's).
|
|
68
|
+
*/
|
|
69
|
+
export declare function harnessScript(): Promise<string>;
|
|
70
|
+
export interface HostConformance {
|
|
71
|
+
readonly leak: ConformanceHost;
|
|
72
|
+
readonly served: ServedConformanceHost;
|
|
73
|
+
/** Redirect entry's host, where the options give `entry`. */
|
|
74
|
+
readonly entry: EntryConformanceHost | null;
|
|
75
|
+
/** Prepares a test's page (the canaries, the foreign origin, the harness's route): call first. */
|
|
76
|
+
begin(page: Page): Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
/** The leak and served hosts on an app's real page, for an app that registers the cases itself. */
|
|
79
|
+
export declare function hostConformance(options: HostConformanceOptions): HostConformance;
|
|
80
|
+
/**
|
|
81
|
+
* Registers the leak and served suites against an app's real page, in a spec file of the app's
|
|
82
|
+
* (so `-g "\[1\.connect\]"` selects one). The served cases for instances the app didn't give are
|
|
83
|
+
* skipped, each with its reason.
|
|
84
|
+
*/
|
|
85
|
+
export declare function defineHostConformance(test: TestType<object, object>, options: HostConformanceOptions): void;
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { expect } from "@playwright/test";
|
|
4
|
+
import { entryTests } from "./entry.js";
|
|
5
|
+
import { leakTests } from "./probes.js";
|
|
6
|
+
import { servedTests } from "./served.js";
|
|
7
|
+
const CANARY = "https://porthole-canary.example";
|
|
8
|
+
/** A page on no one's origin that frames what a case asks (routed by the test, like the canary). */
|
|
9
|
+
const FOREIGN = "http://porthole-foreign.example";
|
|
10
|
+
/** Where the harness loads from, on the app's own origin (routed by the test, never the app's). */
|
|
11
|
+
const HARNESS_PATH = "/__porthole/conformance/harness.js";
|
|
12
|
+
let built = null;
|
|
13
|
+
/**
|
|
14
|
+
* The harness as one script: from the package (`dist/conformance/harness.js`), or, run from the
|
|
15
|
+
* sources, bundled now (esbuild, a development dependency of Porthole's).
|
|
16
|
+
*/
|
|
17
|
+
export function harnessScript() {
|
|
18
|
+
built ??= (async () => {
|
|
19
|
+
try {
|
|
20
|
+
return await readFile(new URL("./harness.js", import.meta.url), "utf8");
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
const { build } = await import("esbuild");
|
|
24
|
+
const result = await build({
|
|
25
|
+
entryPoints: [fileURLToPath(new URL("./harness/page.ts", import.meta.url))],
|
|
26
|
+
bundle: true,
|
|
27
|
+
format: "iife",
|
|
28
|
+
platform: "browser",
|
|
29
|
+
target: "es2022",
|
|
30
|
+
write: false,
|
|
31
|
+
logLevel: "silent",
|
|
32
|
+
});
|
|
33
|
+
const text = result.outputFiles[0]?.text;
|
|
34
|
+
if (text === undefined) {
|
|
35
|
+
throw new Error("the conformance harness didn't build");
|
|
36
|
+
}
|
|
37
|
+
return text;
|
|
38
|
+
}
|
|
39
|
+
})();
|
|
40
|
+
return built;
|
|
41
|
+
}
|
|
42
|
+
/** Calls one of the harness's methods in the page. */
|
|
43
|
+
function invoke(page, method, ...args) {
|
|
44
|
+
return page.evaluate(([name, given]) => {
|
|
45
|
+
const harness = window.portholeConformance;
|
|
46
|
+
const run = harness?.[name];
|
|
47
|
+
if (run === undefined) {
|
|
48
|
+
throw new Error(`the conformance harness has no ${name}`);
|
|
49
|
+
}
|
|
50
|
+
return run(...given);
|
|
51
|
+
}, [method, args]);
|
|
52
|
+
}
|
|
53
|
+
/** The display's frame element's document, once it exists. */
|
|
54
|
+
async function frameIn(page, id) {
|
|
55
|
+
let found = null;
|
|
56
|
+
await expect
|
|
57
|
+
.poll(async () => {
|
|
58
|
+
const element = await page
|
|
59
|
+
.locator(`#porthole-display-${id} iframe`)
|
|
60
|
+
.elementHandle({ timeout: 2_000 })
|
|
61
|
+
.catch(() => null);
|
|
62
|
+
found = (await element?.contentFrame()) ?? null;
|
|
63
|
+
return found !== null;
|
|
64
|
+
})
|
|
65
|
+
.toBe(true);
|
|
66
|
+
return found;
|
|
67
|
+
}
|
|
68
|
+
/** The leak and served hosts on an app's real page, for an app that registers the cases itself. */
|
|
69
|
+
export function hostConformance(options) {
|
|
70
|
+
let current = null;
|
|
71
|
+
const opened = new WeakSet();
|
|
72
|
+
const hits = new Map();
|
|
73
|
+
const open = async (page) => {
|
|
74
|
+
await options.open(page);
|
|
75
|
+
opened.add(page);
|
|
76
|
+
};
|
|
77
|
+
/** Opens the app's page, if it isn't, and loads the harness into it under the page's policy. */
|
|
78
|
+
const ready = async (page) => {
|
|
79
|
+
const loaded = await page
|
|
80
|
+
.evaluate(() => document.documentElement.dataset.portholeConformance === "ready")
|
|
81
|
+
.catch(() => false);
|
|
82
|
+
if (loaded) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
await open(page);
|
|
86
|
+
const nonce = (await options.nonce?.(page)) ??
|
|
87
|
+
(await page.evaluate(() => document.querySelector("script[nonce]")?.nonce || undefined));
|
|
88
|
+
await page.evaluate(([path, given]) => {
|
|
89
|
+
const script = document.createElement("script");
|
|
90
|
+
if (given !== undefined) {
|
|
91
|
+
script.nonce = given;
|
|
92
|
+
}
|
|
93
|
+
script.src = path;
|
|
94
|
+
document.head.append(script);
|
|
95
|
+
}, [HARNESS_PATH, nonce]);
|
|
96
|
+
await expect
|
|
97
|
+
.poll(() => page.evaluate(() => document.documentElement.dataset.portholeConformance), {
|
|
98
|
+
message: "the harness runs on the app's page (does its CSP let a script from its own origin, or with its nonce, run?)",
|
|
99
|
+
})
|
|
100
|
+
.toBe("ready");
|
|
101
|
+
};
|
|
102
|
+
const leak = {
|
|
103
|
+
name: options.name,
|
|
104
|
+
async launch(page, spec) {
|
|
105
|
+
await ready(page);
|
|
106
|
+
const id = await invoke(page, "launch", spec);
|
|
107
|
+
return {
|
|
108
|
+
frame: () => frameIn(page, id),
|
|
109
|
+
async box() {
|
|
110
|
+
const element = await page.locator(`#porthole-display-${id} iframe`).elementHandle();
|
|
111
|
+
const area = await element?.boundingBox();
|
|
112
|
+
return { width: area?.width ?? 0, height: area?.height ?? 0 };
|
|
113
|
+
},
|
|
114
|
+
setContent: (html, given) => invoke(page, "setContent", id, html, given?.partial === true),
|
|
115
|
+
setTheme: (scheme) => invoke(page, "setTheme", id, scheme),
|
|
116
|
+
async answerNextConfirmation(accept, given) {
|
|
117
|
+
await expect
|
|
118
|
+
.poll(() => invoke(page, "pendingConfirmations", id), {
|
|
119
|
+
timeout: given?.timeoutMs ?? 5_000,
|
|
120
|
+
})
|
|
121
|
+
.toBeGreaterThan(0);
|
|
122
|
+
const request = await invoke(page, "answer", id, accept);
|
|
123
|
+
if (request === null) {
|
|
124
|
+
throw new Error("no confirmation was waiting");
|
|
125
|
+
}
|
|
126
|
+
return request;
|
|
127
|
+
},
|
|
128
|
+
calls: () => invoke(page, "calls", id),
|
|
129
|
+
errors: () => invoke(page, "errors", id),
|
|
130
|
+
events: () => invoke(page, "events", id),
|
|
131
|
+
destroy: () => invoke(page, "destroy", id),
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
const locate = async (instance) => {
|
|
136
|
+
const page = current;
|
|
137
|
+
if (page === null) {
|
|
138
|
+
throw new Error("the runner has no page: call begin(page) first");
|
|
139
|
+
}
|
|
140
|
+
if (options.locate !== undefined) {
|
|
141
|
+
return options.locate(instance, page);
|
|
142
|
+
}
|
|
143
|
+
if (options.issue === undefined) {
|
|
144
|
+
throw new Error("the runner needs the app's issue endpoint (issue) or locate");
|
|
145
|
+
}
|
|
146
|
+
if (!opened.has(page)) {
|
|
147
|
+
await open(page);
|
|
148
|
+
}
|
|
149
|
+
// Asked as the page asks it: the context's cookies, the page's origin.
|
|
150
|
+
const response = await page
|
|
151
|
+
.context()
|
|
152
|
+
.request.post(new URL(options.issue.url, page.url()).href, {
|
|
153
|
+
data: { instance },
|
|
154
|
+
headers: { origin: new URL(page.url()).origin, ...options.issue.headers },
|
|
155
|
+
maxRedirects: 0,
|
|
156
|
+
});
|
|
157
|
+
if (response.status() !== 201) {
|
|
158
|
+
throw new Error(`the app's issue endpoint answered ${response.status()}: ${(await response.text()).slice(0, 200)}`);
|
|
159
|
+
}
|
|
160
|
+
return (await response.json());
|
|
161
|
+
};
|
|
162
|
+
const { artifact, app, proxy } = options.instances;
|
|
163
|
+
const served = {
|
|
164
|
+
name: options.name,
|
|
165
|
+
async show(page, instance, given = {}) {
|
|
166
|
+
await ready(page);
|
|
167
|
+
const where = options.locate === undefined && options.issue !== undefined
|
|
168
|
+
? {
|
|
169
|
+
issue: options.issue.url,
|
|
170
|
+
...(options.issue.headers === undefined ? {} : { headers: options.issue.headers }),
|
|
171
|
+
}
|
|
172
|
+
: { location: await locate(instance) };
|
|
173
|
+
const id = await invoke(page, "show", options.config(instance), instance, where, given.content);
|
|
174
|
+
let origin = null;
|
|
175
|
+
await expect
|
|
176
|
+
.poll(async () => {
|
|
177
|
+
origin = await invoke(page, "origin", id);
|
|
178
|
+
return origin ?? (await invoke(page, "failure", id)) ?? "";
|
|
179
|
+
}, { message: "the app's issue endpoint locates the instance" })
|
|
180
|
+
.toMatch(/^https?:\/\//u);
|
|
181
|
+
const located = origin;
|
|
182
|
+
return {
|
|
183
|
+
origin: located,
|
|
184
|
+
async frame() {
|
|
185
|
+
const frame = await frameIn(page, id);
|
|
186
|
+
await frame.waitForURL((url) => url.origin === located);
|
|
187
|
+
// A url-mode app's entry page goes on to the app where the browser kept the cookie; where
|
|
188
|
+
// it didn't (WebKit, P025), it stays, saying so: the frame is that page then.
|
|
189
|
+
await frame
|
|
190
|
+
.waitForURL((url) => url.pathname !== "/__porthole/enter", { timeout: 5_000 })
|
|
191
|
+
.catch(() => undefined);
|
|
192
|
+
await frame.waitForLoadState("load");
|
|
193
|
+
return frame;
|
|
194
|
+
},
|
|
195
|
+
destroy: () => invoke(page, "destroy", id),
|
|
196
|
+
post: (message) => invoke(page, "post", id, message),
|
|
197
|
+
received: () => invoke(page, "received", id),
|
|
198
|
+
};
|
|
199
|
+
},
|
|
200
|
+
locate,
|
|
201
|
+
async frameFromForeignOrigin(page, url) {
|
|
202
|
+
await page.goto(`${FOREIGN}/?frame=${encodeURIComponent(url)}`);
|
|
203
|
+
},
|
|
204
|
+
canary: (name) => Promise.resolve(`${CANARY}/${name}`),
|
|
205
|
+
canaryHits: (name) => Promise.resolve(hits.get(name) ?? 0),
|
|
206
|
+
shell: options.instances.shell,
|
|
207
|
+
// Asked for only by cases that run where the app gave them (`defineHostConformance` skips).
|
|
208
|
+
artifact: artifact ??
|
|
209
|
+
((scope, version) => ({ scope, document: { kind: "artifact", id: "page", version } })),
|
|
210
|
+
app: app ?? ((scope) => ({ scope, document: { kind: "external", url: "http://app.test/" } })),
|
|
211
|
+
proxy: proxy ?? ((scope) => ({ scope, document: { kind: "mcp-proxy" } })),
|
|
212
|
+
};
|
|
213
|
+
const given = options.entry;
|
|
214
|
+
const entry = given === undefined
|
|
215
|
+
? null
|
|
216
|
+
: {
|
|
217
|
+
name: options.name,
|
|
218
|
+
// Signed in on the app's page, the harness there (a frame needs it).
|
|
219
|
+
signIn: async (page) => {
|
|
220
|
+
await open(page);
|
|
221
|
+
await ready(page);
|
|
222
|
+
},
|
|
223
|
+
url: given.url,
|
|
224
|
+
denied: given.denied,
|
|
225
|
+
atSignIn: given.atSignIn,
|
|
226
|
+
completeSignIn: given.completeSignIn,
|
|
227
|
+
async frame(page, url) {
|
|
228
|
+
await ready(page);
|
|
229
|
+
const instance = served.artifact("entry", "1");
|
|
230
|
+
const id = await invoke(page, "show", options.config(instance), instance, {
|
|
231
|
+
location: { url, origin: new URL(url).origin },
|
|
232
|
+
});
|
|
233
|
+
return {
|
|
234
|
+
frame: () => frameIn(page, id),
|
|
235
|
+
async outcome() {
|
|
236
|
+
let found = null;
|
|
237
|
+
await expect
|
|
238
|
+
.poll(async () => {
|
|
239
|
+
const events = await invoke(page, "events", id);
|
|
240
|
+
const each = events.find((event) => event.type === "outcome");
|
|
241
|
+
found = each === undefined ? null : each.detail;
|
|
242
|
+
return found !== null;
|
|
243
|
+
}, { message: "the display has an outcome", timeout: 15_000 })
|
|
244
|
+
.toBe(true);
|
|
245
|
+
return found;
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
return {
|
|
251
|
+
leak,
|
|
252
|
+
served,
|
|
253
|
+
entry,
|
|
254
|
+
async begin(page) {
|
|
255
|
+
current = page;
|
|
256
|
+
const script = await harnessScript();
|
|
257
|
+
await page.route(`**${HARNESS_PATH}`, (route) => route.fulfill({
|
|
258
|
+
status: 200,
|
|
259
|
+
contentType: "text/javascript; charset=utf-8",
|
|
260
|
+
headers: { "cache-control": "no-store" },
|
|
261
|
+
body: script,
|
|
262
|
+
}));
|
|
263
|
+
await page.context().route(`${CANARY}/**`, (route) => {
|
|
264
|
+
const name = new URL(route.request().url()).pathname.slice(1);
|
|
265
|
+
hits.set(name, (hits.get(name) ?? 0) + 1);
|
|
266
|
+
return route.fulfill({ status: 200, contentType: "text/plain", body: "canary" });
|
|
267
|
+
});
|
|
268
|
+
await page.context().route(`${FOREIGN}/**`, (route) => {
|
|
269
|
+
const target = new URL(route.request().url()).searchParams.get("frame") ?? "";
|
|
270
|
+
const attribute = target.replace(/&/gu, "&").replace(/"/gu, """);
|
|
271
|
+
return route.fulfill({
|
|
272
|
+
status: 200,
|
|
273
|
+
contentType: "text/html",
|
|
274
|
+
body: `<!doctype html><title>Foreign</title><iframe sandbox="allow-scripts allow-same-origin" src="${attribute}"></iframe>`,
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const URL_CASES = [
|
|
281
|
+
"served.url.origin",
|
|
282
|
+
"served.url.no-reach",
|
|
283
|
+
"served.url.no-top-navigation",
|
|
284
|
+
"served.url.no-popups",
|
|
285
|
+
"served.url.ticket",
|
|
286
|
+
"served.url.tab",
|
|
287
|
+
"served.url.framed",
|
|
288
|
+
];
|
|
289
|
+
const PROXY_CASES = ["served.proxy.view", "served.proxy.parent-only", "served.proxy.egress"];
|
|
290
|
+
/**
|
|
291
|
+
* Registers the leak and served suites against an app's real page, in a spec file of the app's
|
|
292
|
+
* (so `-g "\[1\.connect\]"` selects one). The served cases for instances the app didn't give are
|
|
293
|
+
* skipped, each with its reason.
|
|
294
|
+
*/
|
|
295
|
+
export function defineHostConformance(test, options) {
|
|
296
|
+
const typed = test;
|
|
297
|
+
const hosts = hostConformance(options);
|
|
298
|
+
const skip = { ...options.skip };
|
|
299
|
+
const absent = (ids, reason) => {
|
|
300
|
+
for (const id of ids) {
|
|
301
|
+
skip[id] ??= reason;
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
if (options.instances.artifact === undefined) {
|
|
305
|
+
absent(["served.artifact.versions"], "the app serves no versions of a stored page");
|
|
306
|
+
}
|
|
307
|
+
if (options.instances.app === undefined) {
|
|
308
|
+
absent(URL_CASES, "the app serves no url-mode apps");
|
|
309
|
+
}
|
|
310
|
+
if (options.instances.proxy === undefined) {
|
|
311
|
+
absent(PROXY_CASES, "the app serves no MCP Apps views");
|
|
312
|
+
}
|
|
313
|
+
typed.describe(`Porthole leak suite (${options.name})`, () => {
|
|
314
|
+
for (const each of leakTests(hosts.leak, { skip })) {
|
|
315
|
+
// Playwright reads the fixtures a test uses from its parameter's destructuring.
|
|
316
|
+
typed(each.title, async ({ page, browserName }, info) => {
|
|
317
|
+
await hosts.begin(page);
|
|
318
|
+
await each.run({ page, browserName }, info);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
typed.describe(`Porthole served modes (${options.name})`, () => {
|
|
323
|
+
for (const each of servedTests(hosts.served, { skip })) {
|
|
324
|
+
typed(each.title, async ({ page, browserName }, info) => {
|
|
325
|
+
await hosts.begin(page);
|
|
326
|
+
await each.run({ page, browserName }, info);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
const entry = hosts.entry;
|
|
331
|
+
if (entry !== null) {
|
|
332
|
+
typed.describe(`Porthole redirect entry (${options.name})`, () => {
|
|
333
|
+
for (const each of entryTests(entry, { skip })) {
|
|
334
|
+
typed(each.title, async ({ page, browserName }, info) => {
|
|
335
|
+
await hosts.begin(page);
|
|
336
|
+
await each.run({ page, browserName }, info);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -101,3 +101,5 @@ export interface LeakSuiteOptions {
|
|
|
101
101
|
export { defineLeakSuite, leakTests, PROBES, type ConformanceFixtures, type ConformanceTest, type Probe, } from "./probes.ts";
|
|
102
102
|
export { httpOracle, settle, udpOracle, type HttpOracle, type UdpOracle } from "./oracles.ts";
|
|
103
103
|
export { defineServedSuite, servedTests, type ServedConformanceHost, type ServedDisplay, type ServedSuiteOptions, } from "./served.ts";
|
|
104
|
+
export { entryTests, type EntryConformanceHost, type EntrySuiteOptions } from "./entry.ts";
|
|
105
|
+
export { defineHostConformance, harnessScript, hostConformance, type HostConformance, type HostConformanceOptions, } from "./host-kit.ts";
|
|
@@ -2,3 +2,7 @@ export { defineLeakSuite, leakTests, PROBES, } from "./probes.js";
|
|
|
2
2
|
export { httpOracle, settle, udpOracle } from "./oracles.js";
|
|
3
3
|
// The served modes' cases (domain and url mode), with their own host interface.
|
|
4
4
|
export { defineServedSuite, servedTests, } from "./served.js";
|
|
5
|
+
// Redirect entry's cases (P068).
|
|
6
|
+
export { entryTests } from "./entry.js";
|
|
7
|
+
// The runner an app points at its real page and issue endpoint (P066).
|
|
8
|
+
export { defineHostConformance, harnessScript, hostConformance, } from "./host-kit.js";
|
|
@@ -375,9 +375,19 @@ export function servedTests(host, options = {}) {
|
|
|
375
375
|
});
|
|
376
376
|
define("served.url.ticket", "an app shows nothing to a browser without its ticket's cookie", async ({ page }) => {
|
|
377
377
|
const location = await host.locate(host.app("url-ticket"));
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
378
|
+
// A fresh browser: no cookie of the content's, none of the app's. Porthole refuses it, or
|
|
379
|
+
// (redirect entry, P068) hands it to the app's authorize route, which asks it to sign in.
|
|
380
|
+
const fresh = await page.context().browser().newContext();
|
|
381
|
+
try {
|
|
382
|
+
const other = await fresh.newPage();
|
|
383
|
+
const bare = await other.goto(`${location.origin}/`);
|
|
384
|
+
const away = new URL(other.url()).origin !== location.origin;
|
|
385
|
+
expect(bare?.status() === 401 || away, `ended at ${other.url()}`).toBe(true);
|
|
386
|
+
expect(await other.evaluate(() => document.documentElement.dataset.app ?? null)).toBeNull();
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
await fresh.close();
|
|
390
|
+
}
|
|
381
391
|
});
|
|
382
392
|
define("served.url.tab", "an app opened on a tab of its own runs, its requests riding on its cookie", async ({ page }) => {
|
|
383
393
|
const location = await host.locate(host.app("url-tab"));
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { PortholeConfig } from "../config/schema.ts";
|
|
2
|
+
import type { InstanceLocator } from "../isolation/types.ts";
|
|
3
|
+
/**
|
|
4
|
+
* The client half of the host kit (P066): a locator that asks the app's issue endpoint
|
|
5
|
+
* (`ContentHost.issue`) for a frame's location, and the choice between served and opaque configs.
|
|
6
|
+
* The client proposes an instance; the server's mapping decides what it means, and the server
|
|
7
|
+
* alone chooses the origin.
|
|
8
|
+
*/
|
|
9
|
+
/** Why the issue endpoint gave no location: its status and code (`not-served`, `forbidden`, …). */
|
|
10
|
+
export declare class LocateError extends Error {
|
|
11
|
+
readonly status: number;
|
|
12
|
+
readonly code: string;
|
|
13
|
+
constructor(message: string, status: number, code: string);
|
|
14
|
+
}
|
|
15
|
+
export interface HttpLocatorOptions {
|
|
16
|
+
/** Headers for each request (an `authorization`), or a function giving them now. */
|
|
17
|
+
readonly headers?: HeadersInit | (() => HeadersInit);
|
|
18
|
+
/** Default `same-origin` (the app's own cookies). */
|
|
19
|
+
readonly credentials?: RequestCredentials;
|
|
20
|
+
/** Content frames aren't served now: refresh what the page knows (`ContentHost.meta`). */
|
|
21
|
+
readonly onNotServed?: () => void;
|
|
22
|
+
readonly fetch?: typeof fetch;
|
|
23
|
+
}
|
|
24
|
+
/** A locator that POSTs `{ instance }` to the app's issue endpoint at `url`. */
|
|
25
|
+
export declare function httpLocator(url: string, options?: HttpLocatorOptions): InstanceLocator;
|
|
26
|
+
/** What the page knows of content (`ContentHost.meta`, as the app's own meta carries it). */
|
|
27
|
+
export interface ServedMeta {
|
|
28
|
+
readonly served: boolean;
|
|
29
|
+
readonly contentSource: string | null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The configs a page's displays run under: served (from the content source) while content is
|
|
33
|
+
* served, opaque otherwise; null while the page doesn't know yet, so a frame never starts opaque
|
|
34
|
+
* and then restarts served.
|
|
35
|
+
*/
|
|
36
|
+
export declare function servedOrOpaque<T extends Readonly<Record<string, PortholeConfig>>>(meta: ServedMeta | null | undefined, served: (contentSource: string) => T, opaque: T): {
|
|
37
|
+
readonly served: boolean;
|
|
38
|
+
readonly configs: T;
|
|
39
|
+
} | null;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client half of the host kit (P066): a locator that asks the app's issue endpoint
|
|
3
|
+
* (`ContentHost.issue`) for a frame's location, and the choice between served and opaque configs.
|
|
4
|
+
* The client proposes an instance; the server's mapping decides what it means, and the server
|
|
5
|
+
* alone chooses the origin.
|
|
6
|
+
*/
|
|
7
|
+
/** Why the issue endpoint gave no location: its status and code (`not-served`, `forbidden`, …). */
|
|
8
|
+
export class LocateError extends Error {
|
|
9
|
+
status;
|
|
10
|
+
code;
|
|
11
|
+
constructor(message, status, code) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "LocateError";
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.code = code;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
19
|
+
/** A locator that POSTs `{ instance }` to the app's issue endpoint at `url`. */
|
|
20
|
+
export function httpLocator(url, options = {}) {
|
|
21
|
+
return {
|
|
22
|
+
async locate(instance, signal) {
|
|
23
|
+
const extra = typeof options.headers === "function" ? options.headers() : options.headers;
|
|
24
|
+
const headers = new Headers(extra);
|
|
25
|
+
headers.set("content-type", "application/json");
|
|
26
|
+
headers.set("accept", "application/json");
|
|
27
|
+
const response = await (options.fetch ?? fetch)(url, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers,
|
|
30
|
+
credentials: options.credentials ?? "same-origin",
|
|
31
|
+
redirect: "error",
|
|
32
|
+
body: JSON.stringify({ instance }),
|
|
33
|
+
...(signal === undefined ? {} : { signal }),
|
|
34
|
+
});
|
|
35
|
+
const data = await response.json().catch(() => null);
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
const error = isRecord(data) && isRecord(data.error) ? data.error : {};
|
|
38
|
+
const code = typeof error.code === "string" ? error.code : "failed";
|
|
39
|
+
if (code === "not-served") {
|
|
40
|
+
options.onNotServed?.();
|
|
41
|
+
}
|
|
42
|
+
throw new LocateError(typeof error.message === "string"
|
|
43
|
+
? error.message
|
|
44
|
+
: `The app's issue endpoint answered ${response.status}`, response.status, code);
|
|
45
|
+
}
|
|
46
|
+
if (!isRecord(data) ||
|
|
47
|
+
typeof data.url !== "string" ||
|
|
48
|
+
typeof data.origin !== "string" ||
|
|
49
|
+
(data.reports !== undefined && typeof data.reports !== "boolean")) {
|
|
50
|
+
throw new LocateError("The app's issue endpoint gave no frame location", response.status, "bad-location");
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
url: data.url,
|
|
54
|
+
origin: data.origin,
|
|
55
|
+
...(typeof data.reports === "boolean" ? { reports: data.reports } : {}),
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The configs a page's displays run under: served (from the content source) while content is
|
|
62
|
+
* served, opaque otherwise; null while the page doesn't know yet, so a frame never starts opaque
|
|
63
|
+
* and then restarts served.
|
|
64
|
+
*/
|
|
65
|
+
export function servedOrOpaque(meta, served, opaque) {
|
|
66
|
+
if (meta === null || meta === undefined) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return meta.served && meta.contentSource !== null
|
|
70
|
+
? { served: true, configs: served(meta.contentSource) }
|
|
71
|
+
: { served: false, configs: opaque };
|
|
72
|
+
}
|
package/dist/host/outcome.js
CHANGED
package/dist/host/types.d.ts
CHANGED
|
@@ -79,6 +79,8 @@ export type FrameBlock =
|
|
|
79
79
|
"framing"
|
|
80
80
|
/** The browser keeps no cookie for it inside a frame (WebKit, P025): open it on a tab. */
|
|
81
81
|
| "cookies"
|
|
82
|
+
/** The person isn't signed in at the app, and its sign-in never shows in a frame: a tab (P068). */
|
|
83
|
+
| "sign-in"
|
|
82
84
|
/** Its ticket was used or its session ended. */
|
|
83
85
|
| "session"
|
|
84
86
|
/** Its app didn't answer. */
|
package/dist/index.d.ts
CHANGED
|
@@ -25,6 +25,7 @@ export { COPY_TEXT_METHOD, MCP_APPS_METHODS, MCP_APPS_PROTOCOL_VERSION, MCP_APPS
|
|
|
25
25
|
export type { CallContext, CallOutcome, CapabilityHandler, CapabilityHandlers, Confirm, ConfirmAnswer, ConfirmRequest, Proceed, HostFunctionBridge, Refusal, } from "./bridge/types.ts";
|
|
26
26
|
export { createHost } from "./host/create-host.ts";
|
|
27
27
|
export { writeClipboard } from "./host/clipboard.ts";
|
|
28
|
+
export { httpLocator, LocateError, servedOrOpaque, type HttpLocatorOptions, type ServedMeta, } from "./host/locator.ts";
|
|
28
29
|
export type { AnnotationControls, DisplayControls, DisplayEvents, FrameBlock, FrameOutcome, DisplayHandle, HostOptions, MountOptions, PortholeHost, } from "./host/types.ts";
|
|
29
30
|
export { ANNOTATE_METHODS, ANNOTATION_LIMITS, type AnchorResult, type AnchorState, type AnnotateMode, type AnnotationAnchor, type AnnotationMark, type AnnotationRect, type AnnotationReport, type AnnotationSelector, type CssSelector, type TextPositionSelector, type TextQuoteSelector, } from "./annotate/types.ts";
|
|
30
31
|
export { parseAnchor, parseRect, parseReport } from "./annotate/validate.ts";
|
package/dist/index.js
CHANGED
|
@@ -22,6 +22,7 @@ export { CHART_PALETTE, DEFAULT_THEME, DEFAULT_THEME_EVENT, DEFAULT_THEME_VARIAB
|
|
|
22
22
|
export { COPY_TEXT_METHOD, MCP_APPS_METHODS, MCP_APPS_PROTOCOL_VERSION, MCP_APPS_STYLE_VARIABLES, PORTHOLE_STYLES, } from "./protocol/mcp-apps.js";
|
|
23
23
|
export { createHost } from "./host/create-host.js";
|
|
24
24
|
export { writeClipboard } from "./host/clipboard.js";
|
|
25
|
+
export { httpLocator, LocateError, servedOrOpaque, } from "./host/locator.js";
|
|
25
26
|
// Annotations (P053): anchors, what a frame reports, and the host's checks on both.
|
|
26
27
|
export { ANNOTATE_METHODS, ANNOTATION_LIMITS, } from "./annotate/types.js";
|
|
27
28
|
export { parseAnchor, parseRect, parseReport } from "./annotate/validate.js";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@botiverse/porthole/node`: the host kit on Node's own HTTP server (P066). Every request and
|
|
3
|
+
* WebSocket whose Host is under the content domain is Porthole's; the app's reserved labels
|
|
4
|
+
* (previews) and its own hosts go to the app's listeners.
|
|
5
|
+
*
|
|
6
|
+
* const content = createContentHost({ …, health: { probe: nodeProbe() } });
|
|
7
|
+
* const porthole = nodeHost(content, { request: appListener, upgrade: appUpgrade });
|
|
8
|
+
* const server = http.createServer(porthole.request);
|
|
9
|
+
* server.on("upgrade", porthole.upgrade);
|
|
10
|
+
* server.listen(8080, () => content.start());
|
|
11
|
+
*/
|
|
12
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
13
|
+
import type { Duplex } from "node:stream";
|
|
14
|
+
import type { ContentHost, HealthProbe } from "../server/host.ts";
|
|
15
|
+
import type { Access } from "../server/types.ts";
|
|
16
|
+
type RequestListener = (request: IncomingMessage, response: ServerResponse) => void;
|
|
17
|
+
type UpgradeListener = (request: IncomingMessage, socket: Duplex, head: Buffer) => void;
|
|
18
|
+
export interface NodeHostOptions {
|
|
19
|
+
/** The app's own request listener (Express, Hono's `getRequestListener`, a router). */
|
|
20
|
+
readonly request: RequestListener;
|
|
21
|
+
/** The app's own upgrade listener (its WebSockets, and its reserved labels' sockets). */
|
|
22
|
+
readonly upgrade?: UpgradeListener;
|
|
23
|
+
/** A url-mode app's WebSocket, once its cookie checks out (`ContentHost.access`). */
|
|
24
|
+
readonly appSocket?: (access: Access, request: IncomingMessage, socket: Duplex, head: Buffer) => void;
|
|
25
|
+
}
|
|
26
|
+
export interface NodeHost {
|
|
27
|
+
readonly request: RequestListener;
|
|
28
|
+
readonly upgrade: UpgradeListener;
|
|
29
|
+
}
|
|
30
|
+
/** The Node request as a fetch `Request`: an absolute-form target kept as it came (P056). */
|
|
31
|
+
export declare function toRequest(request: IncomingMessage, scheme: "https" | "http"): Request;
|
|
32
|
+
/** Writes a fetch `Response` to a Node response, cookies one header each. */
|
|
33
|
+
export declare function sendResponse(response: ServerResponse, answer: Response): Promise<void>;
|
|
34
|
+
export declare function nodeHost<V>(content: ContentHost<V>, app: NodeHostOptions): NodeHost;
|
|
35
|
+
/**
|
|
36
|
+
* The health gate's probe on Node: a GET over the real network (DNS, the edge, TLS with Node's
|
|
37
|
+
* own certificate checks), `*.localhost` resolved to loopback, redirects not followed, at most 4 KB
|
|
38
|
+
* of the answer read.
|
|
39
|
+
*/
|
|
40
|
+
export declare function nodeProbe(): HealthProbe;
|
|
41
|
+
export {};
|