1/**
2 * Pure checks behind T3 Connect link proofs and relay requests: which local
3 * origin a link may point at, and which scopes and lifetimes a proof claims and
4 * accepts.
5 */
6import type {
7 RelayEnvironmentLinkProofPayload,
8 RelayLinkProofRequest,
9 RelayManagedEndpointOrigin,
10 RelayManagedEndpointRuntimeConfig,
11} from "@t3tools/contracts/relay";
12import type { HttpServerRequest } from "effect/http";
13
14const CLOUD_PROOF_MAX_LIFETIME_SECONDS = 5 * 60;
15const CLOUD_PROOF_CLOCK_SKEW_SECONDS = 60;
16const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
17
18function normalizeHostname(hostname: string): string {
19 return hostname
20 .trim()
21 .toLowerCase()
22 .replace(/^\[(.*)\]$/, "$1");
23}
24
25function isLoopbackHostname(hostname: string): boolean {
26 return LOOPBACK_HOSTNAMES.has(normalizeHostname(hostname));
27}
28
29function firstForwardedHeaderValue(value: string | undefined): string | undefined {
30 const first = value?.split(",")[0]?.trim();
31 return first && first.length > 0 ? first : undefined;
32}
33
34export function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest): string | null {
35 try {
36 return new URL(request.originalUrl).href;
37 } catch {
38 const host = firstForwardedHeaderValue(request.headers.host) ?? "127.0.0.1";
39 try {
40 return new URL(request.originalUrl, `http://${host}`).href;
41 } catch {
42 return null;
43 }
44 }
45}
46
47export function hasForwardedAuthorityHeaders(
48 request: HttpServerRequest.HttpServerRequest,
49): boolean {
50 return (
51 firstForwardedHeaderValue(request.headers["x-forwarded-host"]) !== undefined ||
52 firstForwardedHeaderValue(request.headers["x-forwarded-proto"]) !== undefined
53 );
54}
55
56function endpointRequestPort(url: URL): number {
57 return Number(url.port || (url.protocol === "https:" ? 443 : 80));
58}
59
60export function parseManagedEndpointLocalOrigin(localOrigin: string) {
61 const url = new URL(localOrigin);
62 if (
63 localOrigin !== localOrigin.trim() ||
64 (url.protocol !== "http:" && url.protocol !== "https:") ||
65 url.username !== "" ||
66 url.password !== "" ||
67 url.pathname !== "/" ||
68 url.search !== "" ||
69 url.hash !== "" ||
70 localOrigin.includes("?") ||
71 localOrigin.includes("#")
72 ) {
73 throw new Error("Invalid local origin");
74 }
75 const wsUrl = new URL(url.origin);
76 wsUrl.protocol = url.protocol === "https:" ? "wss:" : "ws:";
77 return {
78 httpBaseUrl: url.origin,
79 wsBaseUrl: wsUrl.origin,
80 origin: {
81 localHttpHost: url.hostname,
82 localHttpPort: endpointRequestPort(url),
83 } satisfies RelayManagedEndpointOrigin,
84 };
85}
86
87export function isAllowedEndpointOrigin(input: {
88 readonly origin: RelayManagedEndpointOrigin;
89 readonly requestUrl: string;
90}): boolean {
91 if (!isLoopbackHostname(input.origin.localHttpHost)) {
92 return false;
93 }
94
95 const url = new URL(input.requestUrl);
96 if (!isLoopbackHostname(url.hostname)) {
97 return false;
98 }
99
100 return input.origin.localHttpPort === endpointRequestPort(url);
101}
102
103// A managed (Cloudflare tunnel) endpoint is provisioned by the relay and must
104// point at a loopback origin. A manual endpoint is reached out of band (e.g.
105// Tailscale) or not advertised at all for publish-only links, so it is not
106// tied to the managed-tunnel scope.
107export function isSupportedLinkProviderKind(request: RelayLinkProofRequest): boolean {
108 return (
109 request.endpoint.providerKind === "cloudflare_tunnel" ||
110 request.endpoint.providerKind === "manual"
111 );
112}
113
114export function linkProofScopes(
115 request: RelayLinkProofRequest,
116): RelayEnvironmentLinkProofPayload["scopes"] {
117 return request.endpoint.providerKind === "cloudflare_tunnel"
118 ? ["agent_activity_notifications", "managed_tunnels"]
119 : ["agent_activity_notifications"];
120}
121
122export function hasExactScope(input: {
123 readonly scopes: ReadonlyArray<string>;
124 readonly expected: string;
125}): boolean {
126 return input.scopes.length === 1 && input.scopes[0] === input.expected;
127}
128
129export function hasBoundedCloudProofLifetime(input: {
130 readonly iat: number;
131 readonly exp: number;
132 readonly nowSeconds: number;
133}): boolean {
134 return (
135 input.exp > input.iat &&
136 input.exp - input.iat <= CLOUD_PROOF_MAX_LIFETIME_SECONDS &&
137 input.iat <= input.nowSeconds + CLOUD_PROOF_CLOCK_SKEW_SECONDS
138 );
139}
140
141export function managedEndpointRuntimeConfigsMatch(
142 left: RelayManagedEndpointRuntimeConfig,
143 right: RelayManagedEndpointRuntimeConfig,
144): boolean {
145 return (
146 left.providerKind === right.providerKind &&
147 left.connectorToken === right.connectorToken &&
148 left.tunnelId === right.tunnelId &&
149 left.tunnelName === right.tunnelName
150 );
151}