@botiverse/porthole 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/conformance/clipboard.js +5 -2
  2. package/dist/conformance/entry.d.ts +37 -0
  3. package/dist/conformance/entry.js +120 -0
  4. package/dist/conformance/harness/page.d.ts +32 -0
  5. package/dist/conformance/harness/page.js +215 -0
  6. package/dist/conformance/harness.js +1662 -0
  7. package/dist/conformance/host-kit.d.ts +85 -0
  8. package/dist/conformance/host-kit.js +341 -0
  9. package/dist/conformance/index.d.ts +2 -0
  10. package/dist/conformance/index.js +4 -0
  11. package/dist/conformance/served.js +13 -3
  12. package/dist/host/locator.d.ts +39 -0
  13. package/dist/host/locator.js +72 -0
  14. package/dist/host/outcome.js +1 -0
  15. package/dist/host/types.d.ts +2 -0
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +1 -0
  18. package/dist/node/index.d.ts +41 -0
  19. package/dist/node/index.js +160 -0
  20. package/dist/platform/web.js +12 -1
  21. package/dist/server/domain.d.ts +43 -0
  22. package/dist/server/domain.js +100 -0
  23. package/dist/server/host.d.ts +158 -0
  24. package/dist/server/host.js +416 -0
  25. package/dist/server/index.d.ts +9 -2
  26. package/dist/server/index.js +7 -0
  27. package/dist/server/naming.d.ts +12 -1
  28. package/dist/server/naming.js +9 -8
  29. package/dist/server/pages.d.ts +9 -0
  30. package/dist/server/pages.js +32 -0
  31. package/dist/server/server.js +287 -50
  32. package/dist/server/types.d.ts +37 -0
  33. package/dist/transport/post-message.d.ts +9 -1
  34. package/dist/transport/post-message.js +11 -2
  35. package/package.json +6 -2
  36. package/src/conformance/clipboard.ts +5 -2
  37. package/src/conformance/entry.ts +206 -0
  38. package/src/conformance/harness/page.ts +267 -0
  39. package/src/conformance/host-kit.ts +466 -0
  40. package/src/conformance/index.ts +12 -0
  41. package/src/conformance/served.ts +14 -3
  42. package/src/host/locator.ts +111 -0
  43. package/src/host/outcome.ts +1 -0
  44. package/src/host/types.ts +2 -0
  45. package/src/index.ts +7 -0
  46. package/src/node/index.ts +221 -0
  47. package/src/platform/web.ts +19 -1
  48. package/src/server/domain.ts +158 -0
  49. package/src/server/host.ts +706 -0
  50. package/src/server/index.ts +27 -1
  51. package/src/server/naming.ts +22 -2
  52. package/src/server/pages.ts +44 -0
  53. package/src/server/server.ts +355 -63
  54. package/src/server/types.ts +33 -0
  55. package/src/transport/post-message.ts +12 -1
@@ -1,7 +1,8 @@
1
1
  import { compile } from "../compile/compile.js";
2
2
  import { hostOriginProblem, maybeSameSite, originMatchesSource, parseOrigin, } from "../isolation/served.js";
3
3
  import { sandboxProxyDocument } from "../mcp/proxy.js";
4
- import { createKeys, newTicket, ticketId } from "./keys.js";
4
+ import { base32, createKeys, newTicket, ticketId } from "./keys.js";
5
+ import { randomBytes } from "./crypto.js";
5
6
  import { isHostLabel, reservedLabels } from "./naming.js";
6
7
  import { enterPage, pageScript, SESSION_PATH, statusPage } from "./pages.js";
7
8
  import { memoryTicketStore } from "./tickets.js";
@@ -14,6 +15,15 @@ const PREFIX = "/__porthole/";
14
15
  const DOCUMENT_PATH = /^\/__porthole\/d\/([a-z2-7]{16})$/u;
15
16
  /** Where a url-mode app's ticket is taken, before the app's own landing path. */
16
17
  const ENTER_PATH = "/__porthole/enter";
