@botiverse/porthole 0.1.7 → 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 (59) 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/limits.js +16 -1
  12. package/dist/conformance/served.js +13 -3
  13. package/dist/guide/limits.js +9 -1
  14. package/dist/host/locator.d.ts +39 -0
  15. package/dist/host/locator.js +72 -0
  16. package/dist/host/outcome.js +1 -0
  17. package/dist/host/types.d.ts +2 -0
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +1 -0
  20. package/dist/node/index.d.ts +41 -0
  21. package/dist/node/index.js +160 -0
  22. package/dist/platform/web.js +12 -1
  23. package/dist/server/domain.d.ts +43 -0
  24. package/dist/server/domain.js +100 -0
  25. package/dist/server/host.d.ts +158 -0
  26. package/dist/server/host.js +416 -0
  27. package/dist/server/index.d.ts +9 -2
  28. package/dist/server/index.js +7 -0
  29. package/dist/server/naming.d.ts +12 -1
  30. package/dist/server/naming.js +9 -8
  31. package/dist/server/pages.d.ts +9 -0
  32. package/dist/server/pages.js +32 -0
  33. package/dist/server/server.js +287 -50
  34. package/dist/server/types.d.ts +37 -0
  35. package/dist/transport/post-message.d.ts +9 -1
  36. package/dist/transport/post-message.js +11 -2
  37. package/package.json +6 -2
  38. package/src/conformance/clipboard.ts +5 -2
  39. package/src/conformance/entry.ts +206 -0
  40. package/src/conformance/harness/page.ts +267 -0
  41. package/src/conformance/host-kit.ts +466 -0
  42. package/src/conformance/index.ts +12 -0
  43. package/src/conformance/limits.ts +21 -1
  44. package/src/conformance/served.ts +14 -3
  45. package/src/guide/limits.ts +9 -1
  46. package/src/host/locator.ts +111 -0
  47. package/src/host/outcome.ts +1 -0
  48. package/src/host/types.ts +2 -0
  49. package/src/index.ts +7 -0
  50. package/src/node/index.ts +221 -0
  51. package/src/platform/web.ts +19 -1
  52. package/src/server/domain.ts +158 -0
  53. package/src/server/host.ts +706 -0
  54. package/src/server/index.ts +27 -1
  55. package/src/server/naming.ts +22 -2
  56. package/src/server/pages.ts +44 -0
  57. package/src/server/server.ts +355 -63
  58. package/src/server/types.ts +33 -0
  59. package/src/transport/post-message.ts +12 -1
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 {};
@@ -0,0 +1,160 @@
1
+ import { request as httpRequest } from "node:http";
2
+ import { request as httpsRequest } from "node:https";
3
+ import { Readable } from "node:stream";
4
+ /** The Node request as a fetch `Request`: an absolute-form target kept as it came (P056). */
5
+ export function toRequest(request, scheme) {
6
+ const headers = new Headers();
7
+ for (const [name, value] of Object.entries(request.headers)) {
8
+ for (const each of Array.isArray(value) ? value : value === undefined ? [] : [value]) {
9
+ headers.append(name, each);
10
+ }
11
+ }
12
+ const target = request.url ?? "/";
13
+ const url = target.startsWith("/")
14
+ ? `${scheme}://${request.headers.host ?? "localhost"}${target}`
15
+ : target;
16
+ const method = request.method ?? "GET";
17
+ const body = method === "GET" || method === "HEAD" ? null : Readable.toWeb(request);
18
+ return new Request(url, { method, headers, body, duplex: "half" });
19
+ }
20
+ /** Writes a fetch `Response` to a Node response, cookies one header each. */
21
+ export async function sendResponse(response, answer) {
22
+ const headers = {};
23
+ for (const [name, value] of answer.headers) {
24
+ if (name !== "set-cookie") {
25
+ headers[name] = value;
26
+ }
27
+ }
28
+ const cookies = answer.headers.getSetCookie();
29
+ if (cookies.length > 0) {
30
+ headers["set-cookie"] = cookies;
31
+ }
32
+ response.writeHead(answer.status, headers);
33
+ if (answer.body === null) {
34
+ response.end();
35
+ return;
36
+ }
37
+ for await (const chunk of answer.body) {
38
+ response.write(chunk);
39
+ }
40
+ response.end();
41
+ }
42
+ /** Whether a Node request's Host (or absolute-form target) is under the content domain. */
43
+ function forContent(content, request) {
44
+ const domain = content.domain;
45
+ if (domain === null) {
46
+ return false;
47
+ }
48
+ const host = request.headers.host ?? "";
49
+ let target = "";
50
+ try {
51
+ target = (request.url ?? "/").startsWith("/") ? "" : new URL(request.url ?? "").hostname;
52
+ }
53
+ catch {
54
+ target = "";
55
+ }
56
+ return domain.isContentHost(host) || (target !== "" && domain.isContentHost(target));
57
+ }
58
+ /** Refuses a WebSocket on the content domain with a plain 404, and closes it. */
59
+ function refuseSocket(socket) {
60
+ socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
61
+ }
62
+ export function nodeHost(content, app) {
63
+ const scheme = () => content.domain?.scheme ?? "http";
64
+ return {
65
+ request(request, response) {
66
+ if (!forContent(content, request)) {
67
+ app.request(request, response);
68
+ return;
69
+ }
70
+ content.fetch(toRequest(request, scheme())).then((answer) => {
71
+ if (answer === null) {
72
+ // A label the app routes itself.
73
+ app.request(request, response);
74
+ return;
75
+ }
76
+ return sendResponse(response, answer);
77
+ }, () => {
78
+ if (!response.headersSent) {
79
+ response.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
80
+ }
81
+ response.end("Content couldn't be served.");
82
+ });
83
+ },
84
+ upgrade(request, socket, head) {
85
+ if (!forContent(content, request)) {
86
+ if (app.upgrade === undefined) {
87
+ refuseSocket(socket);
88
+ }
89
+ else {
90
+ app.upgrade(request, socket, head);
91
+ }
92
+ return;
93
+ }
94
+ const asked = toRequest(request, scheme());
95
+ // A reserved label's socket is the app's.
96
+ if (!content.owns(asked)) {
97
+ if (app.upgrade === undefined) {
98
+ refuseSocket(socket);
99
+ }
100
+ else {
101
+ app.upgrade(request, socket, head);
102
+ }
103
+ return;
104
+ }
105
+ const appSocket = app.appSocket;
106
+ if (appSocket === undefined) {
107
+ refuseSocket(socket);
108
+ return;
109
+ }
110
+ content.access(asked).then((access) => {
111
+ if (access === null) {
112
+ refuseSocket(socket);
113
+ }
114
+ else {
115
+ appSocket(access, request, socket, head);
116
+ }
117
+ }, () => refuseSocket(socket));
118
+ },
119
+ };
120
+ }
121
+ /** Resolves every `*.localhost` name to loopback, as browsers do (Node's resolver may not). */
122
+ const loopbackLookup = (hostname, options, callback) => {
123
+ const all = typeof options === "object" && options !== null && options.all === true;
124
+ if (all) {
125
+ callback(null, [{ address: "127.0.0.1", family: 4 }]);
126
+ return;
127
+ }
128
+ callback(null, "127.0.0.1", 4);
129
+ };
130
+ /**
131
+ * The health gate's probe on Node: a GET over the real network (DNS, the edge, TLS with Node's
132
+ * own certificate checks), `*.localhost` resolved to loopback, redirects not followed, at most 4 KB
133
+ * of the answer read.
134
+ */
135
+ export function nodeProbe() {
136
+ return (url, timeoutMs) => new Promise((resolve, reject) => {
137
+ const target = new URL(url);
138
+ const local = target.hostname === "localhost" || target.hostname.endsWith(".localhost");
139
+ const send = target.protocol === "https:" ? httpsRequest : httpRequest;
140
+ const asked = send(target, {
141
+ method: "GET",
142
+ headers: { accept: "application/json" },
143
+ timeout: timeoutMs,
144
+ ...(local ? { lookup: loopbackLookup } : {}),
145
+ }, (response) => {
146
+ let body = "";
147
+ response.setEncoding("utf8");
148
+ response.on("data", (chunk) => {
149
+ if (body.length < 4_096) {
150
+ body += chunk;
151
+ }
152
+ });
153
+ response.on("end", () => resolve({ status: response.statusCode ?? 0, body: body.slice(0, 4_096) }));
154
+ response.on("error", reject);
155
+ });
156
+ asked.on("timeout", () => asked.destroy(new Error(`no answer within ${timeoutMs / 1000} s`)));
157
+ asked.on("error", (error) => reject(new Error(error.code === undefined ? error.message : `${error.code} ${error.message}`)));
158
+ asked.end();
159
+ });
160
+ }
@@ -1,4 +1,10 @@
1
+ import { FRAME_STATUS_METHOD } from "../isolation/types.js";
1
2
  import { postMessageTransport } from "../transport/post-message.js";
