@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,466 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { expect, type Frame, type Page, type TestType } from "@playwright/test";
|
|
4
|
+
import type { PortholeConfig } from "../config/schema.ts";
|
|
5
|
+
import type { FrameLocation, InstanceRef } from "../isolation/types.ts";
|
|
6
|
+
import { entryTests, type EntryConformanceHost } from "./entry.ts";
|
|
7
|
+
import type { ConformanceHarness } from "./harness/page.ts";
|
|
8
|
+
import type { FrameOutcome } from "../host/types.ts";
|
|
9
|
+
import type { ConformanceHost, LaunchedDisplay, LaunchSpec } from "./index.ts";
|
|
10
|
+
import { leakTests, type ConformanceFixtures } from "./probes.ts";
|
|
11
|
+
import { servedTests, type ServedConformanceHost, type ServedDisplay } from "./served.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The conformance runner (P066): an app points it at its real page (its CSP, its nonce, its
|
|
15
|
+
* session) and its issue endpoint; Porthole loads its own harness there (framework-free, on the
|
|
16
|
+
* core's `createHost`), routes the canaries and a foreign origin, and registers the leak and
|
|
17
|
+
* served suites in the app's Playwright project:
|
|
18
|
+
*
|
|
19
|
+
* defineHostConformance(test, {
|
|
20
|
+
* name: "my-app",
|
|
21
|
+
* open: async (page) => { await signIn(page); await page.goto("/viewer"); },
|
|
22
|
+
* issue: { url: "/api/frames" }, // the app's ContentHost.issue route
|
|
23
|
+
* config: (instance) => servedConfigs[presetOf(instance)],
|
|
24
|
+
* instances: { shell: (scope) => ({ scope, document: { kind: "shell", preset: "widget" } }) },
|
|
25
|
+
* });
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export interface HostConformanceOptions {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
/** Opens the app's real page in `page` as a person sees it (signed in); the harness runs there. */
|
|
31
|
+
readonly open: (page: Page) => Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* The page's script nonce, where its CSP is nonce-based. Default: the first `script[nonce]`'s.
|
|
34
|
+
* The harness loads as a script from the page's own origin, so `'self'` policies need none.
|
|
35
|
+
*/
|
|
36
|
+
readonly nonce?: (page: Page) => Promise<string | undefined>;
|
|
37
|
+
/**
|
|
38
|
+
* The app's issue endpoint (`ContentHost.issue`), relative to the page: the harness asks it from
|
|
39
|
+
* the page with the page's own cookies, as the app's client does (`httpLocator`).
|
|
40
|
+
*/
|
|
41
|
+
readonly issue?: { readonly url: string; readonly headers?: Readonly<Record<string, string>> };
|
|
42
|
+
/** Or the location for an instance, got the test's own way. */
|
|
43
|
+
readonly locate?: (instance: InstanceRef, page: Page) => Promise<FrameLocation>;
|
|
44
|
+
/** The config a served instance is framed under (the app's served config for its preset). */
|
|
45
|
+
readonly config: (instance: InstanceRef) => PortholeConfig;
|
|
46
|
+
/**
|
|
47
|
+
* The instances the served cases ask for, each for the scope a case names. `shell` runs the
|
|
48
|
+
* domain-mode cases; `artifact` (versions of one stored page), `app` (url mode) and `proxy` (the
|
|
49
|
+
* MCP Apps sandbox proxy) run theirs where given, and are skipped (with the reason) where not.
|
|
50
|
+
*/
|
|
51
|
+
readonly instances: Pick<ServedConformanceHost, "shell"> &
|
|
52
|
+
Partial<Pick<ServedConformanceHost, "artifact" | "app" | "proxy">>;
|
|
53
|
+
/**
|
|
54
|
+
* Redirect entry (P068), where the app serves clean addresses: the cases open them directly,
|
|
55
|
+
* signed in and out, and in a frame.
|
|
56
|
+
*/
|
|
57
|
+
readonly entry?: {
|
|
58
|
+
/** The clean address of `instances.artifact(scope, "1")` (`ContentHost.urlOf`). */
|
|
59
|
+
readonly url: (scope: string) => string;
|
|
60
|
+
/** A clean address the signed-in viewer may not open. */
|
|
61
|
+
readonly denied: (scope: string) => string;
|
|
62
|
+
/** Whether `page` shows the app's own sign-in. */
|
|
63
|
+
readonly atSignIn: (page: Page) => Promise<boolean>;
|
|
64
|
+
/** On the app's sign-in page: signs in, as a person does. */
|
|
65
|
+
readonly completeSignIn: (page: Page) => Promise<void>;
|
|
66
|
+
};
|
|
67
|
+
/** Case ids to skip, each with its reason (shown in the report). */
|
|
68
|
+
readonly skip?: Readonly<Record<string, string>>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const CANARY = "https://porthole-canary.example";
|
|
72
|
+
/** A page on no one's origin that frames what a case asks (routed by the test, like the canary). */
|
|
73
|
+
const FOREIGN = "http://porthole-foreign.example";
|
|
74
|
+
/** Where the harness loads from, on the app's own origin (routed by the test, never the app's). */
|
|
75
|
+
const HARNESS_PATH = "/__porthole/conformance/harness.js";
|
|
76
|
+
|
|
77
|
+
let built: Promise<string> | null = null;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The harness as one script: from the package (`dist/conformance/harness.js`), or, run from the
|
|
81
|
+
* sources, bundled now (esbuild, a development dependency of Porthole's).
|
|
82
|
+
*/
|
|
83
|
+
export function harnessScript(): Promise<string> {
|
|
84
|
+
built ??= (async () => {
|
|
85
|
+
try {
|
|
86
|
+
return await readFile(new URL("./harness.js", import.meta.url), "utf8");
|
|
87
|
+
} catch {
|
|
88
|
+
const { build } = await import("esbuild");
|
|
89
|
+
const result = await build({
|
|
90
|
+
entryPoints: [fileURLToPath(new URL("./harness/page.ts", import.meta.url))],
|
|
91
|
+
bundle: true,
|
|
92
|
+
format: "iife",
|
|
93
|
+
platform: "browser",
|
|
94
|
+
target: "es2022",
|
|
95
|
+
write: false,
|
|
96
|
+
logLevel: "silent",
|
|
97
|
+
});
|
|
98
|
+
const text = result.outputFiles[0]?.text;
|
|
99
|
+
if (text === undefined) {
|
|
100
|
+
throw new Error("the conformance harness didn't build");
|
|
101
|
+
}
|
|
102
|
+
return text;
|
|
103
|
+
}
|
|
104
|
+
})();
|
|
105
|
+
return built;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type Harness = Record<string, ((...args: unknown[]) => unknown) | undefined>;
|
|
109
|
+
|
|
110
|
+
/** Calls one of the harness's methods in the page. */
|
|
111
|
+
function invoke<K extends keyof ConformanceHarness>(
|
|
112
|
+
page: Page,
|
|
113
|
+
method: K,
|
|
114
|
+
...args: Parameters<ConformanceHarness[K]>
|
|
115
|
+
): Promise<Awaited<ReturnType<ConformanceHarness[K]>>> {
|
|
116
|
+
return page.evaluate(
|
|
117
|
+
([name, given]) => {
|
|
118
|
+
const harness = (window as unknown as { portholeConformance?: Harness }).portholeConformance;
|
|
119
|
+
const run = harness?.[name];
|
|
120
|
+
if (run === undefined) {
|
|
121
|
+
throw new Error(`the conformance harness has no ${name}`);
|
|
122
|
+
}
|
|
123
|
+
return run(...given);
|
|
124
|
+
},
|
|
125
|
+
[method, args] as [string, unknown[]],
|
|
126
|
+
) as Promise<Awaited<ReturnType<ConformanceHarness[K]>>>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The display's frame element's document, once it exists. */
|
|
130
|
+
async function frameIn(page: Page, id: number): Promise<Frame> {
|
|
131
|
+
let found: Frame | null = null;
|
|
132
|
+
await expect
|
|
133
|
+
.poll(async () => {
|
|
134
|
+
const element = await page
|
|
135
|
+
.locator(`#porthole-display-${id} iframe`)
|
|
136
|
+
.elementHandle({ timeout: 2_000 })
|
|
137
|
+
.catch(() => null);
|
|
138
|
+
found = (await element?.contentFrame()) ?? null;
|
|
139
|
+
return found !== null;
|
|
140
|
+
})
|
|
141
|
+
.toBe(true);
|
|
142
|
+
return found as unknown as Frame;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface HostConformance {
|
|
146
|
+
readonly leak: ConformanceHost;
|
|
147
|
+
readonly served: ServedConformanceHost;
|
|
148
|
+
/** Redirect entry's host, where the options give `entry`. */
|
|
149
|
+
readonly entry: EntryConformanceHost | null;
|
|
150
|
+
/** Prepares a test's page (the canaries, the foreign origin, the harness's route): call first. */
|
|
151
|
+
begin(page: Page): Promise<void>;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The leak and served hosts on an app's real page, for an app that registers the cases itself. */
|
|
155
|
+
export function hostConformance(options: HostConformanceOptions): HostConformance {
|
|
156
|
+
let current: Page | null = null;
|
|
157
|
+
const opened = new WeakSet<Page>();
|
|
158
|
+
const hits = new Map<string, number>();
|
|
159
|
+
|
|
160
|
+
const open = async (page: Page) => {
|
|
161
|
+
await options.open(page);
|
|
162
|
+
opened.add(page);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** Opens the app's page, if it isn't, and loads the harness into it under the page's policy. */
|
|
166
|
+
const ready = async (page: Page) => {
|
|
167
|
+
const loaded = await page
|
|
168
|
+
.evaluate(() => document.documentElement.dataset.portholeConformance === "ready")
|
|
169
|
+
.catch(() => false);
|
|
170
|
+
if (loaded) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
await open(page);
|
|
174
|
+
const nonce =
|
|
175
|
+
(await options.nonce?.(page)) ??
|
|
176
|
+
(await page.evaluate(
|
|
177
|
+
() => document.querySelector<HTMLScriptElement>("script[nonce]")?.nonce || undefined,
|
|
178
|
+
));
|
|
179
|
+
await page.evaluate(
|
|
180
|
+
([path, given]) => {
|
|
181
|
+
const script = document.createElement("script");
|
|
182
|
+
if (given !== undefined) {
|
|
183
|
+
script.nonce = given;
|
|
184
|
+
}
|
|
185
|
+
script.src = path;
|
|
186
|
+
document.head.append(script);
|
|
187
|
+
},
|
|
188
|
+
[HARNESS_PATH, nonce] as const,
|
|
189
|
+
);
|
|
190
|
+
await expect
|
|
191
|
+
.poll(() => page.evaluate(() => document.documentElement.dataset.portholeConformance), {
|
|
192
|
+
message:
|
|
193
|
+
"the harness runs on the app's page (does its CSP let a script from its own origin, or with its nonce, run?)",
|
|
194
|
+
})
|
|
195
|
+
.toBe("ready");
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const leak: ConformanceHost = {
|
|
199
|
+
name: options.name,
|
|
200
|
+
async launch(page: Page, spec: LaunchSpec): Promise<LaunchedDisplay> {
|
|
201
|
+
await ready(page);
|
|
202
|
+
const id = await invoke(page, "launch", spec);
|
|
203
|
+
return {
|
|
204
|
+
frame: () => frameIn(page, id),
|
|
205
|
+
async box() {
|
|
206
|
+
const element = await page.locator(`#porthole-display-${id} iframe`).elementHandle();
|
|
207
|
+
const area = await element?.boundingBox();
|
|
208
|
+
return { width: area?.width ?? 0, height: area?.height ?? 0 };
|
|
209
|
+
},
|
|
210
|
+
setContent: (html, given) => invoke(page, "setContent", id, html, given?.partial === true),
|
|
211
|
+
setTheme: (scheme) => invoke(page, "setTheme", id, scheme),
|
|
212
|
+
async answerNextConfirmation(accept, given) {
|
|
213
|
+
await expect
|
|
214
|
+
.poll(() => invoke(page, "pendingConfirmations", id), {
|
|
215
|
+
timeout: given?.timeoutMs ?? 5_000,
|
|
216
|
+
})
|
|
217
|
+
.toBeGreaterThan(0);
|
|
218
|
+
const request = await invoke(page, "answer", id, accept);
|
|
219
|
+
if (request === null) {
|
|
220
|
+
throw new Error("no confirmation was waiting");
|
|
221
|
+
}
|
|
222
|
+
return request;
|
|
223
|
+
},
|
|
224
|
+
calls: () => invoke(page, "calls", id),
|
|
225
|
+
errors: () => invoke(page, "errors", id),
|
|
226
|
+
events: () => invoke(page, "events", id),
|
|
227
|
+
destroy: () => invoke(page, "destroy", id),
|
|
228
|
+
};
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const locate = async (instance: InstanceRef): Promise<FrameLocation> => {
|
|
233
|
+
const page = current;
|
|
234
|
+
if (page === null) {
|
|
235
|
+
throw new Error("the runner has no page: call begin(page) first");
|
|
236
|
+
}
|
|
237
|
+
if (options.locate !== undefined) {
|
|
238
|
+
return options.locate(instance, page);
|
|
239
|
+
}
|
|
240
|
+
if (options.issue === undefined) {
|
|
241
|
+
throw new Error("the runner needs the app's issue endpoint (issue) or locate");
|
|
242
|
+
}
|
|
243
|
+
if (!opened.has(page)) {
|
|
244
|
+
await open(page);
|
|
245
|
+
}
|
|
246
|
+
// Asked as the page asks it: the context's cookies, the page's origin.
|
|
247
|
+
const response = await page
|
|
248
|
+
.context()
|
|
249
|
+
.request.post(new URL(options.issue.url, page.url()).href, {
|
|
250
|
+
data: { instance },
|
|
251
|
+
headers: { origin: new URL(page.url()).origin, ...options.issue.headers },
|
|
252
|
+
maxRedirects: 0,
|
|
253
|
+
});
|
|
254
|
+
if (response.status() !== 201) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`the app's issue endpoint answered ${response.status()}: ${(await response.text()).slice(0, 200)}`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
return (await response.json()) as FrameLocation;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const { artifact, app, proxy } = options.instances;
|
|
263
|
+
const served: ServedConformanceHost = {
|
|
264
|
+
name: options.name,
|
|
265
|
+
async show(page, instance, given = {}): Promise<ServedDisplay> {
|
|
266
|
+
await ready(page);
|
|
267
|
+
const where =
|
|
268
|
+
options.locate === undefined && options.issue !== undefined
|
|
269
|
+
? {
|
|
270
|
+
issue: options.issue.url,
|
|
271
|
+
...(options.issue.headers === undefined ? {} : { headers: options.issue.headers }),
|
|
272
|
+
}
|
|
273
|
+
: { location: await locate(instance) };
|
|
274
|
+
const id = await invoke(
|
|
275
|
+
page,
|
|
276
|
+
"show",
|
|
277
|
+
options.config(instance),
|
|
278
|
+
instance,
|
|
279
|
+
where,
|
|
280
|
+
given.content,
|
|
281
|
+
);
|
|
282
|
+
let origin: string | null = null;
|
|
283
|
+
await expect
|
|
284
|
+
.poll(
|
|
285
|
+
async () => {
|
|
286
|
+
origin = await invoke(page, "origin", id);
|
|
287
|
+
return origin ?? (await invoke(page, "failure", id)) ?? "";
|
|
288
|
+
},
|
|
289
|
+
{ message: "the app's issue endpoint locates the instance" },
|
|
290
|
+
)
|
|
291
|
+
.toMatch(/^https?:\/\//u);
|
|
292
|
+
const located = origin as unknown as string;
|
|
293
|
+
return {
|
|
294
|
+
origin: located,
|
|
295
|
+
async frame() {
|
|
296
|
+
const frame = await frameIn(page, id);
|
|
297
|
+
await frame.waitForURL((url) => url.origin === located);
|
|
298
|
+
// A url-mode app's entry page goes on to the app where the browser kept the cookie; where
|
|
299
|
+
// it didn't (WebKit, P025), it stays, saying so: the frame is that page then.
|
|
300
|
+
await frame
|
|
301
|
+
.waitForURL((url) => url.pathname !== "/__porthole/enter", { timeout: 5_000 })
|
|
302
|
+
.catch(() => undefined);
|
|
303
|
+
await frame.waitForLoadState("load");
|
|
304
|
+
return frame;
|
|
305
|
+
},
|
|
306
|
+
destroy: () => invoke(page, "destroy", id),
|
|
307
|
+
post: (message) => invoke(page, "post", id, message),
|
|
308
|
+
received: () => invoke(page, "received", id),
|
|
309
|
+
};
|
|
310
|
+
},
|
|
311
|
+
locate,
|
|
312
|
+
async frameFromForeignOrigin(page, url) {
|
|
313
|
+
await page.goto(`${FOREIGN}/?frame=${encodeURIComponent(url)}`);
|
|
314
|
+
},
|
|
315
|
+
canary: (name) => Promise.resolve(`${CANARY}/${name}`),
|
|
316
|
+
canaryHits: (name) => Promise.resolve(hits.get(name) ?? 0),
|
|
317
|
+
shell: options.instances.shell,
|
|
318
|
+
// Asked for only by cases that run where the app gave them (`defineHostConformance` skips).
|
|
319
|
+
artifact:
|
|
320
|
+
artifact ??
|
|
321
|
+
((scope, version) => ({ scope, document: { kind: "artifact", id: "page", version } })),
|
|
322
|
+
app: app ?? ((scope) => ({ scope, document: { kind: "external", url: "http://app.test/" } })),
|
|
323
|
+
proxy: proxy ?? ((scope) => ({ scope, document: { kind: "mcp-proxy" } })),
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const given = options.entry;
|
|
327
|
+
const entry: EntryConformanceHost | null =
|
|
328
|
+
given === undefined
|
|
329
|
+
? null
|
|
330
|
+
: {
|
|
331
|
+
name: options.name,
|
|
332
|
+
// Signed in on the app's page, the harness there (a frame needs it).
|
|
333
|
+
signIn: async (page) => {
|
|
334
|
+
await open(page);
|
|
335
|
+
await ready(page);
|
|
336
|
+
},
|
|
337
|
+
url: given.url,
|
|
338
|
+
denied: given.denied,
|
|
339
|
+
atSignIn: given.atSignIn,
|
|
340
|
+
completeSignIn: given.completeSignIn,
|
|
341
|
+
async frame(page, url) {
|
|
342
|
+
await ready(page);
|
|
343
|
+
const instance = served.artifact("entry", "1");
|
|
344
|
+
const id = await invoke(page, "show", options.config(instance), instance, {
|
|
345
|
+
location: { url, origin: new URL(url).origin },
|
|
346
|
+
});
|
|
347
|
+
return {
|
|
348
|
+
frame: () => frameIn(page, id),
|
|
349
|
+
async outcome(): Promise<FrameOutcome> {
|
|
350
|
+
let found: FrameOutcome | null = null;
|
|
351
|
+
await expect
|
|
352
|
+
.poll(
|
|
353
|
+
async () => {
|
|
354
|
+
const events = await invoke(page, "events", id);
|
|
355
|
+
const each = events.find((event) => event.type === "outcome");
|
|
356
|
+
found = each === undefined ? null : each.detail;
|
|
357
|
+
return found !== null;
|
|
358
|
+
},
|
|
359
|
+
{ message: "the display has an outcome", timeout: 15_000 },
|
|
360
|
+
)
|
|
361
|
+
.toBe(true);
|
|
362
|
+
return found as unknown as FrameOutcome;
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
leak,
|
|
370
|
+
served,
|
|
371
|
+
entry,
|
|
372
|
+
async begin(page) {
|
|
373
|
+
current = page;
|
|
374
|
+
const script = await harnessScript();
|
|
375
|
+
await page.route(`**${HARNESS_PATH}`, (route) =>
|
|
376
|
+
route.fulfill({
|
|
377
|
+
status: 200,
|
|
378
|
+
contentType: "text/javascript; charset=utf-8",
|
|
379
|
+
headers: { "cache-control": "no-store" },
|
|
380
|
+
body: script,
|
|
381
|
+
}),
|
|
382
|
+
);
|
|
383
|
+
await page.context().route(`${CANARY}/**`, (route) => {
|
|
384
|
+
const name = new URL(route.request().url()).pathname.slice(1);
|
|
385
|
+
hits.set(name, (hits.get(name) ?? 0) + 1);
|
|
386
|
+
return route.fulfill({ status: 200, contentType: "text/plain", body: "canary" });
|
|
387
|
+
});
|
|
388
|
+
await page.context().route(`${FOREIGN}/**`, (route) => {
|
|
389
|
+
const target = new URL(route.request().url()).searchParams.get("frame") ?? "";
|
|
390
|
+
const attribute = target.replace(/&/gu, "&").replace(/"/gu, """);
|
|
391
|
+
return route.fulfill({
|
|
392
|
+
status: 200,
|
|
393
|
+
contentType: "text/html",
|
|
394
|
+
body: `<!doctype html><title>Foreign</title><iframe sandbox="allow-scripts allow-same-origin" src="${attribute}"></iframe>`,
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const URL_CASES = [
|
|
402
|
+
"served.url.origin",
|
|
403
|
+
"served.url.no-reach",
|
|
404
|
+
"served.url.no-top-navigation",
|
|
405
|
+
"served.url.no-popups",
|
|
406
|
+
"served.url.ticket",
|
|
407
|
+
"served.url.tab",
|
|
408
|
+
"served.url.framed",
|
|
409
|
+
];
|
|
410
|
+
const PROXY_CASES = ["served.proxy.view", "served.proxy.parent-only", "served.proxy.egress"];
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Registers the leak and served suites against an app's real page, in a spec file of the app's
|
|
414
|
+
* (so `-g "\[1\.connect\]"` selects one). The served cases for instances the app didn't give are
|
|
415
|
+
* skipped, each with its reason.
|
|
416
|
+
*/
|
|
417
|
+
export function defineHostConformance(
|
|
418
|
+
test: TestType<object, object>,
|
|
419
|
+
options: HostConformanceOptions,
|
|
420
|
+
): void {
|
|
421
|
+
const typed = test as unknown as TestType<ConformanceFixtures, object>;
|
|
422
|
+
const hosts = hostConformance(options);
|
|
423
|
+
const skip: Record<string, string> = { ...options.skip };
|
|
424
|
+
const absent = (ids: readonly string[], reason: string) => {
|
|
425
|
+
for (const id of ids) {
|
|
426
|
+
skip[id] ??= reason;
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
if (options.instances.artifact === undefined) {
|
|
430
|
+
absent(["served.artifact.versions"], "the app serves no versions of a stored page");
|
|
431
|
+
}
|
|
432
|
+
if (options.instances.app === undefined) {
|
|
433
|
+
absent(URL_CASES, "the app serves no url-mode apps");
|
|
434
|
+
}
|
|
435
|
+
if (options.instances.proxy === undefined) {
|
|
436
|
+
absent(PROXY_CASES, "the app serves no MCP Apps views");
|
|
437
|
+
}
|
|
438
|
+
typed.describe(`Porthole leak suite (${options.name})`, () => {
|
|
439
|
+
for (const each of leakTests(hosts.leak, { skip })) {
|
|
440
|
+
// Playwright reads the fixtures a test uses from its parameter's destructuring.
|
|
441
|
+
typed(each.title, async ({ page, browserName }, info) => {
|
|
442
|
+
await hosts.begin(page);
|
|
443
|
+
await each.run({ page, browserName }, info);
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
typed.describe(`Porthole served modes (${options.name})`, () => {
|
|
448
|
+
for (const each of servedTests(hosts.served, { skip })) {
|
|
449
|
+
typed(each.title, async ({ page, browserName }, info) => {
|
|
450
|
+
await hosts.begin(page);
|
|
451
|
+
await each.run({ page, browserName }, info);
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
const entry = hosts.entry;
|
|
456
|
+
if (entry !== null) {
|
|
457
|
+
typed.describe(`Porthole redirect entry (${options.name})`, () => {
|
|
458
|
+
for (const each of entryTests(entry, { skip })) {
|
|
459
|
+
typed(each.title, async ({ page, browserName }, info) => {
|
|
460
|
+
await hosts.begin(page);
|
|
461
|
+
await each.run({ page, browserName }, info);
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
package/src/conformance/index.ts
CHANGED
|
@@ -116,3 +116,15 @@ export {
|
|
|
116
116
|
type ServedDisplay,
|
|
117
117
|
type ServedSuiteOptions,
|
|
118
118
|
} from "./served.ts";
|
|
119
|
+
|
|
120
|
+
// Redirect entry's cases (P068).
|
|
121
|
+
export { entryTests, type EntryConformanceHost, type EntrySuiteOptions } from "./entry.ts";
|
|
122
|
+
|
|
123
|
+
// The runner an app points at its real page and issue endpoint (P066).
|
|
124
|
+
export {
|
|
125
|
+
defineHostConformance,
|
|
126
|
+
harnessScript,
|
|
127
|
+
hostConformance,
|
|
128
|
+
type HostConformance,
|
|
129
|
+
type HostConformanceOptions,
|
|
130
|
+
} from "./host-kit.ts";
|
|
@@ -549,9 +549,20 @@ export function servedTests(
|
|
|
549
549
|
"an app shows nothing to a browser without its ticket's cookie",
|
|
550
550
|
async ({ page }) => {
|
|
551
551
|
const location = await host.locate(host.app("url-ticket"));
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
552
|
+
// A fresh browser: no cookie of the content's, none of the app's. Porthole refuses it, or
|
|
553
|
+
// (redirect entry, P068) hands it to the app's authorize route, which asks it to sign in.
|
|
554
|
+
const fresh = await page.context().browser()!.newContext();
|
|
555
|
+
try {
|
|
556
|
+
const other = await fresh.newPage();
|
|
557
|
+
const bare = await other.goto(`${location.origin}/`);
|
|
558
|
+
const away = new URL(other.url()).origin !== location.origin;
|
|
559
|
+
expect(bare?.status() === 401 || away, `ended at ${other.url()}`).toBe(true);
|
|
560
|
+
expect(
|
|
561
|
+
await other.evaluate(() => document.documentElement.dataset.app ?? null),
|
|
562
|
+
).toBeNull();
|
|
563
|
+
} finally {
|
|
564
|
+
await fresh.close();
|
|
565
|
+
}
|
|
555
566
|
},
|
|
556
567
|
);
|
|
557
568
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { PortholeConfig } from "../config/schema.ts";
|
|
2
|
+
import type { FrameLocation, InstanceLocator } from "../isolation/types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The client half of the host kit (P066): a locator that asks the app's issue endpoint
|
|
6
|
+
* (`ContentHost.issue`) for a frame's location, and the choice between served and opaque configs.
|
|
7
|
+
* The client proposes an instance; the server's mapping decides what it means, and the server
|
|
8
|
+
* alone chooses the origin.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Why the issue endpoint gave no location: its status and code (`not-served`, `forbidden`, …). */
|
|
12
|
+
export class LocateError extends Error {
|
|
13
|
+
readonly status: number;
|
|
14
|
+
readonly code: string;
|
|
15
|
+
|
|
16
|
+
constructor(message: string, status: number, code: string) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "LocateError";
|
|
19
|
+
this.status = status;
|
|
20
|
+
this.code = code;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface HttpLocatorOptions {
|
|
25
|
+
/** Headers for each request (an `authorization`), or a function giving them now. */
|
|
26
|
+
readonly headers?: HeadersInit | (() => HeadersInit);
|
|
27
|
+
/** Default `same-origin` (the app's own cookies). */
|
|
28
|
+
readonly credentials?: RequestCredentials;
|
|
29
|
+
/** Content frames aren't served now: refresh what the page knows (`ContentHost.meta`). */
|
|
30
|
+
readonly onNotServed?: () => void;
|
|
31
|
+
readonly fetch?: typeof fetch;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
35
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
36
|
+
|
|
37
|
+
/** A locator that POSTs `{ instance }` to the app's issue endpoint at `url`. */
|
|
38
|
+
export function httpLocator(url: string, options: HttpLocatorOptions = {}): InstanceLocator {
|
|
39
|
+
return {
|
|
40
|
+
async locate(instance, signal) {
|
|
41
|
+
const extra = typeof options.headers === "function" ? options.headers() : options.headers;
|
|
42
|
+
const headers = new Headers(extra);
|
|
43
|
+
headers.set("content-type", "application/json");
|
|
44
|
+
headers.set("accept", "application/json");
|
|
45
|
+
const response = await (options.fetch ?? fetch)(url, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers,
|
|
48
|
+
credentials: options.credentials ?? "same-origin",
|
|
49
|
+
redirect: "error",
|
|
50
|
+
body: JSON.stringify({ instance }),
|
|
51
|
+
...(signal === undefined ? {} : { signal }),
|
|
52
|
+
});
|
|
53
|
+
const data: unknown = await response.json().catch(() => null);
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
const error = isRecord(data) && isRecord(data.error) ? data.error : {};
|
|
56
|
+
const code = typeof error.code === "string" ? error.code : "failed";
|
|
57
|
+
if (code === "not-served") {
|
|
58
|
+
options.onNotServed?.();
|
|
59
|
+
}
|
|
60
|
+
throw new LocateError(
|
|
61
|
+
typeof error.message === "string"
|
|
62
|
+
? error.message
|
|
63
|
+
: `The app's issue endpoint answered ${response.status}`,
|
|
64
|
+
response.status,
|
|
65
|
+
code,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (
|
|
69
|
+
!isRecord(data) ||
|
|
70
|
+
typeof data.url !== "string" ||
|
|
71
|
+
typeof data.origin !== "string" ||
|
|
72
|
+
(data.reports !== undefined && typeof data.reports !== "boolean")
|
|
73
|
+
) {
|
|
74
|
+
throw new LocateError(
|
|
75
|
+
"The app's issue endpoint gave no frame location",
|
|
76
|
+
response.status,
|
|
77
|
+
"bad-location",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
url: data.url,
|
|
82
|
+
origin: data.origin,
|
|
83
|
+
...(typeof data.reports === "boolean" ? { reports: data.reports } : {}),
|
|
84
|
+
} satisfies FrameLocation;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** What the page knows of content (`ContentHost.meta`, as the app's own meta carries it). */
|
|
90
|
+
export interface ServedMeta {
|
|
91
|
+
readonly served: boolean;
|
|
92
|
+
readonly contentSource: string | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The configs a page's displays run under: served (from the content source) while content is
|
|
97
|
+
* served, opaque otherwise; null while the page doesn't know yet, so a frame never starts opaque
|
|
98
|
+
* and then restarts served.
|
|
99
|
+
*/
|
|
100
|
+
export function servedOrOpaque<T extends Readonly<Record<string, PortholeConfig>>>(
|
|
101
|
+
meta: ServedMeta | null | undefined,
|
|
102
|
+
served: (contentSource: string) => T,
|
|
103
|
+
opaque: T,
|
|
104
|
+
): { readonly served: boolean; readonly configs: T } | null {
|
|
105
|
+
if (meta === null || meta === undefined) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return meta.served && meta.contentSource !== null
|
|
109
|
+
? { served: true, configs: served(meta.contentSource) }
|
|
110
|
+
: { served: false, configs: opaque };
|
|
111
|
+
}
|
package/src/host/outcome.ts
CHANGED
package/src/host/types.ts
CHANGED
|
@@ -81,6 +81,8 @@ export type FrameBlock =
|
|
|
81
81
|
| "framing"
|
|
82
82
|
/** The browser keeps no cookie for it inside a frame (WebKit, P025): open it on a tab. */
|
|
83
83
|
| "cookies"
|
|
84
|
+
/** The person isn't signed in at the app, and its sign-in never shows in a frame: a tab (P068). */
|
|
85
|
+
| "sign-in"
|
|
84
86
|
/** Its ticket was used or its session ended. */
|
|
85
87
|
| "session"
|
|
86
88
|
/** Its app didn't answer. */
|
package/src/index.ts
CHANGED
|
@@ -131,6 +131,13 @@ export type {
|
|
|
131
131
|
} from "./bridge/types.ts";
|
|
132
132
|
export { createHost } from "./host/create-host.ts";
|
|
133
133
|
export { writeClipboard } from "./host/clipboard.ts";
|
|
134
|
+
export {
|
|
135
|
+
httpLocator,
|
|
136
|
+
LocateError,
|
|
137
|
+
servedOrOpaque,
|
|
138
|
+
type HttpLocatorOptions,
|
|
139
|
+
type ServedMeta,
|
|
140
|
+
} from "./host/locator.ts";
|
|
134
141
|
export type {
|
|
135
142
|
AnnotationControls,
|
|
136
143
|
DisplayControls,
|