1import { createClerkClient, verifyToken } from "@clerk/backend";
2import { sql as drizzleSql } from "drizzle-orm";
3import * as Crypto from "effect/Crypto";
4import * as Context from "effect/Context";
5import * as DateTime from "effect/DateTime";
6import * as Duration from "effect/Duration";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Record from "effect/Record";
11import * as Redacted from "effect/Redacted";
12import * as Schema from "effect/Schema";
13import * as Tracer from "effect/Tracer";
14import * as Headers from "effect/http/Headers";
15import * as HttpEffect from "effect/http/HttpEffect";
16import * as HttpMiddleware from "effect/http/HttpMiddleware";
17import * as HttpRouter from "effect/http/HttpRouter";
18import * as HttpServerRequest from "effect/http/HttpServerRequest";
19import * as HttpServerResponse from "effect/http/HttpServerResponse";
20import * as HttpTraceContext from "effect/http/HttpTraceContext";
21import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
22import * as HttpApiError from "effect/http-api/HttpApiError";
23import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
24import * as HttpObservability from "@t3tools/shared/httpObservability";
25
26import {
27 RelayApi,
28 RelayAgentActivityPublishProofExpiredError,
29 RelayAgentActivityPublishProofInvalidError,
30 RelayClientAuth,
31 RelayClientPrincipal,
32 RelayAccessTokenType,
33 RelayDpopClientAuth,
34 RelayEnvironmentConnectScope,
35 RelayEnvironmentStatusScope,
36 RelayMobileRegistrationScope,
37 RelayAuthInvalidError,
38 type RelayAuthInvalidReason,
39 type RelayDpopFailureReason,
40 RelayEnvironmentAuth,
41 RelayEnvironmentConnectNotAuthorizedError,
42 RelayEnvironmentEndpointTimedOutError,
43 RelayEnvironmentEndpointUnavailableError,
44 RelayEnvironmentLinkFailedError,
45 RelayEnvironmentLinkProofExpiredError,
46 RelayEnvironmentLinkProofInvalidError,
47 RelayEnvironmentLinkUnavailableError,
48 RelayEnvironmentLinkLimitExceededError,
49 RelayEnvironmentPrincipal,
50 type RelayEnvironmentConnectRequest,
51 type RelayManagedEndpointOrigin,
52 RelayManagedEndpointRecoveryProofPayload,
53 type RelayDpopAccessTokenScope,
54 RelayInternalError,
55} from "@t3tools/contracts/relay";
56import {
57 normalizeRelayIssuer,
58 RELAY_MANAGED_TUNNEL_RECOVERY_TYP,
59 verifyRelayJwt,
60} from "@t3tools/shared/relayJwt";
61
62import * as DeliveryAttempts from "../agentActivity/DeliveryAttempts.ts";
63import * as AgentActivityRows from "../agentActivity/AgentActivityRows.ts";
64import * as Devices from "../agentActivity/Devices.ts";
65import * as DpopProofs from "../auth/DpopProofs.ts";
66import * as RelayTokens from "../auth/RelayTokens.ts";
67import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts";
68import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
69import * as HookForwarder from "../hooks/HookForwarder.ts";
70import * as HeldHooks from "../hooks/HeldHooks.ts";
71import * as HookInbox from "../hooks/HookInbox.ts";
72import * as LiveActivities from "../agentActivity/LiveActivities.ts";
73import * as RelayConfiguration from "../Config.ts";
74import * as AgentActivityPublisher from "../agentActivity/AgentActivityPublisher.ts";
75import * as EnvironmentConnector from "../environments/EnvironmentConnector.ts";
76import * as EnvironmentLinker from "../environments/EnvironmentLinker.ts";
77import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts";
78import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllocations.ts";
79import * as EnvironmentPublishSignatures from "../environments/EnvironmentPublishSignatures.ts";
80import * as MobileRegistrations from "../agentActivity/MobileRegistrations.ts";
81import { withSpanAttributes } from "../observability.ts";
82import { isRelayHookPath, redactRelayHookUrl } from "../hooks/HookForwarder.ts";
83import * as RelayDb from "../db.ts";
84
85// Delegated thread IDs carry escaped command provenance and exceed the router's
86// default 100-character path parameter limit. Match the environment server.
87export const RELAY_HTTP_ROUTER_CONFIG = {
88 maxParamLength: 512,
89} as const;
90
91const decodeManagedTunnelRecoveryProof = Schema.decodeUnknownEffect(
92 RelayManagedEndpointRecoveryProofPayload,
93);
94
95const appendRelayCredentialResponseHeaders = HttpEffect.appendPreResponseHandler(
96 (_request, response) =>
97 Effect.succeed(
98 HttpServerResponse.setHeaders(response, {
99 "cache-control": "no-store",
100 pragma: "no-cache",
101 }),
102 ),
103);
104
105const appendRelayDpopChallengeHeader = HttpEffect.appendPreResponseHandler((_request, response) =>
106 Effect.succeed(
107 response.status === 401
108 ? HttpServerResponse.setHeader(response, "www-authenticate", "DPoP")
109 : response,
110 ),
111);
112
113const appendRelayTraceContextResponseHeader = Effect.gen(function* () {
114 const span = yield* Effect.currentParentSpan;
115 if (span._tag !== "Span") {
116 return;
117 }
118 const traceparent = HttpTraceContext.toHeaders(span).traceparent;
119 if (traceparent === undefined) {
120 return;
121 }
122 yield* HttpEffect.appendPreResponseHandler((_request, response) =>
123 Effect.succeed(HttpServerResponse.setHeader(response, "traceparent", traceparent)),
124 );
125}).pipe(Effect.ignore);
126
127const relayCorsMiddleware = HttpMiddleware.cors({
128 allowedMethods: ["GET", "POST", "DELETE", "OPTIONS"],
129 allowedHeaders: ["authorization", "b3", "traceparent", "content-type", "dpop"],
130 exposedHeaders: ["traceparent", "www-authenticate"],
131 maxAge: 86_400,
132});
133
134// The CORS headers come from a pre-response handler, so they reach every response
135// the request sends: handler failures and defects, and the deadline 504 that
136// `traceRelayHttpRequest` produces outside the router.
137export const layerCors = HttpRouter.middleware(
138 Effect.fnUntraced(function* <E, R>(
139 httpEffect: Effect.Effect<
140 HttpServerResponse.HttpServerResponse,
141 E,
142 HttpServerRequest.HttpServerRequest | R
143 >,
144 ) {
145 const request = yield* HttpServerRequest.HttpServerRequest;
146 // Public webhook forwarding is server-to-server: no preflight, no CORS grants.
147 if (isRelayHookPath(request.url)) {
148 return yield* httpEffect;
149 }
150 return yield* relayCorsMiddleware(httpEffect);
151 }),
152 { global: true },
153);
154
155export const layerNotFoundRoute = HttpRouter.add(
156 "*",
157 "/*",
158 HttpServerResponse.empty({ status: 404 }),
159);
160
161export const layerDocsRedirectRoute = HttpRouter.add(
162 "GET",
163 "/",
164 HttpServerResponse.redirect("/docs"),
165);
166
167// Shorter than the mobile client's 10s request timeout on purpose: when a
168// request hangs (e.g. a stuck upstream query), the client would otherwise
169// abort first, the invocation would die with the request span still open, and
170// the batched spans would never export — leaving no server-side trace at all.
171// Failing server-side first turns the hang into a completed 504 whose trace
172// contains the exact child span that stalled, and the response still carries
173// the traceparent back to the client.
174export const RELAY_REQUEST_DEADLINE_MS = 9_000;
175/**
176 * Webhook forwarding reads a body of up to 1 MiB, waits up to the upstream
177 * timeout, and may then hold the request, so it needs more room than an API
178 * call; cutting it off before the hold would drop a request it should keep.
179 */
180export const RELAY_HOOK_REQUEST_DEADLINE_MS = 25_000;
181
182const relayRequestDeadline = <E, R>(
183 httpEffect: Effect.Effect<
184 HttpServerResponse.HttpServerResponse,
185 E,
186 HttpServerRequest.HttpServerRequest | R
187 >,
188 deadlineMs = RELAY_REQUEST_DEADLINE_MS,
189) =>
190 httpEffect.pipe(
191 Effect.timeoutOption(Duration.millis(deadlineMs)),
192 Effect.flatMap(
193 Option.match({
194 onNone: () =>
195 Effect.gen(function* () {
196 const request = yield* HttpServerRequest.HttpServerRequest;
197 yield* Effect.logError("relay request exceeded deadline", {
198 "http.method": request.method,
199 "http.url": request.url,
200 "relay.request.deadline_ms": deadlineMs,
201 });
202 yield* Effect.annotateCurrentSpan({
203 "relay.request.deadline_exceeded": true,
204 });
205 return HttpServerResponse.jsonUnsafe(
206 { error: "relay_request_deadline_exceeded" },
207 { status: 504 },
208 );
209 }),
210 onSome: Effect.succeed,
211 }),
212 ),
213 );
214
215/** Trace context headers in every format the tracer reads (W3C and B3). */
216const SENDER_TRACE_HEADER = /^(traceparent|tracestate|b3|x-b3-.*)$/i;
217
218export const traceRelayHttpRequest = <E, R>(
219 httpEffect: Effect.Effect<
220 HttpServerResponse.HttpServerResponse,
221 E,
222 HttpServerRequest.HttpServerRequest | R
223 >,
224) =>
225 Effect.gen(function* () {
226 const request = yield* HttpServerRequest.HttpServerRequest;
227 const traced = appendRelayTraceContextResponseHeader.pipe(
228 Effect.andThen(relayRequestDeadline(httpEffect)),
229 );
230 if (!isRelayHookPath(request.url)) {
231 return yield* HttpMiddleware.tracer(traced).pipe(
232 // The worker turns its own request span off; this one is ours.
233 Effect.provideService(HttpMiddleware.TracerDisabledWhen, () => false),
234 // HttpMiddleware finalizes its span on the dispatcher; do not close a request-scoped exporter first.
235 Effect.ensuring(Effect.yieldNow),
236 );
237 }
238 // Hook URLs carry a secret token: the tracer and deadline log see a redacted
239 // request, while the route itself still receives the original. A webhook
240 // sender's trace context is dropped, so it cannot pick the trace our relay
241 // and environment spans land in.
242 const redacted = request.modify({
243 url: redactRelayHookUrl(request.url),
244 headers: Headers.removeMany(
245 request.headers,
246 Object.keys(request.headers).filter((name) => SENDER_TRACE_HEADER.test(name)),
247 ),
248 });
249 return yield* HttpMiddleware.tracer(
250 appendRelayTraceContextResponseHeader.pipe(
251 Effect.andThen(
252 relayRequestDeadline(
253 httpEffect.pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, request)),
254 RELAY_HOOK_REQUEST_DEADLINE_MS,
255 ),
256 ),
257 ),
258 ).pipe(
259 Effect.provideService(HttpServerRequest.HttpServerRequest, redacted),
260 // The worker turns its own request span off; this one is ours.
261 Effect.provideService(HttpMiddleware.TracerDisabledWhen, () => false),
262 Effect.ensuring(Effect.yieldNow),
263 );
264 });
265
266// Webhook senders put shared secrets and signatures in headers such as
267// x-hub-signature-256, stripe-signature, x-gitlab-token and x-webhook-key.
268const layerWebhookHeaderRedaction = Layer.effect(
269 Headers.CurrentRedactedNames,
270 Effect.map(Headers.CurrentRedactedNames, (names) => [
271 ...names,
272 /signature/i,
273 /token/i,
274 /secret/i,
275 /key/i,
276 /auth/i,
277 ]),
278);
279
280export const traceRelayHttpRequestWith = <E, R, LayerError, LayerRequirements>(
281 httpEffect: Effect.Effect<
282 HttpServerResponse.HttpServerResponse,
283 E,
284 HttpServerRequest.HttpServerRequest | R
285 >,
286 tracerLayer: Layer.Layer<never, LayerError, LayerRequirements>,
287) =>
288 traceRelayHttpRequest(httpEffect).pipe(
289 Effect.provide(
290 Layer.merge(
291 tracerLayer,
292 layerWebhookHeaderRedaction.pipe(Layer.provide(HttpObservability.layer)),
293 ),
294 ),
295 );
296
297export const withoutCapturedParentSpan = <A, E, R>(
298 effect: Effect.Effect<A, E, R>,
299): Effect.Effect<A, E, R> =>
300 Effect.withFiber((fiber) => {
301 const context = fiber.context;
302 // HttpApiBuilder captures its build context for route handlers; an event parent would outlive export.
303 fiber.setContext(Context.omit(Tracer.ParentSpan)(context));
304 return effect.pipe(Effect.ensuring(Effect.sync(() => fiber.setContext(context))));
305 });
306
307export const layerClientAuth = Layer.effect(
308 RelayClientAuth,
309 Effect.gen(function* () {
310 const config = yield* RelayConfiguration.RelayConfiguration;
311 return {
312 clientBearer: Effect.fn("relay.auth.client.bearer")(function* (httpEffect, { credential }) {
313 const token = readHttpAuthorizationCredential(credential);
314 const verified = yield* verifyRelayClientBearerToken(config, token).pipe(
315 Effect.tapError((error) =>
316 Effect.annotateCurrentSpan(
317 "relay.auth.clerk_verification_failure",
318 clerkVerificationFailureReason(error.cause),
319 ),
320 ),
321 Effect.catch(() => relayAuthInvalidError("invalid_bearer")),
322 );
323 if (!verified.sub) {
324 yield* Effect.annotateCurrentSpan({
325 "relay.auth.clerk_verification_failure": "missing_subject",
326 });
327 return yield* relayAuthInvalidError("invalid_bearer");
328 }
329 yield* Effect.annotateCurrentSpan({
330 "relay.auth.mode": verified.mode,
331 "relay.auth.subject": verified.sub,
332 });
333
334 return yield* httpEffect.pipe(
335 withSpanAttributes({ "user.id": verified.sub }),
336 Effect.provideService(RelayClientPrincipal, {
337 userId: verified.sub,
338 token,
339 }),
340 );
341 }),
342 };
343 }),
344);
345
346export const layerEnvironmentAuth = Layer.effect(
347 RelayEnvironmentAuth,
348 Effect.gen(function* () {
349 const credentials = yield* EnvironmentCredentials.EnvironmentCredentials;
350 return {
351 environmentBearer: Effect.fn("relay.auth.environment.bearer")(function* (
352 httpEffect,
353 { credential },
354 ) {
355 const token = readHttpAuthorizationCredential(credential);
356 const principal = yield* credentials.authenticate(token).pipe(
357 Effect.catchTags({
358 EnvironmentCredentialAuthenticatePersistenceError: () =>
359 relayInternalErrorResponse("persistence_failed"),
360 }),
361 );
362 if (principal._tag === "None") {
363 return yield* relayAuthInvalidError("not_authorized");
364 }
365 yield* Effect.annotateCurrentSpan({
366 "relay.auth.mode": "environment_credential",
367 });
368 return yield* httpEffect.pipe(
369 withSpanAttributes({
370 "relay.environment_id": principal.value.environmentId,
371 }),
372 Effect.provideService(RelayEnvironmentPrincipal, principal.value),
373 );
374 }),
375 };
376 }),
377);
378
379export const layerDpopClientAuth = Layer.effect(
380 RelayDpopClientAuth,
381 Effect.gen(function* () {
382 const relayTokens = yield* RelayTokens.RelayTokens;
383 return {
384 relayDpop: Effect.fn("relay.auth.dpop_client")(function* (httpEffect, { credential }) {
385 yield* appendRelayDpopChallengeHeader;
386 const request = yield* HttpServerRequest.HttpServerRequest;
387 if (!isDpopAuthorizationHeader(request.headers.authorization)) {
388 return yield* relayAuthInvalidError("invalid_bearer");
389 }
390 const token = readHttpAuthorizationCredential(credential);
391 const now = yield* DateTime.now;
392 const verified = yield* relayTokens.verifyDpopAccessToken({
393 token,
394 nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
395 });
396 if (!verified) {
397 return yield* relayAuthInvalidError("invalid_bearer");
398 }
399 yield* Effect.annotateCurrentSpan({
400 "relay.auth.mode": "dpop",
401 "relay.auth.subject": verified.sub,
402 });
403 return yield* httpEffect.pipe(
404 withSpanAttributes({ "user.id": verified.sub }),
405 Effect.provideService(RelayClientPrincipal, {
406 userId: verified.sub,
407 token,
408 proofKeyThumbprint: verified.cnf.jkt,
409 dpopScopes: verified.scope,
410 }),
411 );
412 }),
413 };
414 }),
415);
416
417function isDpopAuthorizationHeader(value: string | undefined): boolean {
418 return /^DPoP +/iu.test(value ?? "");
419}
420
421function readHttpAuthorizationCredential(credential: Redacted.Redacted<string>): string {
422 // Effect beta.73 leaves the scheme separator in decoded HTTP credentials.
423 return Redacted.value(credential).trimStart();
424}
425
426export const layerMetadataApi = HttpApiBuilder.group(
427 RelayApi,
428 "metadata",
429 Effect.fnUntraced(function* (handlers) {
430 const settings = yield* RelayConfiguration.RelayConfiguration;
431 const issuer = normalizeRelayIssuer(settings.relayIssuer);
432 const scopes = [
433 RelayEnvironmentConnectScope,
434 RelayEnvironmentStatusScope,
435 RelayMobileRegistrationScope,
436 ] as const;
437 return handlers
438 .handle("authorizationServer", () =>
439 Effect.succeed({
440 issuer,
441 token_endpoint: `${issuer}/v1/client/dpop-token`,
442 grant_types_supported: ["urn:ietf:params:oauth:grant-type:token-exchange"],
443 token_endpoint_auth_methods_supported: ["none"],
444 dpop_signing_alg_values_supported: ["ES256"],
445 scopes_supported: scopes,
446 }),
447 )
448 .handle("protectedResource", () =>
449 Effect.succeed({
450 resource: issuer,
451 authorization_servers: [issuer],
452 scopes_supported: scopes,
453 dpop_bound_access_tokens_required: true,
454 dpop_signing_alg_values_supported: ["ES256"],
455 }),
456 );
457 }),
458);
459
460export const layerHealthApi = HttpApiBuilder.group(
461 RelayApi,
462 "health",
463 Effect.fnUntraced(function* (handlers) {
464 const db = yield* RelayDb.RelayDb;
465 return handlers.handle(
466 "health",
467 Effect.fn("relay.api.health")(
468 function* () {
469 yield* db.execute(drizzleSql`SELECT 1`);
470 return { ok: true, service: "relay" as const };
471 },
472 Effect.catch(() => relayInternalErrorResponse("database_unavailable")),
473 ),
474 );
475 }),
476);
477
478export const revokeEnvironmentLinkRecord = Effect.fn(
479 "relay.api.client.revokeEnvironmentLinkRecord",
480)(function* (input: {
481 readonly userId: string;
482 readonly environmentId: string;
483 readonly environmentPublicKey: string;
484}) {
485 const transactions = yield* RelayDb.RelayTransactions;
486 const links = yield* EnvironmentLinks.EnvironmentLinks;
487 const credentials = yield* EnvironmentCredentials.EnvironmentCredentials;
488 return yield* transactions.withTransaction(
489 Effect.gen(function* () {
490 const revoked = yield* links.revokeForUser({
491 userId: input.userId,
492 environmentId: input.environmentId,
493 });
494 if (revoked) {
495 yield* credentials.revokeForEnvironmentPublicKey({
496 environmentId: input.environmentId,
497 environmentPublicKey: input.environmentPublicKey,
498 });
499 }
500 return revoked;
501 }),
502 );
503});
504
505export const unlinkEnvironmentRecord = Effect.fn("relay.api.client.unlinkEnvironmentRecord")(
506 function* (input: {
507 readonly userId: string;
508 readonly environmentId: string;
509 /** The stage's tunnel-name namespace, to find this link's held webhook requests. */
510 readonly managedEndpointNamespace?: string | undefined;
511 }) {
512 const links = yield* EnvironmentLinks.EnvironmentLinks;
513 const managedEndpointProvider = yield* ManagedEndpointProvider.ManagedEndpointProvider;
514 const deprovisionTarget = yield* managedEndpointProvider.prepareDeprovision({
515 userId: input.userId,
516 environmentId: input.environmentId,
517 });
518 const link = yield* links.getForUser({
519 userId: input.userId,
520 environmentId: input.environmentId,
521 });
522 const unlinked =
523 link === null
524 ? false
525 : yield* revokeEnvironmentLinkRecord({
526 userId: input.userId,
527 environmentId: link.environmentId,
528 environmentPublicKey: link.environmentPublicKey,
529 });
530 // External teardown cannot share the SQL transaction. Run it only after
531 // revocation commits so a database failure leaves a fully usable active
532 // link. Still run teardown when the link is already revoked, allowing a
533 // retry to finish cleanup after an earlier Cloudflare failure.
534 const deprovisioned = yield* managedEndpointProvider.deprovision({
535 userId: input.userId,
536 environmentId: input.environmentId,
537 target: deprovisionTarget,
538 });
539 // Requests held for this link's endpoint go with it. Best effort: the link
540 // is already gone, and its inbox drops anything left after its TTL.
541 const endpointKey =
542 deprovisionTarget && input.managedEndpointNamespace
543 ? HeldHooks.endpointKeyForTunnelName(
544 input.managedEndpointNamespace,
545 deprovisionTarget.tunnelName,
546 )
547 : null;
548 if (endpointKey !== null) {
549 const inbox = yield* HookInbox.HookInbox;
550 yield* inbox.clear({ endpointKey }).pipe(
551 Effect.catch((error) =>
552 Effect.logWarning("Could not clear held webhook requests", {
553 environmentId: input.environmentId,
554 errorTag: error._tag,
555 }),
556 ),
557 );
558 }
559 if (!deprovisioned) {
560 const key = { userId: input.userId, environmentId: input.environmentId };
561 const retryTarget = yield* managedEndpointProvider.prepareDeprovision(key);
562 if (retryTarget !== null && (yield* links.getForUser(key)) === null) {
563 yield* managedEndpointProvider.deprovision({ ...key, target: retryTarget });
564 }
565 }
566 return unlinked;
567 },
568);
569
570type EnvironmentTunnelRecoveryProofInput = {
571 readonly proof: string;
572 readonly userId: string;
573 readonly environmentId: string;
574 readonly environmentPublicKey: string;
575} & (
576 | {
577 readonly action: "register";
578 readonly tunnelId: string;
579 readonly origin: RelayManagedEndpointOrigin;
580 }
581 | { readonly action: "recover"; readonly origin: RelayManagedEndpointOrigin }
582);
583
584export const verifyEnvironmentTunnelRecoveryProof = Effect.fn(
585 "relay.api.server.verifyEnvironmentTunnelRecoveryProof",
586)(function* (input: EnvironmentTunnelRecoveryProofInput) {
587 const config = yield* RelayConfiguration.RelayConfiguration;
588 const now = yield* DateTime.now;
589 const verified = yield* verifyRelayJwt({
590 publicKey: input.environmentPublicKey,
591 token: input.proof,
592 typ: RELAY_MANAGED_TUNNEL_RECOVERY_TYP,
593 issuer: `t3-env:${input.environmentId}`,
594 audience: normalizeRelayIssuer(config.relayIssuer),
595 nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
596 }).pipe(
597 Effect.flatMap(decodeManagedTunnelRecoveryProof),
598 Effect.mapError(() => new HttpApiError.Unauthorized({})),
599 );
600
601 if (
602 verified.environmentId !== input.environmentId ||
603 verified.sub !== input.environmentId ||
604 verified.cloudUserId !== input.userId ||
605 verified.action !== input.action
606 ) {
607 return yield* new HttpApiError.Unauthorized({});
608 }
609 if (input.action === "register") {
610 if (
611 verified.action !== "register" ||
612 verified.tunnelId !== input.tunnelId ||
613 verified.origin.localHttpHost !== input.origin.localHttpHost ||
614 verified.origin.localHttpPort !== input.origin.localHttpPort
615 ) {
616 return yield* new HttpApiError.Unauthorized({});
617 }
618 return;
619 }
620 if (
621 verified.action !== "recover" ||
622 verified.origin.localHttpHost !== input.origin.localHttpHost ||
623 verified.origin.localHttpPort !== input.origin.localHttpPort
624 ) {
625 return yield* new HttpApiError.Unauthorized({});
626 }
627});
628
629export const registerEnvironmentTunnelRecovery = Effect.fn(
630 "relay.api.server.registerEnvironmentTunnelRecovery",
631)(function* (input: {
632 readonly userId: string;
633 readonly environmentId: string;
634 readonly environmentPublicKey: string;
635 readonly tunnelId: string;
636 readonly origin: RelayManagedEndpointOrigin;
637}) {
638 const links = yield* EnvironmentLinks.EnvironmentLinks;
639 const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations;
640 const managedEndpointProvider = yield* ManagedEndpointProvider.ManagedEndpointProvider;
641 const link = yield* links.getForUser({
642 userId: input.userId,
643 environmentId: input.environmentId,
644 });
645 if (
646 link === null ||
647 link.environmentPublicKey !== input.environmentPublicKey ||
648 link.endpoint.providerKind !== "cloudflare_tunnel"
649 ) {
650 return yield* new HttpApiError.Unauthorized({});
651 }
652 const status = yield* managedEndpointProvider.reconcileOrigin({
653 userId: input.userId,
654 environmentId: input.environmentId,
655 tunnelId: input.tunnelId,
656 origin: input.origin,
657 endpoint: link.endpoint,
658 });
659 if (status === "recovery_required") {
660 return { status };
661 }
662 if (!(yield* allocations.enableRecovery(input))) {
663 return yield* new HttpApiError.Unauthorized({});
664 }
665 return { status };
666});
667
668export const recoverEnvironmentTunnelRecord = Effect.fn(
669 "relay.api.server.recoverEnvironmentTunnelRecord",
670)(function* (input: {
671 readonly userId: string;
672 readonly environmentId: string;
673 readonly environmentPublicKey: string;
674 readonly origin: RelayManagedEndpointOrigin;
675}) {
676 const links = yield* EnvironmentLinks.EnvironmentLinks;
677 const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations;
678 const managedEndpointProvider = yield* ManagedEndpointProvider.ManagedEndpointProvider;
679 const link = yield* links.getForUser({
680 userId: input.userId,
681 environmentId: input.environmentId,
682 });
683 if (
684 link === null ||
685 link.environmentPublicKey !== input.environmentPublicKey ||
686 link.endpoint.providerKind !== "cloudflare_tunnel"
687 ) {
688 return yield* new HttpApiError.Unauthorized({});
689 }
690
691 const recovered = yield* managedEndpointProvider.provision({
692 userId: input.userId,
693 environmentId: input.environmentId,
694 origin: input.origin,
695 });
696 const recoveredTunnelId = recovered.runtime.tunnelId;
697 if (
698 recoveredTunnelId === undefined ||
699 recovered.endpoint.httpBaseUrl !== link.endpoint.httpBaseUrl ||
700 recovered.endpoint.wsBaseUrl !== link.endpoint.wsBaseUrl
701 ) {
702 if (recoveredTunnelId !== undefined) {
703 yield* managedEndpointProvider
704 .release({
705 userId: input.userId,
706 environmentId: input.environmentId,
707 expectedTunnelId: recoveredTunnelId,
708 })
709 .pipe(
710 Effect.catch((cause) =>
711 Effect.logWarning("Failed to clean up a tunnel with a mismatched endpoint", {
712 userId: input.userId,
713 environmentId: input.environmentId,
714 tunnelId: recoveredTunnelId,
715 cause,
716 }),
717 ),
718 );
719 }
720 return yield* new HttpApiError.Unauthorized({});
721 }
722
723 const enabled = yield* allocations.enableRecovery({
724 userId: input.userId,
725 environmentId: input.environmentId,
726 tunnelId: recoveredTunnelId,
727 environmentPublicKey: input.environmentPublicKey,
728 origin: input.origin,
729 });
730 if (!enabled) {
731 const owner = { userId: input.userId, environmentId: input.environmentId };
732 const target = yield* managedEndpointProvider.prepareDeprovision(owner);
733 const currentLink = target === null ? null : yield* links.getForUser(input);
734 if (
735 target !== null &&
736 (currentLink === null || currentLink.endpoint.providerKind !== "cloudflare_tunnel")
737 ) {
738 yield* managedEndpointProvider.deprovision({ ...owner, target }).pipe(
739 Effect.catch((cause) =>
740 Effect.logWarning("Failed to clean up a tunnel after its managed link was removed", {
741 userId: input.userId,
742 environmentId: input.environmentId,
743 cause,
744 }),
745 ),
746 );
747 }
748 return yield* new HttpApiError.Unauthorized({});
749 }
750 return {
751 endpoint: recovered.endpoint,
752 endpointRuntime: recovered.runtime,
753 };
754});
755
756export const layerMobileApi = HttpApiBuilder.group(
757 RelayApi,
758 "mobile",
759 Effect.fnUntraced(function* (handlers) {
760 const registrations = yield* MobileRegistrations.MobileRegistrations;
761 const dpopProofs = yield* DpopProofs.DpopProofReplay;
762 return handlers
763 .handle(
764 "registerDevice",
765 Effect.fn("relay.api.mobile.registerDevice")(function* (args) {
766 const { payload } = args;
767 const { userId, token } = yield* RelayClientPrincipal;
768 const proofKeyThumbprint = yield* requireDpopPrincipalScope("mobile:registration");
769 yield* requireDpopThumbprint(proofKeyThumbprint, {
770 expectedAccessToken: token,
771 }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs));
772 return yield* registrations.registerDevice({ userId, payload });
773 }, mapRelayCommonApiErrors("invalid_dpop")),
774 )
775 .handle(
776 "registerLiveActivity",
777 Effect.fn("relay.api.mobile.registerLiveActivity")(function* (args) {
778 const { payload } = args;
779 const { userId, token } = yield* RelayClientPrincipal;
780 const proofKeyThumbprint = yield* requireDpopPrincipalScope("mobile:registration");
781 yield* requireDpopThumbprint(proofKeyThumbprint, {
782 expectedAccessToken: token,
783 }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs));
784 return yield* registrations.registerLiveActivity({ userId, payload });
785 }, mapRelayCommonApiErrors("invalid_dpop")),
786 )
787 .handle(
788 "getAgentActivitySnapshot",
789 Effect.fn("relay.api.mobile.getAgentActivitySnapshot")(function* () {
790 const { userId, token } = yield* RelayClientPrincipal;
791 const proofKeyThumbprint = yield* requireDpopPrincipalScope("mobile:registration");
792 yield* requireDpopThumbprint(proofKeyThumbprint, {
793 expectedAccessToken: token,
794 }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs));
795 return yield* registrations.getAgentActivitySnapshot({ userId });
796 }, mapRelayCommonApiErrors("invalid_dpop")),
797 )
798 .handle(
799 "unregisterDevice",
800 Effect.fn("relay.api.mobile.unregisterDevice")(function* (args) {
801 const { params } = args;
802 const { userId, token } = yield* RelayClientPrincipal;
803 const proofKeyThumbprint = yield* requireDpopPrincipalScope("mobile:registration");
804 yield* requireDpopThumbprint(proofKeyThumbprint, {
805 expectedAccessToken: token,
806 }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs));
807 return yield* registrations.unregisterDevice({ userId, deviceId: params.deviceId });
808 }, mapRelayCommonApiErrors("invalid_dpop")),
809 );
810 }),
811);
812
813export const layerClientApi = HttpApiBuilder.group(
814 RelayApi,
815 "client",
816 Effect.fnUntraced(function* (handlers) {
817 const config = yield* RelayConfiguration.RelayConfiguration;
818 const crypto = yield* Crypto.Crypto;
819 const relayTokens = yield* RelayTokens.RelayTokens;
820 const linker = yield* EnvironmentLinker.EnvironmentLinker;
821 const links = yield* EnvironmentLinks.EnvironmentLinks;
822 const managedEndpointProvider = yield* ManagedEndpointProvider.ManagedEndpointProvider;
823 const devices = yield* Devices.Devices;
824 return handlers
825 .handle(
826 "listEnvironments",
827 Effect.fn("relay.api.client.listEnvironments")(function* () {
828 const { userId } = yield* RelayClientPrincipal;
829 const environments = yield* links.listForUser({ userId });
830 return { environments };
831 }, mapRelayCommonApiErrors("not_authorized")),
832 )
833 .handle(
834 "listDevices",
835 Effect.fn("relay.api.client.listDevices")(function* () {
836 yield* appendRelayCredentialResponseHeaders;
837 const { userId } = yield* RelayClientPrincipal;
838 const registered = yield* devices.listForUser({ userId });
839 return {
840 devices: registered.flatMap((device) =>
841 device.platform === "ios" && device.iosMajorVersion !== null
842 ? [{ ...device, platform: "ios" as const, iosMajorVersion: device.iosMajorVersion }]
843 : [],
844 ),
845 };
846 }, mapRelayCommonApiErrors("not_authorized")),
847 )
848 .handle(
849 "listDevicesV2",
850 Effect.fn("relay.api.client.listDevicesV2")(function* () {
851 yield* appendRelayCredentialResponseHeaders;
852 const { userId } = yield* RelayClientPrincipal;
853 return { devices: yield* devices.listForUser({ userId }) };
854 }, mapRelayCommonApiErrors("not_authorized")),
855 )
856 .handle(
857 "linkEnvironment",
858 Effect.fn("relay.api.client.linkEnvironment")(
859 function* (args) {
860 const { payload } = args;
861 yield* appendRelayCredentialResponseHeaders;
862 const { userId } = yield* RelayClientPrincipal;
863 const result = yield* linker.link({ userId, request: payload });
864 return {
865 ok: true,
866 cloudUserId: userId,
867 environmentId: result.environmentId,
868 endpoint: result.endpoint,
869 endpointRuntime: result.endpointRuntime,
870 relayIssuer: config.relayIssuer,
871 environmentCredential: result.environmentCredential,
872 cloudMintPublicKey: config.cloudMintPublicKey,
873 };
874 },
875 mapErrorTags({
876 EnvironmentLinkProofExpired: (_error, traceId) =>
877 new RelayEnvironmentLinkProofExpiredError({
878 code: "environment_link_proof_expired",
879 traceId,
880 }),
881 EnvironmentLinkProofInvalid: (linkError, traceId) =>
882 new RelayEnvironmentLinkProofInvalidError({
883 code: "environment_link_proof_invalid",
884 reason: linkError.reason,
885 traceId,
886 }),
887 ManagedEndpointProvisioningNotConfigured: (_error, traceId) =>
888 new RelayEnvironmentLinkUnavailableError({
889 code: "environment_link_unavailable",
890 reason: "managed_endpoint_not_configured",
891 traceId,
892 }),
893 ManagedEndpointProvisioningFailed: (_error, traceId) =>
894 new RelayEnvironmentLinkUnavailableError({
895 code: "environment_link_unavailable",
896 reason: "managed_endpoint_provisioning_failed",
897 traceId,
898 }),
899 ManagedEndpointOriginNotAllowed: (_error, traceId) =>
900 new RelayEnvironmentLinkProofInvalidError({
901 code: "environment_link_proof_invalid",
902 reason: "origin_not_allowed",
903 traceId,
904 }),
905 ManagedTunnelLimitExceeded: (limitError, traceId) =>
906 new RelayEnvironmentLinkLimitExceededError({
907 code: "environment_link_limit_exceeded",
908 maxTunnels: limitError.maxTunnels,
909 traceId,
910 }),
911 EnvironmentLinkUpsertPersistenceError: (_error, traceId) =>
912 new RelayEnvironmentLinkFailedError({
913 code: "environment_link_failed",
914 reason: "link_persistence_failed",
915 traceId,
916 }),
917 EnvironmentCredentialCreatePersistenceError: (_error, traceId) =>
918 new RelayEnvironmentLinkFailedError({
919 code: "environment_link_failed",
920 reason: "credential_persistence_failed",
921 traceId,
922 }),
923 DpopProofReplayPersistenceError: (_error, traceId) =>
924 new RelayEnvironmentLinkFailedError({
925 code: "environment_link_failed",
926 reason: "replay_persistence_failed",
927 traceId,
928 }),
929 }),
930 mapRelayCommonApiErrors("not_authorized"),
931 ),
932 )
933 .handle(
934 "createEnvironmentLinkChallenge",
935 Effect.fn("relay.api.client.createEnvironmentLinkChallenge")(function* (args) {
936 yield* appendRelayCredentialResponseHeaders;
937 const { userId } = yield* RelayClientPrincipal;
938 const now = yield* DateTime.now;
939 const expiresAt = DateTime.add(now, { minutes: 5 });
940 const jti = yield* crypto
941 .randomUUIDv4()
942 .pipe(Effect.catch(() => relayInternalErrorResponse("internal_error")));
943 const challenge = yield* relayTokens
944 .issueLinkChallenge({
945 userId,
946 request: args.payload,
947 jti,
948 issuedAtEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
949 expiresAtEpochSeconds: Math.floor(expiresAt.epochMilliseconds / 1_000),
950 })
951 .pipe(Effect.catch(() => relayInternalErrorResponse("internal_error")));
952 return { challenge, expiresAt: DateTime.formatIso(expiresAt) };
953 }, mapRelayCommonApiErrors("not_authorized")),
954 )
955 .handle(
956 "unlinkEnvironment",
957 Effect.fn("relay.api.client.unlinkEnvironment")(function* (args) {
958 const { params } = args;
959 const { userId } = yield* RelayClientPrincipal;
960 const unlinked = yield* unlinkEnvironmentRecord({
961 userId,
962 environmentId: params.environmentId,
963 managedEndpointNamespace: config.managedEndpointNamespace,
964 }).pipe(
965 Effect.catchTags({
966 SqlError: () => relayInternalErrorResponse("internal_error"),
967 ManagedEndpointDeprovisioningFailed: () =>
968 relayInternalErrorResponse("upstream_unavailable"),
969 }),
970 );
971 return { ok: unlinked };
972 }, mapRelayCommonApiErrors("not_authorized")),
973 )
974 .handle(
975 "releaseEnvironmentTunnel",
976 Effect.fn("relay.api.client.releaseEnvironmentTunnel")(function* (args) {
977 const { params } = args;
978 const { userId } = yield* RelayClientPrincipal;
979 // ok mirrors whether the connector token is now dead: false means a
980 // concurrent provision kept the recorded tunnel alive, so the caller
981 // must not discard its runtime config.
982 const released = yield* managedEndpointProvider
983 .release({
984 userId,
985 environmentId: params.environmentId,
986 })
987 .pipe(Effect.catch(() => relayInternalErrorResponse("upstream_unavailable")));
988 return { ok: released };
989 }, mapRelayCommonApiErrors("not_authorized")),
990 );
991 }),
992);
993
994export const layerTokenApi = HttpApiBuilder.group(
995 RelayApi,
996 "token",
997 Effect.fnUntraced(function* (handlers) {
998 const config = yield* RelayConfiguration.RelayConfiguration;
999 const crypto = yield* Crypto.Crypto;
1000 const dpopProofs = yield* DpopProofs.DpopProofReplay;
1001 const relayTokens = yield* RelayTokens.RelayTokens;
1002 return handlers.handle(
1003 "exchangeDpopAccessToken",
1004 Effect.fn("relay.api.token.exchangeDpopAccessToken")(function* (args) {
1005 yield* appendRelayCredentialResponseHeaders;
1006 const issuer = normalizeRelayIssuer(config.relayIssuer);
1007 const requestedScopes = relayTokens.resolveDpopAccessTokenScopes({
1008 clientId: args.payload.client_id,
1009 scope: args.payload.scope,
1010 });
1011 yield* Effect.annotateCurrentSpan({
1012 "relay.auth.mode": "clerk_bearer_token_exchange",
1013 "relay.oauth.client_id": args.payload.client_id,
1014 "relay.oauth.scopes": args.payload.scope,
1015 });
1016 if (args.payload.resource !== issuer || requestedScopes === null) {
1017 return yield* new HttpApiError.Unauthorized({});
1018 }
1019
1020 const verified = yield* verifyClerkBearerToken(config, args.payload.subject_token).pipe(
1021 Effect.catch(() => relayAuthInvalidError("invalid_bearer")),
1022 );
1023 if (!verified.sub || !hasExpectedClerkAudience(verified.aud, config.clerkJwtAudience)) {
1024 return yield* relayAuthInvalidError("invalid_bearer");
1025 }
1026 const proofKeyThumbprint = yield* requireDpopProof().pipe(
1027 Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs),
1028 );
1029 const now = yield* DateTime.now;
1030 const expiresAt = DateTime.addDuration(now, RelayTokens.RELAY_DPOP_ACCESS_TOKEN_TTL);
1031 const jti = yield* crypto
1032 .randomUUIDv4()
1033 .pipe(Effect.catch(() => relayInternalErrorResponse("internal_error")));
1034 return {
1035 access_token: yield* relayTokens
1036 .issueDpopAccessToken({
1037 userId: verified.sub,
1038 proofKeyThumbprint,
1039 jti,
1040 issuedAtEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
1041 expiresAtEpochSeconds: Math.floor(expiresAt.epochMilliseconds / 1_000),
1042 clientId: args.payload.client_id,
1043 scopes: requestedScopes,
1044 })
1045 .pipe(Effect.catch(() => relayInternalErrorResponse("internal_error"))),
1046 issued_token_type: RelayAccessTokenType,
1047 token_type: "DPoP" as const,
1048 expires_in: Duration.toSeconds(RelayTokens.RELAY_DPOP_ACCESS_TOKEN_TTL),
1049 scope: encodeOAuthScope(requestedScopes),
1050 };
1051 }, mapRelayCommonApiErrors("invalid_dpop")),
1052 );
1053 }),
1054);
1055
1056export const layerDpopClientApi = HttpApiBuilder.group(
1057 RelayApi,
1058 "dpopClient",
1059 Effect.fnUntraced(function* (handlers) {
1060 const connector = yield* EnvironmentConnector.EnvironmentConnector;
1061 const dpopProofs = yield* DpopProofs.DpopProofReplay;
1062 return handlers
1063 .handle(
1064 "connectEnvironment",
1065 Effect.fn("relay.api.dpopClient.connectEnvironment")(
1066 function* (args) {
1067 const { params, payload } = args;
1068 yield* appendRelayCredentialResponseHeaders;
1069 const { userId, token } = yield* RelayClientPrincipal;
1070 const proofKeyThumbprint = yield* requireDpopPrincipalScope("environment:connect");
1071 const requestedThumbprint = resolveConnectClientKeyThumbprint(payload);
1072 if (!requestedThumbprint || requestedThumbprint !== proofKeyThumbprint) {
1073 return yield* new HttpApiError.Unauthorized({});
1074 }
1075 const clientProofKeyThumbprint = yield* requireDpopThumbprint(proofKeyThumbprint, {
1076 expectedAccessToken: token,
1077 }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs));
1078 return yield* connector.connect({
1079 userId,
1080 environmentId: params.environmentId,
1081 clientProofKeyThumbprint,
1082 ...(payload.deviceId ? { deviceId: payload.deviceId } : {}),
1083 });
1084 },
1085 mapRelayCommonApiErrors("invalid_dpop"),
1086 mapErrorTags({
1087 EnvironmentConnectNotAuthorized: (error, traceId) =>
1088 new RelayEnvironmentConnectNotAuthorizedError({
1089 code: "environment_connect_not_authorized",
1090 reason: error.reason,
1091 traceId,
1092 }),
1093 EnvironmentMintRequestFailed: (_error, traceId) =>
1094 new RelayEnvironmentEndpointUnavailableError({
1095 code: "environment_endpoint_unavailable",
1096 reason: "endpoint_request_failed",
1097 traceId,
1098 }),
1099 EnvironmentMintRequestTimedOut: (_error, traceId) =>
1100 new RelayEnvironmentEndpointTimedOutError({
1101 code: "environment_endpoint_timed_out",
1102 traceId,
1103 }),
1104 EnvironmentMintResponseInvalid: (_error, traceId) =>
1105 new RelayEnvironmentEndpointUnavailableError({
1106 code: "environment_endpoint_unavailable",
1107 reason: "endpoint_response_invalid",
1108 traceId,
1109 }),
1110 }),
1111 ),
1112 )
1113 .handle(
1114 "getEnvironmentStatus",
1115 Effect.fn("relay.api.dpopClient.getEnvironmentStatus")(
1116 function* (args) {
1117 const { params } = args;
1118 const { userId, token } = yield* RelayClientPrincipal;
1119 const proofKeyThumbprint = yield* requireDpopPrincipalScope("environment:status");
1120 yield* requireDpopThumbprint(proofKeyThumbprint, {
1121 expectedAccessToken: token,
1122 }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs));
1123 return yield* connector.status({
1124 userId,
1125 environmentId: params.environmentId,
1126 });
1127 },
1128 mapRelayCommonApiErrors("invalid_dpop"),
1129 mapErrorTags({
1130 EnvironmentConnectNotAuthorized: (error, traceId) =>
1131 new RelayEnvironmentConnectNotAuthorizedError({
1132 code: "environment_connect_not_authorized",
1133 reason: error.reason,
1134 traceId,
1135 }),
1136 EnvironmentMintRequestFailed: (_error, traceId) =>
1137 new RelayEnvironmentEndpointUnavailableError({
1138 code: "environment_endpoint_unavailable",
1139 reason: "endpoint_request_failed",
1140 traceId,
1141 }),
1142 EnvironmentMintRequestTimedOut: (_error, traceId) =>
1143 new RelayEnvironmentEndpointTimedOutError({
1144 code: "environment_endpoint_timed_out",
1145 traceId,
1146 }),
1147 EnvironmentMintResponseInvalid: (_error, traceId) =>
1148 new RelayEnvironmentEndpointUnavailableError({
1149 code: "environment_endpoint_unavailable",
1150 reason: "endpoint_response_invalid",
1151 traceId,
1152 }),
1153 }),
1154 ),
1155 );
1156 }),
1157);
1158
1159export const layerServerApi = HttpApiBuilder.group(
1160 RelayApi,
1161 "server",
1162 Effect.fnUntraced(function* (handlers) {
1163 const publisher = yield* AgentActivityPublisher.AgentActivityPublisher;
1164 const publishSignatures = yield* EnvironmentPublishSignatures.EnvironmentPublishSignatures;
1165 const heldHooks = yield* HeldHooks.HeldHooks;
1166 const requireOwnEnvironment = (environmentId: string) =>
1167 Effect.gen(function* () {
1168 const principal = yield* RelayEnvironmentPrincipal;
1169 if (principal.environmentId !== environmentId) {
1170 return yield* new HttpApiError.Unauthorized({});
1171 }
1172 });
1173 const activityHandlers = handlers.handle(
1174 "publishAgentActivity",
1175 Effect.fn("relay.api.server.publishAgentActivity")(
1176 function* (args) {
1177 const { params, payload } = args;
1178 const principal = yield* RelayEnvironmentPrincipal;
1179 if (principal.environmentId !== params.environmentId) {
1180 return yield* new HttpApiError.Unauthorized({});
1181 }
1182 yield* publishSignatures.verify({
1183 environmentId: params.environmentId,
1184 environmentPublicKey: principal.environmentPublicKey,
1185 threadId: params.threadId,
1186 request: payload,
1187 });
1188 return yield* publisher.publish({
1189 environmentId: params.environmentId,
1190 environmentPublicKey: principal.environmentPublicKey,
1191 threadId: params.threadId,
1192 state: payload.state,
1193 });
1194 },
1195 mapErrorTags({
1196 EnvironmentPublishPublicKeyMissing: (_error, traceId) =>
1197 new RelayAuthInvalidError({
1198 code: "auth_invalid",
1199 reason: "not_authorized",
1200 traceId,
1201 }),
1202 EnvironmentPublishSignatureExpired: (_error, traceId) =>
1203 new RelayAgentActivityPublishProofExpiredError({
1204 code: "agent_activity_publish_proof_expired",
1205 traceId,
1206 }),
1207 EnvironmentPublishSignatureInvalid: (_error, traceId) =>
1208 new RelayAgentActivityPublishProofInvalidError({
1209 code: "agent_activity_publish_proof_invalid",
1210 reason: "invalid_signature_or_payload",
1211 traceId,
1212 }),
1213 DpopProofReplayPersistenceError: (_error, traceId) =>
1214 new RelayInternalError({
1215 code: "internal_error",
1216 reason: "persistence_failed",
1217 traceId,
1218 }),
1219 ApnsDeliveryJobQueuePayloadInvalid: (_error, traceId) =>
1220 new RelayInternalError({
1221 code: "internal_error",
1222 reason: "internal_error",
1223 traceId,
1224 }),
1225 ApnsDeliveryJobLiveActivityAggregateMissing: (_error, traceId) =>
1226 new RelayInternalError({
1227 code: "internal_error",
1228 reason: "internal_error",
1229 traceId,
1230 }),
1231 ApnsDeliveryJobLiveActivityNotificationUnexpected: (_error, traceId) =>
1232 new RelayInternalError({
1233 code: "internal_error",
1234 reason: "internal_error",
1235 traceId,
1236 }),
1237 ApnsDeliveryJobPushNotificationMissing: (_error, traceId) =>
1238 new RelayInternalError({
1239 code: "internal_error",
1240 reason: "internal_error",
1241 traceId,
1242 }),
1243 ApnsDeliveryJobPushNotificationAggregateUnexpected: (_error, traceId) =>
1244 new RelayInternalError({
1245 code: "internal_error",
1246 reason: "internal_error",
1247 traceId,
1248 }),
1249 ApnsDeliveryJobCreatedAtInvalid: (_error, traceId) =>
1250 new RelayInternalError({
1251 code: "internal_error",
1252 reason: "internal_error",
1253 traceId,
1254 }),
1255 ApnsDeliveryJobExpiresAtInvalid: (_error, traceId) =>
1256 new RelayInternalError({
1257 code: "internal_error",
1258 reason: "internal_error",
1259 traceId,
1260 }),
1261 ApnsDeliveryJobTimeWindowInvalid: (_error, traceId) =>
1262 new RelayInternalError({
1263 code: "internal_error",
1264 reason: "internal_error",
1265 traceId,
1266 }),
1267 ApnsDeliveryJobTimeWindowTooLong: (_error, traceId) =>
1268 new RelayInternalError({
1269 code: "internal_error",
1270 reason: "internal_error",
1271 traceId,
1272 }),
1273 ApnsDeliveryJobSignatureInvalid: (_error, traceId) =>
1274 new RelayInternalError({
1275 code: "internal_error",
1276 reason: "internal_error",
1277 traceId,
1278 }),
1279 ApnsDeliveryJobExpired: (_error, traceId) =>
1280 new RelayInternalError({
1281 code: "internal_error",
1282 reason: "internal_error",
1283 traceId,
1284 }),
1285 ApnsDeliveryJobClaimInFlight: (_error, traceId) =>
1286 new RelayInternalError({
1287 code: "internal_error",
1288 reason: "internal_error",
1289 traceId,
1290 }),
1291 ApnsDeliveryQueueSendError: (_error, traceId) =>
1292 new RelayInternalError({
1293 code: "internal_error",
1294 reason: "upstream_unavailable",
1295 traceId,
1296 }),
1297 FcmDeliveryError: (_error, traceId) =>
1298 new RelayInternalError({
1299 code: "internal_error",
1300 reason: "upstream_unavailable",
1301 traceId,
1302 }),
1303 }),
1304 mapRelayCommonApiErrors("not_authorized"),
1305 ),
1306 );
1307
1308 return activityHandlers
1309 .handle(
1310 "registerManagedEndpointRecovery",
1311 Effect.fn("relay.api.server.registerManagedEndpointRecovery")(
1312 function* ({ params, payload }) {
1313 const principal = yield* RelayEnvironmentPrincipal;
1314 if (principal.environmentId !== params.environmentId) {
1315 return yield* new HttpApiError.Unauthorized({});
1316 }
1317 yield* verifyEnvironmentTunnelRecoveryProof({
1318 action: "register",
1319 proof: payload.proof,
1320 userId: payload.cloudUserId,
1321 environmentId: params.environmentId,
1322 environmentPublicKey: principal.environmentPublicKey,
1323 tunnelId: payload.tunnelId,
1324 origin: payload.origin,
1325 });
1326 yield* appendRelayCredentialResponseHeaders;
1327 return yield* registerEnvironmentTunnelRecovery({
1328 userId: payload.cloudUserId,
1329 environmentId: params.environmentId,
1330 environmentPublicKey: principal.environmentPublicKey,
1331 tunnelId: payload.tunnelId,
1332 origin: payload.origin,
1333 });
1334 },
1335 Effect.catchTags({
1336 ManagedEndpointOriginNotAllowed: () => Effect.fail(new HttpApiError.Unauthorized({})),
1337 ManagedEndpointProvisioningNotConfigured: () =>
1338 relayInternalErrorResponse("upstream_unavailable"),
1339 ManagedEndpointProvisioningFailed: () =>
1340 relayInternalErrorResponse("upstream_unavailable"),
1341 ManagedTunnelLimitExceeded: () => relayInternalErrorResponse("upstream_unavailable"),
1342 }),
1343 mapRelayCommonApiErrors("not_authorized"),
1344 ),
1345 )
1346 .handle(
1347 "recoverManagedEndpoint",
1348 Effect.fn("relay.api.server.recoverManagedEndpoint")(
1349 function* ({ params, payload }) {
1350 const principal = yield* RelayEnvironmentPrincipal;
1351 if (principal.environmentId !== params.environmentId) {
1352 return yield* new HttpApiError.Unauthorized({});
1353 }
1354 yield* verifyEnvironmentTunnelRecoveryProof({
1355 action: "recover",
1356 proof: payload.proof,
1357 userId: payload.cloudUserId,
1358 environmentId: params.environmentId,
1359 environmentPublicKey: principal.environmentPublicKey,
1360 origin: payload.origin,
1361 });
1362 yield* appendRelayCredentialResponseHeaders;
1363 return yield* recoverEnvironmentTunnelRecord({
1364 userId: payload.cloudUserId,
1365 environmentId: params.environmentId,
1366 environmentPublicKey: principal.environmentPublicKey,
1367 origin: payload.origin,
1368 });
1369 },
1370 Effect.catchTags({
1371 ManagedEndpointOriginNotAllowed: () => Effect.fail(new HttpApiError.Unauthorized({})),
1372 ManagedEndpointProvisioningNotConfigured: () =>
1373 relayInternalErrorResponse("upstream_unavailable"),
1374 ManagedEndpointProvisioningFailed: () =>
1375 relayInternalErrorResponse("upstream_unavailable"),
1376 ManagedEndpointDeprovisioningFailed: () =>
1377 relayInternalErrorResponse("upstream_unavailable"),
1378 ManagedTunnelLimitExceeded: () => relayInternalErrorResponse("upstream_unavailable"),
1379 }),
1380 mapRelayCommonApiErrors("not_authorized"),
1381 ),
1382 )
1383 .handle(
1384 "updateLinkPreferences",
1385 Effect.fn("relay.api.server.updateLinkPreferences")(function* ({ params, payload }) {
1386 yield* requireOwnEnvironment(params.environmentId);
1387 const principal = yield* RelayEnvironmentPrincipal;
1388 yield* heldHooks.setHoldWhileOffline({
1389 environmentId: params.environmentId,
1390 environmentPublicKey: principal.environmentPublicKey,
1391 holdWebhooksWhileOffline: payload.holdWebhooksWhileOffline,
1392 });
1393 return payload;
1394 }, mapRelayCommonApiErrors("not_authorized")),
1395 )
1396 .handle(
1397 "wakeHeldHooks",
1398 Effect.fn("relay.api.server.wakeHeldHooks")(function* ({ params }) {
1399 yield* requireOwnEnvironment(params.environmentId);
1400 const principal = yield* RelayEnvironmentPrincipal;
1401 const pending = yield* heldHooks.wake({
1402 environmentId: params.environmentId,
1403 environmentPublicKey: principal.environmentPublicKey,
1404 });
1405 return { pending };
1406 }, mapRelayCommonApiErrors("not_authorized")),
1407 );
1408 }),
1409);
1410
1411class ClerkTokenVerificationFailed extends Schema.TaggedError<ClerkTokenVerificationFailed>()(
1412 "ClerkTokenVerificationFailed",
1413 {
1414 cause: Schema.Defect(),
1415 },
1416) {
1417 override get message(): string {
1418 return "Clerk token verification failed";
1419 }
1420}
1421
1422const isHttpUnauthorized = Schema.is(HttpApiError.Unauthorized);
1423const isDpopProofRejected = Schema.is(DpopProofs.DpopProofRejected);
1424
1425const currentTraceId = Effect.currentParentSpan.pipe(
1426 Effect.map((span) => span.traceId),
1427 Effect.orElseSucceed(() => "unavailable"),
1428);
1429
1430const RelayCommonPersistenceError = Schema.Union([
1431 Devices.DeviceRegistrationPersistenceError,
1432 Devices.DeviceUnregistrationPersistenceError,
1433 Devices.DeviceListPersistenceError,
1434 LiveActivities.LiveActivityRegistrationPersistenceError,
1435 EnvironmentLinks.EnvironmentLinkUserListPersistenceError,
1436 EnvironmentLinks.EnvironmentLinkListPersistenceError,
1437 EnvironmentLinks.EnvironmentLinkLookupPersistenceError,
1438 EnvironmentLinks.EnvironmentLinkRevokePersistenceError,
1439 ManagedEndpointAllocations.ManagedEndpointAllocationPersistenceError,
1440 EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError,
1441 EnvironmentCredentials.EnvironmentCredentialRevokePersistenceError,
1442 DpopProofs.DpopProofReplayPersistenceError,
1443 LiveActivities.LiveActivityTargetListPersistenceError,
1444 AgentActivityRows.AgentActivityRowUpsertPersistenceError,
1445 AgentActivityRows.AgentActivityRowDeletePersistenceError,
1446 AgentActivityRows.AgentActivityRowListPersistenceError,
1447 LiveActivities.LiveActivityDeliveryMarkPersistenceError,
1448 DeliveryAttempts.DeliveryAttemptRecordPersistenceError,
1449 EnvironmentLinks.EnvironmentLinkEnvironmentLookupPersistenceError,
1450 HookInbox.HookInboxError,
1451]);
1452type RelayCommonPersistenceError = typeof RelayCommonPersistenceError.Type;
1453const isRelayCommonPersistenceError = Schema.is(RelayCommonPersistenceError);
1454
1455type MapRelayCommonApiError<E> =
1456 | Exclude<
1457 E,
1458 HttpApiError.Unauthorized | DpopProofs.DpopProofRejected | RelayCommonPersistenceError
1459 >
1460 | (Extract<E, HttpApiError.Unauthorized> extends never ? never : RelayAuthInvalidError)
1461 | (Extract<E, DpopProofs.DpopProofRejected> extends never ? never : RelayAuthInvalidError)
1462 | (Extract<E, RelayCommonPersistenceError> extends never ? never : RelayInternalError);
1463
1464export function relayDpopFailureReason(
1465 code: DpopProofs.DpopProofFailureCode,
1466): RelayDpopFailureReason {
1467 switch (code) {
1468 case "time_window":
1469 return "time_window";
1470 case "key_mismatch":
1471 return "key_mismatch";
1472 case "method_mismatch":
1473 case "url_mismatch":
1474 return "request_mismatch";
1475 case "access_token_hash_mismatch":
1476 return "token_mismatch";
1477 case "replayed":
1478 return "replay";
1479 case "missing_proof":
1480 case "malformed_proof":
1481 case "invalid_signature":
1482 case "invalid_proof":
1483 return "invalid_proof";
1484 }
1485}
1486
1487function relayInternalErrorResponse(reason: RelayInternalError["reason"]) {
1488 return currentTraceId.pipe(
1489 Effect.flatMap((traceId) =>
1490 Effect.fail(new RelayInternalError({ code: "internal_error", reason, traceId })),
1491 ),
1492 );
1493}
1494
1495function mapRelayCommonApiErrors(authReason: RelayAuthInvalidReason) {
1496 const mapError = Effect.fnUntraced(function* <E>(error: E) {
1497 const traceId = yield* currentTraceId;
1498 if (isDpopProofRejected(error)) {
1499 yield* Effect.annotateCurrentSpan({
1500 "relay.dpop.failure_code": error.code,
1501 });
1502 return yield* Effect.fail(
1503 new RelayAuthInvalidError({
1504 code: "auth_invalid",
1505 reason: authReason,
1506 ...(authReason === "invalid_dpop"
1507 ? { dpopFailureReason: relayDpopFailureReason(error.code) }
1508 : {}),
1509 traceId,
1510 }) as MapRelayCommonApiError<E>,
1511 );
1512 }
1513 if (isHttpUnauthorized(error)) {
1514 if (authReason === "invalid_dpop") {
1515 yield* Effect.annotateCurrentSpan({
1516 "relay.dpop.failure_code": "invalid_proof",
1517 });
1518 }
1519 return yield* Effect.fail(
1520 new RelayAuthInvalidError({
1521 code: "auth_invalid",
1522 reason: authReason,
1523 ...(authReason === "invalid_dpop" ? { dpopFailureReason: "invalid_proof" } : {}),
1524 traceId,
1525 }) as MapRelayCommonApiError<E>,
1526 );
1527 }
1528 if (isRelayCommonPersistenceError(error)) {
1529 return yield* Effect.fail(
1530 new RelayInternalError({
1531 code: "internal_error",
1532 reason: "persistence_failed",
1533 traceId,
1534 }) as MapRelayCommonApiError<E>,
1535 );
1536 }
1537
1538 return yield* Effect.fail(error as MapRelayCommonApiError<E>);
1539 });
1540
1541 return <A, E, R>(
1542 effect: Effect.Effect<A, E, R>,
1543 ): Effect.Effect<A, MapRelayCommonApiError<E>, R> => effect.pipe(Effect.catch(mapError));
1544}
1545
1546type TaggedErrorTag<E> = Extract<E, { readonly _tag: string }>["_tag"];
1547
1548type MapErrorTagCases<E> = {
1549 readonly [K in TaggedErrorTag<E>]+?: (
1550 error: Extract<E, { readonly _tag: K }>,
1551 traceId: string,
1552 ) => unknown;
1553};
1554
1555type MappedTagError<Cases> = Cases[keyof Cases] extends (
1556 ...args: ReadonlyArray<never>
1557) => infer Error
1558 ? Error
1559 : never;
1560
1561type CatchTagCases<E, Cases> = {
1562 readonly [K in TaggedErrorTag<E>]+?: (
1563 error: Extract<E, { readonly _tag: K }>,
1564 ) => Effect.Effect<never, MappedTagError<Cases>>;
1565} & (unknown extends E ? {} : { readonly [K in Exclude<keyof Cases, TaggedErrorTag<E>>]: never });
1566
1567function mapErrorTags<
1568 E,
1569 Cases extends MapErrorTagCases<E> &
1570 (unknown extends E ? {} : { readonly [K in Exclude<keyof Cases, TaggedErrorTag<E>>]: never }),
1571>(cases: Cases) {
1572 const catchCases = Record.map(
1573 cases as Record.ReadonlyRecord<
1574 string,
1575 (error: never, traceId: string) => MappedTagError<Cases>
1576 >,
1577 (makeError) => (error: never) =>
1578 currentTraceId.pipe(Effect.flatMap((traceId) => Effect.fail(makeError(error, traceId)))),
1579 ) as CatchTagCases<E, Cases>;
1580
1581 return <A, R>(
1582 self: Effect.Effect<A, E, R>,
1583 ): Effect.Effect<A, Exclude<E, { readonly _tag: keyof Cases }> | MappedTagError<Cases>, R> =>
1584 // @effect-diagnostics-next-line unsafeEffectTypeAssertion:off
1585 Effect.catchTags(self, catchCases) as Effect.Effect<
1586 A,
1587 Exclude<E, { readonly _tag: keyof Cases }> | MappedTagError<Cases>,
1588 R
1589 >;
1590}
1591
1592function resolveConnectClientKeyThumbprint(payload: RelayEnvironmentConnectRequest): string | null {
1593 const requestedThumbprint = payload.clientKeyThumbprint ?? payload.clientProofKeyThumbprint;
1594 if (!requestedThumbprint) {
1595 return null;
1596 }
1597 if (
1598 payload.clientKeyThumbprint &&
1599 payload.clientProofKeyThumbprint &&
1600 payload.clientKeyThumbprint !== payload.clientProofKeyThumbprint
1601 ) {
1602 return null;
1603 }
1604 return requestedThumbprint;
1605}
1606
1607function safeAuthFailureReason(value: string): string {
1608 return /^[a-z0-9._-]+$/i.test(value) ? value : "unknown";
1609}
1610
1611function clerkVerificationFailureReason(cause: unknown): string {
1612 if (
1613 cause instanceof Error &&
1614 (cause.message.startsWith("Invalid JWT audience claim ") ||
1615 cause.message.startsWith("Invalid JWT audience claim array "))
1616 ) {
1617 return "audience_mismatch";
1618 }
1619 if (typeof cause === "object" && cause !== null && "reason" in cause) {
1620 const reason = (cause as { readonly reason?: unknown }).reason;
1621 if (typeof reason === "string" && reason.length > 0) {
1622 return safeAuthFailureReason(reason);
1623 }
1624 }
1625 if (cause instanceof Error && cause.name) {
1626 return safeAuthFailureReason(cause.name);
1627 }
1628 return "unknown";
1629}
1630
1631function hasExpectedClerkAudience(audience: unknown, expectedAudience: string): boolean {
1632 return typeof audience === "string"
1633 ? audience === expectedAudience
1634 : Array.isArray(audience) &&
1635 audience.some((entry) => typeof entry === "string" && entry === expectedAudience);
1636}
1637
1638function verifyClerkBearerToken(
1639 config: RelayConfiguration.RelayConfiguration["Service"],
1640 token: string,
1641) {
1642 return Effect.tryPromise({
1643 try: () =>
1644 verifyToken(token, {
1645 secretKey: Redacted.value(config.clerkSecretKey),
1646 audience: config.clerkJwtAudience,
1647 }),
1648 catch: (cause) => new ClerkTokenVerificationFailed({ cause }),
1649 }).pipe(
1650 Effect.withSpan("verify_clerk_bearer_token", {
1651 attributes: { "relay.auth.token_length": token.length },
1652 }),
1653 );
1654}
1655
1656function verifyClerkOAuthBearerToken(
1657 config: RelayConfiguration.RelayConfiguration["Service"],
1658 token: string,
1659) {
1660 return Effect.tryPromise({
1661 try: async () => {
1662 const client = createClerkClient({
1663 secretKey: Redacted.value(config.clerkSecretKey),
1664 publishableKey: config.clerkPublishableKey,
1665 });
1666 const state = await client.authenticateRequest(
1667 new Request(config.relayIssuer, {
1668 headers: { authorization: `Bearer ${token}` },
1669 }),
1670 { acceptsToken: "oauth_token" },
1671 );
1672 const auth = state.toAuth();
1673 if (!state.isAuthenticated || !auth.userId) {
1674 throw new Error("Clerk OAuth token is not authenticated.");
1675 }
1676 return { sub: auth.userId };
1677 },
1678 catch: (cause) => new ClerkTokenVerificationFailed({ cause }),
1679 });
1680}
1681
1682export function verifyRelayClientBearerToken(
1683 config: RelayConfiguration.RelayConfiguration["Service"],
1684 token: string,
1685) {
1686 return verifyClerkBearerToken(config, token).pipe(
1687 Effect.flatMap((verified) =>
1688 verified.sub && hasExpectedClerkAudience(verified.aud, config.clerkJwtAudience)
1689 ? Effect.succeed({ sub: verified.sub, mode: "clerk_session_bearer" as const })
1690 : Effect.fail(new ClerkTokenVerificationFailed({ cause: "missing_relay_audience" })),
1691 ),
1692 Effect.catch(() =>
1693 verifyClerkOAuthBearerToken(config, token).pipe(
1694 Effect.map((verified) => ({ ...verified, mode: "clerk_oauth_bearer" as const })),
1695 ),
1696 ),
1697 );
1698}
1699
1700const requireDpopPrincipalScope = Effect.fn("relay.api.require_dpop_principal_scope")(function* (
1701 scope: RelayDpopAccessTokenScope,
1702) {
1703 yield* Effect.annotateCurrentSpan({ "relay.dpop.required_scope": scope });
1704 const principal = yield* RelayClientPrincipal;
1705 if (!principal.proofKeyThumbprint || !principal.dpopScopes?.includes(scope)) {
1706 return yield* new HttpApiError.Unauthorized({});
1707 }
1708 return principal.proofKeyThumbprint;
1709});
1710
1711const requireDpopThumbprint = Effect.fn("relay.api.require_dpop_thumbprint")(function* (
1712 expectedThumbprint: string,
1713 options?: {
1714 readonly expectedAccessToken?: string;
1715 },
1716) {
1717 const request = yield* HttpServerRequest.HttpServerRequest;
1718 const now = yield* DateTime.now;
1719 const url = HttpServerRequest.toURL(request);
1720 if (url._tag === "None") {
1721 return yield* new HttpApiError.Unauthorized({});
1722 }
1723 const dpopProofs = yield* DpopProofs.DpopProofReplay;
1724 return yield* dpopProofs.verifyAndConsume({
1725 proof: request.headers.dpop,
1726 method: request.method,
1727 url: url.value.href,
1728 now,
1729 expectedThumbprint,
1730 ...(options?.expectedAccessToken ? { expectedAccessToken: options.expectedAccessToken } : {}),
1731 });
1732});
1733
1734const requireDpopProof = Effect.fn("relay.api.require_dpop_proof")(function* (options?: {
1735 readonly expectedAccessToken?: string;
1736}) {
1737 const request = yield* HttpServerRequest.HttpServerRequest;
1738 const now = yield* DateTime.now;
1739 const url = HttpServerRequest.toURL(request);
1740 if (url._tag === "None") {
1741 return yield* new HttpApiError.Unauthorized({});
1742 }
1743 const dpopProofs = yield* DpopProofs.DpopProofReplay;
1744 return yield* dpopProofs.verifyAndConsume({
1745 proof: request.headers.dpop,
1746 method: request.method,
1747 url: url.value.href,
1748 now,
1749 ...(options?.expectedAccessToken ? { expectedAccessToken: options.expectedAccessToken } : {}),
1750 });
1751});
1752
1753const relayAuthInvalidError = Effect.fnUntraced(function* (reason: RelayAuthInvalidReason) {
1754 const traceId = yield* currentTraceId;
1755 yield* Effect.annotateCurrentSpan({
1756 "relay.trace_id": traceId,
1757 "relay.error.outbound_tag": "RelayAuthInvalidError",
1758 "relay.error.outbound_reason": reason,
1759 });
1760 return yield* new RelayAuthInvalidError({ code: "auth_invalid", reason, traceId });
1761});