3
+ /** A frame-status message (`porthole/notifications/frame-status`), by its method alone. */
4
+ const isFrameStatus = (data) => data !== null &&
5
+ typeof data === "object" &&
6
+ data.jsonrpc === "2.0" &&
7
+ data.method === FRAME_STATUS_METHOD;
2
8
  /**
3
9
  * The web platform: an `<iframe>` whose policy attributes are set before its document is given,
4
10
  * talking over `postMessage`. Its first load is the document it was given; any later load is the
@@ -50,7 +56,12 @@ export const webPlatform = {
50
56
  });
51
57
  container.append(frame);
52
58
  const view = container.ownerDocument.defaultView ?? window;
53
- const transport = postMessageTransport(() => frame.contentWindow, origin, view);
59
+ // A served frame may pass through the host's own pages (a redirect entry's, P068): what they
60
+ // say of the frame's status is heard too, and nothing else from there.
61
+ const own = view.origin;
62
+ const transport = postMessageTransport(() => frame.contentWindow, origin, view, origin === "null" || own === "null" || own === origin
63
+ ? undefined
64
+ : { origin: own, accept: isFrameStatus });
54
65
  return {
55
66
  transport,
56
67
  element: frame,
@@ -0,0 +1,43 @@
1
+ import type { ContentDomain } from "./types.ts";
2
+ /**
3
+ * A content domain from deployment settings (P066): `content.example.com`, `content.localhost:8790`
4
+ * or `https://content.example.com`, parsed, and checked against the app's own origins. Porthole
5
+ * never serves a content domain that holds an app origin or may share its site: the host kit
6
+ * either throws at startup or (with its opaque fallback) reports it as permanently unhealthy.
7
+ */
8
+ export interface ContentDomainInfo {
9
+ /** The registrable domain content is served under (`content.example.com`). */
10
+ readonly domain: string;
11
+ /** `http` only on loopback (`localhost`, `*.localhost`). */
12
+ readonly scheme: "https" | "http";
13
+ /** A port other than the scheme's default. */
14
+ readonly port?: number;
15
+ /** The frame source every content origin matches (`https://*.content.example.com`). */
16
+ readonly source: string;
17
+ /** The origin a label is served on. */
18
+ originOf(label: string): string;
19
+ /** Whether a host (or hostname) is under the content domain (the apex included). */
20
+ isContentHost(host: string): boolean;
21
+ /** The part of a hostname under the content domain (`a-1`, `x.f`), or null. */
22
+ labelOf(host: string): string | null;
23
+ }
24
+ export type ContentDomainProblem = "invalid" | "clash" | "same-site";
25
+ /** Why a content domain can't be served; `code` says which rule (P066). */
26
+ export declare class ContentDomainError extends Error {
27
+ readonly code: ContentDomainProblem;
28
+ constructor(code: ContentDomainProblem, message: string);
29
+ }
30
+ /** A hostname as it compares: lowercase, no port, no trailing dot. */
31
+ export declare function hostnameOf(host: string): string;
32
+ /**
33
+ * Parses a content domain. `null` for an empty setting (content off). Throws a
34
+ * `ContentDomainError` for one that isn't a domain, or that holds or may share a site with one of
35
+ * `appOrigins` (the clash rules, named in the message).
36
+ */
37
+ export declare function contentDomain(input: string | null | undefined, options: {
38
+ readonly appOrigins: readonly string[];
39
+ }): ContentDomainInfo | null;
40
+ /** The first clash between a content domain and the app's origins, as an error; null if none. */
41
+ export declare function clashOf(info: ContentDomainInfo, appOrigins: readonly string[]): ContentDomainError | null;
42
+ /** The `ContentDomain` `createServer` takes, from parsed settings. */
43
+ export declare function serverDomain(info: ContentDomainInfo, naming: Pick<ContentDomain, "name" | "reserved">): ContentDomain;
@@ -0,0 +1,100 @@
1
+ import { maybeSameSite, parseOrigin } from "../isolation/served.js";
2
+ /** Why a content domain can't be served; `code` says which rule (P066). */
3
+ export class ContentDomainError extends Error {
4
+ code;
5
+ constructor(code, message) {
6
+ super(message);
7
+ this.name = "ContentDomainError";
8
+ this.code = code;
9
+ }
10
+ }
11
+ const HOST = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/u;
12
+ const LOOPBACK = (host) => host === "localhost" || host.endsWith(".localhost");
13
+ const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/u;
14
+ /** A hostname as it compares: lowercase, no port, no trailing dot. */
15
+ export function hostnameOf(host) {
16
+ const lower = host.trim().toLowerCase();
17
+ const bare = lower.startsWith("[")
18
+ ? lower.replace(/(\])(:\d+)?$/u, "$1")
19
+ : lower.replace(/:\d+$/u, "");
20
+ return bare.replace(/\.$/u, "");
21
+ }
22
+ /**
23
+ * Parses a content domain. `null` for an empty setting (content off). Throws a
24
+ * `ContentDomainError` for one that isn't a domain, or that holds or may share a site with one of
25
+ * `appOrigins` (the clash rules, named in the message).
26
+ */
27
+ export function contentDomain(input, options) {
28
+ const raw = (input ?? "").trim().toLowerCase().replace(/\/+$/u, "");
29
+ if (raw === "") {
30
+ return null;
31
+ }
32
+ const match = /^(?:(https?):\/\/)?([^/:]+)(?::(\d{1,5}))?$/u.exec(raw);
33
+ const host = match?.[2]?.replace(/^\.+|\.+$/gu, "") ?? "";
34
+ if (match === null ||
35
+ !HOST.test(host) ||
36
+ IPV4.test(host) ||
37
+ (!host.includes(".") && host !== "localhost")) {
38
+ throw new ContentDomainError("invalid", `The content domain ${JSON.stringify(input)} isn't a domain: give host[:port] (content.example.com, content.localhost:8790)`);
39
+ }
40
+ const loopback = LOOPBACK(host);
41
+ const scheme = match[1] === undefined ? (loopback ? "http" : "https") : match[1];
42
+ if (scheme === "http" && !loopback) {
43
+ throw new ContentDomainError("invalid", `The content domain ${host} is served over http, which only loopback may use: serve it over https`);
44
+ }
45
+ const portNumber = match[3] === undefined ? undefined : Number(match[3]);
46
+ if (portNumber !== undefined && (portNumber < 1 || portNumber > 65_535)) {
47
+ throw new ContentDomainError("invalid", `The content domain's port ${match[3]} isn't a port`);
48
+ }
49
+ const port = portNumber === (scheme === "https" ? 443 : 80) ? undefined : portNumber;
50
+ const portPart = port === undefined ? "" : `:${port}`;
51
+ const info = {
52
+ domain: host,
53
+ scheme,
54
+ ...(port === undefined ? {} : { port }),
55
+ source: `${scheme}://*.${host}${portPart}`,
56
+ originOf: (label) => `${scheme}://${label}.${host}${portPart}`,
57
+ isContentHost: (each) => {
58
+ const name = hostnameOf(each);
59
+ return name === host || name.endsWith(`.${host}`);
60
+ },
61
+ labelOf: (each) => {
62
+ const name = hostnameOf(each);
63
+ return name.endsWith(`.${host}`) ? name.slice(0, -host.length - 1) : null;
64
+ },
65
+ };
66
+ const problem = clashOf(info, options.appOrigins);
67
+ if (problem !== null) {
68
+ throw problem;
69
+ }
70
+ return info;
71
+ }
72
+ /** The first clash between a content domain and the app's origins, as an error; null if none. */
73
+ export function clashOf(info, appOrigins) {
74
+ // A label of the content domain, as a scope's would be: what "same site" is judged against.
75
+ const sample = `a1b2c3d4e5f6g7h8i9j0k1l2.${info.domain}`;
76
+ for (const origin of appOrigins) {
77
+ // A desktop app's own scheme (ferry://app) is never the same site as web content.
78
+ const parsed = parseOrigin(origin);
79
+ if (parsed === null) {
80
+ continue;
81
+ }
82
+ if (info.isContentHost(parsed.host)) {
83
+ return new ContentDomainError("clash", `The content domain ${info.domain} holds the app's own origin ${origin}: content there could read the app's cookies and storage. Serve content from a domain the app uses for nothing else`);
84
+ }
85
+ if (maybeSameSite(parsed.host, sample)) {
86
+ return new ContentDomainError("same-site", `The content domain ${info.domain} may be the same site as the app's origin ${origin}: serve content from a registrable domain the app uses for nothing else`);
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+ /** The `ContentDomain` `createServer` takes, from parsed settings. */
92
+ export function serverDomain(info, naming) {
93
+ return {
94
+ domain: info.domain,
95
+ scheme: info.scheme,
96
+ ...(info.port === undefined ? {} : { port: info.port }),
97
+ ...(naming.name === undefined ? {} : { name: naming.name }),
98
+ ...(naming.reserved === undefined ? {} : { reserved: naming.reserved }),
99
+ };
100
+ }
@@ -0,0 +1,158 @@
1
+ import type { PortholeConfig } from "../config/schema.ts";
2
+ import type { InstanceRef } from "../isolation/types.ts";
3
+ import { type ContentDomainInfo } from "./domain.ts";
4
+ import { type Naming } from "./naming.ts";
5
+ import type { Access, ArtifactStore, AuthorizeAnswer, AuthorizeRequest, Grant, PortholeServer, TicketStore, Upstream } from "./types.ts";
6
+ /**
7
+ * The host kit (P066): everything around `/server` that every host wrote, so an app writes only
8
+ * its policy (four callbacks) and its settings. Web-standard and runtime-agnostic: Node (through
9
+ * `@botiverse/porthole/node`), Cloudflare Workers, Deno, Bun.
10
+ */
11
+ /** Where Porthole answers its health check, on the health label. */
12
+ export declare const HEALTH_PATH = "/__porthole/health";
13
+ /** What the health check's answer is fetched with: Node's needs `*.localhost` on loopback. */
14
+ export type HealthProbe = (url: string, timeoutMs: number) => Promise<{
15
+ readonly status: number;
16
+ readonly body: string;
17
+ }>;
18
+ export interface HealthOptions {
19
+ /** The label Porthole answers its health check on (reserved from naming). */
20
+ readonly label?: string;
21
+ /** How the check is fetched (default: `fetch`; on Node, `nodeProbe()`). */
22
+ readonly probe?: HealthProbe;
23
+ readonly timeoutMs?: number;
24
+ }
25
+ /** Where content stands, for the app's own meta and logs. */
26
+ export interface ContentHealthView {
27
+ /** `off`: no content domain. `checking`: the first check hasn't answered yet. */
28
+ readonly state: "off" | "checking" | "healthy" | "unhealthy";
29
+ readonly reason?: string;
30
+ /** Set when the settings can't be served at all: it never changes until they do. */
31
+ readonly misconfigured?: true;
32
+ readonly checkedAt?: number;
33
+ }
34
+ /** What the kit tells the app's log, by a stable `evt` name. */
35
+ export type ContentHostEvent = {
36
+ readonly evt: "porthole.content.misconfigured";
37
+ readonly code: string;
38
+ readonly reason: string;
39
+ } | {
40
+ readonly evt: "porthole.content.healthy";
41
+ } | {
42
+ readonly evt: "porthole.content.unhealthy";
43
+ readonly reason: string;
44
+ };
45
+ /** What the app's page needs: whether frames are served, from where, and the gate's state. */
46
+ export interface ContentMeta {
47
+ readonly served: boolean;
48
+ readonly contentSource: string | null;
49
+ readonly health: ContentHealthView | null;
50
+ }
51
+ type Awaitable<T> = T | Promise<T>;
52
+ export interface ContentHostOptions<V> {
53
+ /** `content.example.com`, `content.localhost:8790`; empty: content off. Or parsed settings. */
54
+ readonly domain: string | null | undefined | ContentDomainInfo;
55
+ /** The app's own origins: they frame content (`frame-ancestors`) and are checked for clashes. */
56
+ readonly appOrigins: readonly string[];
57
+ /** At least 32 random bytes (or a string of at least 32 bytes). */
58
+ readonly secret: Uint8Array | string;
59
+ readonly tickets?: TicketStore;
60
+ /** The configs content is served under, given the content source (`https://*.<domain>`). */
61
+ readonly configs: Readonly<Record<string, PortholeConfig>> | ((source: string) => Readonly<Record<string, PortholeConfig>>);
62
+ /**
63
+ * How a scope is named (`prefixedName("a-")`); default: keyed labels. Redirect entry needs one
64
+ * that says which scope a label names (`prefixedName` does).
65
+ */
66
+ readonly name?: Naming;
67
+ /** Labels the app routes itself (`p-*` previews): `fetch` hands them back. */
68
+ readonly reserved?: readonly string[] | ((label: string) => boolean);
69
+ /**
70
+ * Policy 1, mapping: the instance a request proposes (`{ instance }`), as the app means it,
71
+ * or null (404). A proposal, never trusted: derive the scope from what it names (a session, a
72
+ * version), check its fields, refuse the rest. The client never chooses an origin.
73
+ */
74
+ readonly instance: (proposal: InstanceRef, viewer: V | null, request: Request) => Awaitable<InstanceRef | null>;
75
+ /** Policy 2, access at issue: may this viewer see it, and with what grant (null: 401/403). */
76
+ readonly access: (instance: InstanceRef, viewer: V | null, request: Request) => Awaitable<Grant | null>;
77
+ /** Policy 3, on every request after (P050): may this cookie still see it. */
78
+ readonly authorize?: (request: AuthorizeRequest) => Awaitable<AuthorizeAnswer>;
79
+ readonly authorizeTtlSeconds?: number;
80
+ /** Policy 4, source: a stored page by id and version (artifact documents). */
81
+ readonly source?: ArtifactStore["get"];
82
+ readonly upstream?: Upstream;
83
+ /**
84
+ * The health gate, and with it the opaque fallback (P066): the kit checks the content domain
85
+ * over the real network; while it doesn't answer, or can't be served at all (misconfigured),
86
+ * frames aren't served and the app's displays run opaque. Absent: settings that can't be
87
+ * served throw at startup.
88
+ */
89
+ readonly health?: HealthOptions;
90
+ /** Issues per viewer (or address) per minute, in this process. */
91
+ readonly rateLimit?: {
92
+ readonly perMinute: number;
93
+ readonly key?: (request: Request, viewer: V | null) => string;
94
+ };
95
+ readonly log?: (event: ContentHostEvent) => void;
96
+ /**
97
+ * Redirect entry (P068): clean addresses (`urlOf`) that open directly, in a new tab, on a
98
+ * reload, from a bookmark or a link someone with access was sent. Without a cookie, the content
99
+ * origin sends the browser to the app's `authorizeUrl` (`ContentHost.authorize`), which asks
100
+ * `at` and `access` and sends it back with a one-time code.
101
+ */
102
+ readonly entry?: EntryOptions<V>;
103
+ }
104
+ export interface EntryOptions<V> {
105
+ /** The app's authorize route (`ContentHost.authorize`), an absolute URL on an app origin. */
106
+ readonly authorizeUrl: string;
107
+ /**
108
+ * Policy 1 for a clean address: the instance a scope's path means, or null (404). The path is
109
+ * the address's pathname; its query rides along but never chooses the instance.
110
+ */
111
+ readonly at: (scope: string, path: string, viewer: V | null, request: Request) => Awaitable<InstanceRef | null>;
112
+ /**
113
+ * 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.
115
+ */
116
+ readonly signIn: (returnTo: string, request: Request) => Awaitable<Response>;
117
+ /** The app's refusal page on a tab (a frame gets Porthole's plain one). Default: Porthole's. */
118
+ readonly refusal?: (status: 403 | 404, request: Request) => Awaitable<Response>;
119
+ }
120
+ export interface ContentHost<V> {
121
+ /** The parsed content domain, or null (off, or unusable settings under the fallback). */
122
+ readonly domain: ContentDomainInfo | null;
123
+ /** The underlying server, or null when nothing is served. */
124
+ readonly server: PortholeServer | null;
125
+ /** The frame sources the app's page CSP must allow (`frame-src`). */
126
+ readonly frameSources: readonly string[];
127
+ /**
128
+ * The routing middleware: an answer for every request under the content domain (the app never
129
+ * answers there); null for the app's own hosts and its reserved labels.
130
+ */
131
+ fetch(request: Request): Promise<Response | null>;
132
+ /**
133
+ * Whether a request is the content domain's (`fetch` answers it): false for the app's own
134
+ * hosts and its reserved labels. For a WebSocket upgrade, which `fetch` can't take.
135
+ */
136
+ owns(request: Request): boolean;
137
+ /** The issue endpoint: POST `{ instance }` → 201 `FrameLocation`, as a fetch handler. */
138
+ issue(viewerOf: (request: Request) => Awaitable<V | null>): (request: Request) => Promise<Response>;
139
+ /** Whether frames are served now, from where, and the gate's state. */
140
+ meta(): ContentMeta;
141
+ /**
142
+ * A scope's clean address (P068), for "open in a new tab", "copy link" and bookmarks: null while
143
+ * nothing is served. Opens through redirect entry (`entry`); frames take tickets (`issue`).
144
+ */
145
+ urlOf(scope: string, path?: string): string | null;
146
+ /** The app's authorize route for redirect entry (`entry.authorizeUrl`), as a fetch handler. */
147
+ authorize(viewerOf: (request: Request) => Awaitable<V | null>): (request: Request) => Promise<Response>;
148
+ /** A url-mode app's access for a WebSocket upgrade (`server.access`), or null. */
149
+ access(request: Request): Promise<Access | null>;
150
+ /** Starts the health gate (after the app listens). */
151
+ start(): void;
152
+ /** Runs one health check now. */
153
+ check(): Promise<ContentHealthView>;
154
+ /** Stops the health gate. */
155
+ close(): void;
156
+ }
157
+ export declare function createContentHost<V>(options: ContentHostOptions<V>): ContentHost<V>;
158
+ export {};