@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.
Files changed (55) hide show
  1. package/dist/conformance/clipboard.js +5 -2
  2. package/dist/conformance/entry.d.ts +37 -0
  3. package/dist/conformance/entry.js +120 -0
  4. package/dist/conformance/harness/page.d.ts +32 -0
  5. package/dist/conformance/harness/page.js +215 -0
  6. package/dist/conformance/harness.js +1662 -0
  7. package/dist/conformance/host-kit.d.ts +85 -0
  8. package/dist/conformance/host-kit.js +341 -0
  9. package/dist/conformance/index.d.ts +2 -0
  10. package/dist/conformance/index.js +4 -0
  11. package/dist/conformance/served.js +13 -3
  12. package/dist/host/locator.d.ts +39 -0
  13. package/dist/host/locator.js +72 -0
  14. package/dist/host/outcome.js +1 -0
  15. package/dist/host/types.d.ts +2 -0
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +1 -0
  18. package/dist/node/index.d.ts +41 -0
  19. package/dist/node/index.js +160 -0
  20. package/dist/platform/web.js +12 -1
  21. package/dist/server/domain.d.ts +43 -0
  22. package/dist/server/domain.js +100 -0
  23. package/dist/server/host.d.ts +158 -0
  24. package/dist/server/host.js +416 -0
  25. package/dist/server/index.d.ts +9 -2
  26. package/dist/server/index.js +7 -0
  27. package/dist/server/naming.d.ts +12 -1
  28. package/dist/server/naming.js +9 -8
  29. package/dist/server/pages.d.ts +9 -0
  30. package/dist/server/pages.js +32 -0
  31. package/dist/server/server.js +287 -50
  32. package/dist/server/types.d.ts +37 -0
  33. package/dist/transport/post-message.d.ts +9 -1
  34. package/dist/transport/post-message.js +11 -2
  35. package/package.json +6 -2
  36. package/src/conformance/clipboard.ts +5 -2
  37. package/src/conformance/entry.ts +206 -0
  38. package/src/conformance/harness/page.ts +267 -0
  39. package/src/conformance/host-kit.ts +466 -0
  40. package/src/conformance/index.ts +12 -0
  41. package/src/conformance/served.ts +14 -3
  42. package/src/host/locator.ts +111 -0
  43. package/src/host/outcome.ts +1 -0
  44. package/src/host/types.ts +2 -0
  45. package/src/index.ts +7 -0
  46. package/src/node/index.ts +221 -0
  47. package/src/platform/web.ts +19 -1
  48. package/src/server/domain.ts +158 -0
  49. package/src/server/host.ts +706 -0
  50. package/src/server/index.ts +27 -1
  51. package/src/server/naming.ts +22 -2
  52. package/src/server/pages.ts +44 -0
  53. package/src/server/server.ts +355 -63
  54. package/src/server/types.ts +33 -0
  55. 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, "&amp;").replace(/"/gu, "&quot;");
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
- const bare = await page.goto(`${location.origin}/`);
379
- expect(bare?.status()).toBe(401);
380
- expect(await page.evaluate(() => document.documentElement.dataset.app ?? null)).toBeNull();
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
+ }
@@ -18,6 +18,7 @@ const SETTLE_MS = 400;
18
18
  const BLOCKS = [
19
19
  "framing",
20
20
  "cookies",
21
+ "sign-in",
21
22
  "session",
22
23
  "upstream",
23
24
  "denied",
@@ -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 {};