Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
69 changes: 69 additions & 0 deletions backend/serverless/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# 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 Secrets Manager secret and is never passed through Terraform state.

The DynamoDB table uses these access patterns:

- `USER#<google-subject> / PROFILE` for a player profile
- `USER#<google-subject> / SCORE#<game>#<timestamp>#<id>` 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 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.

## 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 TF_VAR_arcade_secret_arn="THE_SECRET_ARN_RETURNED_ABOVE"
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.
1 change: 1 addition & 0 deletions backend/serverless/app/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Shared serverless API for the Arcade collection."""
73 changes: 73 additions & 0 deletions backend/serverless/app/auth.py
Original file line number Diff line number Diff line change
@@ -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 Secrets Manager in AWS."""
local_secret = os.getenv("ARCADE_SECRET", "").strip()
if local_secret:
return local_secret

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("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


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
14 changes: 14 additions & 0 deletions backend/serverless/app/google_identity.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading