Skip to content

Runbook: WebSocket API Gateway for Dashboard wss://

Owner: backend infra (Grenguar / Igor) Branch: feat/api-gw-websocket (stacked on feat/api-gw-frontend-ingress)

This PR adds a sibling API Gateway resource — a WebSocket API — so the dashboard's wss:// traffic can ride through API Gateway instead of hitting the ALB directly. The HTTP API and WebSocket API are independent resources that share the same VPC Link target (the ALB HTTPS listener).

Why a second API resource

aws.apigatewayv2.Api(protocol_type="HTTP") and protocol_type="WEBSOCKET" are distinct API resources with different authorizer semantics. The HTTP API's Cognito JWT authorizer is not reusable on the WebSocket API. The WebSocket side needs a Lambda authorizer at $connect because browsers cannot set arbitrary headers on the WS handshake — the JWT must be passed via query string (?Authorization=<jwt>) or Sec-WebSocket-Protocol and validated in Lambda.

What this PR introduces

  • infra/edge/modules/websocket_api.py — the WebSocketApi Pulumi component.
  • infra/edge/__main__.py — instantiation, wired to the existing VPC Link target ALB listener; new exports websocket_api_endpoint, websocket_api_id.
  • A consumer-side [TODO: ws_connect_authorizer_lambda_arn] output expected from the persistent stack. If absent, $connect is unauthenticated for the first dev deploy and the backend FastAPI WS handler is the only auth.

What is intentionally not in this PR

  • The Lambda authorizer itself. Add it to the persistent (or a new edge-side Lambda) stack in a follow-up; export ws_connect_authorizer_lambda_arn. Until then, $connect is open and the app-layer auth carries the load (acceptable for dev).
  • Frontend changes. The dashboard switches its WS base URL when ready.
  • DNS work — uses the auto-generated wss://<ws_api_id>.execute-api… URL.

Authorizer Lambda contract (deferred)

When the ws_connect_authorizer_lambda_arn export lands, the Lambda must accept the JWT from EITHER location:

  • ?Authorization=<jwt> query string (read from event.queryStringParameters.Authorization), or
  • Sec-WebSocket-Protocol request header (read from event.headers["Sec-WebSocket-Protocol"]).

The Pulumi Authorizer resource intentionally omits identity_sources. For REQUEST authorizers, when identity_sources is set, ALL listed sources must be present on the request or API Gateway returns 401 without ever invoking the Lambda. Since we want to support both transport paths, we let the Lambda run unconditionally on every $connect and inspect both locations itself.

Result caching is disabled (authorizer_result_ttl_in_seconds=0) so the Lambda is invoked per-connect. Revisit once we see real traffic — a small TTL keyed off the identity source may be worth it later, but only after the caching key is set up correctly (it cannot be the raw JWT given the dual transport).

How to verify

After just infra-up-edge dev finishes:

WS_URL=$(AWS_PROFILE=tradai pulumi stack output websocket_api_endpoint --cwd infra/edge)
TOKEN=$(curl -s -X POST "$COGNITO_TOKEN_URL" -u "$M2M_CLIENT_ID:$M2M_CLIENT_SECRET" \
  -d "grant_type=client_credentials&scope=tradai-api/read tradai-api/write tradai-api/admin" \
  | jq -r .access_token)

# Connect (using `websocat` or similar). The token rides in the query string:
websocat "${WS_URL}/ws/some-route?Authorization=${TOKEN}"

Expected: connection upgrades to wss; messages echo through the backend's /ws/* handler. If a [TODO: ws_connect_authorizer_lambda_arn] is not yet exported, connections succeed without a token but the backend will reject them at the application layer.

Stacking notes

  • Depends on PR #X (feat/api-gw-frontend-ingress) only at the review-order level — there is no file overlap. If the parent PR merges or is reworked, this PR rebases cleanly onto dev.
  • Coordinates with #516 the same way the parent PR does — drop the WAF / disable_execute_api_endpoint hunks on rebase.