diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 468d160..46ed41d 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -22,8 +22,6 @@ jobs: defaults: run: working-directory: apps/web - env: - VITE_API_URL: /api/v1 steps: - uses: actions/checkout@v4 @@ -42,11 +40,6 @@ jobs: role-to-assume: ${{ secrets.AWS_GITHUB_ACTIONS_ROLE_ARN }} aws-region: ${{ env.AWS_REGION }} - - name: Build frontend - run: | - npm ci - npm run build - - name: Terraform init working-directory: ${{ env.TF_ENV_DIR }} env: @@ -60,6 +53,14 @@ jobs: run: | echo "bucket=$(terraform output -raw web_bucket_name)" >> $GITHUB_OUTPUT echo "distribution=$(terraform output -raw cloudfront_distribution_id)" >> $GITHUB_OUTPUT + echo "api_url=$(terraform output -raw api_url)" >> $GITHUB_OUTPUT + + - name: Build frontend + env: + VITE_API_URL: ${{ steps.tf-outputs.outputs.api_url }}/api/v1 + run: | + npm ci + npm run build - name: Sync assets to S3 run: aws s3 sync dist s3://${{ steps.tf-outputs.outputs.bucket }} --delete diff --git a/apps/api/.env.production.example b/apps/api/.env.production.example index bfbe935..bd8059c 100644 --- a/apps/api/.env.production.example +++ b/apps/api/.env.production.example @@ -23,7 +23,7 @@ AWS_SES_ACCESS_KEY_ID= AWS_SES_SECRET_ACCESS_KEY= BILLING_ENABLED=false CLIENT_URL=https://underflow.example.com -AUTH_COOKIE_DOMAIN=underflow.example.com +AUTH_COOKIE_DOMAIN= AUTH_COOKIE_SAME_SITE=lax LOG_LEVEL=info RATE_LIMIT_AUTH_WINDOW_MS=60000 diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index c3cd375..2e65be5 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -13,6 +13,8 @@ export const app = express(); const stripeWebhookPath = "/api/v1/subscriptions/webhook/stripe"; const jsonParser = express.json(); +app.set("trust proxy", true); + app.use( cors({ origin: env.CLIENT_URL, diff --git a/apps/api/src/config/csrf.ts b/apps/api/src/config/csrf.ts index 921ce7a..784c45a 100644 --- a/apps/api/src/config/csrf.ts +++ b/apps/api/src/config/csrf.ts @@ -13,7 +13,7 @@ export const { getSecret: () => env.CSRF_SECRET, getSessionIdentifier: (req: Request) => { const userAgent = req.headers["user-agent"] ?? "unknown-user-agent"; - return `${req.ip}:${userAgent}`; + return String(userAgent); }, cookieName: csrfCookieName, cookieOptions: { diff --git a/apps/api/src/controllers/user.controller.ts b/apps/api/src/controllers/user.controller.ts index c5e99ce..cbe51a4 100644 --- a/apps/api/src/controllers/user.controller.ts +++ b/apps/api/src/controllers/user.controller.ts @@ -123,4 +123,16 @@ export const userController = { res.status(200).json({ message: "Other sessions logged out successfully" }); }, + + async requestMyAccountDeletion(req: Request, res: Response): Promise { + if (!req.user) { + throw new AppError("Unauthorized", 401); + } + + await userService.requestAccountDeletion(req.user.id); + + res.status(200).json({ + message: "Account deletion request submitted successfully", + }); + }, }; diff --git a/apps/api/src/routes/users.routes.ts b/apps/api/src/routes/users.routes.ts index b15a983..a21e799 100644 --- a/apps/api/src/routes/users.routes.ts +++ b/apps/api/src/routes/users.routes.ts @@ -48,3 +48,9 @@ usersRouter.post( authMiddleware, asyncHandler(userController.logoutOtherSessions), ); +usersRouter.post( + "/me/request-account-deletion", + mutationRateLimit, + authMiddleware, + asyncHandler(userController.requestMyAccountDeletion), +); diff --git a/apps/api/src/services/user.service.ts b/apps/api/src/services/user.service.ts index 75e66b9..7240e9f 100644 --- a/apps/api/src/services/user.service.ts +++ b/apps/api/src/services/user.service.ts @@ -9,6 +9,7 @@ import type { UserSession, User, } from "../types/auth.types.js"; +import { logger } from "../lib/logger.js"; import { AppError } from "../utils/app-error.js"; import { hashPassword, verifyPassword } from "../utils/password.js"; @@ -172,4 +173,17 @@ export const userService = { await refreshTokenRepository.revokeByUserIdExcept(userId, currentSessionId); }, + + async requestAccountDeletion(userId: string): Promise { + const user = await userRepository.findById(userId); + + if (!user) { + throw new AppError("User not found", 404); + } + + logger.warn("Account deletion requested", { + userId: user.id, + email: user.email, + }); + }, }; diff --git a/apps/api/src/test/app.test.ts b/apps/api/src/test/app.test.ts index a17b074..1807cfe 100644 --- a/apps/api/src/test/app.test.ts +++ b/apps/api/src/test/app.test.ts @@ -694,6 +694,35 @@ test("POST /api/v1/users/me/sessions/logout-others revokes all other sessions", } }); +test("POST /api/v1/users/me/request-account-deletion records a deletion request", async () => { + const { app, userService, signAccessToken } = await loadModules(); + const originalRequestAccountDeletion = userService.requestAccountDeletion; + + let capturedUserId: string | null = null; + userService.requestAccountDeletion = async (userId) => { + capturedUserId = userId; + }; + + try { + const user = buildUser(); + const accessToken = signAccessToken(buildAuthenticatedClaims(user)); + + const response = await request(app) + .post("/api/v1/users/me/request-account-deletion") + .set("Authorization", `Bearer ${accessToken}`) + .send({}); + + assert.equal(response.status, 200); + assert.equal(capturedUserId, "user-123"); + assert.equal( + response.body.message, + "Account deletion request submitted successfully", + ); + } finally { + userService.requestAccountDeletion = originalRequestAccountDeletion; + } +}); + test("GET /api/v1/notifications returns the notifications feed", async () => { const { app, notificationService, signAccessToken } = await loadModules(); const originalGetFeedForUser = notificationService.getFeedForUser; diff --git a/apps/web/.env.production.example b/apps/web/.env.production.example index 43322c4..62fdf35 100644 --- a/apps/web/.env.production.example +++ b/apps/web/.env.production.example @@ -1,3 +1,3 @@ -VITE_API_URL=/api/v1 +VITE_API_URL=https://api.underflow.example.com/api/v1 VITE_APP_NAME=Underflow VITE_DEFAULT_THEME=system diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index ad0b371..6a2682c 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -29,7 +29,21 @@ export const AUTH_SESSION_EXPIRED_EVENT = "underflow:auth-session-expired"; const parseResponse = async (response: Response): Promise => { const text = await response.text(); - const data = text ? (JSON.parse(text) as unknown) : {}; + let data: unknown = {}; + + if (text) { + try { + data = JSON.parse(text) as unknown; + } catch { + throw new ApiError( + response.status, + response.ok ? "Received a non-JSON response" : "Unexpected non-JSON error response", + { + responseTextPreview: text.slice(0, 200), + }, + ); + } + } if (!response.ok) { const payload = diff --git a/apps/web/src/lib/api/users.ts b/apps/web/src/lib/api/users.ts index 91b8c19..1b31a8e 100644 --- a/apps/web/src/lib/api/users.ts +++ b/apps/web/src/lib/api/users.ts @@ -53,4 +53,10 @@ export const usersApi = { body: {}, requireAuth: true, }), + requestAccountDeletion: () => + apiRequest<{ message: string }>("/users/me/request-account-deletion", { + method: "POST", + body: {}, + requireAuth: true, + }), }; diff --git a/apps/web/src/pages/app/ProfileSettingsPage.tsx b/apps/web/src/pages/app/ProfileSettingsPage.tsx index c032d72..bc889aa 100644 --- a/apps/web/src/pages/app/ProfileSettingsPage.tsx +++ b/apps/web/src/pages/app/ProfileSettingsPage.tsx @@ -65,6 +65,7 @@ export const ProfileSettingsPage = (): JSX.Element => { const [isSavingPassword, setIsSavingPassword] = useState(false); const [isSavingPreferences, setIsSavingPreferences] = useState(false); const [isLoggingOutOthers, setIsLoggingOutOthers] = useState(false); + const [isRequestingDeletion, setIsRequestingDeletion] = useState(false); const preferencesQuery = useAsyncData( async () => { @@ -200,6 +201,37 @@ export const ProfileSettingsPage = (): JSX.Element => { } }; + const handleRequestAccountDeletion = async () => { + setError(null); + + const confirmed = window.confirm( + "Send an account deletion request? This does not delete your account immediately.", + ); + + if (!confirmed) { + return; + } + + setIsRequestingDeletion(true); + + try { + await usersApi.requestAccountDeletion(); + showToast({ + title: "Deletion request submitted", + description: "Our team can now review your account deletion request.", + tone: "success", + }); + } catch (submitError) { + setError( + submitError instanceof Error + ? submitError.message + : "Unable to submit account deletion request", + ); + } finally { + setIsRequestingDeletion(false); + } + }; + const updatePreference = (key: keyof EmailPreferencesState, value: boolean) => { setEmailPreferences((current) => ({ ...current, [key]: value })); }; @@ -472,8 +504,12 @@ export const ProfileSettingsPage = (): JSX.Element => { resource data.