18
+ /** Where a redirect entry's code is redeemed, before its clean address (P068). */
19
+ const ENTRY_PATH = "/__porthole/entry";
20
+ /** How long a redirect entry may take (the person may sign in on the way), and its code. */
21
+ const ENTRY_STATE_MS = 5 * 60_000;
22
+ const ENTRY_CODE_SECONDS = 60;
23
+ /** How long an entered path is remembered, so an entry that kept no cookie doesn't loop. */
24
+ const ENTERED_SECONDS = 60;
25
+ const NONCE = /^[a-z2-7]{16}$/u;
26
+ const MAX_PATH = 2_048;
17
27
  const LABEL = /^[a-z2-7]{24}$/u;
18
28
  /** A host's opaque subject and reason codes. */
19
29
  const SUBJECT_MAX = 512;
@@ -210,6 +220,18 @@ export function createServer(options) {
210
220
  }
211
221
  }
212
222
  const frameAncestors = options.hostOrigins.join(" ");
223
+ let authorizeUrl = null;
224
+ if (options.entry !== undefined) {
225
+ try {
226
+ authorizeUrl = new URL(options.entry.authorizeUrl);
227
+ }
228
+ catch {
229
+ throw new TypeError("entry.authorizeUrl must be an absolute URL");
230
+ }
231
+ if (!options.hostOrigins.includes(authorizeUrl.origin) || authorizeUrl.hash !== "") {
232
+ throw new TypeError(`entry.authorizeUrl must be on one of the host origins (${options.hostOrigins.join(", ")}), without a fragment`);
233
+ }
234
+ }
213
235
  const compiled = new Map();
