Endpoint URL for optional smoke test
+ (default: https://prefigure.doenet.org/build)
+ --smoke-test Run a basic HTTP smoke test after deployment
+ --dry-run Print commands without executing
+ -h, --help Show this help message
+
+Examples:
+ ./deploy-prefigure-release.sh --version 0.5.16
+ ./deploy-prefigure-release.sh --version 0.5.16 --smoke-test
+EOF
+}
+
+VERSION=""
+REGION="us-east-2"
+ACCOUNT_ID=""
+ECR_REPO="prefigure-repo"
+LAMBDA_FUNCTION="prefigure-function"
+ENDPOINT_URL="https://prefigure.doenet.org/build"
+SMOKE_TEST="false"
+DRY_RUN="false"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --version)
+ VERSION="${2:-}"
+ shift 2
+ ;;
+ --region)
+ REGION="${2:-}"
+ shift 2
+ ;;
+ --account-id)
+ ACCOUNT_ID="${2:-}"
+ shift 2
+ ;;
+ --ecr-repo)
+ ECR_REPO="${2:-}"
+ shift 2
+ ;;
+ --lambda-function)
+ LAMBDA_FUNCTION="${2:-}"
+ shift 2
+ ;;
+ --endpoint)
+ ENDPOINT_URL="${2:-}"
+ shift 2
+ ;;
+ --smoke-test)
+ SMOKE_TEST="true"
+ shift
+ ;;
+ --dry-run)
+ DRY_RUN="true"
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ usage
+ exit 1
+ ;;
+ esac
+done
+
+if [[ -z "$VERSION" ]]; then
+ echo "Error: --version is required." >&2
+ usage
+ exit 1
+fi
+
+if [[ -z "$ACCOUNT_ID" ]]; then
+ if [[ "$DRY_RUN" == "true" ]]; then
+ ACCOUNT_ID="ACCOUNT_ID"
+ else
+ ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)"
+ fi
+fi
+
+if [[ -z "$ACCOUNT_ID" || "$ACCOUNT_ID" == "None" ]]; then
+ echo "Error: Could not determine AWS account ID. Provide --account-id." >&2
+ exit 1
+fi
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR"
+
+IMAGE_TAG="$VERSION"
+IMAGE_LOCAL="$ECR_REPO:$IMAGE_TAG"
+IMAGE_REMOTE="$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$ECR_REPO:$IMAGE_TAG"
+
+run_cmd() {
+ echo "+ $*"
+ if [[ "$DRY_RUN" == "false" ]]; then
+ "$@"
+ fi
+}
+
+echo "Deploying PreFigure version: $VERSION"
+echo "Region: $REGION"
+echo "Account ID: $ACCOUNT_ID"
+echo "ECR image: $IMAGE_REMOTE"
+echo "Lambda function: $LAMBDA_FUNCTION"
+
+if [[ "$DRY_RUN" == "true" ]]; then
+ echo "Dry run mode enabled. No commands will be executed."
+fi
+
+if [[ "$DRY_RUN" == "false" ]]; then
+ if ! aws sts get-caller-identity --no-cli-pager >/dev/null 2>&1; then
+ echo "Error: AWS CLI session is not authenticated." >&2
+ echo "Run: aws login" >&2
+ echo "Then verify with: aws sts get-caller-identity" >&2
+ exit 1
+ fi
+
+ if ! docker info >/dev/null 2>&1; then
+ echo "Error: Docker daemon is not running." >&2
+ echo "Start Docker Desktop (Super/Windows key → 'Docker' → Docker Desktop) and wait for it to be ready." >&2
+ exit 1
+ fi
+fi
+
+if [[ "$DRY_RUN" == "false" ]]; then
+ aws ecr describe-repositories --region "$REGION" --repository-names "$ECR_REPO" >/dev/null
+fi
+
+if [[ "$DRY_RUN" == "false" ]]; then
+ aws ecr get-login-password --region "$REGION" \
+ | docker login --username AWS --password-stdin "$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com"
+else
+ echo "+ aws ecr get-login-password --region $REGION | docker login --username AWS --password-stdin $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com"
+fi
+
+run_cmd docker build --provenance=false --platform linux/amd64 \
+ --build-arg "PREFIG_VERSION=$VERSION" \
+ -t "$IMAGE_LOCAL" .
+
+run_cmd docker tag "$IMAGE_LOCAL" "$IMAGE_REMOTE"
+run_cmd docker push "$IMAGE_REMOTE"
+
+run_cmd aws lambda update-function-code \
+ --no-cli-pager \
+ --region "$REGION" \
+ --function-name "$LAMBDA_FUNCTION" \
+ --image-uri "$IMAGE_REMOTE"
+
+run_cmd aws lambda wait function-updated \
+ --no-cli-pager \
+ --region "$REGION" \
+ --function-name "$LAMBDA_FUNCTION"
+
+echo "Deployment complete."
+
+if [[ "$SMOKE_TEST" == "true" ]]; then
+ echo "Running smoke test against: $ENDPOINT_URL"
+ TMP_XML="/tmp/prefigure-smoke.xml"
+ cat > "$TMP_XML" <<'XML'
+
+
+
+XML
+
+ if [[ "$DRY_RUN" == "false" ]]; then
+ # Check /version endpoint reports expected version
+ VERSION_ENDPOINT="${ENDPOINT_URL%/build}/version"
+ echo "Checking version endpoint: $VERSION_ENDPOINT"
+ VERSION_RESPONSE="$(curl -sS "$VERSION_ENDPOINT")"
+ echo "Version endpoint response: $VERSION_RESPONSE"
+ DEPLOYED_VERSION="$(echo "$VERSION_RESPONSE" | grep -o '"version": *"[^"]*"' | grep -o '"[^"]*"$' | tr -d '"' || true)"
+ if [[ "$DEPLOYED_VERSION" == "$VERSION" ]]; then
+ echo "Version check passed: $DEPLOYED_VERSION"
+ elif [[ -z "$DEPLOYED_VERSION" ]]; then
+ echo "Warning: /version endpoint did not return a version field (response: $VERSION_RESPONSE)" >&2
+ else
+ echo "Warning: /version returned '$DEPLOYED_VERSION', expected '$VERSION'" >&2
+ fi
+
+ HTTP_CODE="$(curl -sS -o /tmp/prefigure-smoke-response.json -w "%{http_code}" \
+ -X POST "$ENDPOINT_URL" \
+ -H "Content-Type: application/xml" \
+ --data-binary @"$TMP_XML")"
+
+ echo "Smoke test HTTP status: $HTTP_CODE"
+ echo "Smoke test response saved: /tmp/prefigure-smoke-response.json"
+
+ if [[ "$HTTP_CODE" != "200" ]]; then
+ echo "Smoke test failed (expected HTTP 200)." >&2
+ exit 1
+ fi
+ else
+ echo "+ curl -X POST $ENDPOINT_URL -H Content-Type: application/xml --data-binary @/tmp/prefigure-smoke.xml"
+ fi
+fi
+
+echo "Done."
diff --git a/prefigure-lambda/prefigure-stack.yml b/prefigure-lambda/prefigure-stack.yml
new file mode 100644
index 000000000..96edde3ff
--- /dev/null
+++ b/prefigure-lambda/prefigure-stack.yml
@@ -0,0 +1,181 @@
+AWSTemplateFormatVersion: "2010-09-09"
+Description: Prefigure Lambda Service with DynamoDB Caching and Custom Domain
+
+Parameters:
+ ContainerImageUri:
+ Type: String
+ Description: Full URI of the ECR image (e.g., 123456789012.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo:latest)
+
+ DomainName:
+ Type: String
+ Default: prefigure.doenet.org
+ Description: The custom domain name for the API
+
+ CertificateArn:
+ Type: String
+ Description: The ARN of the ACM Certificate for the domain (must be in the same region)
+
+ HostedZoneId:
+ Type: String
+ Description: Route53 Hosted Zone ID for doenet.org (Optional - if you want CF to create the DNS record automatically)
+
+Resources:
+ # -------------------------------------------------------------------------
+ # 1. DynamoDB Table (Cache)
+ # -------------------------------------------------------------------------
+ PrefigureCacheTable:
+ Type: AWS::DynamoDB::Table
+ Properties:
+ TableName: PrefigureCache
+ BillingMode: PAY_PER_REQUEST
+ AttributeDefinitions:
+ - AttributeName: xml_hash
+ AttributeType: S
+ KeySchema:
+ - AttributeName: xml_hash
+ KeyType: HASH
+ TimeToLiveSpecification:
+ AttributeName: expiration_time
+ Enabled: true
+
+ # -------------------------------------------------------------------------
+ # 2. IAM Role (Least Privilege)
+ # -------------------------------------------------------------------------
+ LambdaExecutionRole:
+ Type: AWS::IAM::Role
+ Properties:
+ AssumeRolePolicyDocument:
+ Version: "2012-10-17"
+ Statement:
+ - Effect: Allow
+ Principal:
+ Service: lambda.amazonaws.com
+ Action: sts:AssumeRole
+ Policies:
+ # Basic Logging
+ - PolicyName: LambdaBasicExecution
+ PolicyDocument:
+ Version: "2012-10-17"
+ Statement:
+ - Effect: Allow
+ Action:
+ - logs:CreateLogGroup
+ - logs:CreateLogStream
+ - logs:PutLogEvents
+ Resource: "*"
+ # DynamoDB Access (Specific Table Only)
+ - PolicyName: DynamoDBCacheAccess
+ PolicyDocument:
+ Version: "2012-10-17"
+ Statement:
+ - Effect: Allow
+ Action:
+ - dynamodb:GetItem
+ - dynamodb:PutItem
+ Resource: !GetAtt PrefigureCacheTable.Arn
+
+ # -------------------------------------------------------------------------
+ # 3. Lambda Function
+ # -------------------------------------------------------------------------
+ PrefigureFunction:
+ Type: AWS::Lambda::Function
+ Properties:
+ FunctionName: prefigure-function
+ PackageType: Image
+ Code:
+ ImageUri: !Ref ContainerImageUri
+ Role: !GetAtt LambdaExecutionRole.Arn
+ Timeout: 30
+ MemorySize: 1024
+ Architectures:
+ - x86_64
+
+ # -------------------------------------------------------------------------
+ # 4. API Gateway (HTTP API)
+ # -------------------------------------------------------------------------
+ HttpApi:
+ Type: AWS::ApiGatewayV2::Api
+ Properties:
+ Name: PrefigureAPI
+ ProtocolType: HTTP
+ CorsConfiguration:
+ AllowOrigins:
+ - "*"
+ AllowMethods:
+ - POST
+ AllowHeaders:
+ - content-type
+
+ # Integration: Connect API to Lambda
+ LambdaIntegration:
+ Type: AWS::ApiGatewayV2::Integration
+ Properties:
+ ApiId: !Ref HttpApi
+ IntegrationType: AWS_PROXY
+ IntegrationUri: !GetAtt PrefigureFunction.Arn
+ PayloadFormatVersion: "2.0"
+
+ # Route: POST /build
+ BuildRoute:
+ Type: AWS::ApiGatewayV2::Route
+ Properties:
+ ApiId: !Ref HttpApi
+ RouteKey: "POST /build"
+ Target: !Join ["/", ["integrations", !Ref LambdaIntegration]]
+
+ # Permission: Allow API Gateway to invoke Lambda
+ ApiGatewayPermission:
+ Type: AWS::Lambda::Permission
+ Properties:
+ Action: lambda:InvokeFunction
+ FunctionName: !GetAtt PrefigureFunction.Arn
+ Principal: apigateway.amazonaws.com
+ SourceArn: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${HttpApi}/*/*/build"
+
+ # Stage: Default deployment
+ DefaultStage:
+ Type: AWS::ApiGatewayV2::Stage
+ Properties:
+ ApiId: !Ref HttpApi
+ StageName: "$default"
+ AutoDeploy: true
+
+ # -------------------------------------------------------------------------
+ # 5. Custom Domain Name Mapping
+ # -------------------------------------------------------------------------
+ ApiDomainName:
+ Type: AWS::ApiGatewayV2::DomainName
+ Properties:
+ DomainName: !Ref DomainName
+ DomainNameConfigurations:
+ - CertificateArn: !Ref CertificateArn
+ EndpointType: REGIONAL
+
+ ApiMapping:
+ Type: AWS::ApiGatewayV2::ApiMapping
+ Properties:
+ ApiId: !Ref HttpApi
+ DomainName: !Ref ApiDomainName
+ Stage: !Ref DefaultStage
+
+ # -------------------------------------------------------------------------
+ # 6. DNS Record (Optional - Requires Hosted Zone ID)
+ # -------------------------------------------------------------------------
+ # Uncomment if you want CloudFormation to manage the Route53 record
+ # DnsRecord:
+ # Type: AWS::Route53::RecordSet
+ # Properties:
+ # HostedZoneId: !Ref HostedZoneId
+ # Name: !Ref DomainName
+ # Type: A
+ # AliasTarget:
+ # DNSName: !GetAtt ApiDomainName.RegionalDomainName
+ # HostedZoneId: !GetAtt ApiDomainName.RegionalHostedZoneId
+
+Outputs:
+ ApiEndpoint:
+ Description: "API Gateway Endpoint URL"
+ Value: !GetAtt HttpApi.ApiEndpoint
+ DomainEndpoint:
+ Description: "Custom Domain URL"
+ Value: !Sub "https://${DomainName}/build"
diff --git a/prefigure-lambda/test-prefigure.html b/prefigure-lambda/test-prefigure.html
new file mode 100644
index 000000000..f1184f3d0
--- /dev/null
+++ b/prefigure-lambda/test-prefigure.html
@@ -0,0 +1,160 @@
+
+
+
+
+ Prefigure API Test
+
+
+
+
+ Prefigure API Test
+ Edit the XML below and click Build.
+
+
+
+
+
+
+
+
+
+