@botiverse/porthole 0.2.1 → 0.2.2
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/entry.d.ts +8 -2
- package/dist/conformance/entry.js +23 -8
- package/dist/conformance/host-kit.d.ts +8 -3
- package/dist/conformance/host-kit.js +58 -23
- package/dist/conformance/served.d.ts +4 -4
- package/dist/conformance/served.js +26 -16
- package/dist/node/index.d.ts +11 -3
- package/dist/node/index.js +70 -10
- package/dist/server/host.d.ts +42 -8
- package/dist/server/host.js +35 -5
- package/dist/server/index.d.ts +3 -1
- package/dist/server/pages.js +15 -1
- package/dist/server/proxy.js +2 -0
- package/dist/server/server.js +9 -2
- package/dist/server/types.d.ts +15 -6
- package/docs/ARCHITECTURE.md +277 -0
- package/docs/DECISIONS.md +1774 -0
- package/docs/DESIGN.md +1067 -0
- package/docs/OBJECTIVES.md +116 -0
- package/docs/PRINCIPLES.md +155 -0
- package/package.json +4 -3
- package/src/conformance/entry.ts +39 -10
- package/src/conformance/host-kit.ts +71 -28
- package/src/conformance/served.ts +34 -21
- package/src/node/index.ts +73 -10
- package/src/server/host.ts +84 -14
- package/src/server/index.ts +4 -0
- package/src/server/pages.ts +15 -1
- package/src/server/proxy.ts +2 -0
- package/src/server/server.ts +11 -2
- package/src/server/types.ts +18 -7
|
@@ -15,9 +15,15 @@ export interface EntryConformanceHost {
|
|
|
15
15
|
* The clean address of a stored page the signed-in viewer may open (`ContentHost.urlOf`), for
|
|
16
16
|
* `scope`: it draws `<main id="artifact">version 1</main>`.
|
|
17
17
|
*/
|
|
18
|
-
url(scope: string): string
|
|
18
|
+
url(scope: string): string | Promise<string>;
|
|
19
19
|
/** A clean address the signed-in viewer may not open. */
|
|
20
|
-
denied(scope: string): string
|
|
20
|
+
denied(scope: string): string | Promise<string>;
|
|
21
|
+
/**
|
|
22
|
+
* Addresses on the content domain that name nothing (a label the app never assigns, the bare
|
|
23
|
+
* domain, a path no instance has): each must be Porthole's 404 there, never the app's sign-in
|
|
24
|
+
* (P070). Absent: the case is skipped.
|
|
25
|
+
*/
|
|
26
|
+
unknown?(): readonly string[] | Promise<readonly string[]>;
|
|
21
27
|
/** Whether `page` shows the app's own sign-in. */
|
|
22
28
|
atSignIn(page: Page): Promise<boolean>;
|
|
23
29
|
/** On the app's sign-in page: signs in, as a person does (and the app goes back). */
|
|
@@ -31,7 +31,7 @@ export function entryTests(host, options = {}) {
|
|
|
31
31
|
};
|
|
32
32
|
define("served.entry.direct", "a clean address opens directly for a signed-in viewer, and stays clean; a reload rides on its cookie", async ({ page }) => {
|
|
33
33
|
await host.signIn(page);
|
|
34
|
-
const url = host.url("entry-direct");
|
|
34
|
+
const url = await host.url("entry-direct");
|
|
35
35
|
await page.goto(url);
|
|
36
36
|
await expect(page.locator("#artifact")).toHaveText("version 1");
|
|
37
37
|
expect(page.url(), "the address bar shows the clean address, no code").toBe(url);
|
|
@@ -46,7 +46,7 @@ export function entryTests(host, options = {}) {
|
|
|
46
46
|
expect(entries, "a reload needs no entry").toEqual([]);
|
|
47
47
|
});
|
|
48
48
|
define("served.entry.signed-out", "signed out, a clean address goes to the app's own sign-in, then back to the content", async ({ page }) => {
|
|
49
|
-
const url = host.url("entry-signed-out");
|
|
49
|
+
const url = await host.url("entry-signed-out");
|
|
50
50
|
await page.goto(url);
|
|
51
51
|
expect(await host.atSignIn(page), "the app's sign-in").toBe(true);
|
|
52
52
|
expect(await shows(page)).toBe(false);
|
|
@@ -56,15 +56,30 @@ export function entryTests(host, options = {}) {
|
|
|
56
56
|
});
|
|
57
57
|
define("served.entry.denied", "a viewer without access gets the app's refusal, never the content", async ({ page }) => {
|
|
58
58
|
await host.signIn(page);
|
|
59
|
-
const url = host.denied("entry-denied");
|
|
59
|
+
const url = await host.denied("entry-denied");
|
|
60
60
|
const answer = await page.goto(url);
|
|
61
61
|
expect(answer?.status()).toBe(403);
|
|
62
62
|
expect(await shows(page)).toBe(false);
|
|
63
63
|
expect(new URL(page.url()).origin, "the refusal is the app's").not.toBe(new URL(url).origin);
|
|
64
64
|
});
|
|
65
|
+
define("served.entry.unknown", "an address that names nothing is a 404 on the content domain: never a trip to the app's sign-in", async ({ page }, info) => {
|
|
66
|
+
info.skip(host.unknown === undefined, "the host gave no addresses that name nothing (unknown)");
|
|
67
|
+
const request = page.context().request;
|
|
68
|
+
for (const url of (await host.unknown?.()) ?? []) {
|
|
69
|
+
const answer = await request.get(url, {
|
|
70
|
+
maxRedirects: 0,
|
|
71
|
+
headers: { "sec-fetch-dest": "document" },
|
|
72
|
+
});
|
|
73
|
+
expect(answer.status(), url).toBe(404);
|
|
74
|
+
expect(answer.headers().location, url).toBeUndefined();
|
|
75
|
+
const opened = await page.goto(url);
|
|
76
|
+
expect(opened?.status(), url).toBe(404);
|
|
77
|
+
expect(new URL(page.url()).origin, url).toBe(new URL(url).origin);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
65
80
|
define("served.entry.code-once", "an entry code works once: replayed, it opens nothing", async ({ page }) => {
|
|
66
81
|
await host.signIn(page);
|
|
67
|
-
const code = await codeFor(page, host.url("entry-once"));
|
|
82
|
+
const code = await codeFor(page, await host.url("entry-once"));
|
|
68
83
|
const request = page.context().request;
|
|
69
84
|
const top = { "sec-fetch-dest": "document" };
|
|
70
85
|
const first = await request.get(code, { maxRedirects: 0, headers: top });
|
|
@@ -75,8 +90,8 @@ export function entryTests(host, options = {}) {
|
|
|
75
90
|
});
|
|
76
91
|
define("served.entry.code-elsewhere", "an entry code taken to another instance's origin opens nothing there", async ({ page }) => {
|
|
77
92
|
await host.signIn(page);
|
|
78
|
-
const code = new URL(await codeFor(page, host.url("entry-mine")));
|
|
79
|
-
const other = new URL(host.url("entry-theirs"));
|
|
93
|
+
const code = new URL(await codeFor(page, await host.url("entry-mine")));
|
|
94
|
+
const other = new URL(await host.url("entry-theirs"));
|
|
80
95
|
const request = page.context().request;
|
|
81
96
|
const answer = await request.get(`${other.origin}${code.pathname}${code.search}`, {
|
|
82
97
|
maxRedirects: 0,
|
|
@@ -87,7 +102,7 @@ export function entryTests(host, options = {}) {
|
|
|
87
102
|
});
|
|
88
103
|
define("served.entry.framed", "a clean address in a frame shows the content where the browser keeps the cookies, else asks for a tab: never the app's sign-in", async ({ page, browserName }, info) => {
|
|
89
104
|
await host.signIn(page);
|
|
90
|
-
const shown = await host.frame(page, host.url("entry-framed"));
|
|
105
|
+
const shown = await host.frame(page, await host.url("entry-framed"));
|
|
91
106
|
const outcome = await shown.outcome();
|
|
92
107
|
const frame = await shown.frame();
|
|
93
108
|
expect(await host.atSignIn(page)).toBe(false);
|
|
@@ -110,7 +125,7 @@ export function entryTests(host, options = {}) {
|
|
|
110
125
|
await host.signIn(page);
|
|
111
126
|
// The app's page stays; the browser forgets every sign-in.
|
|
112
127
|
await page.context().clearCookies();
|
|
113
|
-
const shown = await host.frame(page, host.url("entry-framed-out"));
|
|
128
|
+
const shown = await host.frame(page, await host.url("entry-framed-out"));
|
|
114
129
|
expect(await shown.outcome()).toMatchObject({ state: "blocked", reason: "sign-in" });
|
|
115
130
|
const frame = await shown.frame();
|
|
116
131
|
await expect(frame.locator("h1")).toHaveText("Open this in a new tab to sign in");
|
|
@@ -50,14 +50,19 @@ export interface HostConformanceOptions {
|
|
|
50
50
|
* signed in and out, and in a frame.
|
|
51
51
|
*/
|
|
52
52
|
readonly entry?: {
|
|
53
|
-
/**
|
|
54
|
-
|
|
53
|
+
/**
|
|
54
|
+
* The clean address of `instances.artifact(scope, "1")` (`ContentHost.urlOf`); may publish it
|
|
55
|
+
* on demand.
|
|
56
|
+
*/
|
|
57
|
+
readonly url: (scope: string) => string | Promise<string>;
|
|
55
58
|
/** A clean address the signed-in viewer may not open. */
|
|
56
|
-
readonly denied: (scope: string) => string
|
|
59
|
+
readonly denied: (scope: string) => string | Promise<string>;
|
|
57
60
|
/** Whether `page` shows the app's own sign-in. */
|
|
58
61
|
readonly atSignIn: (page: Page) => Promise<boolean>;
|
|
59
62
|
/** On the app's sign-in page: signs in, as a person does. */
|
|
60
63
|
readonly completeSignIn: (page: Page) => Promise<void>;
|
|
64
|
+
/** Addresses on the content domain that name nothing: each a 404 there (P070). */
|
|
65
|
+
readonly unknown?: () => readonly string[] | Promise<readonly string[]>;
|
|
61
66
|
};
|
|
62
67
|
/** Case ids to skip, each with its reason (shown in the report). */
|
|
63
68
|
readonly skip?: Readonly<Record<string, string>>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { runInThisContext } from "node:vm";
|
|
3
4
|
import { expect } from "@playwright/test";
|
|
4
5
|
import { entryTests } from "./entry.js";
|
|
5
6
|
import { leakTests } from "./probes.js";
|
|
@@ -224,9 +225,10 @@ export function hostConformance(options) {
|
|
|
224
225
|
denied: given.denied,
|
|
225
226
|
atSignIn: given.atSignIn,
|
|
226
227
|
completeSignIn: given.completeSignIn,
|
|
228
|
+
...(given.unknown === undefined ? {} : { unknown: given.unknown }),
|
|
227
229
|
async frame(page, url) {
|
|
228
230
|
await ready(page);
|
|
229
|
-
const instance = served.artifact("entry", "1");
|
|
231
|
+
const instance = await served.artifact("entry", "1");
|
|
230
232
|
const id = await invoke(page, "show", options.config(instance), instance, {
|
|
231
233
|
location: { url, origin: new URL(url).origin },
|
|
232
234
|
});
|
|
@@ -310,32 +312,65 @@ export function defineHostConformance(test, options) {
|
|
|
310
312
|
if (options.instances.proxy === undefined) {
|
|
311
313
|
absent(PROXY_CASES, "the app serves no MCP Apps views");
|
|
312
314
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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) => {
|
|
315
|
+
// Registered as the app's own spec file's (P070): Playwright locates a test where `test()` is
|
|
316
|
+
// called, so `playwright test <spec>` and `<spec>:<line>` select these.
|
|
317
|
+
const at = callerOf(defineHostConformance);
|
|
318
|
+
const describeOf = Reflect.get(test, "describe");
|
|
319
|
+
const describe = calledFrom(describeOf, at);
|
|
320
|
+
const register = calledFrom(typed, at);
|
|
321
|
+
const suite = (title, tests) => {
|
|
322
|
+
describe(title, () => {
|
|
323
|
+
for (const each of tests) {
|
|
324
|
+
// Playwright reads the fixtures a test uses from its parameter's destructuring.
|
|
325
|
+
register(each.title, async ({ page, browserName }, info) => {
|
|
335
326
|
await hosts.begin(page);
|
|
336
327
|
await each.run({ page, browserName }, info);
|
|
337
328
|
});
|
|
338
329
|
}
|
|
339
330
|
});
|
|
331
|
+
};
|
|
332
|
+
suite(`Porthole leak suite (${options.name})`, leakTests(hosts.leak, { skip }));
|
|
333
|
+
suite(`Porthole served modes (${options.name})`, servedTests(hosts.served, { skip }));
|
|
334
|
+
if (hosts.entry !== null) {
|
|
335
|
+
suite(`Porthole redirect entry (${options.name})`, entryTests(hosts.entry, { skip }));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/** Where a function was called from: the caller's file and line, or null. */
|
|
339
|
+
function callerOf(callee) {
|
|
340
|
+
const holder = {};
|
|
341
|
+
Error.captureStackTrace(holder, callee);
|
|
342
|
+
// The first frame is the caller's: " at file:///…/app.spec.ts:16:1" or " at fn (/…/app.spec.ts:16:1)".
|
|
343
|
+
const frame = (holder.stack ?? "")
|
|
344
|
+
.split("\n")
|
|
345
|
+
.map((line) => line.trim())
|
|
346
|
+
.find((line) => line.startsWith("at "));
|
|
347
|
+
const where = (frame ?? "").slice(3).replace(/^.*\((.*)\)$/u, "$1");
|
|
348
|
+
const found = /^(.+):(\d+):\d+$/u.exec(where);
|
|
349
|
+
if (found?.[1] === undefined || found[2] === undefined) {
|
|
350
|
+
return null;
|
|
340
351
|
}
|
|
352
|
+
try {
|
|
353
|
+
return {
|
|
354
|
+
file: found[1].startsWith("file://") ? fileURLToPath(found[1]) : found[1],
|
|
355
|
+
line: Number(found[2]),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* `register` (Playwright's `test` or `test.describe`), called from a function compiled as the
|
|
364
|
+
* caller's file and line (`vm`'s `filename` and `lineOffset`), so the tests it makes are located
|
|
365
|
+
* there. Without a caller, `register` itself.
|
|
366
|
+
*/
|
|
367
|
+
function calledFrom(register, at) {
|
|
368
|
+
if (at === null) {
|
|
369
|
+
return (title, body) => register(title, body);
|
|
370
|
+
}
|
|
371
|
+
const call = runInThisContext("(register, title, body) => register(title, body)", {
|
|
372
|
+
filename: at.file,
|
|
373
|
+
lineOffset: Math.max(0, at.line - 1),
|
|
374
|
+
});
|
|
375
|
+
return (title, body) => call(register, title, body);
|
|
341
376
|
}
|
|
@@ -53,20 +53,20 @@ export interface ServedConformanceHost {
|
|
|
53
53
|
/** How many requests reached `canary(name)`. */
|
|
54
54
|
canaryHits(name: string): Promise<number>;
|
|
55
55
|
/** A served shell (domain isolation) for `scope`. */
|
|
56
|
-
shell(scope: string): InstanceRef
|
|
56
|
+
shell(scope: string): InstanceRef | Promise<InstanceRef>;
|
|
57
57
|
/**
|
|
58
58
|
* A url-mode app for `scope`: its page loads a script from its own origin that sets
|
|
59
59
|
* `document.documentElement.dataset.app = "ready"`.
|
|
60
60
|
*/
|
|
61
|
-
app(scope: string): InstanceRef
|
|
61
|
+
app(scope: string): InstanceRef | Promise<InstanceRef>;
|
|
62
62
|
/**
|
|
63
63
|
* A stored artifact page (domain isolation) for `scope`: version `"1"` and `"2"` of one page,
|
|
64
64
|
* each with `<main id="artifact">version N</main>` and a script that records
|
|
65
65
|
* `document.documentElement.dataset.rtc = typeof RTCPeerConnection`.
|
|
66
66
|
*/
|
|
67
|
-
artifact(scope: string, version: "1" | "2"): InstanceRef
|
|
67
|
+
artifact(scope: string, version: "1" | "2"): InstanceRef | Promise<InstanceRef>;
|
|
68
68
|
/** The MCP Apps sandbox proxy (domain isolation) for `scope`, opening the view's `egress`. */
|
|
69
|
-
proxy(scope: string, egress?: EgressConfig): InstanceRef
|
|
69
|
+
proxy(scope: string, egress?: EgressConfig): InstanceRef | Promise<InstanceRef>;
|
|
70
70
|
}
|
|
71
71
|
export interface ServedSuiteOptions {
|
|
72
72
|
/** Case ids to skip, each with the reason (shown in the report). */
|
|
@@ -112,7 +112,7 @@ export function servedTests(host, options = {}) {
|
|
|
112
112
|
["url", (scope) => host.app(scope)],
|
|
113
113
|
];
|
|
114
114
|
// A served shell draws (and has a height to click in) once its host gives it content.
|
|
115
|
-
const showMode = (page, mode, instance) => host.show(page, instance, mode === "domain" ? { content: SHELL_CONTENT } : {});
|
|
115
|
+
const showMode = async (page, mode, instance) => host.show(page, await instance, mode === "domain" ? { content: SHELL_CONTENT } : {});
|
|
116
116
|
for (const [mode, instance] of modes) {
|
|
117
117
|
define(`served.${mode}.origin`, "content runs on its own origin, never the host's", async ({ page }) => {
|
|
118
118
|
const display = await showMode(page, mode, instance(`${mode}-origin`));
|
|
@@ -190,7 +190,9 @@ export function servedTests(host, options = {}) {
|
|
|
190
190
|
});
|
|
191
191
|
}
|
|
192
192
|
define("served.domain.address", "a served frame's address is its own, carrying at most Porthole's ticket: never the app's", async ({ page }) => {
|
|
193
|
-
const display = await host.show(page, host.shell("address"), {
|
|
193
|
+
const display = await host.show(page, await host.shell("address"), {
|
|
194
|
+
content: SHELL_CONTENT,
|
|
195
|
+
});
|
|
194
196
|
const frame = await display.frame();
|
|
195
197
|
const seen = await frame.evaluate(() => ({
|
|
196
198
|
origin: location.origin,
|
|
@@ -203,19 +205,25 @@ export function servedTests(host, options = {}) {
|
|
|
203
205
|
expect(seen.origin).not.toBe(new URL(page.url()).origin);
|
|
204
206
|
});
|
|
205
207
|
define("served.domain.storage-scope", "storage persists within a scope and is never shared across scopes", async ({ page }) => {
|
|
206
|
-
const first = await host.show(page, host.shell("scope-a"), {
|
|
208
|
+
const first = await host.show(page, await host.shell("scope-a"), {
|
|
209
|
+
content: SHELL_CONTENT,
|
|
210
|
+
});
|
|
207
211
|
await (await first.frame()).evaluate(() => localStorage.setItem("kept", "a"));
|
|
208
212
|
await first.destroy();
|
|
209
|
-
const again = await host.show(page, host.shell("scope-a"), {
|
|
213
|
+
const again = await host.show(page, await host.shell("scope-a"), {
|
|
214
|
+
content: SHELL_CONTENT,
|
|
215
|
+
});
|
|
210
216
|
expect(await (await again.frame()).evaluate(() => localStorage.getItem("kept"))).toBe("a");
|
|
211
217
|
expect(again.origin).toBe(first.origin);
|
|
212
|
-
const other = await host.show(page, host.shell("scope-b"), {
|
|
218
|
+
const other = await host.show(page, await host.shell("scope-b"), {
|
|
219
|
+
content: SHELL_CONTENT,
|
|
220
|
+
});
|
|
213
221
|
expect(other.origin).not.toBe(first.origin);
|
|
214
222
|
expect(await (await other.frame()).evaluate(() => localStorage.getItem("kept"))).toBeNull();
|
|
215
223
|
});
|
|
216
224
|
define("served.artifact.versions", "each version of a page shows as stored, on its scope's origin, hardened before it runs", async ({ page }) => {
|
|
217
|
-
const one = await host.show(page, host.artifact("artifact-scope", "1"));
|
|
218
|
-
const two = await host.show(page, host.artifact("artifact-scope", "2"));
|
|
225
|
+
const one = await host.show(page, await host.artifact("artifact-scope", "1"));
|
|
226
|
+
const two = await host.show(page, await host.artifact("artifact-scope", "2"));
|
|
219
227
|
expect(two.origin).toBe(one.origin);
|
|
220
228
|
for (const [display, version] of [
|
|
221
229
|
[one, "1"],
|
|
@@ -227,7 +235,7 @@ export function servedTests(host, options = {}) {
|
|
|
227
235
|
}
|
|
228
236
|
});
|
|
229
237
|
define("served.domain.ticket-once", "a ticket opens its content once; without it there is nothing", async ({ page, browserName }) => {
|
|
230
|
-
const location = await host.locate(host.shell("ticket-once"));
|
|
238
|
+
const location = await host.locate(await host.shell("ticket-once"));
|
|
231
239
|
const first = await page.goto(location.url);
|
|
232
240
|
expect(first?.status()).toBe(200);
|
|
233
241
|
// A fresh browser state: no cookie, only the used ticket.
|
|
@@ -243,7 +251,7 @@ export function servedTests(host, options = {}) {
|
|
|
243
251
|
}
|
|
244
252
|
});
|
|
245
253
|
define("served.domain.frame-ancestors", "only the host's origins may frame content", async ({ page }) => {
|
|
246
|
-
const location = await host.locate(host.shell("foreign"));
|
|
254
|
+
const location = await host.locate(await host.shell("foreign"));
|
|
247
255
|
await host.frameFromForeignOrigin(page, location.url);
|
|
248
256
|
await page.waitForTimeout(SETTLE_MS);
|
|
249
257
|
for (const frame of page.frames().slice(1)) {
|
|
@@ -264,7 +272,9 @@ export function servedTests(host, options = {}) {
|
|
|
264
272
|
}
|
|
265
273
|
});
|
|
266
274
|
define("served.domain.egress", "the CSP in the document's headers closes egress; only what the config opens to any https site (images, media) reaches it", async ({ page }) => {
|
|
267
|
-
const display = await host.show(page, host.shell("egress"), {
|
|
275
|
+
const display = await host.show(page, await host.shell("egress"), {
|
|
276
|
+
content: SHELL_CONTENT,
|
|
277
|
+
});
|
|
268
278
|
const frame = await display.frame();
|
|
269
279
|
const kinds = ["fetch", "img", "media", "script", "style", "frame"];
|
|
270
280
|
const urls = Object.fromEntries(await Promise.all(kinds.map(async (kind) => [kind, await host.canary(`egress-${kind}`)])));
|
|
@@ -343,7 +353,7 @@ export function servedTests(host, options = {}) {
|
|
|
343
353
|
.poll(async () => methodsOf(await display.received()))
|
|
344
354
|
.toContain(MCP_APPS_METHODS.sandboxProxyReady);
|
|
345
355
|
define("served.proxy.view", "the sandbox proxy becomes the view, hardened, talking to its host directly", async ({ page }) => {
|
|
346
|
-
const display = await host.show(page, host.proxy("proxy-view"));
|
|
356
|
+
const display = await host.show(page, await host.proxy("proxy-view"));
|
|
347
357
|
await proxyReady(display);
|
|
348
358
|
const canary = await host.canary("proxy-undeclared");
|
|
349
359
|
await display.post(resourceReady(PROBE_VIEW(canary)));
|
|
@@ -357,7 +367,7 @@ export function servedTests(host, options = {}) {
|
|
|
357
367
|
expect(await host.canaryHits("proxy-undeclared")).toBe(0);
|
|
358
368
|
});
|
|
359
369
|
define("served.proxy.parent-only", "the sandbox proxy takes a view only from its host", async ({ page }) => {
|
|
360
|
-
const display = await host.show(page, host.proxy("proxy-parent"));
|
|
370
|
+
const display = await host.show(page, await host.proxy("proxy-parent"));
|
|
361
371
|
await proxyReady(display);
|
|
362
372
|
const frame = await display.frame();
|
|
363
373
|
await frame.evaluate((message) => window.postMessage(message, "*"), resourceReady('<p id="view">Not from the host.</p>'));
|
|
@@ -366,7 +376,7 @@ export function servedTests(host, options = {}) {
|
|
|
366
376
|
});
|
|
367
377
|
define("served.proxy.egress", "the sandbox proxy opens what the view declares, and nothing else", async ({ page }) => {
|
|
368
378
|
const canary = await host.canary("proxy-declared");
|
|
369
|
-
const display = await host.show(page, host.proxy("proxy-egress", { connect: [new URL(canary).origin] }));
|
|
379
|
+
const display = await host.show(page, await host.proxy("proxy-egress", { connect: [new URL(canary).origin] }));
|
|
370
380
|
await proxyReady(display);
|
|
371
381
|
await display.post(resourceReady(PROBE_VIEW(canary)));
|
|
372
382
|
const frame = await display.frame();
|
|
@@ -374,7 +384,7 @@ export function servedTests(host, options = {}) {
|
|
|
374
384
|
expect(await host.canaryHits("proxy-declared")).toBe(1);
|
|
375
385
|
});
|
|
376
386
|
define("served.url.ticket", "an app shows nothing to a browser without its ticket's cookie", async ({ page }) => {
|
|
377
|
-
const location = await host.locate(host.app("url-ticket"));
|
|
387
|
+
const location = await host.locate(await host.app("url-ticket"));
|
|
378
388
|
// A fresh browser: no cookie of the content's, none of the app's. Porthole refuses it, or
|
|
379
389
|
// (redirect entry, P068) hands it to the app's authorize route, which asks it to sign in.
|
|
380
390
|
const fresh = await page.context().browser().newContext();
|
|
@@ -390,7 +400,7 @@ export function servedTests(host, options = {}) {
|
|
|
390
400
|
}
|
|
391
401
|
});
|
|
392
402
|
define("served.url.tab", "an app opened on a tab of its own runs, its requests riding on its cookie", async ({ page }) => {
|
|
393
|
-
const location = await host.locate(host.app("url-tab"));
|
|
403
|
+
const location = await host.locate(await host.app("url-tab"));
|
|
394
404
|
await page.goto(location.url);
|
|
395
405
|
await expect(page.locator("html")).toHaveAttribute("data-app", "ready");
|
|
396
406
|
});
|
|
@@ -398,7 +408,7 @@ export function servedTests(host, options = {}) {
|
|
|
398
408
|
// Measured (P025): WebKit keeps no cookie in a cross-site frame, partitioned or not, so a
|
|
399
409
|
// framed app's subresources are refused there; such hosts open the app on a tab.
|
|
400
410
|
typed.fail(browserName === "webkit", "WebKit keeps no cookie in a cross-site frame");
|
|
401
|
-
const display = await host.show(page, host.app("url-framed"));
|
|
411
|
+
const display = await host.show(page, await host.app("url-framed"));
|
|
402
412
|
const frame = await display.frame();
|
|
403
413
|
await expect
|
|
404
414
|
.poll(() => frame.evaluate(() => document.documentElement.dataset.app ?? null), {
|
package/dist/node/index.d.ts
CHANGED
|
@@ -27,9 +27,17 @@ export interface NodeHost {
|
|
|
27
27
|
readonly request: RequestListener;
|
|
28
28
|
readonly upgrade: UpgradeListener;
|
|
29
29
|
}
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
/**
|
|
31
|
+
* The Node request as a fetch `Request`: an absolute-form target kept as it came (P056). `signal`
|
|
32
|
+
* (`nodeHost` gives one, aborted when the client goes away) lets an upstream stop its work.
|
|
33
|
+
*/
|
|
34
|
+
export declare function toRequest(request: IncomingMessage, scheme: "https" | "http", signal?: AbortSignal): Request;
|
|
35
|
+
/**
|
|
36
|
+
* Writes a fetch `Response` to a Node response, cookies one header each (P070): it waits for the
|
|
37
|
+
* socket to drain before reading on (a slow client never makes it buffer the body), cancels the
|
|
38
|
+
* body when the client goes away (an event stream ends with the page that asked for it), and on
|
|
39
|
+
* an error mid-body destroys the response rather than end it looking complete.
|
|
40
|
+
*/
|
|
33
41
|
export declare function sendResponse(response: ServerResponse, answer: Response): Promise<void>;
|
|
34
42
|
export declare function nodeHost<V>(content: ContentHost<V>, app: NodeHostOptions): NodeHost;
|
|
35
43
|
/**
|
package/dist/node/index.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { request as httpRequest } from "node:http";
|
|
2
2
|
import { request as httpsRequest } from "node:https";
|
|
3
3
|
import { Readable } from "node:stream";
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* The Node request as a fetch `Request`: an absolute-form target kept as it came (P056). `signal`
|
|
6
|
+
* (`nodeHost` gives one, aborted when the client goes away) lets an upstream stop its work.
|
|
7
|
+
*/
|
|
8
|
+
export function toRequest(request, scheme, signal) {
|
|
6
9
|
const headers = new Headers();
|
|
7
10
|
for (const [name, value] of Object.entries(request.headers)) {
|
|
8
11
|
for (const each of Array.isArray(value) ? value : value === undefined ? [] : [value]) {
|
|
@@ -15,9 +18,20 @@ export function toRequest(request, scheme) {
|
|
|
15
18
|
: target;
|
|
16
19
|
const method = request.method ?? "GET";
|
|
17
20
|
const body = method === "GET" || method === "HEAD" ? null : Readable.toWeb(request);
|
|
18
|
-
return new Request(url, {
|
|
21
|
+
return new Request(url, {
|
|
22
|
+
method,
|
|
23
|
+
headers,
|
|
24
|
+
body,
|
|
25
|
+
duplex: "half",
|
|
26
|
+
...(signal === undefined ? {} : { signal }),
|
|
27
|
+
});
|
|
19
28
|
}
|
|
20
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Writes a fetch `Response` to a Node response, cookies one header each (P070): it waits for the
|
|
31
|
+
* socket to drain before reading on (a slow client never makes it buffer the body), cancels the
|
|
32
|
+
* body when the client goes away (an event stream ends with the page that asked for it), and on
|
|
33
|
+
* an error mid-body destroys the response rather than end it looking complete.
|
|
34
|
+
*/
|
|
21
35
|
export async function sendResponse(response, answer) {
|
|
22
36
|
const headers = {};
|
|
23
37
|
for (const [name, value] of answer.headers) {
|
|
@@ -34,10 +48,46 @@ export async function sendResponse(response, answer) {
|
|
|
34
48
|
response.end();
|
|
35
49
|
return;
|
|
36
50
|
}
|
|
37
|
-
|
|
38
|
-
|
|
51
|
+
const reader = answer.body.getReader();
|
|
52
|
+
let gone = false;
|
|
53
|
+
const leave = () => {
|
|
54
|
+
gone = true;
|
|
55
|
+
reader.cancel().catch(() => undefined);
|
|
56
|
+
};
|
|
57
|
+
// The client went away before the end: stop reading, and let the body's source know.
|
|
58
|
+
response.once("close", () => {
|
|
59
|
+
if (!response.writableFinished) {
|
|
60
|
+
leave();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
try {
|
|
64
|
+
for (;;) {
|
|
65
|
+
const { done, value } = await reader.read();
|
|
66
|
+
if (done || gone) {
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
if (!response.write(value)) {
|
|
70
|
+
await new Promise((resolve) => {
|
|
71
|
+
const on = () => {
|
|
72
|
+
response.off("drain", on);
|
|
73
|
+
response.off("close", on);
|
|
74
|
+
resolve();
|
|
75
|
+
};
|
|
76
|
+
response.on("drain", on);
|
|
77
|
+
response.on("close", on);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
// The head is sent: a body cut short must not look whole.
|
|
84
|
+
leave();
|
|
85
|
+
response.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (!gone) {
|
|
89
|
+
response.end();
|
|
39
90
|
}
|
|
40
|
-
response.end();
|
|
41
91
|
}
|
|
42
92
|
/** Whether a Node request's Host (or absolute-form target) is under the content domain. */
|
|
43
93
|
function forContent(content, request) {
|
|
@@ -67,7 +117,15 @@ export function nodeHost(content, app) {
|
|
|
67
117
|
app.request(request, response);
|
|
68
118
|
return;
|
|
69
119
|
}
|
|
70
|
-
|
|
120
|
+
// Aborted when the client goes away first, so an upstream (a proxied app's event stream)
|
|
121
|
+
// stops with it.
|
|
122
|
+
const gone = new AbortController();
|
|
123
|
+
response.once("close", () => {
|
|
124
|
+
if (!response.writableFinished) {
|
|
125
|
+
gone.abort();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
content.fetch(toRequest(request, scheme(), gone.signal)).then((answer) => {
|
|
71
129
|
if (answer === null) {
|
|
72
130
|
// A label the app routes itself.
|
|
73
131
|
app.request(request, response);
|
|
@@ -75,9 +133,11 @@ export function nodeHost(content, app) {
|
|
|
75
133
|
}
|
|
76
134
|
return sendResponse(response, answer);
|
|
77
135
|
}, () => {
|
|
78
|
-
if (
|
|
79
|
-
response.
|
|
136
|
+
if (response.headersSent) {
|
|
137
|
+
response.destroy();
|
|
138
|
+
return;
|
|
80
139
|
}
|
|
140
|
+
response.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
|
|
81
141
|
response.end("Content couldn't be served.");
|
|
82
142
|
});
|
|
83
143
|
},
|
package/dist/server/host.d.ts
CHANGED
|
@@ -21,6 +21,12 @@ export interface HealthOptions {
|
|
|
21
21
|
/** How the check is fetched (default: `fetch`; on Node, `nodeProbe()`). */
|
|
22
22
|
readonly probe?: HealthProbe;
|
|
23
23
|
readonly timeoutMs?: number;
|
|
24
|
+
/**
|
|
25
|
+
* Told each time the gate's state changes (P070): checking to healthy or unhealthy, and every
|
|
26
|
+
* flip between them, with the new view. `meta().served` changes with it, so an app that tells
|
|
27
|
+
* its clients whether frames are served pushes it from here.
|
|
28
|
+
*/
|
|
29
|
+
readonly onChange?: (view: ContentHealthView) => void;
|
|
24
30
|
}
|
|
25
31
|
/** Where content stands, for the app's own meta and logs. */
|
|
26
32
|
export interface ContentHealthView {
|
|
@@ -64,7 +70,13 @@ export interface ContentHostOptions<V> {
|
|
|
64
70
|
* that says which scope a label names (`prefixedName` does).
|
|
65
71
|
*/
|
|
66
72
|
readonly name?: Naming;
|
|
67
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* Labels the app itself serves on the content domain, which `fetch` hands back (null) so the
|
|
75
|
+
* app's own routes answer there: Ferry's `p-*` previews, another server's namespace it routes
|
|
76
|
+
* on. Not for labels nothing should answer: a label the naming never makes, the bare domain
|
|
77
|
+
* and an address `entry.at` says names nothing all get Porthole's 404, with nothing to list
|
|
78
|
+
* (P070). The health label is reserved by the gate itself.
|
|
79
|
+
*/
|
|
68
80
|
readonly reserved?: readonly string[] | ((label: string) => boolean);
|
|
69
81
|
/**
|
|
70
82
|
* Policy 1, mapping: the instance a request proposes (`{ instance }`), as the app means it,
|
|
@@ -105,17 +117,39 @@ export interface EntryOptions<V> {
|
|
|
105
117
|
/** The app's authorize route (`ContentHost.authorize`), an absolute URL on an app origin. */
|
|
106
118
|
readonly authorizeUrl: string;
|
|
107
119
|
/**
|
|
108
|
-
* Policy 1 for a clean address: the instance a scope's path means, or null
|
|
109
|
-
* the address's pathname; its query rides along but never chooses the
|
|
120
|
+
* Policy 1 for a clean address: the instance a scope's path means, or null/undefined: nothing
|
|
121
|
+
* (404). The path is the address's pathname; its query rides along but never chooses the
|
|
122
|
+
* instance. Asked twice: on the content origin, with no viewer, whether the address names
|
|
123
|
+
* anything at all (nothing: Porthole's 404 there, never the app's sign-in, P070); then at the
|
|
124
|
+
* authorize route with the viewer. Say what exists the same way for both; `access` says who
|
|
125
|
+
* may see it.
|
|
110
126
|
*/
|
|
111
|
-
readonly at: (scope: string, path: string, viewer: V | null, request: Request) => Awaitable<InstanceRef | null>;
|
|
127
|
+
readonly at: (scope: string, path: string, viewer: V | null, request: Request) => Awaitable<InstanceRef | null | undefined>;
|
|
112
128
|
/**
|
|
113
129
|
* The app's own sign-in, which comes back to `returnTo` (the authorize URL) once signed in.
|
|
114
|
-
* Only ever on a tab: in a frame, the person is asked to open a new tab instead.
|
|
130
|
+
* Only ever on a tab: in a frame, the person is asked to open a new tab instead. `entry` is the
|
|
131
|
+
* address being opened, decoded (P070).
|
|
115
132
|
*/
|
|
116
|
-
readonly signIn: (returnTo: string, request: Request) => Awaitable<Response>;
|
|
117
|
-
/**
|
|
118
|
-
|
|
133
|
+
readonly signIn: (returnTo: string, request: Request, entry: EntryContext) => Awaitable<Response>;
|
|
134
|
+
/**
|
|
135
|
+
* The app's refusal page on a tab (a frame gets Porthole's plain one), for the address in
|
|
136
|
+
* `entry`. Nothing (null, undefined): Porthole's own (P070).
|
|
137
|
+
*/
|
|
138
|
+
readonly refusal?: (status: 403 | 404, request: Request, entry: EntryContext) => Awaitable<Response | null | undefined>;
|
|
139
|
+
}
|
|
140
|
+
/** The clean address a redirect entry is opening, as its authorize route decoded it (P070). */
|
|
141
|
+
export interface EntryContext {
|
|
142
|
+
/** The address's label, and the scope it names (null: one the naming never makes). */
|
|
143
|
+
readonly label: string;
|
|
144
|
+
readonly scope: string | null;
|
|
145
|
+
/** The path (and query) asked for. */
|
|
146
|
+
readonly path: string;
|
|
147
|
+
/** The clean address itself: where the person comes back to. */
|
|
148
|
+
readonly url: string;
|
|
149
|
+
/** The instance the path names for this viewer (`entry.at`), or null. */
|
|
150
|
+
readonly instance: InstanceRef | null;
|
|
151
|
+
/** Whether it was asked from a frame. */
|
|
152
|
+
readonly framed: boolean;
|
|
119
153
|
}
|
|
120
154
|
export interface ContentHost<V> {
|
|
121
155
|
/** The parsed content domain, or null (off, or unusable settings under the fallback). */
|