@tesser-sh/gateway 0.0.1 → 0.53.2

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 (2) hide show
  1. package/index.js +397 -254
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -1,6 +1,3 @@
1
- // src/cloudflare.ts
2
- import { DurableObject } from "cloudflare:workers";
3
-
4
1
  // ../front/src/page.ts
5
2
  var FAVICON = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect width="24" height="24" rx="4" fill="#000000"/><g transform="translate(2.4 2.4) scale(0.8)"><path fill="#f0f1e9" d="M11.5 11.5H2A9.5 9.5 0 0 1 11.5 2ZM12.5 12.5H22A9.5 9.5 0 0 1 12.5 22Z"/><path fill="#fbffc9" d="M12.5 2H22V11.5H12.5Z"/></g></svg>')}`;
6
3
  function escapeHtml(text) {
@@ -220,6 +217,12 @@ function parseGatewayLabel(label) {
220
217
  return null;
221
218
  return { boxId, port: Number(port) };
222
219
  }
220
+ function gatewayHostBox(gatewayHost, hostname) {
221
+ let suffix = `.${gatewayHost.toLowerCase()}`, host = hostname.toLowerCase().replace(/\.$/, "");
222
+ if (!host.endsWith(suffix))
223
+ return null;
224
+ return parseGatewayLabel(host.slice(0, -suffix.length).split(".").pop())?.boxId ?? null;
225
+ }
223
226
 
224
227
  // ../protocol/src/mux.ts
225
228
  var MUX_WINDOW = 262144, MUX_CHUNK = 65536;
@@ -317,6 +320,68 @@ function decodeFrame(buf) {
317
320
  }
318
321
 
319
322
  // src/cloudflare.ts
323
+ import { Effect as Effect4, Schema as Schema3 } from "effect";
324
+
325
+ // src/jwt.ts
326
+ import { Clock, Effect, Option } from "effect";
327
+ var KEYS_TTL_MS = 600000, REFETCH_MIN_MS = 30000, CLOCK_SKEW_S = 60, keySets = /* @__PURE__ */ new Map;
328
+ function base64url(text) {
329
+ let b64 = text.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(text.length / 4) * 4, "=");
330
+ return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
331
+ }
332
+ function json2(part) {
333
+ try {
334
+ let value = JSON.parse((/* @__PURE__ */ new TextDecoder()).decode(base64url(part)));
335
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
336
+ } catch {
337
+ return null;
338
+ }
339
+ }
340
+ var fetchKeys = (jwksUri, now) => Effect.tryPromise(async () => {
341
+ let res = await fetch(jwksUri);
342
+ if (!res.ok)
343
+ throw Error(`${jwksUri} answered ${res.status}`);
344
+ let body = await res.json(), keys = /* @__PURE__ */ new Map;
345
+ for (let jwk of body.keys ?? []) {
346
+ if (typeof jwk.kid !== "string" || jwk.kty !== "RSA")
347
+ continue;
348
+ keys.set(jwk.kid, await crypto.subtle.importKey("jwk", jwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, !1, ["verify"]));
349
+ }
350
+ let set = { keys, fetchedAt: now };
351
+ return keySets.set(jwksUri, set), set;
352
+ }).pipe(Effect.option), keyFor = Effect.fn("Jwt.keyFor")(function* (jwksUri, kid, now) {
353
+ let set = keySets.get(jwksUri);
354
+ if (set === void 0 || now - set.fetchedAt > KEYS_TTL_MS)
355
+ set = Option.getOrElse(yield* fetchKeys(jwksUri, now), () => set);
356
+ if (set !== void 0 && !set.keys.has(kid) && now - set.fetchedAt > REFETCH_MIN_MS)
357
+ set = Option.getOrElse(yield* fetchKeys(jwksUri, now), () => set);
358
+ return set?.keys.get(kid) ?? null;
359
+ }), verifyRs256 = Effect.fn("Jwt.verifyRs256")(function* (token, issuer, jwksUri) {
360
+ let parts = token.split(".");
361
+ if (parts.length !== 3)
362
+ return null;
363
+ let [h, p, s] = parts, header = json2(h), claims = json2(p);
364
+ if (header === null || claims === null || header.alg !== "RS256" || typeof header.kid !== "string" || claims.iss !== issuer)
365
+ return null;
366
+ let nowMs = yield* Clock.currentTimeMillis, key = yield* keyFor(jwksUri, header.kid, nowMs);
367
+ if (key === null)
368
+ return null;
369
+ let signature = base64url(s);
370
+ if (!(yield* Effect.tryPromise(() => crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signature, (/* @__PURE__ */ new TextEncoder()).encode(`${h}.${p}`))).pipe(Effect.orElseSucceed(() => !1))))
371
+ return null;
372
+ let now = Math.floor(nowMs / 1000);
373
+ if (typeof claims.exp !== "number" || claims.exp + CLOCK_SKEW_S < now)
374
+ return null;
375
+ if (typeof claims.nbf === "number" && claims.nbf - CLOCK_SKEW_S > now)
376
+ return null;
377
+ return claims;
378
+ });
379
+ function audienceIncludes(aud, expected) {
380
+ return aud === expected || Array.isArray(aud) && aud.includes(expected);
381
+ }
382
+
383
+ // src/relay.ts
384
+ import { DurableObject } from "cloudflare:workers";
320
385
  import { Clock as Clock3, Duration as Duration2, Effect as Effect3, Layer, Schema as Schema2 } from "effect";
321
386
 
322
387
  // ../front/src/csp.ts
@@ -368,10 +433,10 @@ var HOP_BY_HOP = [
368
433
  "upgrade"
369
434
  ];
370
435
  function withoutHopByHop(source) {
371
- let headers2 = new Headers(source), connectionHeaders = headers2.get("connection")?.split(",").map((name) => name.trim()).filter(Boolean) ?? [];
436
+ let headers = new Headers(source), connectionHeaders = headers.get("connection")?.split(",").map((name) => name.trim()).filter(Boolean) ?? [];
372
437
  for (let name of [...HOP_BY_HOP, ...connectionHeaders])
373
- headers2.delete(name);
374
- return headers2;
438
+ headers.delete(name);
439
+ return headers;
375
440
  }
