From 9d55ff7c09973a0df319b0d5e09f2c232a95affb Mon Sep 17 00:00:00 2001 From: Jeremy Demers <52353432+JeremyDemers@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:07:15 -0400 Subject: [PATCH 1/4] Pass configured origins to Arcade APIs --- start-arcade.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/start-arcade.sh b/start-arcade.sh index ece3ddb..06881b4 100755 --- a/start-arcade.sh +++ b/start-arcade.sh @@ -87,11 +87,11 @@ start_service() { ( if [[ "$environment_scope" == backend ]]; then - export GOOGLE_CLIENT_ID ARCADE_SECRET + export GOOGLE_CLIENT_ID ARCADE_SECRET ARCADE_ALLOWED_ORIGINS unset NEXT_PUBLIC_GOOGLE_CLIENT_ID else export NEXT_PUBLIC_GOOGLE_CLIENT_ID="$GOOGLE_CLIENT_ID" - unset GOOGLE_CLIENT_ID ARCADE_SECRET + unset GOOGLE_CLIENT_ID ARCADE_SECRET ARCADE_ALLOWED_ORIGINS fi exec setsid bash -c ' From 138f2fc57b866a5688286924a50545290d5399f8 Mon Sep 17 00:00:00 2001 From: Jeremy Demers <52353432+JeremyDemers@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:18:31 -0400 Subject: [PATCH 2/4] Add shared serverless Arcade API --- .github/workflows/ci.yml | 63 ++++++ .gitignore | 6 + README.md | 8 + backend/serverless/README.md | 67 ++++++ backend/serverless/app/__init__.py | 1 + backend/serverless/app/auth.py | 73 +++++++ backend/serverless/app/google_identity.py | 14 ++ backend/serverless/app/main.py | 180 ++++++++++++++++ backend/serverless/app/repository.py | 142 +++++++++++++ backend/serverless/app/schemas.py | 54 +++++ backend/serverless/build-lambda.sh | 30 +++ backend/serverless/deploy.sh | 29 +++ backend/serverless/lambda_handler.py | 5 + backend/serverless/requirements.txt | 5 + .../serverless/terraform/.terraform.lock.hcl | 26 +++ backend/serverless/terraform/backend.tf | 3 + backend/serverless/terraform/main.tf | 196 ++++++++++++++++++ backend/serverless/terraform/outputs.tf | 14 ++ .../terraform/terraform.tfvars.example | 12 ++ backend/serverless/terraform/variables.tf | 66 ++++++ backend/serverless/terraform/versions.tf | 18 ++ backend/serverless/tests/__init__.py | 1 + backend/serverless/tests/test_auth.py | 25 +++ backend/serverless/tests/test_repository.py | 27 +++ 24 files changed, 1065 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 backend/serverless/README.md create mode 100644 backend/serverless/app/__init__.py create mode 100644 backend/serverless/app/auth.py create mode 100644 backend/serverless/app/google_identity.py create mode 100644 backend/serverless/app/main.py create mode 100644 backend/serverless/app/repository.py create mode 100644 backend/serverless/app/schemas.py create mode 100755 backend/serverless/build-lambda.sh create mode 100755 backend/serverless/deploy.sh create mode 100644 backend/serverless/lambda_handler.py create mode 100644 backend/serverless/requirements.txt create mode 100644 backend/serverless/terraform/.terraform.lock.hcl create mode 100644 backend/serverless/terraform/backend.tf create mode 100644 backend/serverless/terraform/main.tf create mode 100644 backend/serverless/terraform/outputs.tf create mode 100644 backend/serverless/terraform/terraform.tfvars.example create mode 100644 backend/serverless/terraform/variables.tf create mode 100644 backend/serverless/terraform/versions.tf create mode 100644 backend/serverless/tests/__init__.py create mode 100644 backend/serverless/tests/test_auth.py create mode 100644 backend/serverless/tests/test_repository.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..45005bd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + frontend: + name: ${{ matrix.game }} frontend + runs-on: ubuntu-latest + strategy: + matrix: + include: + - game: Tetris + directory: tetris/frontend + - game: Neon Shatter + directory: neon-shatter/frontend + defaults: + run: + working-directory: ${{ matrix.directory }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: ${{ matrix.directory }}/package-lock.json + - run: npm ci + - run: npm run lint + - run: npm run build + + serverless-api: + name: Serverless API + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: backend/serverless/requirements.txt + - uses: hashicorp/setup-terraform@v3 + + - name: Install API dependencies + run: python -m pip install -r backend/serverless/requirements.txt + + - name: Run API tests + env: + PYTHONPATH: backend/serverless + run: python -m unittest discover -s backend/serverless/tests -v + + - name: Build Lambda package + run: ./backend/serverless/build-lambda.sh + + - name: Check Terraform + run: | + terraform -chdir=backend/serverless/terraform fmt -check + terraform -chdir=backend/serverless/terraform init -backend=false -input=false + terraform -chdir=backend/serverless/terraform validate diff --git a/.gitignore b/.gitignore index 27c19dd..6539aaf 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,9 @@ venv/ .agents/ GOOGLE_AUTH_SETUP.md .clerk/ +backend/serverless/build/ +backend/serverless/dist/ +backend/serverless/terraform/.terraform/ +backend/serverless/terraform/*.tfstate +backend/serverless/terraform/*.tfstate.* +backend/serverless/terraform/*.tfplan diff --git a/README.md b/README.md index 66cfb1e..82cece8 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ A SaaS-style classic arcade collection. Each game lives in its own directory with a Next.js frontend and FastAPI backend. +Production can use the shared serverless API in `backend/serverless`. It consolidates both games behind API Gateway and Lambda with DynamoDB persistence while preserving the SQLite services used by the local launcher. + Authentication uses Google Identity Services directly. See `docs/google-auth-setup.md` before running either game. ## Games @@ -63,3 +65,9 @@ See `tetris/README.md`, `neon-shatter/README.md`, and and production-domain configuration. -Author: Jeremy Demers + +## Serverless deployment + +See `backend/serverless/README.md` for the shared Lambda API, DynamoDB data model, Terraform configuration, secret setup, and deployment workflow. + +-Author: Jeremy Demers diff --git a/backend/serverless/README.md b/backend/serverless/README.md new file mode 100644 index 0000000..636696c --- /dev/null +++ b/backend/serverless/README.md @@ -0,0 +1,67 @@ +# Shared serverless Arcade API + +This API consolidates Tetris and Neon Shatter authentication, scores, leaderboards, and player statistics into one AWS Lambda function and one DynamoDB table. The existing per-game SQLite APIs remain available for local development. + +## Architecture + +```text +Portfolio browser + | +API Gateway HTTP API + | +Lambda + FastAPI + Mangum + | +DynamoDB on-demand table +``` + +Google Identity Services returns an ID token to the browser. The API verifies that token against the configured Web Client ID, stores or updates the player profile, and returns a signed Arcade session token. The signing secret is read from an existing SSM SecureString and is never passed through Terraform state. + +The DynamoDB table uses these access patterns: + +- `USER# / PROFILE` for a player profile +- `USER# / SCORE###` for score history +- `game-leaderboard-index` for each game's top ten scores + +## Local tests + +```bash +cd backend/serverless +python3.14 -m venv .venv +.venv/bin/pip install -r requirements.txt +.venv/bin/python -m unittest discover -s tests -v +``` + +The repository layer expects DynamoDB. The included unit tests cover token handling, identity normalization, and leaderboard ordering without requiring AWS resources. + +## Prepare AWS + +Create the signing secret once. Do not save its value in a `.tfvars` file: + +```bash +aws ssm put-parameter \ + --name /arcade/prod/token-signing-secret \ + --type SecureString \ + --value "$(openssl rand -hex 48)" +``` + +An encrypted, versioned S3 bucket for Terraform state must also exist before deployment. + +## Build and validate + +```bash +./backend/serverless/build-lambda.sh +terraform -chdir=backend/serverless/terraform init -backend=false +terraform -chdir=backend/serverless/terraform validate +``` + +## Deploy + +Export the public Google Web Client ID and state bucket name, then review and approve Terraform's plan: + +```bash +export TF_VAR_google_client_id="YOUR_CLIENT_ID.apps.googleusercontent.com" +export ARCADE_TERRAFORM_STATE_BUCKET="YOUR_STATE_BUCKET" +./backend/serverless/deploy.sh prod +``` + +The script intentionally does not use `-auto-approve`. After deployment, use the `api_url` output for both portfolio game API variables. diff --git a/backend/serverless/app/__init__.py b/backend/serverless/app/__init__.py new file mode 100644 index 0000000..dd76cb6 --- /dev/null +++ b/backend/serverless/app/__init__.py @@ -0,0 +1 @@ +"""Shared serverless API for the Arcade collection.""" diff --git a/backend/serverless/app/auth.py b/backend/serverless/app/auth.py new file mode 100644 index 0000000..0be751c --- /dev/null +++ b/backend/serverless/app/auth.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import time +from functools import lru_cache +from typing import Any + +import boto3 + +TOKEN_TTL_SECONDS = 60 * 60 * 24 * 14 + + +@lru_cache(maxsize=1) +def signing_secret() -> str: + """Load the JWT signing secret locally or from SSM in AWS.""" + local_secret = os.getenv("ARCADE_SECRET", "").strip() + if local_secret: + return local_secret + + parameter_name = os.getenv("ARCADE_SECRET_PARAMETER_NAME", "").strip() + if not parameter_name: + raise RuntimeError("ARCADE_SECRET or ARCADE_SECRET_PARAMETER_NAME must be configured") + + response = boto3.client("ssm").get_parameter(Name=parameter_name, WithDecryption=True) + secret = response["Parameter"]["Value"].strip() + if len(secret) < 32: + raise RuntimeError("The configured Arcade signing secret is too short") + return secret + + +def _b64(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=") + + +def _unb64(data: str) -> bytes: + return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4)) + + +def create_token(subject: str, username: str, *, secret: str | None = None) -> str: + payload = { + "sub": subject, + "username": username, + "exp": int(time.time()) + TOKEN_TTL_SECONDS, + } + body = _b64(json.dumps(payload, separators=(",", ":")).encode("utf-8")) + signature = hmac.new((secret or signing_secret()).encode(), body.encode(), hashlib.sha256).digest() + return f"{body}.{_b64(signature)}" + + +def decode_token(token: str, *, secret: str | None = None) -> dict[str, Any] | None: + try: + body, signature = token.split(".", 1) + except ValueError: + return None + + expected = _b64( + hmac.new((secret or signing_secret()).encode(), body.encode(), hashlib.sha256).digest() + ) + if not hmac.compare_digest(signature, expected): + return None + + try: + payload = json.loads(_unb64(body)) + except (json.JSONDecodeError, ValueError): + return None + + if not isinstance(payload.get("sub"), str) or payload.get("exp", 0) < time.time(): + return None + return payload diff --git a/backend/serverless/app/google_identity.py b/backend/serverless/app/google_identity.py new file mode 100644 index 0000000..4eaf8f1 --- /dev/null +++ b/backend/serverless/app/google_identity.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from typing import Any + +from google.auth.transport import requests +from google.oauth2 import id_token + + +def verify_google_credential(credential: str, client_id: str) -> dict[str, Any]: + """Verify a Google ID token's signature, audience, issuer, and expiration.""" + identity = id_token.verify_oauth2_token(credential, requests.Request(), client_id) + if not identity.get("sub") or not identity.get("email_verified"): + raise ValueError("Google account identity could not be verified") + return identity diff --git a/backend/serverless/app/main.py b/backend/serverless/app/main.py new file mode 100644 index 0000000..1972325 --- /dev/null +++ b/backend/serverless/app/main.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import os +from typing import Any + +from fastapi import Depends, FastAPI, Header, HTTPException, status +from fastapi.middleware.cors import CORSMiddleware + +from .auth import create_token, decode_token +from .google_identity import verify_google_credential +from .repository import ArcadeRepository, get_repository +from .schemas import ( + AuthResponse, + GoogleExchangeRequest, + NeonScoreCreate, + NeonScoreResponse, + NeonStatsResponse, + TetrisScoreCreate, + TetrisScoreResponse, + TetrisStatsResponse, + UserResponse, +) + +GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "").strip() +LOCAL_ORIGINS = [ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://localhost:3001", + "http://127.0.0.1:3001", + "http://localhost:3003", + "http://127.0.0.1:3003", +] +CONFIGURED_ORIGINS = [ + origin.strip() + for origin in os.getenv("ARCADE_ALLOWED_ORIGINS", "").split(",") + if origin.strip() +] +ALLOWED_ORIGINS = list(dict.fromkeys([*LOCAL_ORIGINS, *CONFIGURED_ORIGINS])) + +app = FastAPI(title="Jeremy Demers Arcade API", version="1.0.0") +app.add_middleware( + CORSMiddleware, + allow_origins=ALLOWED_ORIGINS, + allow_credentials=True, + allow_methods=["GET", "POST", "OPTIONS"], + allow_headers=["Authorization", "Content-Type"], +) + + +def user_response(user: dict[str, Any]) -> UserResponse: + return UserResponse( + id=str(user["id"]), + username=str(user["username"]), + picture_url=user.get("picture_url"), + ) + + +def current_user( + authorization: str | None = Header(default=None), + repository: ArcadeRepository = Depends(get_repository), +) -> dict[str, Any]: + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token") + payload = decode_token(authorization.removeprefix("Bearer ").strip()) + if not payload: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + user = repository.get_user(payload["sub"]) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unknown user") + return {**user, "subject": payload["sub"]} + + +@app.get("/") +def root() -> dict[str, str]: + return {"service": "arcade-api", "status": "ok"} + + +@app.get("/health") +def health() -> dict[str, str]: + return {"status": "ok"} + + +@app.post("/auth/google", response_model=AuthResponse) +def google_exchange( + payload: GoogleExchangeRequest, + repository: ArcadeRepository = Depends(get_repository), +) -> AuthResponse: + if not GOOGLE_CLIENT_ID: + raise HTTPException(status_code=503, detail="Google sign-in is not configured") + try: + identity = verify_google_credential(payload.credential, GOOGLE_CLIENT_ID) + except ValueError as exc: + raise HTTPException(status_code=401, detail="Invalid Google credential") from exc + + user = repository.upsert_google_user(identity) + return AuthResponse( + token=create_token(str(identity["sub"]), str(user["username"])), + user=user_response(user), + ) + + +@app.get("/me", response_model=UserResponse) +def me(user: dict[str, Any] = Depends(current_user)) -> UserResponse: + return user_response(user) + + +@app.get("/games/tetris/leaderboard", response_model=list[TetrisScoreResponse]) +def tetris_leaderboard( + repository: ArcadeRepository = Depends(get_repository), +) -> list[TetrisScoreResponse]: + return [TetrisScoreResponse(**item) for item in repository.leaderboard("tetris")] + + +@app.post( + "/games/tetris/scores", + response_model=TetrisScoreResponse, + status_code=status.HTTP_201_CREATED, +) +def save_tetris_score( + payload: TetrisScoreCreate, + user: dict[str, Any] = Depends(current_user), + repository: ArcadeRepository = Depends(get_repository), +) -> TetrisScoreResponse: + item = repository.save_score( + subject=user["subject"], + username=user["username"], + game_slug="tetris", + score=payload.score, + level=payload.level, + metric_name="lines", + metric_value=payload.lines, + ) + return TetrisScoreResponse(**item) + + +@app.get("/games/tetris/stats", response_model=TetrisStatsResponse) +def tetris_stats( + user: dict[str, Any] = Depends(current_user), + repository: ArcadeRepository = Depends(get_repository), +) -> TetrisStatsResponse: + return TetrisStatsResponse(**repository.stats(user["subject"], "tetris", "lines")) + + +@app.get("/games/neon-shatter/leaderboard", response_model=list[NeonScoreResponse]) +def neon_leaderboard( + repository: ArcadeRepository = Depends(get_repository), +) -> list[NeonScoreResponse]: + return [NeonScoreResponse(**item) for item in repository.leaderboard("neon-shatter")] + + +@app.post( + "/games/neon-shatter/scores", + response_model=NeonScoreResponse, + status_code=status.HTTP_201_CREATED, +) +def save_neon_score( + payload: NeonScoreCreate, + user: dict[str, Any] = Depends(current_user), + repository: ArcadeRepository = Depends(get_repository), +) -> NeonScoreResponse: + item = repository.save_score( + subject=user["subject"], + username=user["username"], + game_slug="neon-shatter", + score=payload.score, + level=payload.level, + metric_name="bricks", + metric_value=payload.bricks, + ) + return NeonScoreResponse(**item) + + +@app.get("/games/neon-shatter/stats", response_model=NeonStatsResponse) +def neon_stats( + user: dict[str, Any] = Depends(current_user), + repository: ArcadeRepository = Depends(get_repository), +) -> NeonStatsResponse: + return NeonStatsResponse( + **repository.stats(user["subject"], "neon-shatter", "bricks") + ) diff --git a/backend/serverless/app/repository.py b/backend/serverless/app/repository.py new file mode 100644 index 0000000..4535e51 --- /dev/null +++ b/backend/serverless/app/repository.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import hashlib +import os +import re +import uuid +from datetime import UTC, datetime +from functools import lru_cache +from typing import Any + +import boto3 +from boto3.dynamodb.conditions import Key +from botocore.exceptions import ClientError + +GAME_LIMITS = { + "tetris": 10_000_000, + "neon-shatter": 100_000_000, +} + + +def public_user_id(subject: str) -> str: + return hashlib.sha256(subject.encode("utf-8")).hexdigest()[:16] + + +def normalized_username(identity: dict[str, Any]) -> str: + email = str(identity.get("email") or "") + raw = str(identity.get("name") or email.partition("@")[0] or "player") + cleaned = re.sub(r"[^a-zA-Z0-9_-]", "_", raw) + cleaned = re.sub(r"_+", "_", cleaned).strip("_").lower() + return (cleaned or "player")[:24] + + +def leaderboard_key(game_slug: str, score: int, created_at: str, score_id: str) -> str: + maximum = GAME_LIMITS[game_slug] + return f"{maximum - score:012d}#{created_at}#{score_id}" + + +class ArcadeRepository: + def __init__(self, table: Any): + self.table = table + + def get_user(self, subject: str) -> dict[str, Any] | None: + response = self.table.get_item(Key={"pk": f"USER#{subject}", "sk": "PROFILE"}) + return response.get("Item") + + def upsert_google_user(self, identity: dict[str, Any]) -> dict[str, Any]: + subject = str(identity["sub"]) + picture_url = str(identity.get("picture") or "").strip() or None + existing = self.get_user(subject) + if existing: + if picture_url != existing.get("picture_url"): + self.table.update_item( + Key={"pk": f"USER#{subject}", "sk": "PROFILE"}, + UpdateExpression="SET picture_url = :picture", + ExpressionAttributeValues={":picture": picture_url}, + ) + existing["picture_url"] = picture_url + return existing + + user = { + "pk": f"USER#{subject}", + "sk": "PROFILE", + "id": public_user_id(subject), + "username": normalized_username(identity), + "picture_url": picture_url, + "created_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + } + try: + self.table.put_item(Item=user, ConditionExpression="attribute_not_exists(pk)") + return user + except ClientError as exc: + if exc.response.get("Error", {}).get("Code") != "ConditionalCheckFailedException": + raise + concurrent_user = self.get_user(subject) + if not concurrent_user: + raise + return concurrent_user + + def save_score( + self, + *, + subject: str, + username: str, + game_slug: str, + score: int, + level: int, + metric_name: str, + metric_value: int, + ) -> dict[str, Any]: + score_id = uuid.uuid4().hex + created_at = datetime.now(UTC).isoformat().replace("+00:00", "Z") + item = { + "pk": f"USER#{subject}", + "sk": f"SCORE#{game_slug}#{created_at}#{score_id}", + "id": score_id, + "username": username, + "game_slug": game_slug, + "leaderboard_key": leaderboard_key(game_slug, score, created_at, score_id), + "score": score, + "level": level, + metric_name: metric_value, + "created_at": created_at, + } + self.table.put_item(Item=item) + return item + + def leaderboard(self, game_slug: str) -> list[dict[str, Any]]: + response = self.table.query( + IndexName="game-leaderboard-index", + KeyConditionExpression=Key("game_slug").eq(game_slug), + ScanIndexForward=True, + Limit=10, + ) + return response.get("Items", []) + + def stats(self, subject: str, game_slug: str, metric_name: str) -> dict[str, int]: + items: list[dict[str, Any]] = [] + query: dict[str, Any] = { + "KeyConditionExpression": Key("pk").eq(f"USER#{subject}") + & Key("sk").begins_with(f"SCORE#{game_slug}#") + } + + while True: + response = self.table.query(**query) + items.extend(response.get("Items", [])) + if "LastEvaluatedKey" not in response: + break + query["ExclusiveStartKey"] = response["LastEvaluatedKey"] + + return { + "games_played": len(items), + "best_score": max((int(item["score"]) for item in items), default=0), + f"total_{metric_name}": sum(int(item.get(metric_name, 0)) for item in items), + } + + +@lru_cache(maxsize=1) +def get_repository() -> ArcadeRepository: + table_name = os.getenv("ARCADE_TABLE_NAME", "").strip() + if not table_name: + raise RuntimeError("ARCADE_TABLE_NAME must be configured") + return ArcadeRepository(boto3.resource("dynamodb").Table(table_name)) diff --git a/backend/serverless/app/schemas.py b/backend/serverless/app/schemas.py new file mode 100644 index 0000000..b22c2e5 --- /dev/null +++ b/backend/serverless/app/schemas.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class UserResponse(BaseModel): + id: str + username: str + picture_url: str | None = None + + +class AuthResponse(BaseModel): + token: str + user: UserResponse + + +class GoogleExchangeRequest(BaseModel): + credential: str = Field(min_length=20, max_length=10_000) + + +class TetrisScoreCreate(BaseModel): + score: int = Field(ge=0, le=10_000_000) + level: int = Field(ge=1, le=99) + lines: int = Field(ge=0, le=10_000) + + +class TetrisScoreResponse(TetrisScoreCreate): + id: str + username: str + created_at: str + + +class TetrisStatsResponse(BaseModel): + games_played: int + best_score: int + total_lines: int + + +class NeonScoreCreate(BaseModel): + score: int = Field(ge=0, le=100_000_000) + level: int = Field(ge=1, le=999) + bricks: int = Field(ge=0, le=1_000_000) + + +class NeonScoreResponse(NeonScoreCreate): + id: str + username: str + created_at: str + + +class NeonStatsResponse(BaseModel): + games_played: int + best_score: int + total_bricks: int diff --git a/backend/serverless/build-lambda.sh b/backend/serverless/build-lambda.sh new file mode 100755 index 0000000..1a6bf1d --- /dev/null +++ b/backend/serverless/build-lambda.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +BUILD_ROOT="$ROOT_DIR/build" +BUILD_DIR="$BUILD_ROOT/package" +BUILD_VENV="$BUILD_ROOT/venv" +DIST_DIR="$ROOT_DIR/dist" + +rm -rf "$BUILD_ROOT" +mkdir -p "$BUILD_DIR" "$DIST_DIR" +python3.14 -m venv "$BUILD_VENV" + +"$BUILD_VENV/bin/python" -m pip install \ + --requirement "$ROOT_DIR/requirements.txt" \ + --target "$BUILD_DIR" \ + --no-compile \ + --upgrade \ + --quiet + +cp -R "$ROOT_DIR/app" "$BUILD_DIR/app" +cp "$ROOT_DIR/lambda_handler.py" "$BUILD_DIR/lambda_handler.py" + +rm -f "$DIST_DIR/arcade-api.zip" +( + cd "$BUILD_DIR" + python3.14 -m zipfile -c "$DIST_DIR/arcade-api.zip" . +) + +printf 'Built %s\n' "$DIST_DIR/arcade-api.zip" diff --git a/backend/serverless/deploy.sh b/backend/serverless/deploy.sh new file mode 100755 index 0000000..a96e9f1 --- /dev/null +++ b/backend/serverless/deploy.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +STATE_BUCKET="${ARCADE_TERRAFORM_STATE_BUCKET:-}" +ENVIRONMENT="${1:-prod}" + +[[ -n "$STATE_BUCKET" ]] || { + printf 'Set ARCADE_TERRAFORM_STATE_BUCKET before deploying.\n' >&2 + exit 1 +} + +[[ -n "${TF_VAR_google_client_id:-}" ]] || { + printf 'Set TF_VAR_google_client_id before deploying.\n' >&2 + exit 1 +} + +"$ROOT_DIR/build-lambda.sh" + +terraform -chdir="$ROOT_DIR/terraform" init \ + -backend-config="bucket=$STATE_BUCKET" \ + -backend-config="key=arcade/$ENVIRONMENT/terraform.tfstate" \ + -backend-config="region=${AWS_REGION:-us-east-1}" \ + -backend-config="encrypt=true" + +terraform -chdir="$ROOT_DIR/terraform" apply \ + -var="environment=$ENVIRONMENT" + +printf '\nArcade API: %s\n' "$(terraform -chdir="$ROOT_DIR/terraform" output -raw api_url)" diff --git a/backend/serverless/lambda_handler.py b/backend/serverless/lambda_handler.py new file mode 100644 index 0000000..0d388c9 --- /dev/null +++ b/backend/serverless/lambda_handler.py @@ -0,0 +1,5 @@ +from mangum import Mangum + +from app.main import app + +handler = Mangum(app, lifespan="off") diff --git a/backend/serverless/requirements.txt b/backend/serverless/requirements.txt new file mode 100644 index 0000000..0645ca8 --- /dev/null +++ b/backend/serverless/requirements.txt @@ -0,0 +1,5 @@ +boto3==1.43.14 +fastapi==0.136.3 +google-auth[requests]==2.55.1 +mangum==0.21.0 +pydantic==2.13.4 diff --git a/backend/serverless/terraform/.terraform.lock.hcl b/backend/serverless/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..0973878 --- /dev/null +++ b/backend/serverless/terraform/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.52.0" + constraints = "~> 6.0" + hashes = [ + "h1:Dg9X3Gde96OCT3TovLKAKumnR64VqIIQ37m/9NRJ00Y=", + "zh:1ab1d78f2336fed42b4e13fa0077a0be9d86a7899897cda5b9f1a60051ec2e93", + "zh:1df11f5f252030803939a1c778931dbdcee1b1070b38b98d9a8cbfc26c2aeb8e", + "zh:20ec9af03c0c1f2f8582a8805d43a4cd1a0a65082308e00f025d87605715a88f", + "zh:2d5562ed0e7cb0892fb537c7989d25fdde1d0ac1f3a768ba15fa087985f2a0f5", + "zh:40d64f668961a172355c3d11d258e19172ffafb421c40d89266b129ed8f92a5d", + "zh:497792bccc33001247473bc32a148c067982cb3d245b8f3610afb920635d2235", + "zh:8011f9167082af74ed9257582a83d54e889388656597721b2691797f1dd6fb58", + "zh:8b10d1bbca51b0da1e1be0967ce75b039f2d5d86f2ca7339994a92d35cdf47a8", + "zh:9549647dbf7c913512c26fed6badd7bf24a29a36c2bd308536849303a85427da", + "zh:97c4b726cdbe48166f4b9e6a1230312a7933dc19dd139d941ca6aca86706eb33", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a484bc8166278b546bfe53698fa9e0a919dffe3bfda3c8bf8a0fc6811b5b9e66", + "zh:aa96e76bd9f93e5395a553fccec485554a74acae46b3834b1296374eba4f010f", + "zh:cf290ee3d0dfe91b596445f860440d9f18826f7395ff785f83f70d36cedadd1c", + "zh:d56b6a79207663673f66d9ed378d705c1bd1b4d117eeb5e2be3938cf1b75b7be", + "zh:febef068317f8e49bd438ccdf2f54345fc3bc4b80a2699d9ab3c449d7e521d8e", + ] +} diff --git a/backend/serverless/terraform/backend.tf b/backend/serverless/terraform/backend.tf new file mode 100644 index 0000000..12c0dbe --- /dev/null +++ b/backend/serverless/terraform/backend.tf @@ -0,0 +1,3 @@ +terraform { + backend "s3" {} +} diff --git a/backend/serverless/terraform/main.tf b/backend/serverless/terraform/main.tf new file mode 100644 index 0000000..7e32a54 --- /dev/null +++ b/backend/serverless/terraform/main.tf @@ -0,0 +1,196 @@ +data "aws_caller_identity" "current" {} +data "aws_partition" "current" {} + +locals { + name_prefix = "${var.project_name}-${var.environment}" + common_tags = { + Project = var.project_name + Environment = var.environment + ManagedBy = "terraform" + } + signing_secret_arn = "arn:${data.aws_partition.current.partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.arcade_secret_parameter_name}" +} + +resource "aws_dynamodb_table" "arcade" { + name = "${local.name_prefix}-data" + billing_mode = "PAY_PER_REQUEST" + hash_key = "pk" + range_key = "sk" + + attribute { + name = "pk" + type = "S" + } + + attribute { + name = "sk" + type = "S" + } + + attribute { + name = "game_slug" + type = "S" + } + + attribute { + name = "leaderboard_key" + type = "S" + } + + global_secondary_index { + name = "game-leaderboard-index" + projection_type = "ALL" + + key_schema { + attribute_name = "game_slug" + key_type = "HASH" + } + + key_schema { + attribute_name = "leaderboard_key" + key_type = "RANGE" + } + } + + point_in_time_recovery { + enabled = true + } + + server_side_encryption { + enabled = true + } +} + +resource "aws_iam_role" "lambda" { + name = "${local.name_prefix}-api-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { + Service = "lambda.amazonaws.com" + } + }] + }) +} + +resource "aws_iam_role_policy" "lambda" { + name = "${local.name_prefix}-api-policy" + role = aws_iam_role.lambda.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "WriteLogs" + Effect = "Allow" + Action = [ + "logs:CreateLogStream", + "logs:PutLogEvents", + ] + Resource = "${aws_cloudwatch_log_group.lambda.arn}:*" + }, + { + Sid = "UseArcadeTable" + Effect = "Allow" + Action = [ + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:UpdateItem", + "dynamodb:Query", + ] + Resource = [ + aws_dynamodb_table.arcade.arn, + "${aws_dynamodb_table.arcade.arn}/index/*", + ] + }, + { + Sid = "ReadSigningSecret" + Effect = "Allow" + Action = "ssm:GetParameter" + Resource = local.signing_secret_arn + }, + ] + }) +} + +resource "aws_cloudwatch_log_group" "lambda" { + name = "/aws/lambda/${local.name_prefix}-api" + retention_in_days = 14 +} + +resource "aws_lambda_function" "api" { + filename = "${path.module}/../dist/arcade-api.zip" + function_name = "${local.name_prefix}-api" + role = aws_iam_role.lambda.arn + handler = "lambda_handler.handler" + runtime = "python3.14" + architectures = ["x86_64"] + memory_size = var.lambda_memory_size + timeout = 15 + source_code_hash = filebase64sha256("${path.module}/../dist/arcade-api.zip") + + reserved_concurrent_executions = 5 + + environment { + variables = { + ARCADE_ALLOWED_ORIGINS = join(",", var.allowed_origins) + ARCADE_SECRET_PARAMETER_NAME = var.arcade_secret_parameter_name + ARCADE_TABLE_NAME = aws_dynamodb_table.arcade.name + GOOGLE_CLIENT_ID = var.google_client_id + } + } + + depends_on = [ + aws_cloudwatch_log_group.lambda, + aws_iam_role_policy.lambda, + ] +} + +resource "aws_apigatewayv2_api" "arcade" { + name = "${local.name_prefix}-api" + protocol_type = "HTTP" + + cors_configuration { + allow_credentials = true + allow_headers = ["Authorization", "Content-Type"] + allow_methods = ["GET", "POST", "OPTIONS"] + allow_origins = var.allowed_origins + max_age = 600 + } +} + +resource "aws_apigatewayv2_integration" "lambda" { + api_id = aws_apigatewayv2_api.arcade.id + integration_type = "AWS_PROXY" + integration_uri = aws_lambda_function.api.invoke_arn + payload_format_version = "2.0" + timeout_milliseconds = 15000 +} + +resource "aws_apigatewayv2_route" "default" { + api_id = aws_apigatewayv2_api.arcade.id + route_key = "$default" + target = "integrations/${aws_apigatewayv2_integration.lambda.id}" +} + +resource "aws_apigatewayv2_stage" "default" { + api_id = aws_apigatewayv2_api.arcade.id + name = "$default" + auto_deploy = true + + default_route_settings { + throttling_burst_limit = var.api_throttle_burst_limit + throttling_rate_limit = var.api_throttle_rate_limit + } +} + +resource "aws_lambda_permission" "api_gateway" { + statement_id = "AllowExecutionFromApiGateway" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.api.function_name + principal = "apigateway.amazonaws.com" + source_arn = "${aws_apigatewayv2_api.arcade.execution_arn}/*" +} diff --git a/backend/serverless/terraform/outputs.tf b/backend/serverless/terraform/outputs.tf new file mode 100644 index 0000000..f87dd94 --- /dev/null +++ b/backend/serverless/terraform/outputs.tf @@ -0,0 +1,14 @@ +output "api_url" { + description = "Browser-facing Arcade HTTP API URL." + value = aws_apigatewayv2_api.arcade.api_endpoint +} + +output "dynamodb_table_name" { + description = "DynamoDB table storing users and scores." + value = aws_dynamodb_table.arcade.name +} + +output "lambda_function_name" { + description = "Shared Arcade Lambda function." + value = aws_lambda_function.api.function_name +} diff --git a/backend/serverless/terraform/terraform.tfvars.example b/backend/serverless/terraform/terraform.tfvars.example new file mode 100644 index 0000000..ef50375 --- /dev/null +++ b/backend/serverless/terraform/terraform.tfvars.example @@ -0,0 +1,12 @@ +aws_region = "us-east-1" +project_name = "arcade" +environment = "prod" + +google_client_id = "YOUR_CLIENT_ID.apps.googleusercontent.com" + +allowed_origins = [ + "https://jeremysdemers.com", + "https://www.jeremysdemers.com", +] + +arcade_secret_parameter_name = "/arcade/prod/token-signing-secret" diff --git a/backend/serverless/terraform/variables.tf b/backend/serverless/terraform/variables.tf new file mode 100644 index 0000000..77413f3 --- /dev/null +++ b/backend/serverless/terraform/variables.tf @@ -0,0 +1,66 @@ +variable "aws_region" { + description = "AWS region for the Arcade API." + type = string + default = "us-east-1" +} + +variable "project_name" { + description = "Resource name prefix." + type = string + default = "arcade" +} + +variable "environment" { + description = "Deployment environment." + type = string + default = "prod" + + validation { + condition = contains(["dev", "test", "prod"], var.environment) + error_message = "Environment must be dev, test, or prod." + } +} + +variable "google_client_id" { + description = "Google OAuth Web Client ID used by the portfolio." + type = string + sensitive = true + + validation { + condition = endswith(var.google_client_id, ".apps.googleusercontent.com") + error_message = "google_client_id must be a Google Web Client ID." + } +} + +variable "allowed_origins" { + description = "Browser origins allowed to call the Arcade API." + type = list(string) + default = [ + "https://jeremysdemers.com", + "https://www.jeremysdemers.com", + ] +} + +variable "arcade_secret_parameter_name" { + description = "Name of an existing SSM SecureString containing the token signing secret." + type = string + default = "/arcade/prod/token-signing-secret" +} + +variable "lambda_memory_size" { + description = "Lambda memory allocation in MB." + type = number + default = 512 +} + +variable "api_throttle_burst_limit" { + description = "Maximum burst of API requests." + type = number + default = 20 +} + +variable "api_throttle_rate_limit" { + description = "Sustained API requests per second." + type = number + default = 10 +} diff --git a/backend/serverless/terraform/versions.tf b/backend/serverless/terraform/versions.tf new file mode 100644 index 0000000..395c306 --- /dev/null +++ b/backend/serverless/terraform/versions.tf @@ -0,0 +1,18 @@ +terraform { + required_version = ">= 1.8.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } +} + +provider "aws" { + region = var.aws_region + + default_tags { + tags = local.common_tags + } +} diff --git a/backend/serverless/tests/__init__.py b/backend/serverless/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/serverless/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/serverless/tests/test_auth.py b/backend/serverless/tests/test_auth.py new file mode 100644 index 0000000..fcd24a6 --- /dev/null +++ b/backend/serverless/tests/test_auth.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import time +import unittest + +from app.auth import create_token, decode_token + + +class TokenTests(unittest.TestCase): + def test_round_trip(self) -> None: + token = create_token("google-subject", "player", secret="x" * 48) + payload = decode_token(token, secret="x" * 48) + + self.assertIsNotNone(payload) + self.assertEqual(payload["sub"], "google-subject") + self.assertEqual(payload["username"], "player") + self.assertGreater(payload["exp"], time.time()) + + def test_rejects_wrong_secret(self) -> None: + token = create_token("google-subject", "player", secret="x" * 48) + self.assertIsNone(decode_token(token, secret="y" * 48)) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/serverless/tests/test_repository.py b/backend/serverless/tests/test_repository.py new file mode 100644 index 0000000..ceca31f --- /dev/null +++ b/backend/serverless/tests/test_repository.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import unittest + +from app.repository import leaderboard_key, normalized_username, public_user_id + + +class RepositoryHelpersTests(unittest.TestCase): + def test_high_scores_sort_before_lower_scores(self) -> None: + high = leaderboard_key("tetris", 50_000, "2026-01-01T00:00:00Z", "a") + low = leaderboard_key("tetris", 5_000, "2026-01-01T00:00:00Z", "b") + self.assertLess(high, low) + + def test_tied_scores_sort_oldest_first(self) -> None: + older = leaderboard_key("neon-shatter", 10_000, "2026-01-01T00:00:00Z", "a") + newer = leaderboard_key("neon-shatter", 10_000, "2026-01-02T00:00:00Z", "b") + self.assertLess(older, newer) + + def test_identity_helpers_are_stable(self) -> None: + identity = {"name": "Jeremy Demers!", "email": "jeremy@example.com"} + self.assertEqual(normalized_username(identity), "jeremy_demers") + self.assertEqual(public_user_id("abc"), public_user_id("abc")) + self.assertEqual(len(public_user_id("abc")), 16) + + +if __name__ == "__main__": + unittest.main() From 4df4752fa2cf33cd2bfd7c6ed7c107be94d46712 Mon Sep 17 00:00:00 2001 From: Jeremy Demers <52353432+JeremyDemers@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:32:27 -0400 Subject: [PATCH 3/4] Use Secrets Manager for Arcade signing --- backend/serverless/README.md | 12 +++++++----- backend/serverless/app/auth.py | 12 ++++++------ backend/serverless/deploy.sh | 8 +++++++- backend/serverless/terraform/main.tf | 16 ++++++---------- .../terraform/terraform.tfvars.example | 2 +- backend/serverless/terraform/variables.tf | 10 +++++++--- 6 files changed, 34 insertions(+), 26 deletions(-) diff --git a/backend/serverless/README.md b/backend/serverless/README.md index 636696c..215ee82 100644 --- a/backend/serverless/README.md +++ b/backend/serverless/README.md @@ -14,7 +14,7 @@ Lambda + FastAPI + Mangum DynamoDB on-demand table ``` -Google Identity Services returns an ID token to the browser. The API verifies that token against the configured Web Client ID, stores or updates the player profile, and returns a signed Arcade session token. The signing secret is read from an existing SSM SecureString and is never passed through Terraform state. +Google Identity Services returns an ID token to the browser. The API verifies that token against the configured Web Client ID, stores or updates the player profile, and returns a signed Arcade session token. The signing secret is read from an existing Secrets Manager secret and is never passed through Terraform state. The DynamoDB table uses these access patterns: @@ -38,10 +38,11 @@ The repository layer expects DynamoDB. The included unit tests cover token handl Create the signing secret once. Do not save its value in a `.tfvars` file: ```bash -aws ssm put-parameter \ - --name /arcade/prod/token-signing-secret \ - --type SecureString \ - --value "$(openssl rand -hex 48)" +aws secretsmanager create-secret \ + --name arcade/prod/token-signing-secret \ + --secret-string "$(openssl rand -hex 48)" \ + --query ARN \ + --output text ``` An encrypted, versioned S3 bucket for Terraform state must also exist before deployment. @@ -60,6 +61,7 @@ Export the public Google Web Client ID and state bucket name, then review and ap ```bash export TF_VAR_google_client_id="YOUR_CLIENT_ID.apps.googleusercontent.com" +export TF_VAR_arcade_secret_arn="THE_SECRET_ARN_RETURNED_ABOVE" export ARCADE_TERRAFORM_STATE_BUCKET="YOUR_STATE_BUCKET" ./backend/serverless/deploy.sh prod ``` diff --git a/backend/serverless/app/auth.py b/backend/serverless/app/auth.py index 0be751c..89301a5 100644 --- a/backend/serverless/app/auth.py +++ b/backend/serverless/app/auth.py @@ -16,17 +16,17 @@ @lru_cache(maxsize=1) def signing_secret() -> str: - """Load the JWT signing secret locally or from SSM in AWS.""" + """Load the JWT signing secret locally or from Secrets Manager in AWS.""" local_secret = os.getenv("ARCADE_SECRET", "").strip() if local_secret: return local_secret - parameter_name = os.getenv("ARCADE_SECRET_PARAMETER_NAME", "").strip() - if not parameter_name: - raise RuntimeError("ARCADE_SECRET or ARCADE_SECRET_PARAMETER_NAME must be configured") + secret_arn = os.getenv("ARCADE_SECRET_ARN", "").strip() + if not secret_arn: + raise RuntimeError("ARCADE_SECRET or ARCADE_SECRET_ARN must be configured") - response = boto3.client("ssm").get_parameter(Name=parameter_name, WithDecryption=True) - secret = response["Parameter"]["Value"].strip() + response = boto3.client("secretsmanager").get_secret_value(SecretId=secret_arn) + secret = response["SecretString"].strip() if len(secret) < 32: raise RuntimeError("The configured Arcade signing secret is too short") return secret diff --git a/backend/serverless/deploy.sh b/backend/serverless/deploy.sh index a96e9f1..2ec5dcc 100755 --- a/backend/serverless/deploy.sh +++ b/backend/serverless/deploy.sh @@ -15,13 +15,19 @@ ENVIRONMENT="${1:-prod}" exit 1 } +[[ -n "${TF_VAR_arcade_secret_arn:-}" ]] || { + printf 'Set TF_VAR_arcade_secret_arn before deploying.\n' >&2 + exit 1 +} + "$ROOT_DIR/build-lambda.sh" terraform -chdir="$ROOT_DIR/terraform" init \ -backend-config="bucket=$STATE_BUCKET" \ -backend-config="key=arcade/$ENVIRONMENT/terraform.tfstate" \ -backend-config="region=${AWS_REGION:-us-east-1}" \ - -backend-config="encrypt=true" + -backend-config="encrypt=true" \ + -backend-config="use_lockfile=true" terraform -chdir="$ROOT_DIR/terraform" apply \ -var="environment=$ENVIRONMENT" diff --git a/backend/serverless/terraform/main.tf b/backend/serverless/terraform/main.tf index 7e32a54..afd4042 100644 --- a/backend/serverless/terraform/main.tf +++ b/backend/serverless/terraform/main.tf @@ -1,6 +1,3 @@ -data "aws_caller_identity" "current" {} -data "aws_partition" "current" {} - locals { name_prefix = "${var.project_name}-${var.environment}" common_tags = { @@ -8,7 +5,6 @@ locals { Environment = var.environment ManagedBy = "terraform" } - signing_secret_arn = "arn:${data.aws_partition.current.partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.arcade_secret_parameter_name}" } resource "aws_dynamodb_table" "arcade" { @@ -109,8 +105,8 @@ resource "aws_iam_role_policy" "lambda" { { Sid = "ReadSigningSecret" Effect = "Allow" - Action = "ssm:GetParameter" - Resource = local.signing_secret_arn + Action = "secretsmanager:GetSecretValue" + Resource = var.arcade_secret_arn }, ] }) @@ -136,10 +132,10 @@ resource "aws_lambda_function" "api" { environment { variables = { - ARCADE_ALLOWED_ORIGINS = join(",", var.allowed_origins) - ARCADE_SECRET_PARAMETER_NAME = var.arcade_secret_parameter_name - ARCADE_TABLE_NAME = aws_dynamodb_table.arcade.name - GOOGLE_CLIENT_ID = var.google_client_id + ARCADE_ALLOWED_ORIGINS = join(",", var.allowed_origins) + ARCADE_SECRET_ARN = var.arcade_secret_arn + ARCADE_TABLE_NAME = aws_dynamodb_table.arcade.name + GOOGLE_CLIENT_ID = var.google_client_id } } diff --git a/backend/serverless/terraform/terraform.tfvars.example b/backend/serverless/terraform/terraform.tfvars.example index ef50375..3b38957 100644 --- a/backend/serverless/terraform/terraform.tfvars.example +++ b/backend/serverless/terraform/terraform.tfvars.example @@ -9,4 +9,4 @@ allowed_origins = [ "https://www.jeremysdemers.com", ] -arcade_secret_parameter_name = "/arcade/prod/token-signing-secret" +arcade_secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:arcade/prod/token-signing-secret-EXAMPLE" diff --git a/backend/serverless/terraform/variables.tf b/backend/serverless/terraform/variables.tf index 77413f3..17062b8 100644 --- a/backend/serverless/terraform/variables.tf +++ b/backend/serverless/terraform/variables.tf @@ -41,10 +41,14 @@ variable "allowed_origins" { ] } -variable "arcade_secret_parameter_name" { - description = "Name of an existing SSM SecureString containing the token signing secret." +variable "arcade_secret_arn" { + description = "ARN of an existing Secrets Manager secret containing the token signing secret." type = string - default = "/arcade/prod/token-signing-secret" + + validation { + condition = can(regex("^arn:[^:]+:secretsmanager:", var.arcade_secret_arn)) + error_message = "arcade_secret_arn must be a Secrets Manager ARN." + } } variable "lambda_memory_size" { From 80142d9268beec25757e959fa6d7cc0a86293205 Mon Sep 17 00:00:00 2001 From: Jeremy Demers <52353432+JeremyDemers@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:43:04 -0400 Subject: [PATCH 4/4] Use account Lambda concurrency pool --- backend/serverless/terraform/main.tf | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/serverless/terraform/main.tf b/backend/serverless/terraform/main.tf index afd4042..dd90417 100644 --- a/backend/serverless/terraform/main.tf +++ b/backend/serverless/terraform/main.tf @@ -128,7 +128,6 @@ resource "aws_lambda_function" "api" { timeout = 15 source_code_hash = filebase64sha256("${path.module}/../dist/arcade-api.zip") - reserved_concurrent_executions = 5 environment { variables = {