@botiverse/porthole 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/conformance/clipboard.js +5 -2
- package/dist/conformance/entry.d.ts +37 -0
- package/dist/conformance/entry.js +120 -0
- package/dist/conformance/harness/page.d.ts +32 -0
- package/dist/conformance/harness/page.js +215 -0
- package/dist/conformance/harness.js +1662 -0
- package/dist/conformance/host-kit.d.ts +85 -0
- package/dist/conformance/host-kit.js +341 -0
- package/dist/conformance/index.d.ts +2 -0
- package/dist/conformance/index.js +4 -0
- package/dist/conformance/served.js +13 -3
- package/dist/host/locator.d.ts +39 -0
- package/dist/host/locator.js +72 -0
- package/dist/host/outcome.js +1 -0
- package/dist/host/types.d.ts +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/node/index.d.ts +41 -0
- package/dist/node/index.js +160 -0
- package/dist/platform/web.js +12 -1
- package/dist/server/domain.d.ts +43 -0
- package/dist/server/domain.js +100 -0
- package/dist/server/host.d.ts +158 -0
- package/dist/server/host.js +416 -0
- package/dist/server/index.d.ts +9 -2
- package/dist/server/index.js +7 -0
- package/dist/server/naming.d.ts +12 -1
- package/dist/server/naming.js +9 -8
- package/dist/server/pages.d.ts +9 -0
- package/dist/server/pages.js +32 -0
- package/dist/server/server.js +287 -50
- package/dist/server/types.d.ts +37 -0
- package/dist/transport/post-message.d.ts +9 -1
- package/dist/transport/post-message.js +11 -2
- package/package.json +6 -2
- package/src/conformance/clipboard.ts +5 -2
- package/src/conformance/entry.ts +206 -0
- package/src/conformance/harness/page.ts +267 -0
- package/src/conformance/host-kit.ts +466 -0
- package/src/conformance/index.ts +12 -0
- package/src/conformance/served.ts +14 -3
- package/src/host/locator.ts +111 -0
- package/src/host/outcome.ts +1 -0
- package/src/host/types.ts +2 -0
- package/src/index.ts +7 -0
- package/src/node/index.ts +221 -0
- package/src/platform/web.ts +19 -1
- package/src/server/domain.ts +158 -0
- package/src/server/host.ts +706 -0
- package/src/server/index.ts +27 -1
- package/src/server/naming.ts +22 -2
- package/src/server/pages.ts +44 -0
- package/src/server/server.ts +355 -63
- package/src/server/types.ts +33 -0
- package/src/transport/post-message.ts +12 -1
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { embedderFrameSourcesOf } from "../compile/policy.js";
|
|
2
|
+
import { base64url, hmacSha256, utf8 } from "./crypto.js";
|
|
3
|
+
import { ContentDomainError, contentDomain, serverDomain, } from "./domain.js";
|
|
4
|
+
import { reservedLabels } from "./naming.js";
|
|
5
|
+
import { appStatusPage } from "./pages.js";
|
|
6
|
+
import { createServer } from "./server.js";
|
|
7
|
+
/**
|
|
8
|
+
* The host kit (P066): everything around `/server` that every host wrote, so an app writes only
|
|
9
|
+
* its policy (four callbacks) and its settings. Web-standard and runtime-agnostic: Node (through
|
|
10
|
+
* `@botiverse/porthole/node`), Cloudflare Workers, Deno, Bun.
|
|
11
|
+
*/
|
|
12
|
+
/** Where Porthole answers its health check, on the health label. */
|
|
13
|
+
export const HEALTH_PATH = "/__porthole/health";
|
|
14
|
+
const DEFAULT_HEALTH_LABEL = "porthole-health";
|
|
15
|
+
const PROBE_TIMEOUT_MS = 10_000;
|
|
16
|
+
const UNHEALTHY_EVERY_MS = 60_000;
|
|
17
|
+
const HEALTHY_EVERY_MS = 600_000;
|
|
18
|
+
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
19
|
+
const json = (status, body, headers = {}) => new Response(JSON.stringify(body), {
|
|
20
|
+
status,
|
|
21
|
+
headers: {
|
|
22
|
+
"content-type": "application/json; charset=utf-8",
|
|
23
|
+
"cache-control": "no-store",
|
|
24
|
+
"x-content-type-options": "nosniff",
|
|
25
|
+
...headers,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const refusal = (status, code, message) => json(status, { error: { code, message } });
|
|
29
|
+
/** A plain page for the content domain's own refusals: never the app's. */
|
|
30
|
+
const plain = (status, text) => new Response(text, {
|
|
31
|
+
status,
|
|
32
|
+
headers: {
|
|
33
|
+
"content-type": "text/plain; charset=utf-8",
|
|
34
|
+
"cache-control": "no-store",
|
|
35
|
+
"x-content-type-options": "nosniff",
|
|
36
|
+
"referrer-policy": "no-referrer",
|
|
37
|
+
"x-robots-tag": "noindex, nofollow",
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
/** The CSP source for an authorize route: its origin and path, so nothing else there frames. */
|
|
41
|
+
const authorizeSource = (url) => {
|
|
42
|
+
const parsed = new URL(url);
|
|
43
|
+
return `${parsed.origin}${parsed.pathname}`;
|
|
44
|
+
};
|
|
45
|
+
/** The default probe: the runtime's `fetch`, no redirects followed, a timeout. */
|
|
46
|
+
const fetchProbe = async (url, timeoutMs) => {
|
|
47
|
+
const response = await fetch(url, {
|
|
48
|
+
redirect: "manual",
|
|
49
|
+
headers: { accept: "application/json" },
|
|
50
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
51
|
+
});
|
|
52
|
+
const body = (await response.text()).slice(0, 4_096);
|
|
53
|
+
return { status: response.status, body };
|
|
54
|
+
};
|
|
55
|
+
export function createContentHost(options) {
|
|
56
|
+
const gate = options.health;
|
|
57
|
+
const log = options.log ?? (() => { });
|
|
58
|
+
const secret = typeof options.secret === "string" ? utf8(options.secret.trim()) : options.secret;
|
|
59
|
+
const healthLabel = gate?.label ?? DEFAULT_HEALTH_LABEL;
|
|
60
|
+
// The id Porthole's health check answers with: keyed, so another deployment's answer fails.
|
|
61
|
+
const healthId = base64url(hmacSha256(secret, utf8("porthole/health"))).slice(0, 22);
|
|
62
|
+
const appReserved = reservedLabels(options.reserved);
|
|
63
|
+
let domain = null;
|
|
64
|
+
let server = null;
|
|
65
|
+
let view = { state: "off" };
|
|
66
|
+
let configs = {};
|
|
67
|
+
try {
|
|
68
|
+
domain =
|
|
69
|
+
options.domain !== null && typeof options.domain === "object"
|
|
70
|
+
? options.domain
|
|
71
|
+
: contentDomain(options.domain, { appOrigins: options.appOrigins });
|
|
72
|
+
if (domain !== null) {
|
|
73
|
+
if (options.entry !== undefined && options.name?.scopeOf === undefined) {
|
|
74
|
+
throw new TypeError("Redirect entry needs a naming that says which scope a label names (prefixedName)");
|
|
75
|
+
}
|
|
76
|
+
configs =
|
|
77
|
+
typeof options.configs === "function" ? options.configs(domain.source) : options.configs;
|
|
78
|
+
server = createServer({
|
|
79
|
+
content: serverDomain(domain, {
|
|
80
|
+
...(options.name === undefined ? {} : { name: options.name }),
|
|
81
|
+
reserved: (label) => appReserved(label) || (gate !== undefined && label === healthLabel),
|
|
82
|
+
}),
|
|
83
|
+
secret,
|
|
84
|
+
hostOrigins: options.appOrigins,
|
|
85
|
+
configs,
|
|
86
|
+
...(options.source === undefined ? {} : { artifacts: { get: options.source } }),
|
|
87
|
+
...(options.tickets === undefined ? {} : { tickets: options.tickets }),
|
|
88
|
+
...(options.upstream === undefined ? {} : { upstream: options.upstream }),
|
|
89
|
+
...(options.entry === undefined
|
|
90
|
+
? {}
|
|
91
|
+
: { entry: { authorizeUrl: options.entry.authorizeUrl } }),
|
|
92
|
+
...(options.authorize === undefined
|
|
93
|
+
? {}
|
|
94
|
+
: {
|
|
95
|
+
authorize: {
|
|
96
|
+
check: options.authorize,
|
|
97
|
+
...(options.authorizeTtlSeconds === undefined
|
|
98
|
+
? {}
|
|
99
|
+
: { ttlSeconds: options.authorizeTtlSeconds }),
|
|
100
|
+
},
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
view = gate === undefined ? { state: "healthy" } : { state: "checking" };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
// Porthole never serves settings it refuses. Without the fallback, the app can't start so;
|
|
108
|
+
// with it, they are a lasting "unhealthy: misconfigured", told once.
|
|
109
|
+
if (gate === undefined) {
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
const code = error instanceof ContentDomainError ? error.code : "refused";
|
|
113
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
114
|
+
server = null;
|
|
115
|
+
view = { state: "unhealthy", reason: `misconfigured: ${reason}`, misconfigured: true };
|
|
116
|
+
log({ evt: "porthole.content.misconfigured", code, reason });
|
|
117
|
+
}
|
|
118
|
+
const served = () => server !== null && view.state === "healthy";
|
|
119
|
+
// The health gate: one check at a time, again after a minute while unhealthy, ten while healthy.
|
|
120
|
+
let timer = null;
|
|
121
|
+
let running = null;
|
|
122
|
+
let closed = false;
|
|
123
|
+
const schedule = () => {
|
|
124
|
+
if (closed || gate === undefined || view.misconfigured === true || domain === null) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
timer = setTimeout(() => void check(), view.state === "healthy" ? HEALTHY_EVERY_MS : UNHEALTHY_EVERY_MS);
|
|
128
|
+
timer.unref?.();
|
|
129
|
+
};
|
|
130
|
+
const probe = async () => {
|
|
131
|
+
if (domain === null) {
|
|
132
|
+
return "no content domain";
|
|
133
|
+
}
|
|
134
|
+
const host = `${healthLabel}.${domain.domain}`;
|
|
135
|
+
try {
|
|
136
|
+
const answer = await (gate?.probe ?? fetchProbe)(`${domain.originOf(healthLabel)}${HEALTH_PATH}`, gate?.timeoutMs ?? PROBE_TIMEOUT_MS);
|
|
137
|
+
if (answer.status !== 200) {
|
|
138
|
+
return `${host} answered ${answer.status}, not Porthole's health check`;
|
|
139
|
+
}
|
|
140
|
+
let said = null;
|
|
141
|
+
try {
|
|
142
|
+
said = JSON.parse(answer.body);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
said = null;
|
|
146
|
+
}
|
|
147
|
+
if (!isRecord(said) || said.porthole !== "content-domain") {
|
|
148
|
+
return `${host} is answered by something else`;
|
|
149
|
+
}
|
|
150
|
+
return said.id === healthId ? null : `${host} is answered by another deployment`;
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
return `${host}: ${error instanceof Error ? error.message : String(error)}`;
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
const check = async () => {
|
|
157
|
+
if (gate === undefined || domain === null || view.misconfigured === true) {
|
|
158
|
+
return view;
|
|
159
|
+
}
|
|
160
|
+
running ??= (async () => {
|
|
161
|
+
if (timer !== null) {
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
timer = null;
|
|
164
|
+
}
|
|
165
|
+
const reason = await probe();
|
|
166
|
+
const before = view;
|
|
167
|
+
view =
|
|
168
|
+
reason === null
|
|
169
|
+
? { state: "healthy", checkedAt: Date.now() }
|
|
170
|
+
: { state: "unhealthy", reason, checkedAt: Date.now() };
|
|
171
|
+
if (before.state !== view.state || before.reason !== view.reason) {
|
|
172
|
+
log(reason === null
|
|
173
|
+
? { evt: "porthole.content.healthy" }
|
|
174
|
+
: { evt: "porthole.content.unhealthy", reason });
|
|
175
|
+
}
|
|
176
|
+
running = null;
|
|
177
|
+
schedule();
|
|
178
|
+
return view;
|
|
179
|
+
})();
|
|
180
|
+
return running;
|
|
181
|
+
};
|
|
182
|
+
// Issues per key per minute, in this process.
|
|
183
|
+
const issued = new Map();
|
|
184
|
+
const withinRate = (key) => {
|
|
185
|
+
const limit = options.rateLimit;
|
|
186
|
+
if (limit === undefined) {
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
const at = Date.now();
|
|
190
|
+
const recent = (issued.get(key) ?? []).filter((time) => at - time < 60_000);
|
|
191
|
+
if (recent.length >= limit.perMinute) {
|
|
192
|
+
issued.set(key, recent);
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
recent.push(at);
|
|
196
|
+
issued.set(key, recent);
|
|
197
|
+
if (issued.size > 50_000) {
|
|
198
|
+
issued.clear();
|
|
199
|
+
}
|
|
200
|
+
return true;
|
|
201
|
+
};
|
|
202
|
+
/** The content domain's label for a request, or null: the app's host, or a label it reserved. */
|
|
203
|
+
const routeOf = (request) => {
|
|
204
|
+
if (domain === null) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
let url;
|
|
208
|
+
try {
|
|
209
|
+
url = new URL(request.url);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
const header = request.headers.get("host");
|
|
215
|
+
const byUrl = domain.isContentHost(url.hostname);
|
|
216
|
+
const byHeader = header !== null && domain.isContentHost(header);
|
|
217
|
+
if (!byUrl && !byHeader) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
const label = (byUrl ? domain.labelOf(url.hostname) : header === null ? null : domain.labelOf(header)) ??
|
|
221
|
+
"";
|
|
222
|
+
const top = label.split(".").at(-1) ?? "";
|
|
223
|
+
return top !== "" && appReserved(top) ? null : { url, label };
|
|
224
|
+
};
|
|
225
|
+
return {
|
|
226
|
+
get domain() {
|
|
227
|
+
return domain;
|
|
228
|
+
},
|
|
229
|
+
get server() {
|
|
230
|
+
return server;
|
|
231
|
+
},
|
|
232
|
+
frameSources: server === null || domain === null
|
|
233
|
+
? []
|
|
234
|
+
: [
|
|
235
|
+
...embedderFrameSourcesOf(Object.values(configs)),
|
|
236
|
+
// A frame on a clean address goes by the authorize route (only that path).
|
|
237
|
+
...(options.entry === undefined ? [] : [authorizeSource(options.entry.authorizeUrl)]),
|
|
238
|
+
],
|
|
239
|
+
owns: (request) => routeOf(request) !== null,
|
|
240
|
+
async fetch(request) {
|
|
241
|
+
const route = routeOf(request);
|
|
242
|
+
if (route === null) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
const { url, label } = route;
|
|
246
|
+
if (server === null) {
|
|
247
|
+
return plain(503, "Content is unavailable.");
|
|
248
|
+
}
|
|
249
|
+
if (gate !== undefined && label === healthLabel) {
|
|
250
|
+
return url.pathname === HEALTH_PATH && request.method.toUpperCase() === "GET"
|
|
251
|
+
? json(200, { porthole: "content-domain", id: healthId })
|
|
252
|
+
: plain(404, "Not found.");
|
|
253
|
+
}
|
|
254
|
+
return (await server.handle(request)) ?? plain(404, "Not found.");
|
|
255
|
+
},
|
|
256
|
+
issue(viewerOf) {
|
|
257
|
+
return async (request) => {
|
|
258
|
+
if (request.method.toUpperCase() !== "POST") {
|
|
259
|
+
return refusal(405, "method", "POST { instance } here");
|
|
260
|
+
}
|
|
261
|
+
if (!served() || server === null) {
|
|
262
|
+
return refusal(503, "not-served", domain === null && view.misconfigured !== true
|
|
263
|
+
? "This app serves no content frames"
|
|
264
|
+
: `Content frames aren't served now: ${view.reason ?? "the content domain is checking"}`);
|
|
265
|
+
}
|
|
266
|
+
let viewer;
|
|
267
|
+
try {
|
|
268
|
+
viewer = await viewerOf(request);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return refusal(500, "failed", "The app couldn't tell who is asking");
|
|
272
|
+
}
|
|
273
|
+
const key = options.rateLimit?.key?.(request, viewer) ??
|
|
274
|
+
(viewer === null
|
|
275
|
+
? `address:${request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for") ?? ""}`
|
|
276
|
+
: `viewer:${JSON.stringify(viewer)}`);
|
|
277
|
+
if (!withinRate(key)) {
|
|
278
|
+
return refusal(429, "rate-limited", "Too many frames asked for: try again in a minute");
|
|
279
|
+
}
|
|
280
|
+
let body;
|
|
281
|
+
try {
|
|
282
|
+
body = await request.json();
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return refusal(400, "bad-json", "The body is JSON: { instance }");
|
|
286
|
+
}
|
|
287
|
+
const proposal = isRecord(body) ? body.instance : undefined;
|
|
288
|
+
if (!isRecord(proposal) ||
|
|
289
|
+
typeof proposal.scope !== "string" ||
|
|
290
|
+
!isRecord(proposal.document) ||
|
|
291
|
+
typeof proposal.document.kind !== "string") {
|
|
292
|
+
return refusal(400, "bad-request", "The body is { instance: { scope, document } }");
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
const instance = await options.instance(proposal, viewer, request);
|
|
296
|
+
if (instance === null) {
|
|
297
|
+
return refusal(404, "not-found", "There is nothing to show for that instance");
|
|
298
|
+
}
|
|
299
|
+
const grant = await options.access(instance, viewer, request);
|
|
300
|
+
if (grant === null) {
|
|
301
|
+
return viewer === null
|
|
302
|
+
? refusal(401, "unauthenticated", "Sign in to see this")
|
|
303
|
+
: refusal(403, "forbidden", "You can't see this");
|
|
304
|
+
}
|
|
305
|
+
let location;
|
|
306
|
+
try {
|
|
307
|
+
location = await server.issue(instance, grant);
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
return refusal(409, "refused", error instanceof Error ? error.message : String(error));
|
|
311
|
+
}
|
|
312
|
+
return json(201, location);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
return refusal(500, "failed", "The app's policy failed");
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
},
|
|
319
|
+
meta: () => ({
|
|
320
|
+
served: served(),
|
|
321
|
+
contentSource: domain?.source ?? null,
|
|
322
|
+
health: gate === undefined ? null : view,
|
|
323
|
+
}),
|
|
324
|
+
urlOf(scope, path = "/") {
|
|
325
|
+
return server === null || !served() ? null : server.urlOf(scope, path);
|
|
326
|
+
},
|
|
327
|
+
authorize(viewerOf) {
|
|
328
|
+
const appPage = (status, title, detail, reason, method, link) => appStatusPage(status, title, detail, options.appOrigins.join(" "), reason, options.appOrigins, link, method);
|
|
329
|
+
return async (request) => {
|
|
330
|
+
const method = request.method.toUpperCase();
|
|
331
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
332
|
+
return appPage(405, "Not allowed", "Open this address in a browser.", "error", method);
|
|
333
|
+
}
|
|
334
|
+
const entry = options.entry;
|
|
335
|
+
if (entry === undefined || server === null || domain === null || !served()) {
|
|
336
|
+
return appPage(503, "Content is unavailable", "This app isn't serving content now. Try again later.", "error", method);
|
|
337
|
+
}
|
|
338
|
+
const state = server.entryState(new URL(request.url).searchParams.get("state") ?? "");
|
|
339
|
+
if (state === null) {
|
|
340
|
+
return appPage(400, "This link isn't valid", "Open the address again.", "error", method);
|
|
341
|
+
}
|
|
342
|
+
const dest = request.headers.get("sec-fetch-dest");
|
|
343
|
+
const framed = dest === null ? state.framed : dest !== "document";
|
|
344
|
+
const clean = `${domain.originOf(state.label)}${state.path}`;
|
|
345
|
+
if (state.expired) {
|
|
346
|
+
return appPage(401, "This took too long", "Open the address again.", "session", method, framed ? undefined : { href: clean, text: "Open it again" });
|
|
347
|
+
}
|
|
348
|
+
const scope = options.name?.scopeOf?.(state.label) ?? null;
|
|
349
|
+
let viewer;
|
|
350
|
+
try {
|
|
351
|
+
viewer = await viewerOf(request);
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return appPage(500, "Something went wrong", "The app couldn't tell who is asking.", "error", method);
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
const instance = scope === null
|
|
358
|
+
? null
|
|
359
|
+
: await entry.at(scope, new URL(state.path, "https://x.invalid").pathname, viewer, request);
|
|
360
|
+
const grant = instance === null ? null : await options.access(instance, viewer, request);
|
|
361
|
+
if (instance === null || grant === null) {
|
|
362
|
+
if (viewer === null) {
|
|
363
|
+
// The app's sign-in never shows in a frame: the person opens a tab for it.
|
|
364
|
+
return framed
|
|
365
|
+
? appPage(401, "Open this in a new tab to sign in", "Signing in doesn't work inside a frame.", "sign-in", method)
|
|
366
|
+
: await entry.signIn(request.url, request);
|
|
367
|
+
}
|
|
368
|
+
const status = instance === null ? 404 : 403;
|
|
369
|
+
if (!framed && entry.refusal !== undefined) {
|
|
370
|
+
return await entry.refusal(status, request);
|
|
371
|
+
}
|
|
372
|
+
return status === 404
|
|
373
|
+
? appPage(404, "Not found", "There is nothing here.", "not-found", method)
|
|
374
|
+
: appPage(403, "You can't open this", "You don't have access to it.", "denied", method);
|
|
375
|
+
}
|
|
376
|
+
const key = options.rateLimit?.key?.(request, viewer) ??
|
|
377
|
+
(viewer === null ? `entry:${state.label}` : `viewer:${JSON.stringify(viewer)}`);
|
|
378
|
+
if (!withinRate(key)) {
|
|
379
|
+
return appPage(429, "Too many requests", "Try again in a minute.", "error", method);
|
|
380
|
+
}
|
|
381
|
+
let location;
|
|
382
|
+
try {
|
|
383
|
+
location = await server.enterBy(state, instance, grant);
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
return appPage(409, "This can't be opened here", "Open the address again.", "error", method);
|
|
387
|
+
}
|
|
388
|
+
return new Response(null, {
|
|
389
|
+
status: 302,
|
|
390
|
+
headers: {
|
|
391
|
+
location,
|
|
392
|
+
"cache-control": "no-store",
|
|
393
|
+
"referrer-policy": "no-referrer",
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
return appPage(500, "Something went wrong", "The app's policy failed.", "error", method);
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
},
|
|
402
|
+
access: async (request) => (server === null ? null : server.access(request)),
|
|
403
|
+
start() {
|
|
404
|
+
closed = false;
|
|
405
|
+
void check();
|
|
406
|
+
},
|
|
407
|
+
check,
|
|
408
|
+
close() {
|
|
409
|
+
closed = true;
|
|
410
|
+
if (timer !== null) {
|
|
411
|
+
clearTimeout(timer);
|
|
412
|
+
timer = null;
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
*
|
|
10
10
|
* and calls `issue` from its own authenticated API when a viewer opens served content.
|
|
11
11
|
*
|
|
12
|
+
* The host kit (P066): `createContentHost` wraps `createServer` with the routing middleware
|
|
13
|
+
* (`fetch`), the issue endpoint (`issue`), the content domain's parsing and clash guards
|
|
14
|
+
* (`contentDomain`), and an optional health gate with an opaque fallback. An app writes its four
|
|
15
|
+
* policy callbacks and its settings; on Node, `@botiverse/porthole/node` adapts it.
|
|
16
|
+
*
|
|
12
17
|
* Runs on any runtime with web-standard `Request`/`Response` and `crypto.getRandomValues`: Node,
|
|
13
18
|
* Cloudflare Workers (no `nodejs_compat` needed), Deno, Bun (P044). On Workers, keep tickets in D1
|
|
14
19
|
* (`d1TicketStore`, P045).
|
|
@@ -28,8 +33,10 @@
|
|
|
28
33
|
* content without a valid ticket or cookie gets nothing of the content.
|
|
29
34
|
*/
|
|
30
35
|
export { createServer } from "./server.ts";
|
|
36
|
+
export { createContentHost, HEALTH_PATH, type ContentHealthView, type ContentHost, type ContentHostEvent, type ContentHostOptions, type EntryOptions, type ContentMeta, type HealthOptions, type HealthProbe, } from "./host.ts";
|
|
37
|
+
export { clashOf, contentDomain, ContentDomainError, hostnameOf, type ContentDomainInfo, type ContentDomainProblem, } from "./domain.ts";
|
|
31
38
|
export { d1TicketSchema, d1TicketStore, memoryTicketStore, type D1Like, type D1StatementLike, } from "./tickets.ts";
|
|
32
39
|
export { FRAME_STATUS_METHOD } from "../isolation/types.ts";
|
|
33
|
-
export { isHostLabel, prefixedName } from "./naming.ts";
|
|
40
|
+
export { isHostLabel, prefixedName, type Naming } from "./naming.ts";
|
|
34
41
|
export { proxyTo, type ProxyOptions } from "./proxy.ts";
|
|
35
|
-
export type { Access, ArtifactStore, AuthorizeAnswer, AuthorizeRequest, Authorizer, ContentDomain, Grant, PortholeServer, ServerOptions, TicketRecord, TicketStore, Upstream, } from "./types.ts";
|
|
42
|
+
export type { Access, ArtifactStore, AuthorizeAnswer, AuthorizeRequest, Authorizer, ContentDomain, EntryState, Grant, PortholeServer, ServerOptions, TicketRecord, TicketStore, Upstream, } from "./types.ts";
|
package/dist/server/index.js
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
*
|
|
10
10
|
* and calls `issue` from its own authenticated API when a viewer opens served content.
|
|
11
11
|
*
|
|
12
|
+
* The host kit (P066): `createContentHost` wraps `createServer` with the routing middleware
|
|
13
|
+
* (`fetch`), the issue endpoint (`issue`), the content domain's parsing and clash guards
|
|
14
|
+
* (`contentDomain`), and an optional health gate with an opaque fallback. An app writes its four
|
|
15
|
+
* policy callbacks and its settings; on Node, `@botiverse/porthole/node` adapts it.
|
|
16
|
+
*
|
|
12
17
|
* Runs on any runtime with web-standard `Request`/`Response` and `crypto.getRandomValues`: Node,
|
|
13
18
|
* Cloudflare Workers (no `nodejs_compat` needed), Deno, Bun (P044). On Workers, keep tickets in D1
|
|
14
19
|
* (`d1TicketStore`, P045).
|
|
@@ -28,6 +33,8 @@
|
|
|
28
33
|
* content without a valid ticket or cookie gets nothing of the content.
|
|
29
34
|
*/
|
|
30
35
|
export { createServer } from "./server.js";
|
|
36
|
+
export { createContentHost, HEALTH_PATH, } from "./host.js";
|
|
37
|
+
export { clashOf, contentDomain, ContentDomainError, hostnameOf, } from "./domain.js";
|
|
31
38
|
export { d1TicketSchema, d1TicketStore, memoryTicketStore, } from "./tickets.js";
|
|
32
39
|
export { FRAME_STATUS_METHOD } from "../isolation/types.js";
|
|
33
40
|
export { isHostLabel, prefixedName } from "./naming.js";
|
package/dist/server/naming.d.ts
CHANGED
|
@@ -14,7 +14,18 @@ export declare function isHostLabel(label: unknown): label is string;
|
|
|
14
14
|
* already be label-safe (lowercase letters, digits, inner hyphens); anything else throws rather
|
|
15
15
|
* than being folded into another scope's name.
|
|
16
16
|
*/
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* How scopes are named (`ContentDomain.name`). A naming that can say which scope a label names
|
|
19
|
+
* (`scopeOf`) gives every scope clean addresses a redirect entry can open (P068).
|
|
20
|
+
*/
|
|
21
|
+
export interface Naming {
|
|
22
|
+
(scope: string): string;
|
|
23
|
+
/** The scope a label names, or null for a label this naming never makes. */
|
|
24
|
+
readonly scopeOf?: (label: string) => string | null;
|
|
25
|
+
}
|
|
26
|
+
export declare function prefixedName(prefix: string): Naming & {
|
|
27
|
+
readonly scopeOf: (label: string) => string | null;
|
|
28
|
+
};
|
|
18
29
|
/**
|
|
19
30
|
* Whether a label is reserved (`ContentDomain.reserved`): a whole label, a prefix ending in `*`
|
|
20
31
|
* (`p-*`), or the host's own function. Entries are checked once, here; a function that throws
|
package/dist/server/naming.js
CHANGED
|
@@ -13,24 +13,25 @@ export function isHostLabel(label) {
|
|
|
13
13
|
}
|
|
14
14
|
const PREFIX = /^[a-z][a-z0-9]{0,15}-$/u;
|
|
15
15
|
const ID = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/u;
|
|
16
|
-
/**
|
|
17
|
-
* A naming for `content.name` that puts each scope on `<prefix><scope>` (`prefixedName("a-")`:
|
|
18
|
-
* scope `k3x9` is served on `a-k3x9.<domain>`). One-to-one, so no two scopes share an origin, and
|
|
19
|
-
* no name it makes is a reserved one or a keyed default (those have no hyphen). Scopes must
|
|
20
|
-
* already be label-safe (lowercase letters, digits, inner hyphens); anything else throws rather
|
|
21
|
-
* than being folded into another scope's name.
|
|
22
|
-
*/
|
|
23
16
|
export function prefixedName(prefix) {
|
|
24
17
|
if (!PREFIX.test(prefix)) {
|
|
25
18
|
throw new TypeError("A name prefix is a lowercase letter, then letters or digits, then '-' (a-)");
|
|
26
19
|
}
|
|
27
|
-
|
|
20
|
+
const name = (scope) => {
|
|
28
21
|
const label = `${prefix}${scope}`;
|
|
29
22
|
if (!ID.test(scope) || !isHostLabel(label)) {
|
|
30
23
|
throw new TypeError(`Scope ${JSON.stringify(scope)} can't be named under ${prefix}: lowercase letters, digits and inner hyphens, ${63 - prefix.length} at most`);
|
|
31
24
|
}
|
|
32
25
|
return label;
|
|
33
26
|
};
|
|
27
|
+
const scopeOf = (label) => {
|
|
28
|
+
if (!label.startsWith(prefix)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const scope = label.slice(prefix.length);
|
|
32
|
+
return ID.test(scope) && isHostLabel(label) ? scope : null;
|
|
33
|
+
};
|
|
34
|
+
return Object.assign(name, { scopeOf });
|
|
34
35
|
}
|
|
35
36
|
const PREFIX_ENTRY = /^[a-z0-9]([a-z0-9-]{0,61})?\*$/u;
|
|
36
37
|
/**
|
package/dist/server/pages.d.ts
CHANGED
|
@@ -9,3 +9,12 @@ export declare function pageScript(pathname: string): Response | null;
|
|
|
9
9
|
export declare function statusPage(status: number, title: string, detail: string, frameAncestors: string, method?: string, reason?: FrameBlock, hostOrigins?: readonly string[], code?: string): Response;
|
|
10
10
|
/** The page a framed app is entered through (its answer also sets the session cookie). */
|
|
11
11
|
export declare function enterPage(target: string, frameAncestors: string, hostOrigins: readonly string[], cookie: string): Response;
|
|
12
|
+
/**
|
|
13
|
+
* A redirect entry's page on the app's own origin (P068): why it shows nothing, told to the page
|
|
14
|
+
* that frames it like a content origin's page. Plain, no app chrome; its one script inline under
|
|
15
|
+
* its hash (the app origin serves no script of Porthole's).
|
|
16
|
+
*/
|
|
17
|
+
export declare function appStatusPage(status: number, title: string, detail: string, frameAncestors: string, reason: FrameBlock, hostOrigins: readonly string[], link?: {
|
|
18
|
+
readonly href: string;
|
|
19
|
+
readonly text: string;
|
|
20
|
+
}, method?: string): Response;
|
package/dist/server/pages.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FRAME_STATUS_METHOD } from "../isolation/types.js";
|
|
2
|
+
import { base64url, sha256, utf8 } from "./crypto.js";
|
|
2
3
|
/**
|
|
3
4
|
* The server's own pages on a content origin: why it shows nothing of the content, and the page a
|
|
4
5
|
* framed app is entered through. Plain, framable only by the host origins, never cached. Each tells
|
|
@@ -94,3 +95,34 @@ export function enterPage(target, frameAncestors, hostOrigins, cookie) {
|
|
|
94
95
|
headers.set("set-cookie", cookie);
|
|
95
96
|
return new Response(body, { status: 200, headers });
|
|
96
97
|
}
|
|
98
|
+
/** The status script's hash, for a page that carries it inline (`script-src 'sha256-…'`). */
|
|
99
|
+
let statusHash = null;
|
|
100
|
+
const hashOfStatus = () => {
|
|
101
|
+
statusHash ??= base64url(sha256(utf8(STATUS_SCRIPT)))
|
|
102
|
+
.replace(/-/gu, "+")
|
|
103
|
+
.replace(/_/gu, "/");
|
|
104
|
+
const padding = (4 - (statusHash.length % 4)) % 4;
|
|
105
|
+
return `'sha256-${statusHash}${"=".repeat(padding)}'`;
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* A redirect entry's page on the app's own origin (P068): why it shows nothing, told to the page
|
|
109
|
+
* that frames it like a content origin's page. Plain, no app chrome; its one script inline under
|
|
110
|
+
* its hash (the app origin serves no script of Porthole's).
|
|
111
|
+
*/
|
|
112
|
+
export function appStatusPage(status, title, detail, frameAncestors, reason, hostOrigins, link, method = "GET") {
|
|
113
|
+
const more = link === undefined
|
|
114
|
+
? ""
|
|
115
|
+
: `<p><a href="${escape(link.href)}" target="_top" rel="noopener">${escape(link.text)}</a></p>`;
|
|
116
|
+
const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)}</title><style>${STYLE}a{color:inherit}</style></head><body><main data-porthole-status="${status}" data-porthole-reason="${escape(reason)}" data-porthole-hosts="${escape(JSON.stringify(hostOrigins))}"><h1>${escape(title)}</h1><p>${escape(detail)}</p>${more}</main><script>${STATUS_SCRIPT}</script></body></html>`;
|
|
117
|
+
return new Response(method === "HEAD" ? null : body, {
|
|
118
|
+
status,
|
|
119
|
+
headers: {
|
|
120
|
+
"content-type": "text/html; charset=utf-8",
|
|
121
|
+
"cache-control": "no-store",
|
|
122
|
+
"content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; script-src ${hashOfStatus()}; base-uri 'none'; form-action 'none'; frame-ancestors ${frameAncestors}`,
|
|
123
|
+
"referrer-policy": "no-referrer",
|
|
124
|
+
"x-content-type-options": "nosniff",
|
|
125
|
+
"x-robots-tag": "noindex, nofollow",
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|