214
236
  for (const [name, config] of Object.entries(options.configs)) {
215
237
  compiled.set(name, compile(config));
@@ -351,7 +373,7 @@ export function createServer(options) {
351
373
  * page that frames it; on a tab of its own, `Lax`. (Engines that keep no cookie in a cross-site
352
374
  * frame, measured: WebKit, still get the document, which comes with the ticket's answer.)
353
375
  */
354
- function sessionCookie(request, record) {
376
+ function sessionCookie(request, record, path) {
355
377
  const sessionSeconds = record.sessionSeconds ?? DEFAULT_SESSION_SECONDS;
356
378
  const session = {
357
379
  i: record.instance,
@@ -359,18 +381,27 @@ export function createServer(options) {
359
381
  x: Date.now() + sessionSeconds * 1_000,
360
382
  ...(record.anonymous === true ? { a: 1 } : {}),
361
383
  ...(record.subject === undefined ? {} : { s: record.subject }),
384
+ ...(path === undefined ? {} : { p: path }),
362
385
  };
363
- const framed = request.headers.get("sec-fetch-dest") !== "document";
364
386
  return [
365
387
  `${cookieName(keys.instanceKey(record.instance))}=${keys.sign(session)}`,
366
388
  "Path=/",
367
389
  "HttpOnly",
368
390
  `Max-Age=${sessionSeconds}`,
369
- ...(framed
370
- ? ["Secure", "SameSite=None", "Partitioned"]
371
- : [...(secure ? ["Secure"] : []), "SameSite=Lax"]),
391
+ ...cookieContext(request),
372
392
  ].join("; ");
373
393
  }
394
+ /** A cookie's context: a third party's in a frame (partitioned), first-party `Lax` on a tab. */
395
+ function cookieContext(request) {
396
+ const framed = request.headers.get("sec-fetch-dest") !== "document";
397
+ return framed
398
+ ? ["Secure", "SameSite=None", "Partitioned"]
399
+ : [...(secure ? ["Secure"] : []), "SameSite=Lax"];
400
+ }
401
+ /** The cookie a redirect entry's browser holds while it is away (P068), by its nonce. */
402
+ const nonceCookie = (nonce) => cookieName(`n-${nonce}`);
403
+ /** What an entered path's nonce cookie says after its code was redeemed: the loop guard. */
404
+ const enteredMark = (pathname) => `e.${keys.instanceKey({ entered: pathname })}`;
374
405
  /** The access a request's own cookies give on `label`'s origin, for `key`'s instance or the app. */
375
406
  function accessOf(request, label, key) {
376
407
  let best = null;
@@ -417,6 +448,45 @@ export function createServer(options) {
417
448
  }
418
449
  return best;
419
450
  }
451
+ /** The access a request's cookies give to the document entered at `pathname` (P068). */
452
+ function accessAtPath(request, label, pathname) {
453
+ let best = null;
454
+ for (const [name, value] of cookiesOf(request)) {
455
+ if (!ourCookie(name)) {
456
+ continue;
457
+ }
458
+ const session = keys.verify(value);
459
+ if (!isRecord(session) ||
460
+ session.p !== pathname ||
461
+ typeof session.w !== "string" ||
462
+ typeof session.x !== "number" ||
463
+ session.x <= Date.now() ||
464
+ (session.s !== undefined && typeof session.s !== "string")) {
465
+ continue;
466
+ }
467
+ let instance;
468
+ try {
469
+ instance = cleanInstance(session.i);
470
+ if (instance.document.kind === "external" || nameOf(instance.scope) !== label) {
471
+ continue;
472
+ }
473
+ }
474
+ catch {
475
+ continue;
476
+ }
477
+ if (best === null || session.x > best.expiresAt) {
478
+ best = {
479
+ instance,
480
+ viewer: session.w,
481
+ origin: originOfLabel(label),
482
+ expiresAt: session.x,
483
+ ...(session.a === 1 ? { anonymous: true } : {}),
484
+ ...(typeof session.s === "string" ? { subject: session.s } : {}),
485
+ };
486
+ }
487
+ }
488
+ return best;
489
+ }
420
490
  /** A taken ticket, if it is good for `label`'s origin. */
421
491
  async function take(ticket, label) {
422
492
  if (ticket === null || ticket.length === 0 || ticket.length > 128) {
@@ -471,6 +541,11 @@ export function createServer(options) {
471
541
  if (access === null) {
472
542
  return page(401, "This link has been used", "Content here opens once per link. Open it again from the app that showed it.", method, "session");
473
543
  }
544
+ return serveAccess(request, access, cookie);
545
+ }
546
+ /** The document an access opens, once the host's check lets it in. */
547
+ async function serveAccess(request, access, cookie) {
548
+ const method = request.method.toUpperCase();
474
549
  const refusal = await denied(request, access);
475
550
  if (refusal !== null) {
476
551
  return refusal;
@@ -485,6 +560,103 @@ export function createServer(options) {
485
560
  }
486
561
  return new Response(method === "HEAD" ? null : served.html, { status: 200, headers });
487
562
  }
563
+ /**
564
+ * A clean address without a cookie for it (P068): off to the host's authorize route with a
565
+ * signed state, the browser holding the state's nonce in a cookie of this origin. A path just
566
+ * entered whose cookie still didn't come back is a browser that keeps none here: no loop.
567
+ */
568
+ function beginEntry(request, url, label, to) {
569
+ const method = request.method.toUpperCase();
570
+ if (method !== "GET" && method !== "HEAD") {
571
+ return page(401, "Open this from the app", "Open this address in a browser.", method, "session");
572
+ }
573
+ const path = `${url.pathname}${url.search}`;
574
+ if (path.length > MAX_PATH) {
575
+ return page(414, "Address too long", "This address is too long to open.", method);
576
+ }
577
+ const mark = enteredMark(url.pathname);
578
+ if (cookiesOf(request).some(([name, value]) => ourCookie(name) && value === mark)) {
579
+ return page(401, "Open this in a new tab", "This browser keeps no sign-in for it inside a frame.", method, "cookies");
580
+ }
581
+ const nonce = base32(randomBytes(10));
582
+ const framed = request.headers.get("sec-fetch-dest") !== "document";
583
+ const state = keys.sign({
584
+ e: 1,
585
+ l: label,
586
+ p: path,
587
+ n: nonce,
588
+ x: Date.now() + ENTRY_STATE_MS,
589
+ f: framed ? 1 : 0,
590
+ });
591
+ const location = new URL(to);
592
+ location.searchParams.set("state", state);
593
+ return new Response(null, {
594
+ status: 302,
595
+ headers: {
596
+ location: location.href,
597
+ "set-cookie": [
598
+ `${nonceCookie(nonce)}=1`,
599
+ "Path=/",
600
+ "HttpOnly",
601
+ `Max-Age=${ENTRY_STATE_MS / 1_000}`,
602
+ ...cookieContext(request),
603
+ ].join("; "),
604
+ "cache-control": "no-store",
605
+ "referrer-policy": "no-referrer",
606
+ "x-robots-tag": "noindex, nofollow",
607
+ },
608
+ });
609
+ }
610
+ /**
611
+ * Redeems a redirect entry's code (P068): once, on its own label, in the browser that began it
612
+ * (its nonce cookie comes with it). Sets the session and goes to the clean address, the code
613
+ * gone from the address bar and from any referrer.
614
+ */
615
+ async function redeem(request, url, label) {
616
+ const method = request.method.toUpperCase();
617
+ if (method !== "GET") {
618
+ return page(405, "Not allowed", "Open this link in a browser.", method);
619
+ }
620
+ const record = await take(url.searchParams.get("code"), label);
621
+ if (record?.entry === undefined) {
622
+ return page(403, "This sign-in link has been used", "It works once, for a short time. Open the address again.", method, "session");
623
+ }
624
+ const { nonce, path } = record.entry;
625
+ const named = nonceCookie(nonce);
626
+ if (!cookiesOf(request).some(([name, value]) => name === named && value === "1")) {
627
+ return page(401, "Open this in a new tab", "This browser keeps no sign-in for it inside a frame.", method, "cookies");
628
+ }
629
+ const origin = originOfLabel(label);
630
+ const target = new URL(path, origin);
631
+ const access = {
632
+ instance: record.instance,
633
+ viewer: record.viewer,
634
+ origin,
635
+ expiresAt: Date.now() + (record.sessionSeconds ?? DEFAULT_SESSION_SECONDS) * 1_000,
636
+ ...(record.anonymous === true ? { anonymous: true } : {}),
637
+ ...(record.subject === undefined ? {} : { subject: record.subject }),
638
+ };
639
+ const refusal = await denied(request, access);
640
+ if (refusal !== null) {
641
+ return refusal;
642
+ }
643
+ const external = record.instance.document.kind === "external";
644
+ const headers = new Headers({
645
+ location: `${origin}${target.pathname}${target.search}`,
646
+ "cache-control": "no-store",
647
+ "referrer-policy": "no-referrer",
648
+ "x-robots-tag": "noindex, nofollow",
649
+ });
650
+ headers.append("set-cookie", sessionCookie(request, record, external ? undefined : target.pathname));
651
+ headers.append("set-cookie", [
652
+ `${named}=${enteredMark(target.pathname)}`,
653
+ "Path=/",
654
+ "HttpOnly",
655
+ `Max-Age=${ENTERED_SECONDS}`,
656
+ ...cookieContext(request),
657
+ ].join("; "));
658
+ return new Response(null, { status: 303, headers });
659
+ }
488
660
  async function enter(request, url, label) {
489
661
  const method = request.method.toUpperCase();
490
662
  if (method !== "GET") {
@@ -574,6 +746,55 @@ export function createServer(options) {
574
746
  return page(502, "The app didn't answer", "Its server couldn't be reached.", method);
575
747
  }
576
748
  }
749
+ /** A one-time ticket (or entry code) for an instance the host decided a viewer may see. */
750
+ async function mint(instance, grant, entry) {
751
+ const clean = cleanInstance(instance);
752
+ check(clean);
753
+ const anonymous = grant.anonymous === true;
754
+ if (anonymous) {
755
+ if (grant.viewer !== undefined) {
756
+ throw new TypeError("An anonymous grant names no viewer");
757
+ }
758
+ if (clean.document.kind === "external") {
759
+ throw new TypeError("A url-mode app is never opened anonymously (P046)");
760
+ }
761
+ }
762
+ else if (typeof grant.viewer !== "string" ||
763
+ grant.viewer.length === 0 ||
764
+ grant.viewer.length > 512) {
765
+ throw new TypeError("A grant's viewer is 1 to 512 characters (or the grant is anonymous)");
766
+ }
767
+ if (grant.subject !== undefined &&
768
+ (typeof grant.subject !== "string" || grant.subject.length > SUBJECT_MAX)) {
769
+ throw new TypeError(`A grant's subject is a string of at most ${SUBJECT_MAX} characters`);
770
+ }
771
+ const ttl = seconds(grant.ttlSeconds, DEFAULT_TICKET_SECONDS, MAX_TICKET_SECONDS, "ttlSeconds");
772
+ const sessionSeconds = seconds(grant.sessionSeconds, DEFAULT_SESSION_SECONDS, MAX_SESSION_SECONDS, "sessionSeconds");
773
+ const key = keys.instanceKey(clean);
774
+ const viewer = anonymous ? "" : grant.viewer;
775
+ const probe = keys.sign({
776
+ i: clean,
777
+ w: viewer,
778
+ x: Number.MAX_SAFE_INTEGER,
779
+ ...(anonymous ? { a: 1 } : {}),
780
+ ...(grant.subject === undefined ? {} : { s: grant.subject }),
781
+ ...(entry === undefined ? {} : { p: entry.path }),
782
+ });
783
+ if (cookieName(key).length + probe.length + 120 > MAX_COOKIE_BYTES) {
784
+ throw new TypeError("This instance is too large to carry in a cookie (fewer origins?)");
785
+ }
786
+ const ticket = newTicket();
787
+ await tickets.put(ticketId(ticket), {
788
+ instance: clean,
789
+ viewer,
790
+ expiresAt: Date.now() + ttl * 1_000,
791
+ ...(grant.sessionSeconds === undefined ? {} : { sessionSeconds }),
792
+ ...(anonymous ? { anonymous: true } : {}),
793
+ ...(grant.subject === undefined ? {} : { subject: grant.subject }),
794
+ ...(entry === undefined ? {} : { entry }),
795
+ });
796
+ return { clean, key, ticket };
797
+ }
577
798
  const underDomain = (host) => host === domain || host.endsWith(`.${domain}`);
578
799
  /**
579
800
  * The label a request is for: null when it isn't under the content domain at all (or is in a
@@ -620,55 +841,55 @@ export function createServer(options) {
620
841
  contentSource,
621
842
  originFor: (scope) => originOfLabel(nameOf(scope)),
622
843
  async issue(instance, grant) {
623
- const clean = cleanInstance(instance);
624
- check(clean);
625
- const anonymous = grant.anonymous === true;
626
- if (anonymous) {
627
- if (grant.viewer !== undefined) {
628
- throw new TypeError("An anonymous grant names no viewer");
629
- }
630
- if (clean.document.kind === "external") {
631
- throw new TypeError("A url-mode app is never opened anonymously (P046)");
632
- }
633
- }
634
- else if (typeof grant.viewer !== "string" ||
635
- grant.viewer.length === 0 ||
636
- grant.viewer.length > 512) {
637
- throw new TypeError("A grant's viewer is 1 to 512 characters (or the grant is anonymous)");
638
- }
639
- if (grant.subject !== undefined &&
640
- (typeof grant.subject !== "string" || grant.subject.length > SUBJECT_MAX)) {
641
- throw new TypeError(`A grant's subject is a string of at most ${SUBJECT_MAX} characters`);
642
- }
643
- const ttl = seconds(grant.ttlSeconds, DEFAULT_TICKET_SECONDS, MAX_TICKET_SECONDS, "ttlSeconds");
644
- const sessionSeconds = seconds(grant.sessionSeconds, DEFAULT_SESSION_SECONDS, MAX_SESSION_SECONDS, "sessionSeconds");
645
- const key = keys.instanceKey(clean);
646
- const viewer = anonymous ? "" : grant.viewer;
647
- const probe = keys.sign({
648
- i: clean,
649
- w: viewer,
650
- x: Number.MAX_SAFE_INTEGER,
651
- ...(anonymous ? { a: 1 } : {}),
652
- ...(grant.subject === undefined ? {} : { s: grant.subject }),
653
- });
654
- if (cookieName(key).length + probe.length + 120 > MAX_COOKIE_BYTES) {
655
- throw new TypeError("This instance is too large to carry in a cookie (fewer origins?)");
656
- }
657
- const ticket = newTicket();
658
- await tickets.put(ticketId(ticket), {
659
- instance: clean,
660
- viewer,
661
- expiresAt: Date.now() + ttl * 1_000,
662
- ...(grant.sessionSeconds === undefined ? {} : { sessionSeconds }),
663
- ...(anonymous ? { anonymous: true } : {}),
664
- ...(grant.subject === undefined ? {} : { subject: grant.subject }),
665
- });
844
+ const { clean, key, ticket } = await mint(instance, grant);
666
845
  const origin = originOfLabel(nameOf(clean.scope));
667
846
  const path = clean.document.kind === "external"
668
847
  ? `${ENTER_PATH}?ticket=${ticket}`
669
848
  : `${PREFIX}d/${key}?ticket=${ticket}`;
670
849
  return { url: `${origin}${path}`, origin, reports: true };
671
850
  },
851
+ urlOf(scope, path = "/") {
852
+ if (!path.startsWith("/") || path.startsWith("//") || path.startsWith(PREFIX)) {
853
+ throw new TypeError("A clean address's path starts with one / and isn't Porthole's own");
854
+ }
855
+ return `${originOfLabel(nameOf(scope))}${path}`;
856
+ },
857
+ entryState(token) {
858
+ const state = token.length > 8_192 ? null : keys.verify(token);
859
+ if (!isRecord(state) ||
860
+ state.e !== 1 ||
861
+ typeof state.l !== "string" ||
862
+ !ours(state.l) ||
863
+ typeof state.p !== "string" ||
864
+ !state.p.startsWith("/") ||
865
+ typeof state.n !== "string" ||
866
+ !NONCE.test(state.n) ||
867
+ typeof state.x !== "number" ||
868
+ (state.f !== 0 && state.f !== 1)) {
869
+ return null;
870
+ }
871
+ return {
872
+ label: state.l,
873
+ path: state.p,
874
+ nonce: state.n,
875
+ framed: state.f === 1,
876
+ expired: state.x <= Date.now(),
877
+ };
878
+ },
879
+ async enterBy(state, instance, grant) {
880
+ if (state.expired) {
881
+ throw new TypeError("This entry's state has expired: start again from the clean address");
882
+ }
883
+ const clean = cleanInstance(instance);
884
+ if (nameOf(clean.scope) !== state.label) {
885
+ throw new TypeError("The instance isn't served on the address the entry began at");
886
+ }
887
+ const { ticket } = await mint(clean, { ...grant, ttlSeconds: ENTRY_CODE_SECONDS }, {
888
+ path: state.path,
889
+ nonce: state.nonce,
890
+ });
891
+ return `${originOfLabel(state.label)}${ENTRY_PATH}?code=${ticket}`;
892
+ },
672
893
  async access(request) {
673
894
  const at = labelOf(request);
674
895
  if (at === null || at === "mismatch" || !ours(at.label)) {
@@ -694,6 +915,9 @@ export function createServer(options) {
694
915
  if (url.pathname === ENTER_PATH) {
695
916
  return await enter(request, url, label);
696
917
  }
918
+ if (url.pathname === ENTRY_PATH && authorizeUrl !== null) {
919
+ return await redeem(request, url, label);
920
+ }
697
921
  const script = pageScript(url.pathname);
698
922
  if (script !== null) {
699
923
  return script;
@@ -708,8 +932,21 @@ export function createServer(options) {
708
932
  if (document?.[1] !== undefined) {
709
933
  return await serveDocument(request, url, label, document[1]);
710
934
  }
935
+ if (url.pathname.startsWith(PREFIX)) {
936
+ return page(404, "Nothing here", "This address names no content.", method);
937
+ }
938
+ // A clean address (P068): the document entered here, else the app, else entry.
939
+ if (authorizeUrl !== null) {
940
+ const entered = accessAtPath(request, label, url.pathname);
941
+ if (entered !== null) {
942
+ return await serveAccess(request, entered, null);
943
+ }
944
+ if (options.upstream === undefined || accessOf(request, label, null) === null) {
945
+ return beginEntry(request, url, label, authorizeUrl);
946
+ }
947
+ }
711
948
  // Without an upstream, only Porthole's own paths are served here: any other is nothing.
712
- if (url.pathname.startsWith(PREFIX) || options.upstream === undefined) {
949
+ if (options.upstream === undefined) {
713
950
  return page(404, "Nothing here", "This address names no content.", method);
714
951
  }
715
952
  return await serveApp(request, label);
@@ -66,6 +66,14 @@ export interface TicketRecord {
66
66
  readonly expiresAt: number;
67
67
  /** The grant's `sessionSeconds`, if it set one. */
68
68
  readonly sessionSeconds?: number;
69
+ /**
70
+ * An entry code's (P068): the clean path it returns to, and the nonce of the browser that asked
71
+ * (its cookie must come with the code). A ticket from `issue` has none.
72
+ */
73
+ readonly entry?: {
74
+ readonly path: string;
75
+ readonly nonce: string;
76
+ };
69
77
  }
70
78
  /** Where tickets live between `issue` and their one use. Default: in memory (one process). */
71
79
  export interface TicketStore {
@@ -146,6 +154,26 @@ export interface ServerOptions {
146
154
  readonly upstream?: Upstream;
147
155
  /** The host's per-request check (P050). Absent: a good cookie is enough until it ends. */
148
156
  readonly authorize?: Authorizer;
157
+ /**
158
+ * Redirect entry (P068): a request on a clean address (`urlOf`) without a cookie for it is sent
159
+ * to the host's authorize route, on a host origin, which decides and sends it back with a
160
+ * one-time code (`enterBy`). Absent: a clean address without a cookie is refused, as before.
161
+ */
162
+ readonly entry?: {
163
+ readonly authorizeUrl: string;
164
+ };
165
+ }
166
+ /** A redirect entry's state, as the host's authorize route reads it (`PortholeServer.entryState`). */
167
+ export interface EntryState {
168
+ /** The label the clean address is on. */
169
+ readonly label: string;
170
+ /** The path (and query) it asked for. */
171
+ readonly path: string;
172
+ /** Whether it was asked from a frame. */
173
+ readonly framed: boolean;
174
+ /** Older than five minutes: start again from the clean address. */
175
+ readonly expired: boolean;
176
+ readonly nonce: string;
149
177
  }
150
178
  export interface PortholeServer {
151
179
  /**
@@ -168,4 +196,13 @@ export interface PortholeServer {
168
196
  access(request: Request): Promise<Access | null>;
169
197
  /** The frame source every content origin matches (`https://*.example-usercontent.com`). */
170
198
  readonly contentSource: string;
199
+ /** A clean address on a scope's origin (P068): what a tab, a link, a bookmark opens. */
200
+ urlOf(scope: string, path?: string): string;
201
+ /** A redirect entry's state, signature checked; null for one this server didn't make. */
202
+ entryState(token: string): EntryState | null;
203
+ /**
204
+ * Once the host decided (its authorize route): a one-time code for `instance`, which must be
205
+ * served on the state's label, and the URL that redeems it there (`/__porthole/entry`).
206
+ */
207
+ enterBy(state: EntryState, instance: InstanceRef, grant: Grant): Promise<string>;
171
208
  }
@@ -6,4 +6,12 @@ import type { Transport } from "./types.ts";
6
6
  * Messages go to the frame's window addressed to that origin; an opaque frame can only be
7
7
  * addressed as `"*"`, so nothing secret is ever sent.
8
8
  */
9
- export declare function postMessageTransport(frameWindow: () => Window | null, expectedOrigin: string, host?: Window): Transport;
9
+ export declare function postMessageTransport(frameWindow: () => Window | null, expectedOrigin: string, host?: Window,
10
+ /**
11
+ * Another origin the frame's window may say some things from: the host's own, whose pages a
12
+ * served frame passes through on a redirect entry (P068), for their frame status only.
13
+ */
14
+ also?: {
15
+ readonly origin: string;
16
+ readonly accept: (data: unknown) => boolean;
17
+ }): Transport;
@@ -5,12 +5,21 @@
5
5
  * Messages go to the frame's window addressed to that origin; an opaque frame can only be
6
6
  * addressed as `"*"`, so nothing secret is ever sent.
7
7
  */
8
- export function postMessageTransport(frameWindow, expectedOrigin, host = window) {
8
+ export function postMessageTransport(frameWindow, expectedOrigin, host = window,
9
+ /**
10
+ * Another origin the frame's window may say some things from: the host's own, whose pages a
11
+ * served frame passes through on a redirect entry (P068), for their frame status only.
12
+ */
13
+ also) {
9
14
  const listeners = new Set();
10
15
  let closed = false;
11
16
  const onMessage = (event) => {
12
17
  const source = frameWindow();
13
- if (closed || source === null || event.source !== source || event.origin !== expectedOrigin) {
18
+ if (closed || source === null || event.source !== source) {
19
+ return;
20
+ }
21
+ if (event.origin !== expectedOrigin &&
22
+ (also === undefined || event.origin !== also.origin || !also.accept(event.data))) {
14
23
  return;
15
24
  }
16
25
  for (const listener of listeners) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botiverse/porthole",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "description": "A host-side sandbox for agent-made HTML: isolation, egress hardening, a themed shell, gated host functions, and the agent manual, from one config.",
5
5
  "license": "Apache-2.0",
6
6
  "files": [
@@ -29,6 +29,10 @@
29
29
  "./server": {
30
30
  "types": "./dist/server/index.d.ts",
31
31
  "default": "./dist/server/index.js"
32
+ },
33
+ "./node": {
34
+ "types": "./dist/node/index.d.ts",
35
+ "default": "./dist/node/index.js"
32
36
  }
33
37
  },
34
38
  "devDependencies": {
@@ -58,7 +62,7 @@
58
62
  "node": ">=24"
59
63
  },
60
64
  "scripts": {
61
- "build": "tsc -p tsconfig.build.json",
65
+ "build": "tsc -p tsconfig.build.json && node scripts/build-harness.ts",
62
66
  "test:leak": "playwright test -c leak/playwright.config.ts"
63
67
  }
64
68
  }
@@ -12,14 +12,17 @@ import { settle } from "./oracles.ts";
12
12
 
13
13
  const SEED = "porthole-seed";
14
14
 
15
- /** A text area of the oracle's own on the host's page. */
15
+ /**
16
+ * A text area of the oracle's own on the host's page: in the conformance harness's layer where
17
+ * there is one (over an app's own page, P066), else at the top of the page.
18
+ */
16
19
  async function area(page: Page, id: string): Promise<void> {
17
20
  await page.evaluate((each) => {
18
21
  if (document.getElementById(each) === null) {
19
22
  const element = document.createElement("textarea");
20
23
  element.id = each;
21
24
  element.style.cssText = "display:block;width:200px;height:40px";
22
- document.body.prepend(element);
25
+ (document.getElementById("porthole-conformance") ?? document.body).prepend(element);
23
26
  }
24
27
  }, id);
25
28
  }