@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
@@ -0,0 +1,706 @@
1
+ import type { PortholeConfig } from "../config/schema.ts";
2
+ import { embedderFrameSourcesOf } from "../compile/policy.ts";
3
+ import type { FrameLocation, InstanceRef } from "../isolation/types.ts";
4
+ import { base64url, hmacSha256, utf8 } from "./crypto.ts";
5
+ import {
6
+ ContentDomainError,
7
+ contentDomain,
8
+ serverDomain,
9
+ type ContentDomainInfo,
10
+ } from "./domain.ts";
11
+ import { reservedLabels, type Naming } from "./naming.ts";
12
+ import { appStatusPage } from "./pages.ts";
13
+ import { createServer } from "./server.ts";
14
+ import type {
15
+ Access,
16
+ ArtifactStore,
17
+ AuthorizeAnswer,
18
+ AuthorizeRequest,
19
+ Grant,
20
+ PortholeServer,
21
+ TicketStore,
22
+ Upstream,
23
+ } from "./types.ts";
24
+
25
+ /**
26
+ * The host kit (P066): everything around `/server` that every host wrote, so an app writes only
27
+ * its policy (four callbacks) and its settings. Web-standard and runtime-agnostic: Node (through
28
+ * `@botiverse/porthole/node`), Cloudflare Workers, Deno, Bun.
29
+ */
30
+
31
+ /** Where Porthole answers its health check, on the health label. */
32
+ export const HEALTH_PATH = "/__porthole/health";
33
+ const DEFAULT_HEALTH_LABEL = "porthole-health";
34
+ const PROBE_TIMEOUT_MS = 10_000;
35
+ const UNHEALTHY_EVERY_MS = 60_000;
36
+ const HEALTHY_EVERY_MS = 600_000;
37
+
38
+ /** What the health check's answer is fetched with: Node's needs `*.localhost` on loopback. */
39
+ export type HealthProbe = (
40
+ url: string,
41
+ timeoutMs: number,
42
+ ) => Promise<{ readonly status: number; readonly body: string }>;
43
+
44
+ export interface HealthOptions {
45
+ /** The label Porthole answers its health check on (reserved from naming). */
46
+ readonly label?: string;
47
+ /** How the check is fetched (default: `fetch`; on Node, `nodeProbe()`). */
48
+ readonly probe?: HealthProbe;
49
+ readonly timeoutMs?: number;
50
+ }
51
+
52
+ /** Where content stands, for the app's own meta and logs. */
53
+ export interface ContentHealthView {
54
+ /** `off`: no content domain. `checking`: the first check hasn't answered yet. */
55
+ readonly state: "off" | "checking" | "healthy" | "unhealthy";
56
+ readonly reason?: string;
57
+ /** Set when the settings can't be served at all: it never changes until they do. */
58
+ readonly misconfigured?: true;
59
+ readonly checkedAt?: number;
60
+ }
61
+
62
+ /** What the kit tells the app's log, by a stable `evt` name. */
63
+ export type ContentHostEvent =
64
+ | {
65
+ readonly evt: "porthole.content.misconfigured";
66
+ readonly code: string;
67
+ readonly reason: string;
68
+ }
69
+ | { readonly evt: "porthole.content.healthy" }
70
+ | { readonly evt: "porthole.content.unhealthy"; readonly reason: string };
71
+
72
+ /** What the app's page needs: whether frames are served, from where, and the gate's state. */
73
+ export interface ContentMeta {
74
+ readonly served: boolean;
75
+ readonly contentSource: string | null;
76
+ readonly health: ContentHealthView | null;
77
+ }
78
+
79
+ type Awaitable<T> = T | Promise<T>;
80
+
81
+ export interface ContentHostOptions<V> {
82
+ /** `content.example.com`, `content.localhost:8790`; empty: content off. Or parsed settings. */
83
+ readonly domain: string | null | undefined | ContentDomainInfo;
84
+ /** The app's own origins: they frame content (`frame-ancestors`) and are checked for clashes. */
85
+ readonly appOrigins: readonly string[];
86
+ /** At least 32 random bytes (or a string of at least 32 bytes). */
87
+ readonly secret: Uint8Array | string;
88
+ readonly tickets?: TicketStore;
89
+ /** The configs content is served under, given the content source (`https://*.<domain>`). */
90
+ readonly configs:
91
+ | Readonly<Record<string, PortholeConfig>>
92
+ | ((source: string) => Readonly<Record<string, PortholeConfig>>);
93
+ /**
94
+ * How a scope is named (`prefixedName("a-")`); default: keyed labels. Redirect entry needs one
95
+ * that says which scope a label names (`prefixedName` does).
96
+ */
97
+ readonly name?: Naming;
98
+ /** Labels the app routes itself (`p-*` previews): `fetch` hands them back. */
99
+ readonly reserved?: readonly string[] | ((label: string) => boolean);
100
+ /**
101
+ * Policy 1, mapping: the instance a request proposes (`{ instance }`), as the app means it,
102
+ * or null (404). A proposal, never trusted: derive the scope from what it names (a session, a
103
+ * version), check its fields, refuse the rest. The client never chooses an origin.
104
+ */
105
+ readonly instance: (
106
+ proposal: InstanceRef,
107
+ viewer: V | null,
108
+ request: Request,
109
+ ) => Awaitable<InstanceRef | null>;
110
+ /** Policy 2, access at issue: may this viewer see it, and with what grant (null: 401/403). */
111
+ readonly access: (
112
+ instance: InstanceRef,
113
+ viewer: V | null,
114
+ request: Request,
115
+ ) => Awaitable<Grant | null>;
116
+ /** Policy 3, on every request after (P050): may this cookie still see it. */
117
+ readonly authorize?: (request: AuthorizeRequest) => Awaitable<AuthorizeAnswer>;
118
+ readonly authorizeTtlSeconds?: number;
119
+ /** Policy 4, source: a stored page by id and version (artifact documents). */
120
+ readonly source?: ArtifactStore["get"];
121
+ readonly upstream?: Upstream;
122
+ /**
123
+ * The health gate, and with it the opaque fallback (P066): the kit checks the content domain
124
+ * over the real network; while it doesn't answer, or can't be served at all (misconfigured),
125
+ * frames aren't served and the app's displays run opaque. Absent: settings that can't be
126
+ * served throw at startup.
127
+ */
128
+ readonly health?: HealthOptions;
129
+ /** Issues per viewer (or address) per minute, in this process. */
130
+ readonly rateLimit?: {
131
+ readonly perMinute: number;
132
+ readonly key?: (request: Request, viewer: V | null) => string;
133
+ };
134
+ readonly log?: (event: ContentHostEvent) => void;
135
+ /**
136
+ * Redirect entry (P068): clean addresses (`urlOf`) that open directly, in a new tab, on a
137
+ * reload, from a bookmark or a link someone with access was sent. Without a cookie, the content
138
+ * origin sends the browser to the app's `authorizeUrl` (`ContentHost.authorize`), which asks
139
+ * `at` and `access` and sends it back with a one-time code.
140
+ */
141
+ readonly entry?: EntryOptions<V>;
142
+ }
143
+
144
+ export interface EntryOptions<V> {
145
+ /** The app's authorize route (`ContentHost.authorize`), an absolute URL on an app origin. */
146
+ readonly authorizeUrl: string;
147
+ /**
148
+ * Policy 1 for a clean address: the instance a scope's path means, or null (404). The path is
149
+ * the address's pathname; its query rides along but never chooses the instance.
150
+ */
151
+ readonly at: (
152
+ scope: string,
153
+ path: string,
154
+ viewer: V | null,
155
+ request: Request,
156
+ ) => Awaitable<InstanceRef | null>;
157
+ /**
158
+ * The app's own sign-in, which comes back to `returnTo` (the authorize URL) once signed in.
159
+ * Only ever on a tab: in a frame, the person is asked to open a new tab instead.
160
+ */
161
+ readonly signIn: (returnTo: string, request: Request) => Awaitable<Response>;
162
+ /** The app's refusal page on a tab (a frame gets Porthole's plain one). Default: Porthole's. */
163
+ readonly refusal?: (status: 403 | 404, request: Request) => Awaitable<Response>;
164
+ }
165
+
166
+ export interface ContentHost<V> {
167
+ /** The parsed content domain, or null (off, or unusable settings under the fallback). */
168
+ readonly domain: ContentDomainInfo | null;
169
+ /** The underlying server, or null when nothing is served. */
170
+ readonly server: PortholeServer | null;
171
+ /** The frame sources the app's page CSP must allow (`frame-src`). */
172
+ readonly frameSources: readonly string[];
173
+ /**
174
+ * The routing middleware: an answer for every request under the content domain (the app never
175
+ * answers there); null for the app's own hosts and its reserved labels.
176
+ */
177
+ fetch(request: Request): Promise<Response | null>;
178
+ /**
179
+ * Whether a request is the content domain's (`fetch` answers it): false for the app's own
180
+ * hosts and its reserved labels. For a WebSocket upgrade, which `fetch` can't take.
181
+ */
182
+ owns(request: Request): boolean;
183
+ /** The issue endpoint: POST `{ instance }` → 201 `FrameLocation`, as a fetch handler. */
184
+ issue(
185
+ viewerOf: (request: Request) => Awaitable<V | null>,
186
+ ): (request: Request) => Promise<Response>;
187
+ /** Whether frames are served now, from where, and the gate's state. */
188
+ meta(): ContentMeta;
189
+ /**
190
+ * A scope's clean address (P068), for "open in a new tab", "copy link" and bookmarks: null while
191
+ * nothing is served. Opens through redirect entry (`entry`); frames take tickets (`issue`).
192
+ */
193
+ urlOf(scope: string, path?: string): string | null;
194
+ /** The app's authorize route for redirect entry (`entry.authorizeUrl`), as a fetch handler. */
195
+ authorize(
196
+ viewerOf: (request: Request) => Awaitable<V | null>,
197
+ ): (request: Request) => Promise<Response>;
198
+ /** A url-mode app's access for a WebSocket upgrade (`server.access`), or null. */
199
+ access(request: Request): Promise<Access | null>;
200
+ /** Starts the health gate (after the app listens). */
201
+ start(): void;
202
+ /** Runs one health check now. */
203
+ check(): Promise<ContentHealthView>;
204
+ /** Stops the health gate. */
205
+ close(): void;
206
+ }
207
+
208
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
209
+ value !== null && typeof value === "object" && !Array.isArray(value);
210
+
211
+ const json = (status: number, body: unknown, headers: Record<string, string> = {}): Response =>
212
+ new Response(JSON.stringify(body), {
213
+ status,
214
+ headers: {
215
+ "content-type": "application/json; charset=utf-8",
216
+ "cache-control": "no-store",
217
+ "x-content-type-options": "nosniff",
218
+ ...headers,
219
+ },
220
+ });
221
+
222
+ const refusal = (status: number, code: string, message: string) =>
223
+ json(status, { error: { code, message } });
224
+
225
+ /** A plain page for the content domain's own refusals: never the app's. */
226
+ const plain = (status: number, text: string): Response =>
227
+ new Response(text, {
228
+ status,
229
+ headers: {
230
+ "content-type": "text/plain; charset=utf-8",
231
+ "cache-control": "no-store",
232
+ "x-content-type-options": "nosniff",
233
+ "referrer-policy": "no-referrer",
234
+ "x-robots-tag": "noindex, nofollow",
235
+ },
236
+ });
237
+
238
+ type FrameBlockReason = Parameters<typeof appStatusPage>[4];
239
+
240
+ /** The CSP source for an authorize route: its origin and path, so nothing else there frames. */
241
+ const authorizeSource = (url: string): string => {
242
+ const parsed = new URL(url);
243
+ return `${parsed.origin}${parsed.pathname}`;
244
+ };
245
+
246
+ /** The default probe: the runtime's `fetch`, no redirects followed, a timeout. */
247
+ const fetchProbe: HealthProbe = async (url, timeoutMs) => {
248
+ const response = await fetch(url, {
249
+ redirect: "manual",
250
+ headers: { accept: "application/json" },
251
+ signal: AbortSignal.timeout(timeoutMs),
252
+ });
253
+ const body = (await response.text()).slice(0, 4_096);
254
+ return { status: response.status, body };
255
+ };
256
+
257
+ export function createContentHost<V>(options: ContentHostOptions<V>): ContentHost<V> {
258
+ const gate = options.health;
259
+ const log = options.log ?? (() => {});
260
+ const secret = typeof options.secret === "string" ? utf8(options.secret.trim()) : options.secret;
261
+ const healthLabel = gate?.label ?? DEFAULT_HEALTH_LABEL;
262
+ // The id Porthole's health check answers with: keyed, so another deployment's answer fails.
263
+ const healthId = base64url(hmacSha256(secret, utf8("porthole/health"))).slice(0, 22);
264
+ const appReserved = reservedLabels(options.reserved);
265
+
266
+ let domain: ContentDomainInfo | null = null;
267
+ let server: PortholeServer | null = null;
268
+ let view: ContentHealthView = { state: "off" };
269
+ let configs: Readonly<Record<string, PortholeConfig>> = {};
270
+
271
+ try {
272
+ domain =
273
+ options.domain !== null && typeof options.domain === "object"
274
+ ? options.domain
275
+ : contentDomain(options.domain, { appOrigins: options.appOrigins });
276
+ if (domain !== null) {
277
+ if (options.entry !== undefined && options.name?.scopeOf === undefined) {
278
+ throw new TypeError(
279
+ "Redirect entry needs a naming that says which scope a label names (prefixedName)",
280
+ );
281
+ }
282
+ configs =
283
+ typeof options.configs === "function" ? options.configs(domain.source) : options.configs;
284
+ server = createServer({
285
+ content: serverDomain(domain, {
286
+ ...(options.name === undefined ? {} : { name: options.name }),
287
+ reserved: (label) => appReserved(label) || (gate !== undefined && label === healthLabel),
288
+ }),
289
+ secret,
290
+ hostOrigins: options.appOrigins,
291
+ configs,
292
+ ...(options.source === undefined ? {} : { artifacts: { get: options.source } }),
293
+ ...(options.tickets === undefined ? {} : { tickets: options.tickets }),
294
+ ...(options.upstream === undefined ? {} : { upstream: options.upstream }),
295
+ ...(options.entry === undefined
296
+ ? {}
297
+ : { entry: { authorizeUrl: options.entry.authorizeUrl } }),
298
+ ...(options.authorize === undefined
299
+ ? {}
300
+ : {
301
+ authorize: {
302
+ check: options.authorize,
303
+ ...(options.authorizeTtlSeconds === undefined
304
+ ? {}
305
+ : { ttlSeconds: options.authorizeTtlSeconds }),
306
+ },
307
+ }),
308
+ });
309
+ view = gate === undefined ? { state: "healthy" } : { state: "checking" };
310
+ }
311
+ } catch (error) {
312
+ // Porthole never serves settings it refuses. Without the fallback, the app can't start so;
313
+ // with it, they are a lasting "unhealthy: misconfigured", told once.
314
+ if (gate === undefined) {
315
+ throw error;
316
+ }
317
+ const code = error instanceof ContentDomainError ? error.code : "refused";
318
+ const reason = error instanceof Error ? error.message : String(error);
319
+ server = null;
320
+ view = { state: "unhealthy", reason: `misconfigured: ${reason}`, misconfigured: true };
321
+ log({ evt: "porthole.content.misconfigured", code, reason });
322
+ }
323
+
324
+ const served = (): boolean => server !== null && view.state === "healthy";
325
+
326
+ // The health gate: one check at a time, again after a minute while unhealthy, ten while healthy.
327
+ let timer: ReturnType<typeof setTimeout> | null = null;
328
+ let running: Promise<ContentHealthView> | null = null;
329
+ let closed = false;
330
+ const schedule = () => {
331
+ if (closed || gate === undefined || view.misconfigured === true || domain === null) {
332
+ return;
333
+ }
334
+ timer = setTimeout(
335
+ () => void check(),
336
+ view.state === "healthy" ? HEALTHY_EVERY_MS : UNHEALTHY_EVERY_MS,
337
+ );
338
+ (timer as { unref?: () => void }).unref?.();
339
+ };
340
+ const probe = async (): Promise<string | null> => {
341
+ if (domain === null) {
342
+ return "no content domain";
343
+ }
344
+ const host = `${healthLabel}.${domain.domain}`;
345
+ try {
346
+ const answer = await (gate?.probe ?? fetchProbe)(
347
+ `${domain.originOf(healthLabel)}${HEALTH_PATH}`,
348
+ gate?.timeoutMs ?? PROBE_TIMEOUT_MS,
349
+ );
350
+ if (answer.status !== 200) {
351
+ return `${host} answered ${answer.status}, not Porthole's health check`;
352
+ }
353
+ let said: unknown = null;
354
+ try {
355
+ said = JSON.parse(answer.body);
356
+ } catch {
357
+ said = null;
358
+ }
359
+ if (!isRecord(said) || said.porthole !== "content-domain") {
360
+ return `${host} is answered by something else`;
361
+ }
362
+ return said.id === healthId ? null : `${host} is answered by another deployment`;
363
+ } catch (error) {
364
+ return `${host}: ${error instanceof Error ? error.message : String(error)}`;
365
+ }
366
+ };
367
+ const check = async (): Promise<ContentHealthView> => {
368
+ if (gate === undefined || domain === null || view.misconfigured === true) {
369
+ return view;
370
+ }
371
+ running ??= (async () => {
372
+ if (timer !== null) {
373
+ clearTimeout(timer);
374
+ timer = null;
375
+ }
376
+ const reason = await probe();
377
+ const before = view;
378
+ view =
379
+ reason === null
380
+ ? { state: "healthy", checkedAt: Date.now() }
381
+ : { state: "unhealthy", reason, checkedAt: Date.now() };
382
+ if (before.state !== view.state || before.reason !== view.reason) {
383
+ log(
384
+ reason === null
385
+ ? { evt: "porthole.content.healthy" }
386
+ : { evt: "porthole.content.unhealthy", reason },
387
+ );
388
+ }
389
+ running = null;
390
+ schedule();
391
+ return view;
392
+ })();
393
+ return running;
394
+ };
395
+
396
+ // Issues per key per minute, in this process.
397
+ const issued = new Map<string, number[]>();
398
+ const withinRate = (key: string): boolean => {
399
+ const limit = options.rateLimit;
400
+ if (limit === undefined) {
401
+ return true;
402
+ }
403
+ const at = Date.now();
404
+ const recent = (issued.get(key) ?? []).filter((time) => at - time < 60_000);
405
+ if (recent.length >= limit.perMinute) {
406
+ issued.set(key, recent);
407
+ return false;
408
+ }
409
+ recent.push(at);
410
+ issued.set(key, recent);
411
+ if (issued.size > 50_000) {
412
+ issued.clear();
413
+ }
414
+ return true;
415
+ };
416
+
417
+ /** The content domain's label for a request, or null: the app's host, or a label it reserved. */
418
+ const routeOf = (request: Request): { readonly url: URL; readonly label: string } | null => {
419
+ if (domain === null) {
420
+ return null;
421
+ }
422
+ let url: URL;
423
+ try {
424
+ url = new URL(request.url);
425
+ } catch {
426
+ return null;
427
+ }
428
+ const header = request.headers.get("host");
429
+ const byUrl = domain.isContentHost(url.hostname);
430
+ const byHeader = header !== null && domain.isContentHost(header);
431
+ if (!byUrl && !byHeader) {
432
+ return null;
433
+ }
434
+ const label =
435
+ (byUrl ? domain.labelOf(url.hostname) : header === null ? null : domain.labelOf(header)) ??
436
+ "";
437
+ const top = label.split(".").at(-1) ?? "";
438
+ return top !== "" && appReserved(top) ? null : { url, label };
439
+ };
440
+
441
+ return {
442
+ get domain() {
443
+ return domain;
444
+ },
445
+ get server() {
446
+ return server;
447
+ },
448
+ frameSources:
449
+ server === null || domain === null
450
+ ? []
451
+ : [
452
+ ...embedderFrameSourcesOf(Object.values(configs)),
453
+ // A frame on a clean address goes by the authorize route (only that path).
454
+ ...(options.entry === undefined ? [] : [authorizeSource(options.entry.authorizeUrl)]),
455
+ ],
456
+
457
+ owns: (request) => routeOf(request) !== null,
458
+
459
+ async fetch(request) {
460
+ const route = routeOf(request);
461
+ if (route === null) {
462
+ return null;
463
+ }
464
+ const { url, label } = route;
465
+ if (server === null) {
466
+ return plain(503, "Content is unavailable.");
467
+ }
468
+ if (gate !== undefined && label === healthLabel) {
469
+ return url.pathname === HEALTH_PATH && request.method.toUpperCase() === "GET"
470
+ ? json(200, { porthole: "content-domain", id: healthId })
471
+ : plain(404, "Not found.");
472
+ }
473
+ return (await server.handle(request)) ?? plain(404, "Not found.");
474
+ },
475
+
476
+ issue(viewerOf) {
477
+ return async (request) => {
478
+ if (request.method.toUpperCase() !== "POST") {
479
+ return refusal(405, "method", "POST { instance } here");
480
+ }
481
+ if (!served() || server === null) {
482
+ return refusal(
483
+ 503,
484
+ "not-served",
485
+ domain === null && view.misconfigured !== true
486
+ ? "This app serves no content frames"
487
+ : `Content frames aren't served now: ${view.reason ?? "the content domain is checking"}`,
488
+ );
489
+ }
490
+ let viewer: V | null;
491
+ try {
492
+ viewer = await viewerOf(request);
493
+ } catch {
494
+ return refusal(500, "failed", "The app couldn't tell who is asking");
495
+ }
496
+ const key =
497
+ options.rateLimit?.key?.(request, viewer) ??
498
+ (viewer === null
499
+ ? `address:${request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for") ?? ""}`
500
+ : `viewer:${JSON.stringify(viewer)}`);
501
+ if (!withinRate(key)) {
502
+ return refusal(429, "rate-limited", "Too many frames asked for: try again in a minute");
503
+ }
504
+ let body: unknown;
505
+ try {
506
+ body = await request.json();
507
+ } catch {
508
+ return refusal(400, "bad-json", "The body is JSON: { instance }");
509
+ }
510
+ const proposal = isRecord(body) ? body.instance : undefined;
511
+ if (
512
+ !isRecord(proposal) ||
513
+ typeof proposal.scope !== "string" ||
514
+ !isRecord(proposal.document) ||
515
+ typeof proposal.document.kind !== "string"
516
+ ) {
517
+ return refusal(400, "bad-request", "The body is { instance: { scope, document } }");
518
+ }
519
+ try {
520
+ const instance = await options.instance(
521
+ proposal as unknown as InstanceRef,
522
+ viewer,
523
+ request,
524
+ );
525
+ if (instance === null) {
526
+ return refusal(404, "not-found", "There is nothing to show for that instance");
527
+ }
528
+ const grant = await options.access(instance, viewer, request);
529
+ if (grant === null) {
530
+ return viewer === null
531
+ ? refusal(401, "unauthenticated", "Sign in to see this")
532
+ : refusal(403, "forbidden", "You can't see this");
533
+ }
534
+ let location: FrameLocation;
535
+ try {
536
+ location = await server.issue(instance, grant);
537
+ } catch (error) {
538
+ return refusal(409, "refused", error instanceof Error ? error.message : String(error));
539
+ }
540
+ return json(201, location);
541
+ } catch {
542
+ return refusal(500, "failed", "The app's policy failed");
543
+ }
544
+ };
545
+ },
546
+
547
+ meta: () => ({
548
+ served: served(),
549
+ contentSource: domain?.source ?? null,
550
+ health: gate === undefined ? null : view,
551
+ }),
552
+
553
+ urlOf(scope, path = "/") {
554
+ return server === null || !served() ? null : server.urlOf(scope, path);
555
+ },
556
+
557
+ authorize(viewerOf) {
558
+ const appPage = (
559
+ status: number,
560
+ title: string,
561
+ detail: string,
562
+ reason: FrameBlockReason,
563
+ method: string,
564
+ link?: { readonly href: string; readonly text: string },
565
+ ) =>
566
+ appStatusPage(
567
+ status,
568
+ title,
569
+ detail,
570
+ options.appOrigins.join(" "),
571
+ reason,
572
+ options.appOrigins,
573
+ link,
574
+ method,
575
+ );
576
+ return async (request) => {
577
+ const method = request.method.toUpperCase();
578
+ if (method !== "GET" && method !== "HEAD") {
579
+ return appPage(405, "Not allowed", "Open this address in a browser.", "error", method);
580
+ }
581
+ const entry = options.entry;
582
+ if (entry === undefined || server === null || domain === null || !served()) {
583
+ return appPage(
584
+ 503,
585
+ "Content is unavailable",
586
+ "This app isn't serving content now. Try again later.",
587
+ "error",
588
+ method,
589
+ );
590
+ }
591
+ const state = server.entryState(new URL(request.url).searchParams.get("state") ?? "");
592
+ if (state === null) {
593
+ return appPage(400, "This link isn't valid", "Open the address again.", "error", method);
594
+ }
595
+ const dest = request.headers.get("sec-fetch-dest");
596
+ const framed = dest === null ? state.framed : dest !== "document";
597
+ const clean = `${domain.originOf(state.label)}${state.path}`;
598
+ if (state.expired) {
599
+ return appPage(
600
+ 401,
601
+ "This took too long",
602
+ "Open the address again.",
603
+ "session",
604
+ method,
605
+ framed ? undefined : { href: clean, text: "Open it again" },
606
+ );
607
+ }
608
+ const scope = options.name?.scopeOf?.(state.label) ?? null;
609
+ let viewer: V | null;
610
+ try {
611
+ viewer = await viewerOf(request);
612
+ } catch {
613
+ return appPage(
614
+ 500,
615
+ "Something went wrong",
616
+ "The app couldn't tell who is asking.",
617
+ "error",
618
+ method,
619
+ );
620
+ }
621
+ try {
622
+ const instance =
623
+ scope === null
624
+ ? null
625
+ : await entry.at(
626
+ scope,
627
+ new URL(state.path, "https://x.invalid").pathname,
628
+ viewer,
629
+ request,
630
+ );
631
+ const grant = instance === null ? null : await options.access(instance, viewer, request);
632
+ if (instance === null || grant === null) {
633
+ if (viewer === null) {
634
+ // The app's sign-in never shows in a frame: the person opens a tab for it.
635
+ return framed
636
+ ? appPage(
637
+ 401,
638
+ "Open this in a new tab to sign in",
639
+ "Signing in doesn't work inside a frame.",
640
+ "sign-in",
641
+ method,
642
+ )
643
+ : await entry.signIn(request.url, request);
644
+ }
645
+ const status = instance === null ? 404 : 403;
646
+ if (!framed && entry.refusal !== undefined) {
647
+ return await entry.refusal(status, request);
648
+ }
649
+ return status === 404
650
+ ? appPage(404, "Not found", "There is nothing here.", "not-found", method)
651
+ : appPage(
652
+ 403,
653
+ "You can't open this",
654
+ "You don't have access to it.",
655
+ "denied",
656
+ method,
657
+ );
658
+ }
659
+ const key =
660
+ options.rateLimit?.key?.(request, viewer) ??
661
+ (viewer === null ? `entry:${state.label}` : `viewer:${JSON.stringify(viewer)}`);
662
+ if (!withinRate(key)) {
663
+ return appPage(429, "Too many requests", "Try again in a minute.", "error", method);
664
+ }
665
+ let location: string;
666
+ try {
667
+ location = await server.enterBy(state, instance, grant);
668
+ } catch {
669
+ return appPage(
670
+ 409,
671
+ "This can't be opened here",
672
+ "Open the address again.",
673
+ "error",
674
+ method,
675
+ );
676
+ }
677
+ return new Response(null, {
678
+ status: 302,
679
+ headers: {
680
+ location,
681
+ "cache-control": "no-store",
682
+ "referrer-policy": "no-referrer",
683
+ },
684
+ });
685
+ } catch {
686
+ return appPage(500, "Something went wrong", "The app's policy failed.", "error", method);
687
+ }
688
+ };
689
+ },
690
+
691
+ access: async (request) => (server === null ? null : server.access(request)),
692
+
693
+ start() {
694
+ closed = false;
695
+ void check();
696
+ },
697
+ check,
698
+ close() {
699
+ closed = true;
700
+ if (timer !== null) {
701
+ clearTimeout(timer);
702
+ timer = null;
703
+ }
704
+ },
705
+ };
706
+ }