376
441
  function wantsDocument(request) {
377
442
  return request.method === "GET" && (request.headers.get("sec-fetch-mode") === "navigate" || request.headers.get("accept")?.toLowerCase().includes("text/html") === !0);
@@ -396,7 +461,16 @@ function rewriteUrl(input, ws, ctx) {
396
461
  let port = Number(url.port || (url.protocol === "https:" || url.protocol === "wss:" ? 443 : 80));
397
462
  if (!ctx.ports.includes(port))
398
463
  return input;
399
- return url.pathname = `${port === ctx.port ? "" : `/__tesser/${port}`}${url.pathname}`, url.protocol = ws ? page.protocol === "https:" ? "wss:" : "ws:" : page.protocol, url.port = "", url.host = name === page.hostname ? page.host : sub === null ? ctx.host : `${sub}.${ctx.host}`, url.href;
464
+ return url.pathname = `${sub !== null ? `/__tesser/${sub}.${port}` : port === ctx.port ? "" : `/__tesser/${port}`}${url.pathname}`, url.protocol = ws ? page.protocol === "https:" ? "wss:" : "ws:" : page.protocol, url.port = "", url.host = name === page.hostname || sub !== null ? page.host : ctx.host, url.href;
465
+ }
466
+ function rewriteSrcset(value, to) {
467
+ let changed = !1, candidates = value.split(",").map((candidate) => {
468
+ let [url = "", ...descriptors] = candidate.trim().split(/\s+/), rewritten = to(url);
469
+ if (rewritten !== url)
470
+ changed = !0;
471
+ return [rewritten, ...descriptors].join(" ");
472
+ });
473
+ return changed ? candidates.join(", ") : value;
400
474
  }
401
475
  function shimSource() {
402
476
  return `(() => {
@@ -410,6 +484,7 @@ function shimSource() {
410
484
  const port = Number(params.get("port"));
411
485
  const rewriteUrl = ${rewriteUrl.toString()};
412
486
  const to = (input, ws) => rewriteUrl(String(input), ws, { href: location.href, host, ports, port });
487
+ const rewriteSrcset = ${rewriteSrcset.toString()};
413
488
  const fetch = window.fetch;
414
489
  window.fetch = function (input, init) {
415
490
  if (!(input instanceof Request)) return fetch.call(this, to(input, false), init);
@@ -437,6 +512,51 @@ function shimSource() {
437
512
  };
438
513
  wrap("WebSocket", true);
439
514
  wrap("EventSource", false);
515
+ const loads = [
516
+ ["HTMLImageElement", ["src", "srcset"]],
517
+ ["HTMLSourceElement", ["src", "srcset"]],
518
+ ["HTMLScriptElement", ["src"]],
519
+ ["HTMLIFrameElement", ["src"]],
520
+ ["HTMLMediaElement", ["src"]],
521
+ ["HTMLVideoElement", ["poster"]],
522
+ ["HTMLEmbedElement", ["src"]],
523
+ ["HTMLTrackElement", ["src"]],
524
+ ["HTMLLinkElement", ["href"]],
525
+ ].filter(([name]) => window[name]);
526
+ const fix = (name, value) => name === "srcset" ? rewriteSrcset(String(value), (url) => to(url, false)) : to(value, false);
527
+ const loaded = (element, name) => loads.some(([type, names]) => element instanceof window[type] && names.includes(name));
528
+ const setAttribute = Element.prototype.setAttribute;
529
+ Element.prototype.setAttribute = function (name, value) {
530
+ const lower = String(name).toLowerCase();
531
+ return setAttribute.call(this, name, loaded(this, lower) ? fix(lower, value) : value);
532
+ };
533
+ for (const [type, names] of loads) {
534
+ for (const name of names) {
535
+ const proto = window[type].prototype;
536
+ const descriptor = Object.getOwnPropertyDescriptor(proto, name);
537
+ if (!descriptor?.set) continue;
538
+ Object.defineProperty(proto, name, { ...descriptor, set(value) { descriptor.set.call(this, fix(name, value)); } });
539
+ }
540
+ }
541
+ const selector = "img, source, script, iframe, video, audio, embed, track, link";
542
+ const sweep = (element) => {
543
+ for (const name of ["src", "srcset", "poster", "href"]) {
544
+ const value = element.getAttribute(name);
545
+ if (value !== null && loaded(element, name)) {
546
+ const rewritten = fix(name, value);
547
+ if (rewritten !== value) setAttribute.call(element, name, rewritten);
548
+ }
549
+ }
550
+ };
551
+ new MutationObserver((records) => {
552
+ for (const record of records) {
553
+ for (const node of record.addedNodes) {
554
+ if (!(node instanceof Element)) continue;
555
+ if (node.matches(selector)) sweep(node);
556
+ for (const element of node.querySelectorAll(selector)) sweep(element);
557
+ }
558
+ }
559
+ }).observe(document, { childList: true, subtree: true });
440
560
  const follow = (element, attribute) => {
441
561
  const value = element?.getAttribute(attribute);
442
562
  if (!value) return;
@@ -459,10 +579,10 @@ function shimTag(params, nonce) {
459
579
  }
460
580
 
461
581
  // src/core.ts
462
- import { Clock, Context, Duration, Effect, Schedule, Schema } from "effect";
582
+ import { Clock as Clock2, Context, Duration, Effect as Effect2, Schedule, Schema } from "effect";
463
583
 
464
584
  // src/route.ts
465
- var PORT_PATH = /^\/__tesser\/(\d{1,5})(\/.*)?$/;
585
+ var PORT_PATH = /^\/__tesser\/(?:([a-z0-9-]+(?:\.[a-z0-9-]+)*)\.)?(\d{1,5})(\/.*)?$/;
466
586
  function parseTarget(gatewayHost, url) {
467
587
  let bare = url.hostname.toLowerCase().replace(/\.$/, ""), suffix = `.${gatewayHost.toLowerCase()}`;
468
588
  if (!bare.endsWith(suffix))
@@ -470,14 +590,14 @@ function parseTarget(gatewayHost, url) {
470
590
  let labels = bare.slice(0, -suffix.length).split("."), label = labels[labels.length - 1], parsed = parseGatewayLabel(label);
471
591
  if (parsed === null)
472
592
  return null;
473
- let gateway = `${gatewayHost}${url.port ? `:${url.port}` : ""}`, base = { boxId: parsed.boxId, sub: labels.slice(0, -1).join("."), boxHost: `${label}.${gateway}`, gateway, root: parsed.port }, match = PORT_PATH.exec(url.pathname);
593
+ let gateway = `${gatewayHost}${url.port ? `:${url.port}` : ""}`, sub = labels.slice(0, -1).join("."), base = { boxId: parsed.boxId, sub, pageSub: sub, boxHost: `${label}.${gateway}`, gateway, root: parsed.port }, match = PORT_PATH.exec(url.pathname);
474
594
  if (match === null)
475
595
  return { ...base, port: null, path: `${url.pathname}${url.search}` };
476
- return { ...base, port: Number(match[1]), path: `${match[2] ?? "/"}${url.search}` };
596
+ return { ...base, sub: match[1] ?? base.sub, port: Number(match[2]), path: `${match[3] ?? "/"}${url.search}` };
477
597
  }
478
- function parseUrl(raw) {
598
+ function parseUrl(raw, base) {
479
599
  try {
480
- return new URL(raw);
600
+ return new URL(raw, base);
481
601
  } catch {
482
602
  return null;
483
603
  }
@@ -510,7 +630,7 @@ class Waker extends Context.Service()("@tesser/gateway/Waker") {
510
630
 
511
631
  class Upstream extends Context.Service()("@tesser/gateway/Upstream") {
512
632
  }
513
- var WAKING_WINDOW = Duration.minutes(5), startingRetry = { schedule: Schedule.spaced(Duration.seconds(1)), times: 60 }, respond = (response) => ({ _tag: "response", response });
633
+ var VIEWER_COOKIE = "__Host-tesser-viewer", GATEWAY_COOKIES = /* @__PURE__ */ new Set(["CF_Authorization", VIEWER_COOKIE]), WAKING_WINDOW = Duration.minutes(5), startingRetry = { schedule: Schedule.spaced(Duration.seconds(1)), times: 60 }, respond = (response) => ({ _tag: "response", response });
514
634
  function page(request, state) {
515
635
  let status = viewerStatus(state);
516
636
  if (wantsDocument(request))
@@ -522,21 +642,21 @@ function viewerBox(box) {
522
642
  return { name: box.name, sharedBy: box.sharedBy };
523
643
  }
524
644
  function upstreamHeaders(request, target, port, root) {
525
- let headers2 = withoutHopByHop(request.headers);
526
- for (let name of [...headers2.keys()])
645
+ let headers = withoutHopByHop(request.headers);
646
+ for (let name of [...headers.keys()])
527
647
  if (name.startsWith("cf-access-") || name.startsWith("x-forwarded-"))
528
- headers2.delete(name);
529
- headers2.delete("forwarded");
530
- let cookie = headers2.get("cookie")?.split(";").filter((c) => c.trim().split("=")[0] !== "CF_Authorization").join(";").trim();
648
+ headers.delete(name);
649
+ headers.delete("forwarded"), headers.delete("x-tesser-viewer"), headers.delete("x-tesser-ticket");
650
+ let cookie = headers.get("cookie")?.split(";").filter((c) => !GATEWAY_COOKIES.has(c.trim().split("=")[0])).join(";").trim();
531
651
  if (cookie)
532
- headers2.set("cookie", cookie);
652
+ headers.set("cookie", cookie);
533
653
  else
534
- headers2.delete("cookie");
535
- headers2.set("host", presentedHost(target.sub, port)), headers2.set("x-forwarded-for", "127.0.0.1"), headers2.set("x-forwarded-proto", "http"), headers2.set("accept-encoding", "identity");
654
+ headers.delete("cookie");
655
+ headers.set("host", presentedHost(target.sub, port)), headers.set("x-forwarded-for", "127.0.0.1"), headers.set("x-forwarded-proto", "http"), headers.set("accept-encoding", "identity");
536
656
  let origin = request.headers.get("origin");
537
657
  if (origin !== null && parseUrl(origin)?.host === new URL(request.url).host)
538
- headers2.set("origin", `http://${presentedHost(target.sub, root)}`);
539
- return headers2;
658
+ headers.set("origin", `http://${presentedHost(target.pageSub, root)}`);
659
+ return headers;
540
660
  }
541
661
  function isHtmlDocument(request, response) {
542
662
  if (!wantsDocument(request) || response.status < 200 || response.status >= 300)
@@ -544,62 +664,62 @@ function isHtmlDocument(request, response) {
544
664
  return (response.headers.get("content-type")?.toLowerCase() ?? "").includes("text/html") && !response.headers.has("content-range");
545
665
  }
546
666
  function downstream(request, response, upstreamUrl, ctx, shownOrigin) {
547
- let headers2 = withoutHopByHop(response.headers);
548
- headers2.delete("content-encoding"), headers2.delete("content-length");
549
- let location = headers2.get("location");
667
+ let headers = withoutHopByHop(response.headers);
668
+ headers.delete("content-encoding"), headers.delete("content-length");
669
+ let location = headers.get("location");
550
670
  if (location !== null)
551
- headers2.set("location", rewriteUrl(parseUrl(location) === null ? new URL(location, upstreamUrl).href : location, !1, ctx));
671
+ headers.set("location", rewriteUrl(parseUrl(location) === null ? new URL(location, upstreamUrl).href : location, !1, ctx));
552
672
  let origin = request.headers.get("origin");
553
- if (origin !== null && shownOrigin !== null && headers2.get("access-control-allow-origin") === shownOrigin)
554
- headers2.set("access-control-allow-origin", origin);
555
- let init = { status: response.status, statusText: response.statusText, headers: headers2 };
673
+ if (origin !== null && shownOrigin !== null && headers.get("access-control-allow-origin") === shownOrigin)
674
+ headers.set("access-control-allow-origin", origin);
675
+ let init = { status: response.status, statusText: response.statusText, headers };
556
676
  if (!isHtmlDocument(request, response))
557
677
  return new Response(response.body, init);
558
- let csp = headers2.get("content-security-policy");
678
+ let csp = headers.get("content-security-policy");
559
679
  if (csp !== null)
560
- headers2.set("content-security-policy", allowSelfScripts(csp));
680
+ headers.set("content-security-policy", allowSelfScripts(csp));
561
681
  let tag = shimTag({ host: ctx.host, ports: ctx.ports, port: ctx.port }, csp === null ? void 0 : scriptNonce(csp)), placed = !1, inject = {
562
682
  element(element) {
563
683
  if (placed)
564
684
  return;
565
685
  placed = !0, element.prepend(tag, { html: !0 });
566
686
  }
567
- }, link = (attribute) => ({
687
+ }, link = (attribute, rewrite = (value) => rewriteUrl(value, !1, ctx)) => ({
568
688
  element(element) {
569
689
  let value = element.getAttribute(attribute);
570
690
  if (value === null)
571
691
  return;
572
- let rewritten = rewriteUrl(value, !1, ctx);
692
+ let rewritten = rewrite(value);
573
693
  if (rewritten !== value)
574
694
  element.setAttribute(attribute, rewritten);
575
695
  }
576
696
  });
577
- return new HTMLRewriter().on("head", inject).on("body", inject).on("a[href], area[href], link[href]", link("href")).on("form[action]", link("action")).on("button[formaction], input[formaction]", link("formaction")).on("script[src], img[src], iframe[src], source[src], video[src], audio[src], embed[src]", link("src")).transform(new Response(response.body, init));
697
+ return new HTMLRewriter().on("head", inject).on("body", inject).on("a[href], area[href], link[href]", link("href")).on("form[action]", link("action")).on("button[formaction], input[formaction]", link("formaction")).on("script[src], img[src], iframe[src], source[src], video[src], audio[src], embed[src]", link("src")).on("img[srcset], source[srcset]", link("srcset", (value) => rewriteSrcset(value, (url) => rewriteUrl(url, !1, ctx)))).transform(new Response(response.body, init));
578
698
  }
579
- var wakingPage = Effect.fn("Gateway.wakingPage")(function* (request, box, port, stage, since) {
580
- let now = yield* Clock.currentTimeMillis;
699
+ var wakingPage = Effect2.fn("Gateway.wakingPage")(function* (request, box, port, stage, since) {
700
+ let now = yield* Clock2.currentTimeMillis;
581
701
  return page(request, { kind: "waking", box: viewerBox(box), stage, port, seconds: Math.max(0, Math.round((now - since) / 1000)) });
582
- }), refused = Effect.fn("Gateway.refused")(function* (request, box, port) {
583
- let status = yield* (yield* Waker).status(box.boxId), now = yield* Clock.currentTimeMillis;
702
+ }), refused = Effect2.fn("Gateway.refused")(function* (request, box, port) {
703
+ let status = yield* (yield* Waker).status(box.boxId), now = yield* Clock2.currentTimeMillis;
584
704
  if (box.app === "exited")
585
705
  return page(request, { kind: "stopped", box: viewerBox(box) });
586
706
  if (status !== null && now - status.since < Duration.toMillis(WAKING_WINDOW))
587
707
  return yield* wakingPage(request, box, port, box.app === "running" ? "port" : "app", status.since);
588
708
  return page(request, { kind: "not-answering", box: viewerBox(box), port });
589
- }), serve = Effect.fn("Gateway.serve")(function* (request, target, box, port, root) {
590
- let headers2 = upstreamHeaders(request, target, port, root);
709
+ }), serve = Effect2.fn("Gateway.serve")(function* (request, target, box, port, root) {
710
+ let headers = upstreamHeaders(request, target, port, root);
591
711
  if (request.headers.get("upgrade")?.toLowerCase() === "websocket")
592
- return { _tag: "websocket", box, port, path: target.path, headers: headers2 };
593
- let ctx = { href: request.url, host: target.boxHost, ports: box.ports, port: root }, upstreamUrl = `http://${headers2.get("host")}${target.path}`, shownOrigin = headers2.get("origin"), replayable = request.method === "GET" || request.method === "HEAD", document = wantsDocument(request), upstream = yield* Upstream, attempt = Effect.suspend(() => upstream.fetch(box, port, new Request(upstreamUrl, {
712
+ return { _tag: "websocket", box, port, path: target.path, headers };
713
+ let ctx = { href: request.url, host: target.boxHost, ports: box.ports, port: root }, upstreamUrl = `http://${headers.get("host")}${target.path}`, shownOrigin = headers.get("origin"), replayable = request.method === "GET" || request.method === "HEAD", document = wantsDocument(request), upstream = yield* Upstream, attempt = Effect2.suspend(() => upstream.fetch(box, port, new Request(upstreamUrl, {
594
714
  method: request.method,
595
- headers: headers2,
715
+ headers,
596
716
  body: replayable ? void 0 : request.body,
597
717
  redirect: "manual",
598
718
  signal: request.signal,
599
719
  ...replayable ? {} : { duplex: "half" }
600
720
  })));
601
- return yield* (document || !replayable ? attempt : attempt.pipe(Effect.retry(startingRetry))).pipe(Effect.map((response) => respond(downstream(request, response, upstreamUrl, ctx, shownOrigin))), Effect.catchTag("UpstreamRefused", () => document ? refused(request, box, port) : Effect.succeed(page(request, { kind: "not-answering", box: viewerBox(box), port }))));
602
- }), handle = Effect.fn("Gateway.handle")(function* (request) {
721
+ return yield* (document || !replayable ? attempt : attempt.pipe(Effect2.retry(startingRetry))).pipe(Effect2.map((response) => respond(downstream(request, response, upstreamUrl, ctx, shownOrigin))), Effect2.catchTag("UpstreamRefused", () => document ? refused(request, box, port) : Effect2.succeed(page(request, { kind: "not-answering", box: viewerBox(box), port }))));
722
+ }), handle = Effect2.fn("Gateway.handle")(function* (request) {
603
723
  let directory = yield* BoxDirectory, waker = yield* Waker, url = new URL(request.url), host = yield* directory.host, target = parseTarget(host, url);
604
724
  if (target === null)
605
725
  return page(request, { kind: "not-shared" });
@@ -625,75 +745,17 @@ var wakingPage = Effect.fn("Gateway.wakingPage")(function* (request, box, port,
625
745
  if (wantsDocument(request)) {
626
746
  if (box.power === "launching" && status === null)
627
747
  return page(request, { kind: "booting", box: viewerBox(box) });
628
- return yield* wakingPage(request, box, port, "machine", status?.since ?? (yield* Clock.currentTimeMillis));
748
+ return yield* wakingPage(request, box, port, "machine", status?.since ?? (yield* Clock2.currentTimeMillis));
629
749
  }
630
750
  return yield* serve(request, target, yield* waker.awake(box.boxId), port, root);
631
751
  }
632
752
  return yield* serve(request, target, box, port, root);
633
- }, (effect, request) => effect.pipe(Effect.catchTags({
634
- BoxNotShared: () => Effect.succeed(page(request, { kind: "not-shared" })),
635
- DirectoryUnavailable: () => Effect.succeed(page(request, { kind: "unavailable" })),
636
- WakeFailed: (e) => Effect.succeed(page(request, { kind: "wake-failed", box: { name: e.boxId, sharedBy: null }, reason: e.reason }))
753
+ }, (effect, request) => effect.pipe(Effect2.catchTags({
754
+ BoxNotShared: () => Effect2.succeed(page(request, { kind: "not-shared" })),
755
+ DirectoryUnavailable: () => Effect2.succeed(page(request, { kind: "unavailable" })),
756
+ WakeFailed: (e) => Effect2.succeed(page(request, { kind: "wake-failed", box: { name: e.boxId, sharedBy: null }, reason: e.reason }))
637
757
  })));
638
758
 
639
- // src/jwt.ts
640
- import { Clock as Clock2, Effect as Effect2, Option } from "effect";
641
- var KEYS_TTL_MS = 600000, REFETCH_MIN_MS = 30000, CLOCK_SKEW_S = 60, keySets = /* @__PURE__ */ new Map;
642
- function base64url(text2) {
643
- let b64 = text2.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(text2.length / 4) * 4, "=");
644
- return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
645
- }
646
- function json2(part) {
647
- try {
648
- let value = JSON.parse((/* @__PURE__ */ new TextDecoder()).decode(base64url(part)));
649
- return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
650
- } catch {
651
- return null;
652
- }
653
- }
654
- var fetchKeys = (jwksUri, now) => Effect2.tryPromise(async () => {
655
- let res = await fetch(jwksUri);
656
- if (!res.ok)
657
- throw Error(`${jwksUri} answered ${res.status}`);
658
- let body2 = await res.json(), keys = /* @__PURE__ */ new Map;
659
- for (let jwk of body2.keys ?? []) {
660
- if (typeof jwk.kid !== "string" || jwk.kty !== "RSA")
661
- continue;
662
- keys.set(jwk.kid, await crypto.subtle.importKey("jwk", jwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, !1, ["verify"]));
663
- }
664
- let set = { keys, fetchedAt: now };
665
- return keySets.set(jwksUri, set), set;
666
- }).pipe(Effect2.option), keyFor = Effect2.fn("Jwt.keyFor")(function* (jwksUri, kid, now) {
667
- let set = keySets.get(jwksUri);
668
- if (set === void 0 || now - set.fetchedAt > KEYS_TTL_MS)
669
- set = Option.getOrElse(yield* fetchKeys(jwksUri, now), () => set);
670
- if (set !== void 0 && !set.keys.has(kid) && now - set.fetchedAt > REFETCH_MIN_MS)
671
- set = Option.getOrElse(yield* fetchKeys(jwksUri, now), () => set);
672
- return set?.keys.get(kid) ?? null;
673
- }), verifyRs256 = Effect2.fn("Jwt.verifyRs256")(function* (token, issuer, jwksUri) {
674
- let parts = token.split(".");
675
- if (parts.length !== 3)
676
- return null;
677
- let [h, p, s] = parts, header = json2(h), claims = json2(p);
678
- if (header === null || claims === null || header.alg !== "RS256" || typeof header.kid !== "string" || claims.iss !== issuer)
679
- return null;
680
- let nowMs = yield* Clock2.currentTimeMillis, key = yield* keyFor(jwksUri, header.kid, nowMs);
681
- if (key === null)
682
- return null;
683
- let signature = base64url(s);
684
- if (!(yield* Effect2.tryPromise(() => crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signature, (/* @__PURE__ */ new TextEncoder()).encode(`${h}.${p}`))).pipe(Effect2.orElseSucceed(() => !1))))
685
- return null;
686
- let now = Math.floor(nowMs / 1000);
687
- if (typeof claims.exp !== "number" || claims.exp + CLOCK_SKEW_S < now)
688
- return null;
689
- if (typeof claims.nbf === "number" && claims.nbf - CLOCK_SKEW_S > now)
690
- return null;
691
- return claims;
692
- });
693
- function audienceIncludes(aud, expected) {
694
- return aud === expected || Array.isArray(aud) && aud.includes(expected);
695
- }
696
-
697
759
  // src/tunnel.ts
698
760
  var UPGRADE_TIMEOUT_MS = 30000, NULL_BODY = /* @__PURE__ */ new Set([101, 204, 205, 304]);
699
761
 
@@ -708,12 +770,12 @@ class Tunnel {
708
770
  this.socket = socket;
709
771
  this.inUse = inUse;
710
772
  }
711
- send(frame2) {
773
+ send(frame) {
712
774
  let ws = this.socket();
713
775
  if (ws === null)
714
776
  return !1;
715
777
  try {
716
- return ws.send(encodeFrame(frame2)), !0;
778
+ return ws.send(encodeFrame(frame)), !0;
717
779
  } catch {
718
780
  return !1;
719
781
  }
@@ -728,14 +790,14 @@ class Tunnel {
728
790
  async fetch(port, request) {
729
791
  let url = new URL(request.url), stream = this.newStream(), s = { kind: "http", head: null, controller: null, buffered: [], owed: 0, window: MUX_WINDOW, onCredit: null, closed: !1 }, head = new Promise((resolve, reject) => s.head = { resolve, reject });
730
792
  this.streams.set(stream, s);
731
- let headers2 = [...request.headers];
732
- if (!this.send({ type: "open", stream, port, method: request.method, path: `${url.pathname}${url.search}`, headers: headers2, ws: !1 }))
793
+ let headers = [...request.headers];
794
+ if (!this.send({ type: "open", stream, port, method: request.method, path: `${url.pathname}${url.search}`, headers, ws: !1 }))
733
795
  throw this.streams.delete(stream), new StreamRefused("the box is not connected");
734
796
  request.signal.addEventListener("abort", () => this.abort(stream, "the viewer went away")), this.pump(stream, s, request.body);
735
797
  let h = await head;
736
798
  if (NULL_BODY.has(h.status) || request.method === "HEAD")
737
799
  return this.abort(stream, "no body expected"), new Response(null, { status: h.status, statusText: h.statusText, headers: h.headers });
738
- let body2 = new ReadableStream({
800
+ let body = new ReadableStream({
739
801
  start: (controller) => {
740
802
  s.controller = controller;
741
803
  for (let chunk of s.buffered.splice(0))
@@ -746,11 +808,11 @@ class Tunnel {
746
808
  pull: () => this.credit(stream, s),
747
809
  cancel: () => this.abort(stream, "the viewer stopped reading")
748
810
  }, { highWaterMark: MUX_WINDOW, size: (chunk) => chunk.byteLength });
749
- return new Response(body2, { status: h.status, statusText: h.statusText, headers: h.headers });
811
+ return new Response(body, { status: h.status, statusText: h.statusText, headers: h.headers });
750
812
  }
751
- async upgrade(port, path, headers2) {
813
+ async upgrade(port, path, headers) {
752
814
  let stream = this.newStream(), head = new Promise((resolve, reject) => this.streams.set(stream, { kind: "ws", resolve, reject }));
753
- if (!this.send({ type: "open", stream, port, method: "GET", path, headers: headers2, ws: !0 }))
815
+ if (!this.send({ type: "open", stream, port, method: "GET", path, headers, ws: !0 }))
754
816
  throw this.streams.delete(stream), new StreamRefused("the box is not connected");
755
817
  let timeout = setTimeout(() => {
756
818
  this.fail(stream, new StreamRefused("the app did not answer the websocket upgrade")), this.send({ type: "reset", stream, reason: "the upgrade timed out" });
@@ -761,12 +823,12 @@ class Tunnel {
761
823
  clearTimeout(timeout);
762
824
  }
763
825
  }
764
- async pump(stream, s, body2) {
765
- if (body2 === null) {
826
+ async pump(stream, s, body) {
827
+ if (body === null) {
766
828
  this.send({ type: "end", stream });
767
829
  return;
768
830
  }
769
- let reader = body2.getReader();
831
+ let reader = body.getReader();
770
832
  try {
771
833
  for (;; ) {
772
834
  let { done, value } = await reader.read();
@@ -815,41 +877,41 @@ class Tunnel {
815
877
  s.controller?.error(err);
816
878
  } catch {}
817
879
  }
818
- onFrame(frame2) {
819
- let s = this.streams.get(frame2.stream);
880
+ onFrame(frame) {
881
+ let s = this.streams.get(frame.stream);
820
882
  if (s === void 0)
821
883
  return !1;
822
884
  if (s.kind === "ws") {
823
- if (frame2.type === "head")
824
- this.streams.delete(frame2.stream), s.resolve(frame2);
825
- else if (frame2.type === "reset")
826
- this.streams.delete(frame2.stream), s.reject(new StreamRefused(frame2.reason));
885
+ if (frame.type === "head")
886
+ this.streams.delete(frame.stream), s.resolve(frame);
887
+ else if (frame.type === "reset")
888
+ this.streams.delete(frame.stream), s.reject(new StreamRefused(frame.reason));
827
889
  return !0;
828
890
  }
829
- switch (frame2.type) {
891
+ switch (frame.type) {
830
892
  case "head":
831
- return s.head?.resolve(frame2), s.head = null, !0;
893
+ return s.head?.resolve(frame), s.head = null, !0;
832
894
  case "data":
833
895
  if (s.controller === null)
834
- return s.buffered.push(frame2.data), s.owed += frame2.data.byteLength, !0;
896
+ return s.buffered.push(frame.data), s.owed += frame.data.byteLength, !0;
835
897
  try {
836
- s.controller.enqueue(frame2.data);
898
+ s.controller.enqueue(frame.data);
837
899
  } catch {
838
900
  return !0;
839
901
  }
840
- if (s.owed += frame2.data.byteLength, (s.controller.desiredSize ?? 0) > 0)
841
- this.credit(frame2.stream, s);
902
+ if (s.owed += frame.data.byteLength, (s.controller.desiredSize ?? 0) > 0)
903
+ this.credit(frame.stream, s);
842
904
  return !0;
843
905
  case "end":
844
- this.streams.delete(frame2.stream), s.closed = !0;
906
+ this.streams.delete(frame.stream), s.closed = !0;
845
907
  try {
846
908
  s.controller?.close();
847
909
  } catch {}
848
910
  return !0;
849
911
  case "reset":
850
- return this.fail(frame2.stream, new StreamRefused(frame2.reason)), !0;
912
+ return this.fail(frame.stream, new StreamRefused(frame.reason)), !0;
851
913
  case "credit":
852
- return s.window += frame2.bytes, s.onCredit?.(), s.onCredit = null, !0;
914
+ return s.window += frame.bytes, s.onCredit?.(), s.onCredit = null, !0;
853
915
  default:
854
916
  return !0;
855
917
  }
@@ -860,8 +922,9 @@ class Tunnel {
860
922
  }
861
923
  }
862
924
 
863
- // src/cloudflare.ts
864
- var TICKET_HEADER = "x-tesser-ticket", ACCESS_HEADER = "cf-access-jwt-assertion", WAKE_TIMEOUT = Duration2.minutes(5), WAKE_RETRY_MS = 30000, DIAL_GRACE = Duration2.seconds(30), RECHECK_MS = 60000, RETRYABLE = /* @__PURE__ */ new Set([0, 408, 409, 429]), Ticket = Schema2.Struct({
925
+ // src/relay.ts
926
+ var TICKET_HEADER = "x-tesser-ticket", WAKE_TIMEOUT = Duration2.minutes(5), WAKE_RETRY_MS = 30000, DIAL_GRACE = Duration2.seconds(30), RECHECK_MS = 60000, STALE_MS = 300000, RETRYABLE = /* @__PURE__ */ new Set([0, 408, 409, 429]), Ticket = Schema2.Struct({
927
+ org: Schema2.String,
865
928
  box: Schema2.String,
866
929
  name: Schema2.String,
867
930
  sharedBy: Schema2.NullOr(Schema2.String),
@@ -878,63 +941,18 @@ var TICKET_HEADER = "x-tesser-ticket", ACCESS_HEADER = "cf-access-jwt-assertion"
878
941
  app: Schema2.NullOr(Schema2.Literals(["none", "running", "unsupervised", "exited"]))
879
942
  })
880
943
  }), ErrorBody = Schema2.Struct({ error: Schema2.Struct({ message: Schema2.String }) });
881
- function plain(status, text2) {
882
- return new Response(`${text2}
944
+ function plain(status, text) {
945
+ return new Response(`${text}
883
946
  `, { status, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } });
884
947
  }
885
- var verifyTicket = Effect3.fn("Gateway.verifyTicket")(function* (env, request) {
886
- let token = request.headers.get("authorization")?.match(/^Bearer (\S+)$/)?.[1];
887
- if (token === void 0)
888
- return null;
889
- let issuer = env.TESSER_URL.replace(/\/$/, ""), claims = yield* verifyRs256(token, issuer, `${issuer}/.well-known/jwks.json`);
890
- if (claims === null || !audienceIncludes(claims.aud, `https://${env.GATEWAY_HOST}`) || claims.org !== env.TESSER_ORG)
891
- return null;
892
- if (claims.sub !== `box:${env.TESSER_ORG}:${String(claims.box)}`)
893
- return null;
894
- return yield* Schema2.decodeUnknownEffect(Ticket)(claims).pipe(Effect3.option);
895
- }), verifyViewer = Effect3.fn("Gateway.verifyViewer")(function* (env, request) {
896
- let token = request.headers.get(ACCESS_HEADER);
897
- if (token === null)
898
- return null;
899
- let issuer = `https://${env.ACCESS_TEAM}`, claims = yield* verifyRs256(token, issuer, `${issuer}/cdn-cgi/access/certs`);
900
- return claims !== null && audienceIncludes(claims.aud, env.ACCESS_AUD) ? claims : null;
901
- });
902
- function boxIdFromHost(env, hostname) {
903
- let suffix = `.${env.GATEWAY_HOST.toLowerCase()}`, host = hostname.toLowerCase();
904
- if (!host.endsWith(suffix))
905
- return null;
906
- let label = host.slice(0, -suffix.length).split(".").pop(), match = /^box-([0-9a-z]+)(?:--\d{1,5})?$/.exec(label);
907
- return match === null ? null : `box_${match[1]}`;
908
- }
909
- var cloudflare_default = {
910
- async fetch(request, env) {
911
- let url = new URL(request.url), apex = url.hostname.toLowerCase() === env.GATEWAY_HOST.toLowerCase();
912
- if (apex && url.pathname === GATEWAY_DIAL_PATH) {
913
- if (request.headers.get("upgrade")?.toLowerCase() !== "websocket")
914
- return plain(426, "boxes dial this with a websocket");
915
- let ticket = await Effect3.runPromise(verifyTicket(env, request));
916
- if (ticket === null || ticket._tag === "None")
917
- return plain(401, "a box dials in with a share ticket signed by tesser");
918
- let headers3 = new Headers(request.headers);
919
- return headers3.delete("authorization"), headers3.set(TICKET_HEADER, JSON.stringify(ticket.value)), env.BOXES.get(env.BOXES.idFromName(ticket.value.box)).fetch(new Request(request, { headers: headers3 }));
920
- }
921
- if (apex && url.pathname === "/__tesser/health")
922
- return plain(200, "ok");
923
- let boxId = boxIdFromHost(env, url.hostname);
924
- if (boxId === null)
925
- return new Response(viewerPage({ kind: "not-shared" }), { status: 404, headers: STATUS_HEADERS });
926
- if (await Effect3.runPromise(verifyViewer(env, request)) === null)
927
- return plain(403, "this gateway only answers requests Cloudflare Access let through");
928
- let headers2 = new Headers(request.headers);
929
- return headers2.delete(TICKET_HEADER), env.BOXES.get(env.BOXES.idFromName(boxId)).fetch(new Request(request, { headers: headers2 }));
930
- }
931
- };
932
948
 
933
- class GatewayBoxes extends DurableObject {
949
+ class BoxRelay extends DurableObject {
934
950
  tunnel = new Tunnel(() => this.boxSocket(), (stream) => this.ctx.getWebSockets(`v:${stream}`).length > 0);
935
951
  waking = null;
936
952
  connected = [];
937
- verifiedAt = 0;
953
+ verifiedAt = /* @__PURE__ */ new Map;
954
+ unstored = /* @__PURE__ */ new Map;
955
+ failingSince = /* @__PURE__ */ new Map;
938
956
  constructor(ctx, env) {
939
957
  super(ctx, env);
940
958
  ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair(MUX_PING, MUX_PONG));
@@ -963,9 +981,13 @@ class GatewayBoxes extends DurableObject {
963
981
  }
964
982
  async fetch(request) {
965
983
  let ticket = request.headers.get(TICKET_HEADER);
966
- if (ticket !== null)
967
- return this.acceptBox(JSON.parse(ticket));
968
- let handled = await Effect3.runPromise(handle(request).pipe(Effect3.provide(this.layer(request.headers.get(ACCESS_HEADER)))));
984
+ if (ticket !== null) {
985
+ let decoded = Schema2.decodeUnknownOption(Ticket)(JSON.parse(ticket));
986
+ if (decoded._tag === "None" || !this.admits(decoded.value))
987
+ return plain(403, "this box answers for another org here");
988
+ return this.acceptBox(decoded.value);
989
+ }
990
+ let handled = await Effect3.runPromise(handle(request).pipe(Effect3.provide(this.layer(this.viewer(request)))));
969
991
  if (handled._tag === "response")
970
992
  return handled.response;
971
993
  return this.bridge(request, handled);
@@ -975,32 +997,99 @@ class GatewayBoxes extends DurableObject {
975
997
  if (previous !== null)
976
998
  this.retire(previous, 4000, "replaced by a newer connection");
977
999
  let [client, server] = Object.values(new WebSocketPair);
978
- this.ctx.acceptWebSocket(server, ["box"]), server.serializeAttachment({ ticket }), this.waking = null, this.verifiedAt = Date.now();
1000
+ this.ctx.acceptWebSocket(server, ["box"]), server.serializeAttachment({ ticket }), this.waking = null, this.verifiedAt.clear(), this.failingSince.clear();
979
1001
  for (let resolve of this.connected.splice(0))
980
1002
  resolve();
981
- return this.ctx.waitUntil(this.ctx.storage.setAlarm(ticket.exp * 1000)), new Response(null, { status: 101, webSocket: client });
1003
+ return this.ctx.waitUntil(this.armAlarm(ticket)), new Response(null, { status: 101, webSocket: client });
1004
+ }
1005
+ socketViewer(ws) {
1006
+ let { stream, viewer } = ws.deserializeAttachment();
1007
+ return { stream, viewer: viewer ?? this.unstored.get(stream) ?? null };
1008
+ }
1009
+ viewers() {
1010
+ return new Set(this.ctx.getWebSockets("viewer").flatMap((ws) => this.socketViewer(ws).viewer ?? []));
1011
+ }
1012
+ async armAlarm(ticket) {
1013
+ let want = this.viewers().size > 0 ? Math.min(ticket.exp * 1000, Date.now() + RECHECK_MS) : ticket.exp * 1000, pending = await this.ctx.storage.getAlarm();
1014
+ if (pending === null || pending <= Date.now() || want < pending)
1015
+ await this.ctx.storage.setAlarm(want);
1016
+ }
1017
+ closeViewer(viewer, reason) {
1018
+ for (let ws of this.ctx.getWebSockets("viewer")) {
1019
+ let { stream, viewer: who } = this.socketViewer(ws);
1020
+ if (who !== viewer)
1021
+ continue;
1022
+ this.tunnel.send({ type: "close", stream, code: 1008, reason });
1023
+ try {
1024
+ ws.close(1008, reason);
1025
+ } catch {}
1026
+ }
1027
+ }
1028
+ verdict(viewer, answer, now, open) {
1029
+ if (answer?.status === 200) {
1030
+ for (let [key, at] of this.verifiedAt)
1031
+ if (now - at > STALE_MS)
1032
+ this.verifiedAt.delete(key);
1033
+ return this.verifiedAt.set(viewer, now), this.failingSince.delete(viewer), "ok";
1034
+ }
1035
+ if (answer?.status === 404 || answer?.status === 403)
1036
+ this.verifiedAt.delete(viewer), this.failingSince.delete(viewer);
1037
+ let box = this.boxSocket();
1038
+ if (answer?.status === 404) {
1039
+ if (box !== null)
1040
+ this.retire(box, 4003, "share revoked");
1041
+ return "revoked";
1042
+ }
1043
+ if (answer?.status === 403)
1044
+ return this.closeViewer(viewer, "access ended"), "refused";
1045
+ let verified = this.verifiedAt.get(viewer);
1046
+ if (open && verified === void 0 && !this.failingSince.has(viewer))
1047
+ this.failingSince.set(viewer, now);
1048
+ let since = verified ?? (open ? this.failingSince.get(viewer) : 0);
1049
+ if (now - since > STALE_MS) {
1050
+ if (open)
1051
+ this.closeViewer(viewer, "tesser can't confirm access right now");
1052
+ return "stale";
1053
+ }
1054
+ return "unknown";
982
1055
  }
983
1056
  async alarm() {
984
1057
  let ws = this.boxSocket();
985
1058
  if (ws === null)
986
1059
  return;
987
1060
  let { ticket } = ws.deserializeAttachment();
988
- if (ticket.exp * 1000 <= Date.now())
1061
+ if (ticket.exp * 1000 <= Date.now()) {
989
1062
  this.retire(ws, 4001, "share expired");
990
- else
991
- await this.ctx.storage.setAlarm(ticket.exp * 1000);
1063
+ return;
1064
+ }
1065
+ for (let viewer of this.viewers()) {
1066
+ let answer = await this.lookupBox(ticket.box, viewer).catch(() => null);
1067
+ if (this.verdict(viewer, answer, Date.now(), !0) === "revoked")
1068
+ return;
1069
+ }
1070
+ await this.armAlarm(ticket);
992
1071
  }
993
1072
  async bridge(request, target) {
994
- let headers2 = [...target.headers].filter(([name]) => !/^sec-websocket-(key|version|extensions)$/i.test(name)), upgraded;
1073
+ let headers = [...target.headers].filter(([name]) => !/^sec-websocket-(key|version|extensions)$/i.test(name)), upgraded;
995
1074
  try {
996
- upgraded = await this.tunnel.upgrade(target.port, target.path, headers2);
1075
+ upgraded = await this.tunnel.upgrade(target.port, target.path, headers);
997
1076
  } catch (err) {
998
1077
  return plain(502, `${target.box.name} refused the websocket: ${err instanceof Error ? err.message : String(err)}`);
999
1078
  }
1000
1079
  if (upgraded.head.status !== 101)
1001
1080
  return plain(502, `${target.box.name} answered ${upgraded.head.status} to the websocket`);
1002
1081
  let [client, server] = Object.values(new WebSocketPair);
1003
- this.ctx.acceptWebSocket(server, ["viewer", `v:${upgraded.stream}`]), server.serializeAttachment({ stream: upgraded.stream });
1082
+ this.ctx.acceptWebSocket(server, ["viewer", `v:${upgraded.stream}`]);
1083
+ let viewer = this.viewer(request);
1084
+ try {
1085
+ server.serializeAttachment({ stream: upgraded.stream, viewer });
1086
+ } catch {
1087
+ if (server.serializeAttachment({ stream: upgraded.stream, viewer: null }), viewer !== null)
1088
+ this.unstored.set(upgraded.stream, viewer);
1089
+ }
1090
+ let ticket = this.live();
1091
+ if (ticket !== null)
1092
+ await this.armAlarm(ticket);
1004
1093
  let protocol = upgraded.head.headers.find(([name]) => name.toLowerCase() === "sec-websocket-protocol")?.[1];
1005
1094
  return new Response(null, { status: 101, webSocket: client, headers: protocol === void 0 ? {} : { "sec-websocket-protocol": protocol } });
1006
1095
  }
@@ -1008,29 +1097,29 @@ class GatewayBoxes extends DurableObject {
1008
1097
  if (this.ctx.getTags(ws).includes("box")) {
1009
1098
  if (typeof message === "string")
1010
1099
  return;
1011
- let frame2;
1100
+ let frame;
1012
1101
  try {
1013
- frame2 = decodeFrame(new Uint8Array(message));
1102
+ frame = decodeFrame(new Uint8Array(message));
1014
1103
  } catch {
1015
1104
  return;
1016
1105
  }
1017
- if (this.tunnel.onFrame(frame2))
1106
+ if (this.tunnel.onFrame(frame))
1018
1107
  return;
1019
- let viewer = this.ctx.getWebSockets(`v:${frame2.stream}`)[0];
1108
+ let viewer = this.ctx.getWebSockets(`v:${frame.stream}`)[0];
1020
1109
  if (viewer === void 0) {
1021
- if (frame2.type === "text" || frame2.type === "binary")
1022
- this.tunnel.send({ type: "close", stream: frame2.stream, code: 1001, reason: "the viewer went away" });
1110
+ if (frame.type === "text" || frame.type === "binary")
1111
+ this.tunnel.send({ type: "close", stream: frame.stream, code: 1001, reason: "the viewer went away" });
1023
1112
  return;
1024
1113
  }
1025
1114
  try {
1026
- if (frame2.type === "text")
1027
- viewer.send(frame2.text);
1028
- else if (frame2.type === "binary")
1029
- viewer.send(frame2.data);
1030
- else if (frame2.type === "close")
1031
- viewer.close(frame2.code, frame2.reason);
1032
- else if (frame2.type === "reset")
1033
- viewer.close(1011, frame2.reason.slice(0, 120));
1115
+ if (frame.type === "text")
1116
+ viewer.send(frame.text);
1117
+ else if (frame.type === "binary")
1118
+ viewer.send(frame.data);
1119
+ else if (frame.type === "close")
1120
+ viewer.close(frame.code, frame.reason);
1121
+ else if (frame.type === "reset")
1122
+ viewer.close(1011, frame.reason.slice(0, 120));
1034
1123
  } catch {}
1035
1124
  return;
1036
1125
  }
@@ -1044,52 +1133,43 @@ class GatewayBoxes extends DurableObject {
1044
1133
  this.retire(ws, 1000, "box hung up");
1045
1134
  return;
1046
1135
  }
1047
- let { stream } = ws.deserializeAttachment(), sendable = code === 1005 ? 1000 : code === 1004 || code === 1006 || code === 1015 || code < 1000 || code > 4999 ? 1001 : code;
1136
+ let { stream } = ws.deserializeAttachment();
1137
+ this.unstored.delete(stream);
1138
+ let sendable = code === 1005 ? 1000 : code === 1004 || code === 1006 || code === 1015 || code < 1000 || code > 4999 ? 1001 : code;
1048
1139
  this.tunnel.send({ type: "close", stream, code: sendable, reason: reason.slice(0, 120) });
1049
1140
  }
1050
1141
  webSocketError(ws) {
1051
1142
  this.webSocketClose(ws, 1006, "socket error");
1052
1143
  }
1053
- tesser(method, path, jwt) {
1054
- let env = this.env;
1055
- return Effect3.tryPromise({
1056
- try: async () => {
1057
- let res = await fetch(`${env.TESSER_URL.replace(/\/$/, "")}/v1/${env.TESSER_ORG}${path}`, { method, headers: jwt === null ? {} : { [ACCESS_HEADER]: jwt } }), body2 = await res.json().catch(() => null);
1058
- return { status: res.status, body: body2 };
1059
- },
1060
- catch: (err) => new DirectoryUnavailable({ reason: err instanceof Error ? err.message : String(err) })
1061
- });
1144
+ ask(call) {
1145
+ return Effect3.tryPromise({ try: call, catch: (err) => new DirectoryUnavailable({ reason: err instanceof Error ? err.message : String(err) }) });
1062
1146
  }
1063
- layer(jwt) {
1064
- let self = this, remote = Effect3.fn("Cloudflare.remote")(function* (boxId) {
1065
- let { status: status2, body: body2 } = yield* self.tesser("GET", `/gateway/boxes/${boxId}`, jwt);
1066
- if (status2 === 403 || status2 === 404)
1147
+ layer(viewer) {
1148
+ let self = this, remote = Effect3.fn("Relay.remote")(function* (boxId) {
1149
+ let { status, body } = yield* self.ask(() => self.lookupBox(boxId, viewer));
1150
+ if (status === 403 || status === 404)
1067
1151
  return yield* new BoxNotShared({ boxId });
1068
- if (status2 !== 200) {
1069
- let message = Schema2.decodeUnknownOption(ErrorBody)(body2);
1070
- return yield* new DirectoryUnavailable({ reason: message._tag === "Some" ? message.value.error.message : `tesser answered ${status2}` });
1152
+ if (status !== 200) {
1153
+ let message = Schema2.decodeUnknownOption(ErrorBody)(body);
1154
+ return yield* new DirectoryUnavailable({ reason: message._tag === "Some" ? message.value.error.message : `tesser answered ${status}` });
1071
1155
  }
1072
- return (yield* Schema2.decodeUnknownEffect(BoxResponse)(body2).pipe(Effect3.mapError(() => new DirectoryUnavailable({ reason: "tesser answered with a box this gateway can't read" })))).box;
1073
- }), lookup = Effect3.fn("Cloudflare.lookup")(function* (boxId) {
1156
+ return (yield* Schema2.decodeUnknownEffect(BoxResponse)(body).pipe(Effect3.mapError(() => new DirectoryUnavailable({ reason: "tesser answered with a box this gateway can't read" })))).box;
1157
+ }), lookup = Effect3.fn("Relay.lookup")(function* (boxId) {
1074
1158
  let ticket = self.live();
1075
1159
  if (ticket === null) {
1076
1160
  let box = yield* remote(boxId);
1077
1161
  return { ...box, power: box.power === "awake" ? "launching" : box.power };
1078
1162
  }
1079
1163
  let now = yield* Clock3.currentTimeMillis;
1080
- if (jwt !== null && now - self.verifiedAt > RECHECK_MS) {
1081
- let checked = yield* remote(boxId).pipe(Effect3.result);
1082
- if (checked._tag === "Success")
1083
- self.verifiedAt = now;
1084
- else if (checked.failure._tag === "BoxNotShared") {
1085
- let ws = self.boxSocket();
1086
- if (ws !== null)
1087
- self.retire(ws, 4003, "share revoked");
1088
- return yield* checked.failure;
1089
- }
1164
+ if (viewer !== null && now - (self.verifiedAt.get(viewer) ?? 0) > RECHECK_MS) {
1165
+ let answer = yield* self.ask(() => self.lookupBox(boxId, viewer)).pipe(Effect3.orElseSucceed(() => null)), verdict = self.verdict(viewer, answer, now, !1);
1166
+ if (verdict === "revoked" || verdict === "refused")
1167
+ return yield* new BoxNotShared({ boxId });
1168
+ if (verdict === "stale")
1169
+ return yield* new DirectoryUnavailable({ reason: "tesser didn't answer whether you can still open this box" });
1090
1170
  }
1091
1171
  return { boxId, name: ticket.name, sharedBy: ticket.sharedBy, privateIp: "", ports: ticket.ports, power: "awake", app: "running" };
1092
- }), wake = Effect3.fn("Cloudflare.wake")(function* (boxId, retry) {
1172
+ }), wake = Effect3.fn("Relay.wake")(function* (boxId, retry) {
1093
1173
  let now = yield* Clock3.currentTimeMillis, current = self.waking;
1094
1174
  if (current?.inflight)
1095
1175
  return;
@@ -1099,20 +1179,20 @@ class GatewayBoxes extends DurableObject {
1099
1179
  return;
1100
1180
  let waking = { since: current === null || current.failure !== null || now - current.since > Duration2.toMillis(WAKE_TIMEOUT) ? now : current.since, kickedAt: now, failure: null, inflight: !0 };
1101
1181
  self.waking = waking;
1102
- let kicked = yield* self.tesser("POST", `/gateway/boxes/${boxId}/wake`, jwt).pipe(Effect3.result, Effect3.ensuring(Effect3.sync(() => waking.inflight = !1)));
1182
+ let kicked = yield* self.ask(() => self.wakeBox(boxId, viewer)).pipe(Effect3.result, Effect3.ensuring(Effect3.sync(() => waking.inflight = !1)));
1103
1183
  if (kicked._tag === "Failure")
1104
1184
  return;
1105
- let { status: status2, body: body2 } = kicked.success;
1106
- if (status2 < 300 || status2 >= 500 || RETRYABLE.has(status2))
1185
+ let { status, body } = kicked.success;
1186
+ if (status < 300 || status >= 500 || RETRYABLE.has(status))
1107
1187
  return;
1108
- let message = Schema2.decodeUnknownOption(ErrorBody)(body2);
1109
- waking.failure = message._tag === "Some" ? message.value.error.message : `tesser answered ${status2}`;
1110
- }), status = Effect3.fn("Cloudflare.status")(function* (_boxId) {
1188
+ let message = Schema2.decodeUnknownOption(ErrorBody)(body);
1189
+ waking.failure = message._tag === "Some" ? message.value.error.message : `tesser answered ${status}`;
1190
+ }), status = Effect3.fn("Relay.status")(function* (_boxId) {
1111
1191
  let now = yield* Clock3.currentTimeMillis, w = self.waking;
1112
1192
  if (w === null || w.failure === null && now - w.since > Duration2.toMillis(WAKE_TIMEOUT))
1113
1193
  return null;
1114
1194
  return { since: w.since, failure: w.failure };
1115
- }), dialed = (boxId, within, reason) => Effect3.promise(() => new Promise((resolve) => self.connected.push(resolve))).pipe(Effect3.timeoutOrElse({ duration: within, orElse: () => Effect3.fail(new WakeFailed({ boxId, reason })) })), awake = Effect3.fn("Cloudflare.awake")(function* (boxId) {
1195
+ }), dialed = (boxId, within, reason) => Effect3.promise(() => new Promise((resolve) => self.connected.push(resolve))).pipe(Effect3.timeoutOrElse({ duration: within, orElse: () => Effect3.fail(new WakeFailed({ boxId, reason })) })), awake = Effect3.fn("Relay.awake")(function* (boxId) {
1116
1196
  if (self.live() === null)
1117
1197
  if ((yield* remote(boxId)).power === "awake")
1118
1198
  yield* dialed(boxId, DIAL_GRACE, "the box is up but hasn't connected to this gateway; its boxd may need an update");
@@ -1125,10 +1205,73 @@ class GatewayBoxes extends DurableObject {
1125
1205
  }
1126
1206
  return yield* lookup(boxId);
1127
1207
  }), upstream = (box, port, request) => Effect3.tryPromise({ try: () => self.tunnel.fetch(port, request), catch: () => new UpstreamRefused({ boxId: box.boxId, port }) });
1128
- return Layer.mergeAll(Layer.succeed(BoxDirectory)({ host: Effect3.succeed(self.env.GATEWAY_HOST), lookup }), Layer.succeed(Waker)({ wake, status, awake }), Layer.succeed(Upstream)({ fetch: upstream }));
1208
+ return Layer.mergeAll(Layer.succeed(BoxDirectory)({ host: Effect3.succeed(self.host()), lookup }), Layer.succeed(Waker)({ wake, status, awake }), Layer.succeed(Upstream)({ fetch: upstream }));
1209
+ }
1210
+ }
1211
+
1212
+ // src/cloudflare.ts
1213
+ var ACCESS_HEADER = "cf-access-jwt-assertion", verifyTicket = Effect4.fn("Gateway.verifyTicket")(function* (env, request) {
1214
+ let token = request.headers.get("authorization")?.match(/^Bearer (\S+)$/)?.[1];
1215
+ if (token === void 0)
1216
+ return null;
1217
+ let issuer = env.TESSER_URL.replace(/\/$/, ""), claims = yield* verifyRs256(token, issuer, `${issuer}/.well-known/jwks.json`);
1218
+ if (claims === null || !audienceIncludes(claims.aud, `https://${env.GATEWAY_HOST}`) || claims.org !== env.TESSER_ORG)
1219
+ return null;
1220
+ if (claims.sub !== `box:${env.TESSER_ORG}:${String(claims.box)}`)
1221
+ return null;
1222
+ return yield* Schema3.decodeUnknownEffect(Ticket)(claims).pipe(Effect4.option);
1223
+ }), verifyViewer = Effect4.fn("Gateway.verifyViewer")(function* (env, request) {
1224
+ let token = request.headers.get(ACCESS_HEADER);
1225
+ if (token === null)
1226
+ return null;
1227
+ let issuer = `https://${env.ACCESS_TEAM}`, claims = yield* verifyRs256(token, issuer, `${issuer}/cdn-cgi/access/certs`);
1228
+ return claims !== null && audienceIncludes(claims.aud, env.ACCESS_AUD) ? claims : null;
1229
+ }), cloudflare_default = {
1230
+ async fetch(request, env) {
1231
+ let url = new URL(request.url), apex = url.hostname.toLowerCase() === env.GATEWAY_HOST.toLowerCase();
1232
+ if (apex && url.pathname === GATEWAY_DIAL_PATH) {
1233
+ if (request.headers.get("upgrade")?.toLowerCase() !== "websocket")
1234
+ return plain(426, "boxes dial this with a websocket");
1235
+ let ticket = await Effect4.runPromise(verifyTicket(env, request));
1236
+ if (ticket === null || ticket._tag === "None")
1237
+ return plain(401, "a box dials in with a share ticket signed by tesser");
1238
+ let headers = new Headers(request.headers);
1239
+ return headers.delete("authorization"), headers.set(TICKET_HEADER, JSON.stringify(ticket.value)), env.BOXES.get(env.BOXES.idFromName(ticket.value.box)).fetch(new Request(request, { headers }));
1240
+ }
1241
+ if (apex && url.pathname === "/__tesser/health")
1242
+ return plain(200, "ok");
1243
+ let boxId = gatewayHostBox(env.GATEWAY_HOST, url.hostname);
1244
+ if (boxId === null)
1245
+ return new Response(viewerPage({ kind: "not-shared" }), { status: 404, headers: STATUS_HEADERS });
1246
+ if (await Effect4.runPromise(verifyViewer(env, request)) === null)
1247
+ return plain(403, "this gateway only answers requests Cloudflare Access let through");
1248
+ let headers = new Headers(request.headers);
1249
+ return headers.delete(TICKET_HEADER), env.BOXES.get(env.BOXES.idFromName(boxId)).fetch(new Request(request, { headers }));
1250
+ }
1251
+ };
1252
+
1253
+ class GatewayBoxes extends BoxRelay {
1254
+ host() {
1255
+ return this.env.GATEWAY_HOST;
1256
+ }
1257
+ viewer(request) {
1258
+ return request.headers.get(ACCESS_HEADER);
1259
+ }
1260
+ admits(ticket) {
1261
+ return ticket.org === this.env.TESSER_ORG;
1262
+ }
1263
+ lookupBox(boxId, viewer) {
1264
+ return this.tesser("GET", `/gateway/boxes/${boxId}`, viewer);
1265
+ }
1266
+ wakeBox(boxId, viewer) {
1267
+ return this.tesser("POST", `/gateway/boxes/${boxId}/wake`, viewer);
1268
+ }
1269
+ async tesser(method, path, jwt) {
1270
+ let res = await fetch(`${this.env.TESSER_URL.replace(/\/$/, "")}/v1/${this.env.TESSER_ORG}${path}`, { method, headers: jwt === null ? {} : { [ACCESS_HEADER]: jwt } }), body = await res.json().catch(() => null);
1271
+ return { status: res.status, body };
1129
1272
  }
1130
1273
  }
1131
1274
  export {
1132
- cloudflare_default as default,
1133
- GatewayBoxes
1275
+ GatewayBoxes,
1276
+ cloudflare_default as default
1134
1277
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tesser-sh/gateway",
3
- "version": "0.0.1",
3
+ "version": "0.53.2",
4
4
  "description": "Tesser's share gateway as a Cloudflare Worker and Durable Object, deployed in your own account",
5
5
  "license": "MIT",
6
6
  "type": "module",