- diff --git a/apps/web/src/pages/app/app-pages.test.tsx b/apps/web/src/pages/app/app-pages.test.tsx index 2fceab8..a8c2439 100644 --- a/apps/web/src/pages/app/app-pages.test.tsx +++ b/apps/web/src/pages/app/app-pages.test.tsx @@ -11,6 +11,7 @@ import { AwsAccountsPage } from "./AwsAccountsPage"; import { ConnectAwsAccountPage } from "./ConnectAwsAccountPage"; import { AlertsPage } from "./AlertsPage"; import { CreateAlertPage } from "./CreateAlertPage"; +import { ProfileSettingsPage } from "./ProfileSettingsPage"; import { WorkspaceSettingsPage } from "./WorkspaceSettingsPage"; const authApiMock = vi.hoisted(() => ({ @@ -52,6 +53,17 @@ const alertsApiMock = vi.hoisted(() => ({ remove: vi.fn(), })); +const usersApiMock = vi.hoisted(() => ({ + updateProfile: vi.fn(), + updatePassword: vi.fn(), + getPreferences: vi.fn(), + updatePreferences: vi.fn(), + getSessions: vi.fn(), + revokeSession: vi.fn(), + logoutOtherSessions: vi.fn(), + requestAccountDeletion: vi.fn(), +})); + vi.mock("../../lib/api/auth", () => ({ authApi: authApiMock, })); @@ -68,6 +80,10 @@ vi.mock("../../lib/api/alerts", () => ({ alertsApi: alertsApiMock, })); +vi.mock("../../lib/api/users", () => ({ + usersApi: usersApiMock, +})); + const signedInUser = { id: "user-1", email: "user@example.com", @@ -163,6 +179,34 @@ beforeEach(() => { awsApiMock.update.mockResolvedValue({ awsAccount: { ...awsAccount, name: "Prod updated" } }); alertsApiMock.list.mockResolvedValue({ alerts: [] }); alertsApiMock.remove.mockResolvedValue(undefined); + usersApiMock.getPreferences.mockResolvedValue({ + preferences: { + costAlerts: true, + driftReports: true, + maintenance: false, + featureReleases: true, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }); + usersApiMock.getSessions.mockResolvedValue({ + sessions: [ + { + id: "session-current", + deviceLabel: "Mac", + userAgent: "Mozilla/5.0 (Macintosh)", + ipAddress: "127.0.0.1", + createdAt: "2026-01-01T00:00:00.000Z", + lastUsedAt: "2026-01-01T00:00:00.000Z", + revokedAt: null, + isCurrent: true, + }, + ], + }); + usersApiMock.requestAccountDeletion.mockResolvedValue({ + message: "Account deletion request submitted successfully", + }); + vi.spyOn(window, "confirm").mockImplementation(() => true); }); afterEach(() => { @@ -477,3 +521,17 @@ test("alert creation submits a scoped rule", async () => { }); }); }); + +test("profile settings submits an account deletion request", async () => { + renderPage( + "/app/settings/profile", + "/app/settings/profile", + , + ); + + fireEvent.click(await screen.findByRole("button", { name: "Request Account Deletion" })); + + await waitFor(() => { + expect(usersApiMock.requestAccountDeletion).toHaveBeenCalledTimes(1); + }); +}); diff --git a/docs/production-deployment.md b/docs/production-deployment.md index fffbe7c..efedef6 100644 --- a/docs/production-deployment.md +++ b/docs/production-deployment.md @@ -3,7 +3,7 @@ This document is the first-deployment guide for running Underflow on AWS with: - `https://underflow.[yourdomain].com` for the web app -- `https://underflow.[yourdomain].com/api/*` for the API through the same CloudFront distribution +- `https://api.underflow.[yourdomain].com/api/*` for the API - ECS Fargate for the API and worker - RDS PostgreSQL - S3 + CloudFront for the frontend @@ -12,17 +12,21 @@ This document is the first-deployment guide for running Underflow on AWS with: ## Topology - CloudFront - - default behavior serves the Vite build from S3 - - `/api/*` forwards to the API ALB + - serves the Vite build from S3 for `underflow.[yourdomain].com` - ECS Fargate - one API service - one worker service - one migration task definition used during deploy +- Application Load Balancer + - terminates TLS for `api.underflow.[yourdomain].com` + - forwards `/api/v1/*` traffic to the API ECS service - RDS PostgreSQL - private subnets only - Route 53 + ACM - - certificate in `us-west-2` + - CloudFront certificate in `us-east-1` + - API ALB certificate in the primary region - alias record for `underflow.[yourdomain].com` + - alias record for `api.underflow.[yourdomain].com` ## One-Time Bootstrap Order @@ -141,7 +145,8 @@ Add these as `production` environment secrets in GitHub: ### `deploy-web.yml` -- builds the frontend with `VITE_API_URL=/api/v1` +- reads `api_url` from Terraform output +- builds the frontend with `VITE_API_URL=https://api.underflow.[yourdomain].com/api/v1` - uploads static assets to S3 - invalidates CloudFront @@ -160,22 +165,22 @@ The ECS task runtime role is responsible for: - `sts:AssumeRole` into customer `UnderflowCostExplorerRead` roles - SES send permissions +For the split-domain setup, prefer leaving `AUTH_COOKIE_DOMAIN` empty so auth cookies remain host-only on `api.underflow.[yourdomain].com`. + ### Web Production web builds should use: ```text -VITE_API_URL=/api/v1 +VITE_API_URL=https://api.underflow.[yourdomain].com/api/v1 ``` -That keeps browser requests same-origin behind CloudFront. - ## First-Deploy Smoke Tests Run these checks immediately after the first deployment: 1. `https://underflow.[yourdomain].com` loads successfully. -2. `https://underflow.[yourdomain].com/api/v1/health` returns `200`. +2. `https://api.underflow.[yourdomain].com/api/v1/health` returns `200`. 3. Signup works. 4. Login works. 5. Forgot-password email is delivered. diff --git a/infra/terraform/envs/production/outputs.tf b/infra/terraform/envs/production/outputs.tf index 6f0818c..4437a40 100644 --- a/infra/terraform/envs/production/outputs.tf +++ b/infra/terraform/envs/production/outputs.tf @@ -72,3 +72,8 @@ output "app_url" { description = "Public application URL." value = module.platform.app_url } + +output "api_url" { + description = "Public API base URL." + value = module.platform.api_url +} diff --git a/infra/terraform/modules/platform_stack/cdn.tf b/infra/terraform/modules/platform_stack/cdn.tf index 5e36a10..6ff724c 100644 --- a/infra/terraform/modules/platform_stack/cdn.tf +++ b/infra/terraform/modules/platform_stack/cdn.tf @@ -2,14 +2,6 @@ data "aws_cloudfront_cache_policy" "caching_optimized" { name = "Managed-CachingOptimized" } -data "aws_cloudfront_cache_policy" "caching_disabled" { - name = "Managed-CachingDisabled" -} - -data "aws_cloudfront_origin_request_policy" "all_viewer_except_host_header" { - name = "Managed-AllViewerExceptHostHeader" -} - resource "aws_s3_bucket" "web" { bucket = var.web_bucket_name force_destroy = false @@ -59,6 +51,30 @@ resource "aws_cloudfront_origin_access_control" "web" { signing_protocol = "sigv4" } +resource "aws_cloudfront_function" "spa_rewrite" { + name = "${var.name_prefix}-spa-rewrite" + runtime = "cloudfront-js-1.0" + comment = "Rewrite extensionless routes to /index.html for the SPA." + publish = true + code = <<-EOT +function handler(event) { + var request = event.request; + var uri = request.uri || "/"; + + if (uri === "/" || uri === "") { + request.uri = "/index.html"; + return request; + } + + if (uri.indexOf(".", uri.lastIndexOf("/")) === -1) { + request.uri = "/index.html"; + } + + return request; +} +EOT +} + resource "aws_acm_certificate" "site" { provider = aws.us_east_1 domain_name = var.app_domain_name @@ -71,6 +87,17 @@ resource "aws_acm_certificate" "site" { tags = local.tags } +resource "aws_acm_certificate" "api" { + domain_name = local.api_domain + validation_method = "DNS" + + lifecycle { + create_before_destroy = true + } + + tags = local.tags +} + resource "aws_route53_record" "cert_validation" { for_each = { for dvo in aws_acm_certificate.site.domain_validation_options : dvo.domain_name => { @@ -87,12 +114,33 @@ resource "aws_route53_record" "cert_validation" { records = [each.value.record] } +resource "aws_route53_record" "api_cert_validation" { + for_each = { + for dvo in aws_acm_certificate.api.domain_validation_options : dvo.domain_name => { + name = dvo.resource_record_name + record = dvo.resource_record_value + type = dvo.resource_record_type + } + } + + zone_id = var.route53_zone_id + name = each.value.name + type = each.value.type + ttl = 60 + records = [each.value.record] +} + resource "aws_acm_certificate_validation" "site" { provider = aws.us_east_1 certificate_arn = aws_acm_certificate.site.arn validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn] } +resource "aws_acm_certificate_validation" "api" { + certificate_arn = aws_acm_certificate.api.arn + validation_record_fqdns = [for record in aws_route53_record.api_cert_validation : record.fqdn] +} + resource "aws_cloudfront_distribution" "site" { enabled = true is_ipv6_enabled = true @@ -106,18 +154,6 @@ resource "aws_cloudfront_distribution" "site" { origin_id = "web-s3" } - origin { - domain_name = aws_lb.api.dns_name - origin_id = "api-alb" - - custom_origin_config { - http_port = 80 - https_port = 443 - origin_protocol_policy = "http-only" - origin_ssl_protocols = ["TLSv1.2"] - } - } - default_cache_behavior { target_origin_id = "web-s3" viewer_protocol_policy = "redirect-to-https" @@ -125,31 +161,11 @@ resource "aws_cloudfront_distribution" "site" { cached_methods = ["GET", "HEAD"] compress = true cache_policy_id = data.aws_cloudfront_cache_policy.caching_optimized.id - } - - ordered_cache_behavior { - path_pattern = "/api/*" - target_origin_id = "api-alb" - viewer_protocol_policy = "redirect-to-https" - allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"] - cached_methods = ["GET", "HEAD"] - compress = true - cache_policy_id = data.aws_cloudfront_cache_policy.caching_disabled.id - origin_request_policy_id = data.aws_cloudfront_origin_request_policy.all_viewer_except_host_header.id - } - custom_error_response { - error_code = 403 - response_code = 200 - response_page_path = "/index.html" - error_caching_min_ttl = 0 - } - - custom_error_response { - error_code = 404 - response_code = 200 - response_page_path = "/index.html" - error_caching_min_ttl = 0 + function_association { + event_type = "viewer-request" + function_arn = aws_cloudfront_function.spa_rewrite.arn + } } restrictions { @@ -205,3 +221,15 @@ resource "aws_route53_record" "app" { zone_id = aws_cloudfront_distribution.site.hosted_zone_id } } + +resource "aws_route53_record" "api" { + zone_id = var.route53_zone_id + name = local.api_domain + type = "A" + + alias { + evaluate_target_health = true + name = aws_lb.api.dns_name + zone_id = aws_lb.api.zone_id + } +} diff --git a/infra/terraform/modules/platform_stack/compute.tf b/infra/terraform/modules/platform_stack/compute.tf index 08f1185..2911016 100644 --- a/infra/terraform/modules/platform_stack/compute.tf +++ b/infra/terraform/modules/platform_stack/compute.tf @@ -92,6 +92,24 @@ resource "aws_lb_listener" "api_http" { port = 80 protocol = "HTTP" + default_action { + type = "redirect" + + redirect { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } +} + +resource "aws_lb_listener" "api_https" { + load_balancer_arn = aws_lb.api.arn + port = 443 + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" + certificate_arn = aws_acm_certificate_validation.api.certificate_arn + default_action { type = "forward" target_group_arn = aws_lb_target_group.api.arn @@ -237,7 +255,7 @@ resource "aws_ecs_service" "api" { rollback = true } - depends_on = [aws_lb_listener.api_http] + depends_on = [aws_lb_listener.api_https] tags = local.tags } diff --git a/infra/terraform/modules/platform_stack/locals.tf b/infra/terraform/modules/platform_stack/locals.tf index 19c5f4d..17c870f 100644 --- a/infra/terraform/modules/platform_stack/locals.tf +++ b/infra/terraform/modules/platform_stack/locals.tf @@ -6,6 +6,7 @@ data "aws_availability_zones" "available" { locals { worker_image = var.worker_image_uri != "" ? var.worker_image_uri : var.api_image_uri + api_domain = "api.${var.app_domain_name}" tags = merge( { diff --git a/infra/terraform/modules/platform_stack/networking.tf b/infra/terraform/modules/platform_stack/networking.tf index d1f8701..6a627cc 100644 --- a/infra/terraform/modules/platform_stack/networking.tf +++ b/infra/terraform/modules/platform_stack/networking.tf @@ -124,6 +124,13 @@ resource "aws_security_group" "alb" { cidr_blocks = ["0.0.0.0/0"] } + ingress { + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + egress { from_port = 0 to_port = 0 diff --git a/infra/terraform/modules/platform_stack/outputs.tf b/infra/terraform/modules/platform_stack/outputs.tf index 38936ac..062ac0e 100644 --- a/infra/terraform/modules/platform_stack/outputs.tf +++ b/infra/terraform/modules/platform_stack/outputs.tf @@ -72,3 +72,8 @@ output "app_url" { description = "Public application URL." value = "https://${var.app_domain_name}" } + +output "api_url" { + description = "Public API base URL." + value = "https://${local.api_domain}" +}