| 1 | import { createClerkClient, verifyToken } from "@clerk/backend"; |
| 2 | import { sql as drizzleSql } from "drizzle-orm"; |
| 3 | import * as Crypto from "effect/Crypto"; |
| 4 | import * as Context from "effect/Context"; |
| 5 | import * as DateTime from "effect/DateTime"; |
| 6 | import * as Duration from "effect/Duration"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Option from "effect/Option"; |
| 10 | import * as Record from "effect/Record"; |
| 11 | import * as Redacted from "effect/Redacted"; |
| 12 | import * as Schema from "effect/Schema"; |
| 13 | import * as Tracer from "effect/Tracer"; |
| 14 | import * as Headers from "effect/http/Headers"; |
| 15 | import * as HttpEffect from "effect/http/HttpEffect"; |
| 16 | import * as HttpMiddleware from "effect/http/HttpMiddleware"; |
| 17 | import * as HttpRouter from "effect/http/HttpRouter"; |
| 18 | import * as HttpServerRequest from "effect/http/HttpServerRequest"; |
| 19 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 20 | import * as HttpTraceContext from "effect/http/HttpTraceContext"; |
| 21 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 22 | import * as HttpApiError from "effect/http-api/HttpApiError"; |
| 23 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; |
| 24 | import * as HttpObservability from "@t3tools/shared/httpObservability"; |
| 25 | |
| 26 | import { |
| 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"; |
| 56 | import { |
| 57 | normalizeRelayIssuer, |
| 58 | RELAY_MANAGED_TUNNEL_RECOVERY_TYP, |
| 59 | verifyRelayJwt, |
| 60 | } from "@t3tools/shared/relayJwt"; |
| 61 | |
| 62 | import * as DeliveryAttempts from "../agentActivity/DeliveryAttempts.ts"; |
| 63 | import * as AgentActivityRows from "../agentActivity/AgentActivityRows.ts"; |
| 64 | import * as Devices from "../agentActivity/Devices.ts"; |
| 65 | import * as DpopProofs from "../auth/DpopProofs.ts"; |
| 66 | import * as RelayTokens from "../auth/RelayTokens.ts"; |
| 67 | import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts"; |
| 68 | import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; |
| 69 | import * as HookForwarder from "../hooks/HookForwarder.ts"; |
| 70 | import * as HeldHooks from "../hooks/HeldHooks.ts"; |
| 71 | import * as HookInbox from "../hooks/HookInbox.ts"; |
| 72 | import * as LiveActivities from "../agentActivity/LiveActivities.ts"; |
| 73 | import * as RelayConfiguration from "../Config.ts"; |
| 74 | import * as AgentActivityPublisher from "../agentActivity/AgentActivityPublisher.ts"; |
| 75 | import * as EnvironmentConnector from "../environments/EnvironmentConnector.ts"; |
| 76 | import * as EnvironmentLinker from "../environments/EnvironmentLinker.ts"; |
| 77 | import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts"; |
| 78 | import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllocations.ts"; |
| 79 | import * as EnvironmentPublishSignatures from "../environments/EnvironmentPublishSignatures.ts"; |
| 80 | import * as MobileRegistrations from "../agentActivity/MobileRegistrations.ts"; |
| 81 | import { withSpanAttributes } from "../observability.ts"; |
| 82 | import { isRelayHookPath, redactRelayHookUrl } from "../hooks/HookForwarder.ts"; |
| 83 | import * 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. |
| 87 | export const RELAY_HTTP_ROUTER_CONFIG = { |
| 88 | maxParamLength: 512, |
| 89 | } as const; |
| 90 | |
| 91 | const decodeManagedTunnelRecoveryProof = Schema.decodeUnknownEffect( |
| 92 | RelayManagedEndpointRecoveryProofPayload, |
| 93 | ); |
| 94 | |
| 95 | const 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 | |
| 105 | const appendRelayDpopChallengeHeader = HttpEffect.appendPreResponseHandler((_request, response) => |
| 106 | Effect.succeed( |
| 107 | response.status === 401 |
| 108 | ? HttpServerResponse.setHeader(response, "www-authenticate", "DPoP") |
| 109 | : response, |
| 110 | ), |
| 111 | ); |
| 112 | |
| 113 | const 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 | |
| 127 | const 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. |
| 137 | export 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 | |
| 155 | export const layerNotFoundRoute = HttpRouter.add( |
| 156 | "*", |
| 157 | "/*", |
| 158 | HttpServerResponse.empty({ status: 404 }), |
| 159 | ); |
| 160 | |
| 161 | export 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. |
| 174 | export 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 | */ |
| 180 | export const RELAY_HOOK_REQUEST_DEADLINE_MS = 25_000; |
| 181 | |
| 182 | const 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). */ |
| 216 | const SENDER_TRACE_HEADER = /^(traceparent|tracestate|b3|x-b3-.*)$/i; |
| 217 | |
| 218 | export 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. |
| 268 | const 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 | |
| 280 | export 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 | |
| 297 | export 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 | |
| 307 | export 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 | |
| 346 | export 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 | |
| 379 | export 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 | |
| 417 | function isDpopAuthorizationHeader(value: string | undefined): boolean { |
| 418 | return /^DPoP +/iu.test(value ?? ""); |
| 419 | } |
| 420 | |
| 421 | function 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 | |
| 426 | export 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 | |
| 460 | export 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 | |
| 478 | export 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 | |
| 505 | export 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 | |
| 570 | type 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 | |
| 584 | export 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 | |
| 629 | export 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 | |
| 668 | export 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 | |
| 756 | export 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 | |
| 813 | export 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 | |
| 994 | export 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 | |
| 1056 | export 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 | |
| 1159 | export 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 | |
| 1411 | class 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 | |
| 1422 | const isHttpUnauthorized = Schema.is(HttpApiError.Unauthorized); |
| 1423 | const isDpopProofRejected = Schema.is(DpopProofs.DpopProofRejected); |
| 1424 | |
| 1425 | const currentTraceId = Effect.currentParentSpan.pipe( |
| 1426 | Effect.map((span) => span.traceId), |
| 1427 | Effect.orElseSucceed(() => "unavailable"), |
| 1428 | ); |
| 1429 | |
| 1430 | const 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 | ]); |
| 1452 | type RelayCommonPersistenceError = typeof RelayCommonPersistenceError.Type; |
| 1453 | const isRelayCommonPersistenceError = Schema.is(RelayCommonPersistenceError); |
| 1454 | |
| 1455 | type 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 | |
| 1464 | export 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 | |
| 1487 | function 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 | |
| 1495 | function 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 | |
| 1546 | type TaggedErrorTag<E> = Extract<E, { readonly _tag: string }>["_tag"]; |
| 1547 | |
| 1548 | type MapErrorTagCases<E> = { |
| 1549 | readonly [K in TaggedErrorTag<E>]+?: ( |
| 1550 | error: Extract<E, { readonly _tag: K }>, |
| 1551 | traceId: string, |
| 1552 | ) => unknown; |
| 1553 | }; |
| 1554 | |
| 1555 | type MappedTagError<Cases> = Cases[keyof Cases] extends ( |
| 1556 | ...args: ReadonlyArray<never> |
| 1557 | ) => infer Error |
| 1558 | ? Error |
| 1559 | : never; |
| 1560 | |
| 1561 | type 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 | |
| 1567 | function 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 | |
| 1592 | function 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 | |
| 1607 | function safeAuthFailureReason(value: string): string { |
| 1608 | return /^[a-z0-9._-]+$/i.test(value) ? value : "unknown"; |
| 1609 | } |
| 1610 | |
| 1611 | function 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 | |
| 1631 | function 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 | |
| 1638 | function 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 | |
| 1656 | function 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 | |
| 1682 | export 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 | |
| 1700 | const 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 | |
| 1711 | const 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 | |
| 1734 | const 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 | |
| 1753 | const 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 | }); |