Skip to content
Source
Sign inSign up
Open History to see the last commit

EnvironmentLinker.ts

340 lines11.9 KB

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