@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
@@ -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,26 @@
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 {
37
+ createContentHost,
38
+ HEALTH_PATH,
39
+ type ContentHealthView,
40
+ type ContentHost,
41
+ type ContentHostEvent,
42
+ type ContentHostOptions,
43
+ type EntryOptions,
44
+ type ContentMeta,
45
+ type HealthOptions,
46
+ type HealthProbe,
47
+ } from "./host.ts";
48
+ export {
49
+ clashOf,
50
+ contentDomain,
51
+ ContentDomainError,
52
+ hostnameOf,
53
+ type ContentDomainInfo,
54
+ type ContentDomainProblem,
55
+ } from "./domain.ts";
31
56
  export {
32
57
  d1TicketSchema,
33
58
  d1TicketStore,
@@ -36,7 +61,7 @@ export {
36
61
  type D1StatementLike,
37
62
  } from "./tickets.ts";
38
63
  export { FRAME_STATUS_METHOD } from "../isolation/types.ts";
39
- export { isHostLabel, prefixedName } from "./naming.ts";
64
+ export { isHostLabel, prefixedName, type Naming } from "./naming.ts";
40
65
  export { proxyTo, type ProxyOptions } from "./proxy.ts";
41
66
  export type {
42
67
  Access,
@@ -45,6 +70,7 @@ export type {
45
70
  AuthorizeRequest,
46
71
  Authorizer,
47
72
  ContentDomain,
73
+ EntryState,
48
74
  Grant,
49
75
  PortholeServer,
50
76
  ServerOptions,
@@ -24,13 +24,25 @@ const ID = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/u;
24
24
  * already be label-safe (lowercase letters, digits, inner hyphens); anything else throws rather
25
25
  * than being folded into another scope's name.
26
26
  */
27
- export function prefixedName(prefix: string): (scope: string) => string {
27
+ /**
28
+ * How scopes are named (`ContentDomain.name`). A naming that can say which scope a label names
29
+ * (`scopeOf`) gives every scope clean addresses a redirect entry can open (P068).
30
+ */
31
+ export interface Naming {
32
+ (scope: string): string;
33
+ /** The scope a label names, or null for a label this naming never makes. */
34
+ readonly scopeOf?: (label: string) => string | null;
35
+ }
36
+
37
+ export function prefixedName(prefix: string): Naming & {
38
+ readonly scopeOf: (label: string) => string | null;
39
+ } {
28
40
  if (!PREFIX.test(prefix)) {
29
41
  throw new TypeError(
30
42
  "A name prefix is a lowercase letter, then letters or digits, then '-' (a-)",
31
43
  );
32
44
  }
33
- return (scope) => {
45
+ const name = (scope: string): string => {
34
46
  const label = `${prefix}${scope}`;
35
47
  if (!ID.test(scope) || !isHostLabel(label)) {
36
48
  throw new TypeError(
@@ -39,6 +51,14 @@ export function prefixedName(prefix: string): (scope: string) => string {
39
51
  }
40
52
  return label;
41
53
  };
54
+ const scopeOf = (label: string): string | null => {
55
+ if (!label.startsWith(prefix)) {
56
+ return null;
57
+ }
58
+ const scope = label.slice(prefix.length);
59
+ return ID.test(scope) && isHostLabel(label) ? scope : null;
60
+ };
61
+ return Object.assign(name, { scopeOf });
42
62
  }
43
63
 
44
64
  const PREFIX_ENTRY = /^[a-z0-9]([a-z0-9-]{0,61})?\*$/u;
@@ -1,5 +1,6 @@
1
1
  import { FRAME_STATUS_METHOD } from "../isolation/types.ts";
2
2
  import type { FrameBlock } from "../host/types.ts";
3
+ import { base64url, sha256, utf8 } from "./crypto.ts";
3
4
 
4
5
  /**
5
6
  * The server's own pages on a content origin: why it shows nothing of the content, and the page a
@@ -126,3 +127,46 @@ export function enterPage(
126
127
  headers.set("set-cookie", cookie);
127
128
  return new Response(body, { status: 200, headers });
128
129
  }
130
+
131
+ /** The status script's hash, for a page that carries it inline (`script-src 'sha256-…'`). */
132
+ let statusHash: string | null = null;
133
+ const hashOfStatus = (): string => {
134
+ statusHash ??= base64url(sha256(utf8(STATUS_SCRIPT)))
135
+ .replace(/-/gu, "+")
136
+ .replace(/_/gu, "/");
137
+ const padding = (4 - (statusHash.length % 4)) % 4;
138
+ return `'sha256-${statusHash}${"=".repeat(padding)}'`;
139
+ };
140
+
141
+ /**
142
+ * A redirect entry's page on the app's own origin (P068): why it shows nothing, told to the page
143
+ * that frames it like a content origin's page. Plain, no app chrome; its one script inline under
144
+ * its hash (the app origin serves no script of Porthole's).
145
+ */
146
+ export function appStatusPage(
147
+ status: number,
148
+ title: string,
149
+ detail: string,
150
+ frameAncestors: string,
151
+ reason: FrameBlock,
152
+ hostOrigins: readonly string[],
153
+ link?: { readonly href: string; readonly text: string },
154
+ method = "GET",
155
+ ): Response {
156
+ const more =
157
+ link === undefined
158
+ ? ""
159
+ : `<p><a href="${escape(link.href)}" target="_top" rel="noopener">${escape(link.text)}</a></p>`;
160
+ 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>`;
161
+ return new Response(method === "HEAD" ? null : body, {
162
+ status,
163
+ headers: {
164
+ "content-type": "text/html; charset=utf-8",
165
+ "cache-control": "no-store",
166
+ "content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; script-src ${hashOfStatus()}; base-uri 'none'; form-action 'none'; frame-ancestors ${frameAncestors}`,
167
+ "referrer-policy": "no-referrer",
168
+ "x-content-type-options": "nosniff",
169
+ "x-robots-tag": "noindex, nofollow",
170
+ },
171
+ });
172
+ }
@@ -8,7 +8,8 @@ import {
8
8
  } from "../isolation/served.ts";
9
9
  import type { FrameLocation, InstanceRef, ServedDocument } from "../isolation/types.ts";
10
10
  import { sandboxProxyDocument } from "../mcp/proxy.ts";
11
- import { createKeys, newTicket, ticketId } from "./keys.ts";
11
+ import { base32, createKeys, newTicket, ticketId } from "./keys.ts";
12
+ import { randomBytes } from "./crypto.ts";
12
13
  import { isHostLabel, reservedLabels } from "./naming.ts";
13
14
  import { enterPage, pageScript, SESSION_PATH, statusPage } from "./pages.ts";
14
15
  import type { FrameBlock } from "../host/types.ts";
@@ -16,6 +17,8 @@ import { memoryTicketStore } from "./tickets.ts";
16
17
  import type {
17
18
  Access,
18
19
  AuthorizeAnswer,
20
+ EntryState,
21
+ Grant,
19
22
  PortholeServer,
20
23
  ServerOptions,
21
24
  TicketRecord,
@@ -31,6 +34,15 @@ const PREFIX = "/__porthole/";
31
34
  const DOCUMENT_PATH = /^\/__porthole\/d\/([a-z2-7]{16})$/u;
32
35
  /** Where a url-mode app's ticket is taken, before the app's own landing path. */
33
36
  const ENTER_PATH = "/__porthole/enter";
37
+ /** Where a redirect entry's code is redeemed, before its clean address (P068). */
38
+ const ENTRY_PATH = "/__porthole/entry";
39
+ /** How long a redirect entry may take (the person may sign in on the way), and its code. */
40
+ const ENTRY_STATE_MS = 5 * 60_000;
41
+ const ENTRY_CODE_SECONDS = 60;
42
+ /** How long an entered path is remembered, so an entry that kept no cookie doesn't loop. */
43
+ const ENTERED_SECONDS = 60;
44
+ const NONCE = /^[a-z2-7]{16}$/u;
45
+ const MAX_PATH = 2_048;
34
46
 
35
47
  const LABEL = /^[a-z2-7]{24}$/u;
36
48
  /** A host's opaque subject and reason codes. */
@@ -68,6 +80,8 @@ interface Session {
68
80
  readonly x: number;
69
81
  readonly a?: 1;
70
82
  readonly s?: string;
83
+ /** The clean path a document was entered at (P068): it is served there by this cookie. */
84
+ readonly p?: string;
71
85
  }
72
86
 
73
87
  const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -263,6 +277,19 @@ export function createServer(options: ServerOptions): PortholeServer {
263
277
  }
264
278
  }
265
279
  const frameAncestors = options.hostOrigins.join(" ");
280
+ let authorizeUrl: URL | null = null;
281
+ if (options.entry !== undefined) {
282
+ try {
283
+ authorizeUrl = new URL(options.entry.authorizeUrl);
284
+ } catch {
285
+ throw new TypeError("entry.authorizeUrl must be an absolute URL");
286
+ }
287
+ if (!options.hostOrigins.includes(authorizeUrl.origin) || authorizeUrl.hash !== "") {
288
+ throw new TypeError(
289
+ `entry.authorizeUrl must be on one of the host origins (${options.hostOrigins.join(", ")}), without a fragment`,
290
+ );
291
+ }
292
+ }
266
293
 
267
294
  const compiled = new Map<string, CompiledConfig>();
268
295
  for (const [name, config] of Object.entries(options.configs)) {
@@ -435,7 +462,7 @@ export function createServer(options: ServerOptions): PortholeServer {
435
462
  * page that frames it; on a tab of its own, `Lax`. (Engines that keep no cookie in a cross-site
436
463
  * frame, measured: WebKit, still get the document, which comes with the ticket's answer.)
437
464
  */
438
- function sessionCookie(request: Request, record: TicketRecord): string {
465
+ function sessionCookie(request: Request, record: TicketRecord, path?: string): string {
439
466
  const sessionSeconds = record.sessionSeconds ?? DEFAULT_SESSION_SECONDS;
440
467
  const session: Session = {
441
468
  i: record.instance,
@@ -443,19 +470,30 @@ export function createServer(options: ServerOptions): PortholeServer {
443
470
  x: Date.now() + sessionSeconds * 1_000,
444
471
  ...(record.anonymous === true ? { a: 1 as const } : {}),
445
472
  ...(record.subject === undefined ? {} : { s: record.subject }),
473
+ ...(path === undefined ? {} : { p: path }),
446
474
  };
447
- const framed = request.headers.get("sec-fetch-dest") !== "document";
448
475
  return [
449
476
  `${cookieName(keys.instanceKey(record.instance))}=${keys.sign(session)}`,
450
477
  "Path=/",
451
478
  "HttpOnly",
452
479
  `Max-Age=${sessionSeconds}`,
453
- ...(framed
454
- ? ["Secure", "SameSite=None", "Partitioned"]
455
- : [...(secure ? ["Secure"] : []), "SameSite=Lax"]),
480
+ ...cookieContext(request),
456
481
  ].join("; ");
457
482
  }
458
483
 
484
+ /** A cookie's context: a third party's in a frame (partitioned), first-party `Lax` on a tab. */
485
+ function cookieContext(request: Request): string[] {
486
+ const framed = request.headers.get("sec-fetch-dest") !== "document";
487
+ return framed
488
+ ? ["Secure", "SameSite=None", "Partitioned"]
489
+ : [...(secure ? ["Secure"] : []), "SameSite=Lax"];
490
+ }
491
+
492
+ /** The cookie a redirect entry's browser holds while it is away (P068), by its nonce. */
493
+ const nonceCookie = (nonce: string): string => cookieName(`n-${nonce}`);
494
+ /** What an entered path's nonce cookie says after its code was redeemed: the loop guard. */
495
+ const enteredMark = (pathname: string): string => `e.${keys.instanceKey({ entered: pathname })}`;
496
+
459
497
  /** The access a request's own cookies give on `label`'s origin, for `key`'s instance or the app. */
460
498
  function accessOf(request: Request, label: string, key: string | null): Access | null {
461
499
  let best: Access | null = null;
@@ -505,6 +543,47 @@ export function createServer(options: ServerOptions): PortholeServer {
505
543
  return best;
506
544
  }
507
545
 
546
+ /** The access a request's cookies give to the document entered at `pathname` (P068). */
547
+ function accessAtPath(request: Request, label: string, pathname: string): Access | null {
548
+ let best: Access | null = null;
549
+ for (const [name, value] of cookiesOf(request)) {
550
+ if (!ourCookie(name)) {
551
+ continue;
552
+ }
553
+ const session = keys.verify(value);
554
+ if (
555
+ !isRecord(session) ||
556
+ session.p !== pathname ||
557
+ typeof session.w !== "string" ||
558
+ typeof session.x !== "number" ||
559
+ session.x <= Date.now() ||
560
+ (session.s !== undefined && typeof session.s !== "string")
561
+ ) {
562
+ continue;
563
+ }
564
+ let instance: InstanceRef;
565
+ try {
566
+ instance = cleanInstance(session.i);
567
+ if (instance.document.kind === "external" || nameOf(instance.scope) !== label) {
568
+ continue;
569
+ }
570
+ } catch {
571
+ continue;
572
+ }
573
+ if (best === null || session.x > best.expiresAt) {
574
+ best = {
575
+ instance,
576
+ viewer: session.w,
577
+ origin: originOfLabel(label),
578
+ expiresAt: session.x,
579
+ ...(session.a === 1 ? { anonymous: true } : {}),
580
+ ...(typeof session.s === "string" ? { subject: session.s } : {}),
581
+ };
582
+ }
583
+ }
584
+ return best;
585
+ }
586
+
508
587
  /** A taken ticket, if it is good for `label`'s origin. */
509
588
  async function take(ticket: string | null, label: string): Promise<TicketRecord | null> {
510
589
  if (ticket === null || ticket.length === 0 || ticket.length > 128) {
@@ -573,6 +652,16 @@ export function createServer(options: ServerOptions): PortholeServer {
573
652
  "session",
574
653
  );
575
654
  }
655
+ return serveAccess(request, access, cookie);
656
+ }
657
+
658
+ /** The document an access opens, once the host's check lets it in. */
659
+ async function serveAccess(
660
+ request: Request,
661
+ access: Access,
662
+ cookie: string | null,
663
+ ): Promise<Response> {
664
+ const method = request.method.toUpperCase();
576
665
  const refusal = await denied(request, access);
577
666
  if (refusal !== null) {
578
667
  return refusal;
@@ -588,6 +677,135 @@ export function createServer(options: ServerOptions): PortholeServer {
588
677
  return new Response(method === "HEAD" ? null : served.html, { status: 200, headers });
589
678
  }
590
679
 
680
+ /**
681
+ * A clean address without a cookie for it (P068): off to the host's authorize route with a
682
+ * signed state, the browser holding the state's nonce in a cookie of this origin. A path just
683
+ * entered whose cookie still didn't come back is a browser that keeps none here: no loop.
684
+ */
685
+ function beginEntry(request: Request, url: URL, label: string, to: URL): Response {
686
+ const method = request.method.toUpperCase();
687
+ if (method !== "GET" && method !== "HEAD") {
688
+ return page(
689
+ 401,
690
+ "Open this from the app",
691
+ "Open this address in a browser.",
692
+ method,
693
+ "session",
694
+ );
695
+ }
696
+ const path = `${url.pathname}${url.search}`;
697
+ if (path.length > MAX_PATH) {
698
+ return page(414, "Address too long", "This address is too long to open.", method);
699
+ }
700
+ const mark = enteredMark(url.pathname);
701
+ if (cookiesOf(request).some(([name, value]) => ourCookie(name) && value === mark)) {
702
+ return page(
703
+ 401,
704
+ "Open this in a new tab",
705
+ "This browser keeps no sign-in for it inside a frame.",
706
+ method,
707
+ "cookies",
708
+ );
709
+ }
710
+ const nonce = base32(randomBytes(10));
711
+ const framed = request.headers.get("sec-fetch-dest") !== "document";
712
+ const state = keys.sign({
713
+ e: 1,
714
+ l: label,
715
+ p: path,
716
+ n: nonce,
717
+ x: Date.now() + ENTRY_STATE_MS,
718
+ f: framed ? 1 : 0,
719
+ });
720
+ const location = new URL(to);
721
+ location.searchParams.set("state", state);
722
+ return new Response(null, {
723
+ status: 302,
724
+ headers: {
725
+ location: location.href,
726
+ "set-cookie": [
727
+ `${nonceCookie(nonce)}=1`,
728
+ "Path=/",
729
+ "HttpOnly",
730
+ `Max-Age=${ENTRY_STATE_MS / 1_000}`,
731
+ ...cookieContext(request),
732
+ ].join("; "),
733
+ "cache-control": "no-store",
734
+ "referrer-policy": "no-referrer",
735
+ "x-robots-tag": "noindex, nofollow",
736
+ },
737
+ });
738
+ }
739
+
740
+ /**
741
+ * Redeems a redirect entry's code (P068): once, on its own label, in the browser that began it
742
+ * (its nonce cookie comes with it). Sets the session and goes to the clean address, the code
743
+ * gone from the address bar and from any referrer.
744
+ */
745
+ async function redeem(request: Request, url: URL, label: string): Promise<Response> {
746
+ const method = request.method.toUpperCase();
747
+ if (method !== "GET") {
748
+ return page(405, "Not allowed", "Open this link in a browser.", method);
749
+ }
750
+ const record = await take(url.searchParams.get("code"), label);
751
+ if (record?.entry === undefined) {
752
+ return page(
753
+ 403,
754
+ "This sign-in link has been used",
755
+ "It works once, for a short time. Open the address again.",
756
+ method,
757
+ "session",
758
+ );
759
+ }
760
+ const { nonce, path } = record.entry;
761
+ const named = nonceCookie(nonce);
762
+ if (!cookiesOf(request).some(([name, value]) => name === named && value === "1")) {
763
+ return page(
764
+ 401,
765
+ "Open this in a new tab",
766
+ "This browser keeps no sign-in for it inside a frame.",
767
+ method,
768
+ "cookies",
769
+ );
770
+ }
771
+ const origin = originOfLabel(label);
772
+ const target = new URL(path, origin);
773
+ const access: Access = {
774
+ instance: record.instance,
775
+ viewer: record.viewer,
776
+ origin,
777
+ expiresAt: Date.now() + (record.sessionSeconds ?? DEFAULT_SESSION_SECONDS) * 1_000,
778
+ ...(record.anonymous === true ? { anonymous: true } : {}),
779
+ ...(record.subject === undefined ? {} : { subject: record.subject }),
780
+ };
781
+ const refusal = await denied(request, access);
782
+ if (refusal !== null) {
783
+ return refusal;
784
+ }
785
+ const external = record.instance.document.kind === "external";
786
+ const headers = new Headers({
787
+ location: `${origin}${target.pathname}${target.search}`,
788
+ "cache-control": "no-store",
789
+ "referrer-policy": "no-referrer",
790
+ "x-robots-tag": "noindex, nofollow",
791
+ });
792
+ headers.append(
793
+ "set-cookie",
794
+ sessionCookie(request, record, external ? undefined : target.pathname),
795
+ );
796
+ headers.append(
797
+ "set-cookie",
798
+ [
799
+ `${named}=${enteredMark(target.pathname)}`,
800
+ "Path=/",
801
+ "HttpOnly",
802
+ `Max-Age=${ENTERED_SECONDS}`,
803
+ ...cookieContext(request),
804
+ ].join("; "),
805
+ );
806
+ return new Response(null, { status: 303, headers });
807
+ }
808
+
591
809
  async function enter(request: Request, url: URL, label: string): Promise<Response> {
592
810
  const method = request.method.toUpperCase();
593
811
  if (method !== "GET") {
@@ -696,6 +914,68 @@ export function createServer(options: ServerOptions): PortholeServer {
696
914
  }
697
915
  }
698
916
 
917
+ /** A one-time ticket (or entry code) for an instance the host decided a viewer may see. */
918
+ async function mint(
919
+ instance: InstanceRef,
920
+ grant: Grant,
921
+ entry?: TicketRecord["entry"],
922
+ ): Promise<{ readonly clean: InstanceRef; readonly key: string; readonly ticket: string }> {
923
+ const clean = cleanInstance(instance);
924
+ check(clean);
925
+ const anonymous = grant.anonymous === true;
926
+ if (anonymous) {
927
+ if (grant.viewer !== undefined) {
928
+ throw new TypeError("An anonymous grant names no viewer");
929
+ }
930
+ if (clean.document.kind === "external") {
931
+ throw new TypeError("A url-mode app is never opened anonymously (P046)");
932
+ }
933
+ } else if (
934
+ typeof grant.viewer !== "string" ||
935
+ grant.viewer.length === 0 ||
936
+ grant.viewer.length > 512
937
+ ) {
938
+ throw new TypeError("A grant's viewer is 1 to 512 characters (or the grant is anonymous)");
939
+ }
940
+ if (
941
+ grant.subject !== undefined &&
942
+ (typeof grant.subject !== "string" || grant.subject.length > SUBJECT_MAX)
943
+ ) {
944
+ throw new TypeError(`A grant's subject is a string of at most ${SUBJECT_MAX} characters`);
945
+ }
946
+ const ttl = seconds(grant.ttlSeconds, DEFAULT_TICKET_SECONDS, MAX_TICKET_SECONDS, "ttlSeconds");
947
+ const sessionSeconds = seconds(
948
+ grant.sessionSeconds,
949
+ DEFAULT_SESSION_SECONDS,
950
+ MAX_SESSION_SECONDS,
951
+ "sessionSeconds",
952
+ );
953
+ const key = keys.instanceKey(clean);
954
+ const viewer = anonymous ? "" : grant.viewer;
955
+ const probe = keys.sign({
956
+ i: clean,
957
+ w: viewer,
958
+ x: Number.MAX_SAFE_INTEGER,
959
+ ...(anonymous ? { a: 1 } : {}),
960
+ ...(grant.subject === undefined ? {} : { s: grant.subject }),
961
+ ...(entry === undefined ? {} : { p: entry.path }),
962
+ });
963
+ if (cookieName(key).length + probe.length + 120 > MAX_COOKIE_BYTES) {
964
+ throw new TypeError("This instance is too large to carry in a cookie (fewer origins?)");
965
+ }
966
+ const ticket = newTicket();
967
+ await tickets.put(ticketId(ticket), {
968
+ instance: clean,
969
+ viewer,
970
+ expiresAt: Date.now() + ttl * 1_000,
971
+ ...(grant.sessionSeconds === undefined ? {} : { sessionSeconds }),
972
+ ...(anonymous ? { anonymous: true } : {}),
973
+ ...(grant.subject === undefined ? {} : { subject: grant.subject }),
974
+ ...(entry === undefined ? {} : { entry }),
975
+ });
976
+ return { clean, key, ticket };
977
+ }
978
+
699
979
  const underDomain = (host: string): boolean => host === domain || host.endsWith(`.${domain}`);
700
980
 
701
981
  /**
@@ -749,62 +1029,7 @@ export function createServer(options: ServerOptions): PortholeServer {
749
1029
  originFor: (scope) => originOfLabel(nameOf(scope)),
750
1030
 
751
1031
  async issue(instance, grant): Promise<FrameLocation> {
752
- const clean = cleanInstance(instance);
753
- check(clean);
754
- const anonymous = grant.anonymous === true;
755
- if (anonymous) {
756
- if (grant.viewer !== undefined) {
757
- throw new TypeError("An anonymous grant names no viewer");
758
- }
759
- if (clean.document.kind === "external") {
760
- throw new TypeError("A url-mode app is never opened anonymously (P046)");
761
- }
762
- } else if (
763
- typeof grant.viewer !== "string" ||
764
- grant.viewer.length === 0 ||
765
- grant.viewer.length > 512
766
- ) {
767
- throw new TypeError("A grant's viewer is 1 to 512 characters (or the grant is anonymous)");
768
- }
769
- if (
770
- grant.subject !== undefined &&
771
- (typeof grant.subject !== "string" || grant.subject.length > SUBJECT_MAX)
772
- ) {
773
- throw new TypeError(`A grant's subject is a string of at most ${SUBJECT_MAX} characters`);
774
- }
775
- const ttl = seconds(
776
- grant.ttlSeconds,
777
- DEFAULT_TICKET_SECONDS,
778
- MAX_TICKET_SECONDS,
779
- "ttlSeconds",
780
- );
781
- const sessionSeconds = seconds(
782
- grant.sessionSeconds,
783
- DEFAULT_SESSION_SECONDS,
784
- MAX_SESSION_SECONDS,
785
- "sessionSeconds",
786
- );
787
- const key = keys.instanceKey(clean);
788
- const viewer = anonymous ? "" : grant.viewer;
789
- const probe = keys.sign({
790
- i: clean,
791
- w: viewer,
792
- x: Number.MAX_SAFE_INTEGER,
793
- ...(anonymous ? { a: 1 } : {}),
794
- ...(grant.subject === undefined ? {} : { s: grant.subject }),
795
- });
796
- if (cookieName(key).length + probe.length + 120 > MAX_COOKIE_BYTES) {
797
- throw new TypeError("This instance is too large to carry in a cookie (fewer origins?)");
798
- }
799
- const ticket = newTicket();
800
- await tickets.put(ticketId(ticket), {
801
- instance: clean,
802
- viewer,
803
- expiresAt: Date.now() + ttl * 1_000,
804
- ...(grant.sessionSeconds === undefined ? {} : { sessionSeconds }),
805
- ...(anonymous ? { anonymous: true } : {}),
806
- ...(grant.subject === undefined ? {} : { subject: grant.subject }),
807
- });
1032
+ const { clean, key, ticket } = await mint(instance, grant);
808
1033
  const origin = originOfLabel(nameOf(clean.scope));
809
1034
  const path =
810
1035
  clean.document.kind === "external"
@@ -813,6 +1038,57 @@ export function createServer(options: ServerOptions): PortholeServer {
813
1038
  return { url: `${origin}${path}`, origin, reports: true };
814
1039
  },
815
1040
 
1041
+ urlOf(scope, path = "/") {
1042
+ if (!path.startsWith("/") || path.startsWith("//") || path.startsWith(PREFIX)) {
1043
+ throw new TypeError("A clean address's path starts with one / and isn't Porthole's own");
1044
+ }
1045
+ return `${originOfLabel(nameOf(scope))}${path}`;
1046
+ },
1047
+
1048
+ entryState(token) {
1049
+ const state = token.length > 8_192 ? null : keys.verify(token);
1050
+ if (
1051
+ !isRecord(state) ||
1052
+ state.e !== 1 ||
1053
+ typeof state.l !== "string" ||
1054
+ !ours(state.l) ||
1055
+ typeof state.p !== "string" ||
1056
+ !state.p.startsWith("/") ||
1057
+ typeof state.n !== "string" ||
1058
+ !NONCE.test(state.n) ||
1059
+ typeof state.x !== "number" ||
1060
+ (state.f !== 0 && state.f !== 1)
1061
+ ) {
1062
+ return null;
1063
+ }
1064
+ return {
1065
+ label: state.l,
1066
+ path: state.p,
1067
+ nonce: state.n,
1068
+ framed: state.f === 1,
1069
+ expired: state.x <= Date.now(),
1070
+ };
1071
+ },
1072
+
1073
+ async enterBy(state: EntryState, instance: InstanceRef, grant: Grant): Promise<string> {
1074
+ if (state.expired) {
1075
+ throw new TypeError("This entry's state has expired: start again from the clean address");
1076
+ }
1077
+ const clean = cleanInstance(instance);
1078
+ if (nameOf(clean.scope) !== state.label) {
1079
+ throw new TypeError("The instance isn't served on the address the entry began at");
1080
+ }
1081
+ const { ticket } = await mint(
1082
+ clean,
1083
+ { ...grant, ttlSeconds: ENTRY_CODE_SECONDS },
1084
+ {
1085
+ path: state.path,
1086
+ nonce: state.nonce,
1087
+ },
1088
+ );
1089
+ return `${originOfLabel(state.label)}${ENTRY_PATH}?code=${ticket}`;
1090
+ },
1091
+
816
1092
  async access(request) {
817
1093
  const at = labelOf(request);
818
1094
  if (at === null || at === "mismatch" || !ours(at.label)) {
@@ -844,6 +1120,9 @@ export function createServer(options: ServerOptions): PortholeServer {
844
1120
  if (url.pathname === ENTER_PATH) {
845
1121
  return await enter(request, url, label);
846
1122
  }
1123
+ if (url.pathname === ENTRY_PATH && authorizeUrl !== null) {
1124
+ return await redeem(request, url, label);
1125
+ }
847
1126
  const script = pageScript(url.pathname);
848
1127
  if (script !== null) {
849
1128
  return script;
@@ -859,8 +1138,21 @@ export function createServer(options: ServerOptions): PortholeServer {
859
1138
  if (document?.[1] !== undefined) {
860
1139
  return await serveDocument(request, url, label, document[1]);
861
1140
  }
1141
+ if (url.pathname.startsWith(PREFIX)) {
1142
+ return page(404, "Nothing here", "This address names no content.", method);
1143
+ }
1144
+ // A clean address (P068): the document entered here, else the app, else entry.
1145
+ if (authorizeUrl !== null) {
1146
+ const entered = accessAtPath(request, label, url.pathname);
1147
+ if (entered !== null) {
1148
+ return await serveAccess(request, entered, null);
1149
+ }
1150
+ if (options.upstream === undefined || accessOf(request, label, null) === null) {
1151
+ return beginEntry(request, url, label, authorizeUrl);
1152
+ }
1153
+ }
862
1154
  // Without an upstream, only Porthole's own paths are served here: any other is nothing.
863
- if (url.pathname.startsWith(PREFIX) || options.upstream === undefined) {
1155
+ if (options.upstream === undefined) {
864
1156
  return page(404, "Nothing here", "This address names no content.", method);
865
1157
  }
866
1158
  return await serveApp(request, label);