1import {
2 RelayEnvironmentLinkProofPayload,
3 RelayEnvironmentLinkProofInvalidReason,
4 type RelayEnvironmentLinkRequest,
5} from "@t3tools/contracts/relay";
6import {
7 decodeRelayJwt,
8 normalizeRelayIssuer,
9 RELAY_LINK_PROOF_TYP,
10 verifyRelayJwt,
11} from "@t3tools/shared/relayJwt";
12import * as Context from "effect/Context";
13import * as DateTime from "effect/DateTime";
14import * as Effect from "effect/Effect";
15import * as Layer from "effect/Layer";
16import * as Schema from "effect/Schema";
17
18import * as DpopProofs from "../auth/DpopProofs.ts";
19import * as RelayTokens from "../auth/RelayTokens.ts";
20import * as EnvironmentCredentials from "./EnvironmentCredentials.ts";
21import * as EnvironmentLinks from "./EnvironmentLinks.ts";
22import * as ManagedEndpointProvider from "./ManagedEndpointProvider.ts";
23import * as RelayConfiguration from "../Config.ts";
24
25export class EnvironmentLinkProofExpired extends Schema.TaggedError<EnvironmentLinkProofExpired>()(
26 "EnvironmentLinkProofExpired",
27 {
28 userId: Schema.String,
29 environmentId: Schema.String,
30 expiresAt: Schema.String,
31 },
32) {
33 override get message(): string {
34 return `Environment '${this.environmentId}' link proof expired at ${this.expiresAt}`;
35 }
36}
37
38export class EnvironmentLinkProofInvalid extends Schema.TaggedError<EnvironmentLinkProofInvalid>()(
39 "EnvironmentLinkProofInvalid",
40 {
41 userId: Schema.String,
42 environmentId: Schema.String,
43 reason: RelayEnvironmentLinkProofInvalidReason,
44 stage: Schema.Literals([
45 "decode_token",
46 "decode_payload",
47 "verify_proof",
48 "authorize_capabilities",
49 "validate_descriptor",
50 "verify_challenge",
51 "validate_expiration",
52 "consume_proof_nonce",
53 "consume_challenge_nonce",
54 "validate_origin",
55 "validate_endpoint",
56 ]),
57 cause: Schema.optional(Schema.Defect()),
58 },
59) {
60 override get message(): string {
61 return `Environment '${this.environmentId}' link proof is invalid during ${this.stage}: ${this.reason}`;
62 }
63}
64
65export type EnvironmentLinkError =
66 | EnvironmentLinkProofExpired
67 | EnvironmentLinkProofInvalid
68 | DpopProofs.DpopProofReplayPersistenceError
69 | EnvironmentLinks.EnvironmentLinkUpsertPersistenceError
70 | EnvironmentCredentials.EnvironmentCredentialCreatePersistenceError
71 | ManagedEndpointProvider.ManagedEndpointProviderError;
72
73export class EnvironmentLinker extends Context.Service<
74 EnvironmentLinker,
75 {
76 readonly link: (input: {
77 readonly userId: string;
78 readonly request: RelayEnvironmentLinkRequest;
79 }) => Effect.Effect<
80 {
81 readonly environmentId: RelayEnvironmentLinkProofPayload["environmentId"];
82 readonly endpoint: RelayEnvironmentLinkProofPayload["endpoint"];
83 readonly endpointRuntime:
84 | ManagedEndpointProvider.ManagedEndpointProvisioningResult["runtime"]
85 | null;
86 readonly environmentCredential: string;
87 },
88 EnvironmentLinkError
89 >;
90 }
91>()("t3code-relay/environments/EnvironmentLinker") {}
92
93const decodeProof = Schema.decodeUnknownEffect(RelayEnvironmentLinkProofPayload);
94
95function proofAuthorizesRequestedCapabilities(
96 proof: RelayEnvironmentLinkProofPayload,
97 request: RelayEnvironmentLinkRequest,
98): boolean {
99 const scopes = new Set(proof.scopes);
100 if (request.managedTunnelsEnabled && !scopes.has("managed_tunnels")) {
101 return false;
102 }
103 return !(
104 (request.notificationsEnabled || request.liveActivitiesEnabled) &&
105 !scopes.has("agent_activity_notifications")
106 );
107}
108
109function isSecureManagedEndpoint(endpoint: RelayEnvironmentLinkProofPayload["endpoint"]): boolean {
110 try {
111 const httpUrl = new URL(endpoint.httpBaseUrl);
112 const wsUrl = new URL(endpoint.wsBaseUrl);
113 return httpUrl.protocol === "https:" && wsUrl.protocol === "wss:";
114 } catch {
115 return false;
116 }
117}
118
119function normalizeHostname(hostname: string): string {
120 return hostname
121 .trim()
122 .toLowerCase()
123 .replace(/^\[(.*)\]$/u, "$1");
124}
125
126function isLoopbackManagedTunnelOrigin(
127 origin: RelayEnvironmentLinkProofPayload["origin"],
128): boolean {
129 const hostname = normalizeHostname(origin.localHttpHost);
130 return (
131 (hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost") &&
132 Number.isInteger(origin.localHttpPort) &&
133 origin.localHttpPort > 0 &&
134 origin.localHttpPort <= 65_535
135 );
136}
137
138const make = Effect.gen(function* () {
139 const links = yield* EnvironmentLinks.EnvironmentLinks;
140 const credentials = yield* EnvironmentCredentials.EnvironmentCredentials;
141 const managedEndpointProvider = yield* ManagedEndpointProvider.ManagedEndpointProvider;
142 const proofReplay = yield* DpopProofs.DpopProofReplay;
143 const relayTokens = yield* RelayTokens.RelayTokens;
144 const config = yield* RelayConfiguration.RelayConfiguration;
145
146 return EnvironmentLinker.of({
147 link: Effect.fn("relay.environment_linker.link")(function* (input) {
148 const now = yield* DateTime.now;
149 const nowSeconds = Math.floor(now.epochMilliseconds / 1_000);
150 const unverified = yield* Effect.try({
151 try: () => decodeRelayJwt(input.request.proof),
152 catch: (cause) =>
153 new EnvironmentLinkProofInvalid({
154 userId: input.userId,
155 environmentId: "unknown",
156 reason: "invalid_signature_or_scope",
157 stage: "decode_token",
158 cause,
159 }),
160 });
161 const candidate = yield* decodeProof(unverified).pipe(
162 Effect.mapError(
163 (cause) =>
164 new EnvironmentLinkProofInvalid({
165 userId: input.userId,
166 environmentId: "unknown",
167 reason: "invalid_signature_or_scope",
168 stage: "decode_payload",
169 cause,
170 }),
171 ),
172 );
173 yield* Effect.annotateCurrentSpan({
174 "relay.environment_id": candidate.environmentId,
175 "relay.link.notifications_enabled": input.request.notificationsEnabled,
176 "relay.link.live_activities_enabled": input.request.liveActivitiesEnabled,
177 "relay.link.managed_tunnels_enabled": input.request.managedTunnelsEnabled,
178 });
179 if (candidate.exp <= nowSeconds) {
180 return yield* new EnvironmentLinkProofExpired({
181 userId: input.userId,
182 environmentId: candidate.environmentId,
183 expiresAt: DateTime.formatIso(DateTime.makeUnsafe(candidate.exp * 1_000)),
184 });
185 }
186 const issuer = `t3-env:${candidate.environmentId}`;
187 const relayIssuer = normalizeRelayIssuer(config.relayIssuer);
188 const verified = yield* verifyRelayJwt({
189 publicKey: candidate.environmentPublicKey,
190 token: input.request.proof,
191 typ: RELAY_LINK_PROOF_TYP,
192 issuer,
193 audience: relayIssuer,
194 nowEpochSeconds: nowSeconds,
195 }).pipe(
196 Effect.flatMap(decodeProof),
197 Effect.mapError(
198 (cause) =>
199 new EnvironmentLinkProofInvalid({
200 userId: input.userId,
201 environmentId: candidate.environmentId,
202 reason: "invalid_signature_or_scope",
203 stage: "verify_proof",
204 cause,
205 }),
206 ),
207 );
208 if (
209 verified.sub !== verified.environmentId ||
210 !proofAuthorizesRequestedCapabilities(verified, input.request)
211 ) {
212 return yield* new EnvironmentLinkProofInvalid({
213 userId: input.userId,
214 environmentId: candidate.environmentId,
215 reason: "invalid_signature_or_scope",
216 stage: "authorize_capabilities",
217 });
218 }
219 if (verified.descriptor.environmentId !== verified.environmentId) {
220 return yield* new EnvironmentLinkProofInvalid({
221 userId: input.userId,
222 environmentId: verified.environmentId,
223 reason: "descriptor_mismatch",
224 stage: "validate_descriptor",
225 });
226 }
227 const challenge = yield* relayTokens.verifyLinkChallenge({
228 token: verified.challenge,
229 userId: input.userId,
230 request: {
231 notificationsEnabled: input.request.notificationsEnabled,
232 liveActivitiesEnabled: input.request.liveActivitiesEnabled,
233 managedTunnelsEnabled: input.request.managedTunnelsEnabled,
234 },
235 nowEpochSeconds: nowSeconds,
236 });
237 if (challenge === null) {
238 return yield* new EnvironmentLinkProofInvalid({
239 userId: input.userId,
240 environmentId: verified.environmentId,
241 reason: "challenge_invalid",
242 stage: "verify_challenge",
243 });
244 }
245 const expiresAt = DateTime.make(verified.exp * 1_000);
246 if (expiresAt._tag === "None") {
247 return yield* new EnvironmentLinkProofInvalid({
248 userId: input.userId,
249 environmentId: verified.environmentId,
250 reason: "invalid_signature_or_scope",
251 stage: "validate_expiration",
252 });
253 }
254 const consumedNonce = yield* proofReplay.consume({
255 thumbprint: verified.environmentPublicKey,
256 jti: verified.jti,
257 iat: verified.iat,
258 expiresAt: expiresAt.value,
259 });
260 if (!consumedNonce) {
261 return yield* new EnvironmentLinkProofInvalid({
262 userId: input.userId,
263 environmentId: verified.environmentId,
264 reason: "replayed_nonce",
265 stage: "consume_proof_nonce",
266 });
267 }
268 const consumedChallenge = yield* proofReplay.consume({
269 thumbprint: "relay-environment-link-challenge",
270 jti: challenge.jti,
271 iat: challenge.iat,
272 expiresAt: expiresAt.value,
273 });
274 if (!consumedChallenge) {
275 return yield* new EnvironmentLinkProofInvalid({
276 userId: input.userId,
277 environmentId: verified.environmentId,
278 reason: "challenge_invalid",
279 stage: "consume_challenge_nonce",
280 });
281 }
282 if (input.request.managedTunnelsEnabled && !isLoopbackManagedTunnelOrigin(verified.origin)) {
283 return yield* new EnvironmentLinkProofInvalid({
284 userId: input.userId,
285 environmentId: verified.environmentId,
286 reason: "origin_not_allowed",
287 stage: "validate_origin",
288 });
289 }
290 // Downgrading a managed link to publish-only must release the tunnel and
291 // DNS that were provisioned for it — nothing else cleans them up until a
292 // full unlink. Best effort: a cleanup failure must not block the link
293 // itself, and the provider treats an absent allocation as already
294 // deprovisioned, so retrying on every non-tunnel link is cheap.
295 if (!input.request.managedTunnelsEnabled) {
296 yield* managedEndpointProvider
297 .deprovision({
298 userId: input.userId,
299 environmentId: verified.environmentId,
300 })
301 .pipe(
302 Effect.tapError((error) =>
303 Effect.logWarning("managed endpoint deprovision on publish-only link failed", {
304 environmentId: verified.environmentId,
305 errorTag: error._tag,
306 }),
307 ),
308 Effect.ignore,
309 );
310 }
311 const provisioned = input.request.managedTunnelsEnabled
312 ? yield* managedEndpointProvider.provision({
313 userId: input.userId,
314 environmentId: verified.environmentId,
315 origin: verified.origin,
316 })
317 : null;
318 const endpoint = provisioned?.endpoint ?? verified.endpoint;
319 // The secure-endpoint requirement only matters when the relay advertises
320 // this endpoint for other devices to reach (managed tunnel). Publish-only
321 // links are reached out of band (e.g. Tailscale) and their stored endpoint
322 // is never used for routing, so a nominal endpoint is acceptable.
323 if (input.request.managedTunnelsEnabled && !isSecureManagedEndpoint(endpoint)) {
324 return yield* new EnvironmentLinkProofInvalid({
325 userId: input.userId,
326 environmentId: verified.environmentId,
327 reason: "endpoint_not_secure",
328 stage: "validate_endpoint",
329 });
330 }
331 yield* links.upsert({ ...input, proof: verified, endpoint });
332 const environmentCredential = yield* credentials.create({
333 environmentId: verified.environmentId,
334 environmentPublicKey: verified.environmentPublicKey,
335 });
336 return {
337 environmentId: verified.environmentId,
338 endpoint,
339 endpointRuntime: provisioned?.runtime ?? null,
340 environmentCredential,
341 };
342 }),
343 });
344});
345
346export const layer = Layer.effect(EnvironmentLinker, make);