Compare commits

..

No commits in common. "main" and "v1.0.1" have entirely different histories.
main ... v1.0.1

59 changed files with 952 additions and 3127 deletions

32
.github/workflows/ci-terraform.yaml vendored Normal file
View file

@ -0,0 +1,32 @@
name: Terraform CI
on:
pull_request:
branches: [main]
paths:
- "terraform/**"
- ".github/workflows/ci-terraform.yaml"
permissions:
contents: read
jobs:
terraform:
runs-on: ubuntu-latest
defaults:
run:
working-directory: terraform
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: "1.9.8"
- name: Terraform fmt
run: terraform fmt -check -recursive
- name: Terraform init
run: terraform init -backend=false
- name: Terraform validate
run: terraform validate

View file

@ -1,38 +1,21 @@
name: CI name: CI
on: on:
pull_request: pull_request:
branches: [main, hotfix/**, release/**] branches: [main]
merge_group: merge_group:
push:
branches: [hotfix/**, release/**]
permissions: permissions:
contents: read contents: read
jobs: jobs:
autofix: ci:
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
permissions:
contents: write
secrets: inherit
with:
presets: ruff,terraform
terraform-version: "1.9.8"
node-version: "24.19.0"
format-command: npm run format:templates:write
lint-fix-command: npx eslint "src/server/templates/*.js" --fix
lint:
needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
with: with:
python-version: "3.12.14" python-version: "3.12.14"
requirements: "requirements-api.txt"
collect-only: false
template-js: template-js:
needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
@ -51,12 +34,7 @@ jobs:
- name: Check template JavaScript - name: Check template JavaScript
run: npm run check:templates run: npm run check:templates
- name: Lint OpenAPI
run: npm run openapi:lint
test: test:
needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
@ -82,30 +60,3 @@ jobs:
- name: Run tests - name: Run tests
run: pytest run: pytest
terraform:
needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
with:
terraform-version: "1.9.8"
ci-complete:
name: ci-complete
needs: [autofix, lint, template-js, test, terraform]
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Require portions
env:
LINT: ${{ needs.lint.result }}
TEMPLATE_JS: ${{ needs.template-js.result }}
TEST: ${{ needs.test.result }}
TERRAFORM: ${{ needs.terraform.result }}
run: |
set -euo pipefail
test "${LINT}" = success
test "${TEMPLATE_JS}" = success
test "${TEST}" = success
test "${TERRAFORM}" = success

View file

@ -7,4 +7,4 @@ permissions:
jobs: jobs:
review: review:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21 uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11

View file

@ -1,8 +1,8 @@
name: Deploy API name: Deploy API
# Fargate image CD. GitHub Actions builds the Flask image, pushes to ECR, # Fargate image CD (PLAT-215). GitHub Actions builds the Flask image, pushes
# and registers a new task definition. Terraform owns the cluster, service, # to ECR, and registers a new task definition. Terraform owns the cluster,
# ALB, and ignores container_definitions / task_definition. # service, ALB, and ignores container_definitions / task_definition.
# #
# push to main -> dev, at github.sha # push to main -> dev, at github.sha
# release: published -> prod, at the release tag # release: published -> prod, at the release tag
@ -18,7 +18,9 @@ on:
- "terraform/**" - "terraform/**"
- "docs/**" - "docs/**"
- "*.md" - "*.md"
- ".github/workflows/weekly-menu.yml"
- ".github/workflows/ci.yml" - ".github/workflows/ci.yml"
- ".github/workflows/ci-terraform.yaml"
release: release:
types: [published] types: [published]
workflow_dispatch: workflow_dispatch:
@ -38,33 +40,198 @@ permissions:
contents: read contents: read
jobs: jobs:
deploy-dev: target:
name: Deploy API to dev name: Resolve target
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev') runs-on: ubuntu-latest
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21 timeout-minutes: 5
permissions: outputs:
contents: read environment: ${{ steps.resolve.outputs.environment }}
id-token: write ref: ${{ steps.resolve.outputs.ref }}
secrets: inherit steps:
with: - id: resolve
environment: dev env:
ref: ${{ inputs.ref }} EVENT_NAME: ${{ github.event_name }}
ssm-prefix: /meal-order-manager/deploy GITHUB_REF_NAME_IN: ${{ github.ref }}
docker-platform: linux/amd64 GITHUB_SHA_IN: ${{ github.sha }}
extra-task-env: '{"SENTRY_DSN_PARAM":"/meal-order-manager/sentry-dsn"}' RELEASE_TAG: ${{ github.event.release.tag_name }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
INPUT_ENVIRONMENT: ${{ inputs.environment }}
INPUT_REF: ${{ inputs.ref }}
run: |
set -euo pipefail
case "${EVENT_NAME}" in
push)
if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then
echo "push deploys only run from main" >&2
exit 1
fi
environment=dev
ref="${GITHUB_SHA_IN}"
;;
release)
environment=prod
ref="${RELEASE_TAG}"
status="$(gh api "repos/${REPO}/compare/main...${RELEASE_TAG}" --jq .status)"
if [ "${status}" != "behind" ] && [ "${status}" != "identical" ]; then
echo "release tag ${RELEASE_TAG} is not on main (compare status: ${status})" >&2
exit 1
fi
;;
workflow_dispatch)
environment="${INPUT_ENVIRONMENT}"
ref="${INPUT_REF:-${GITHUB_SHA_IN}}"
;;
*)
echo "unsupported event ${EVENT_NAME}" >&2
exit 1
;;
esac
{
echo "environment=${environment}"
echo "ref=${ref}"
} >> "${GITHUB_OUTPUT}"
echo "Deploying ${ref} to ${environment}"
deploy-prod: deploy:
name: Deploy API to prod name: Deploy API to ${{ needs.target.outputs.environment }}
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod') needs: target
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21 runs-on: ubuntu-latest
timeout-minutes: 30
environment: ${{ needs.target.outputs.environment }}
concurrency:
group: deploy-api-${{ needs.target.outputs.environment }}
cancel-in-progress: false
permissions: permissions:
contents: read contents: read
id-token: write id-token: write
secrets: inherit env:
AWS_REGION: us-east-1
DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
environment: prod ref: ${{ needs.target.outputs.ref }}
ref: ${{ github.event.release.tag_name || inputs.ref }} persist-credentials: false
ssm-prefix: /meal-order-manager/deploy
docker-platform: linux/amd64 - name: Resolve commit
ship-gate: true id: commit
extra-task-env: '{"SENTRY_DSN_PARAM":"/meal-order-manager/sentry-dsn"}' run: |
set -euo pipefail
sha="$(git rev-parse HEAD)"
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
echo "Building ${sha}"
- name: Configure AWS credentials using OIDC
uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
with:
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
aws-region: us-east-1
audience: sts.amazonaws.com
- name: Get deploy parameters
id: deploy
run: |
set -euo pipefail
get_param() {
aws ssm get-parameter --name "$1" --query Parameter.Value --output text
}
CLUSTER=$(get_param /meal-order-manager/deploy/cluster)
SERVICE=$(get_param /meal-order-manager/deploy/service)
FAMILY=$(get_param /meal-order-manager/deploy/task-family)
ECR=$(get_param /meal-order-manager/deploy/ecr-repository)
CONTAINER=$(get_param /meal-order-manager/deploy/container-name)
API_URL=$(get_param /meal-order-manager/deploy/api-url)
{
echo "cluster=${CLUSTER}"
echo "service=${SERVICE}"
echo "family=${FAMILY}"
echo "ecr=${ECR}"
echo "container=${CONTAINER}"
echo "api_url=${API_URL}"
} >> "${GITHUB_OUTPUT}"
- name: Login to Amazon ECR
uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2.1.7
- name: Build and push image
env:
ECR: ${{ steps.deploy.outputs.ecr }}
GIT_SHA: ${{ steps.commit.outputs.sha }}
ENVIRONMENT: ${{ needs.target.outputs.environment }}
run: |
set -euo pipefail
docker build \
--build-arg "GIT_SHA=${GIT_SHA}" \
-t "${ECR}:${GIT_SHA}" \
-t "${ECR}:${ENVIRONMENT}" \
.
docker push "${ECR}:${GIT_SHA}"
docker push "${ECR}:${ENVIRONMENT}"
- name: Register task definition and update service
env:
CLUSTER: ${{ steps.deploy.outputs.cluster }}
SERVICE: ${{ steps.deploy.outputs.service }}
FAMILY: ${{ steps.deploy.outputs.family }}
CONTAINER: ${{ steps.deploy.outputs.container }}
IMAGE: ${{ steps.deploy.outputs.ecr }}:${{ steps.commit.outputs.sha }}
GIT_SHA: ${{ steps.commit.outputs.sha }}
run: |
set -euo pipefail
aws ecs describe-task-definition \
--task-definition "${FAMILY}" \
--query taskDefinition \
--output json \
| python3 -c '
import json, os, sys
td = json.load(sys.stdin)
for key in (
"taskDefinitionArn",
"revision",
"status",
"requiresAttributes",
"compatibilities",
"registeredAt",
"registeredBy",
"deregisteredAt",
):
td.pop(key, None)
image = os.environ["IMAGE"]
sha = os.environ["GIT_SHA"]
name = os.environ["CONTAINER"]
for container in td["containerDefinitions"]:
if container["name"] != name:
continue
container["image"] = image
env = {item["name"]: item["value"] for item in container.get("environment", [])}
env["GIT_SHA"] = sha
container["environment"] = [{"name": key, "value": value} for key, value in env.items()]
container.pop("command", None)
json.dump(td, sys.stdout)
' > /tmp/task-def.json
REV="$(aws ecs register-task-definition --cli-input-json file:///tmp/task-def.json --query taskDefinition.revision --output text)"
aws ecs update-service \
--cluster "${CLUSTER}" \
--service "${SERVICE}" \
--task-definition "${FAMILY}:${REV}" \
--force-new-deployment \
>/dev/null
aws ecs wait services-stable --cluster "${CLUSTER}" --services "${SERVICE}"
- name: Verify health SHA
env:
API_URL: ${{ steps.deploy.outputs.api_url }}
EXPECTED_SHA: ${{ steps.commit.outputs.sha }}
run: |
set -euo pipefail
for _ in 1 2 3 4 5 6; do
BODY="$(curl -fsS "${API_URL}/api/health" || true)"
echo "${BODY}"
if echo "${BODY}" | python3 -c 'import json,os,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("sha")==os.environ["EXPECTED_SHA"] else 1)'; then
exit 0
fi
sleep 10
done
echo "health SHA did not match ${EXPECTED_SHA}" >&2
exit 1

View file

@ -10,4 +10,4 @@ permissions:
jobs: jobs:
label: label:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21 uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11

179
.github/workflows/weekly-menu.yml vendored Normal file
View file

@ -0,0 +1,179 @@
name: Weekly Menu Scrape & Publish
on:
schedule:
# Monday 7:30am EST = 12:30 UTC
- cron: '30 12 * * 1'
# Monday 7:30am EDT = 11:30 UTC
- cron: '30 11 * * 1'
workflow_dispatch:
permissions:
id-token: write
contents: read
concurrency:
group: weekly-menu
cancel-in-progress: false
jobs:
scrape-and-publish:
runs-on: ubuntu-latest
# A hung Playwright scrape would otherwise hold the weekly-menu concurrency
# group for the 360-minute default.
timeout-minutes: 30
env:
AWS_REGION: us-east-1
steps:
- name: Timezone guard
if: github.event_name == 'schedule'
env:
CRON: ${{ github.event.schedule }}
run: |
OFFSET=$(TZ='America/New_York' date +%z)
echo "Cron: $CRON | Eastern offset: $OFFSET"
if { [ "$OFFSET" = "-0400" ] && [ "$CRON" = "30 12 * * 1" ]; } || \
{ [ "$OFFSET" = "-0500" ] && [ "$CRON" = "30 11 * * 1" ]; }; then
echo "Wrong-timezone cron fired — skipping"
echo "SKIP_RUN=true" >> "$GITHUB_ENV"
fi
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
if: env.SKIP_RUN != 'true'
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
if: env.SKIP_RUN != 'true'
with:
python-version: '3.12.14'
- name: Install dependencies
if: env.SKIP_RUN != 'true'
run: |
pip install -r requirements.txt
playwright install chromium --with-deps
- name: Configure AWS credentials
if: env.SKIP_RUN != 'true'
uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
with:
role-to-assume: ${{ secrets.AWS_WEEKLY_MENU_ROLE_ARN }}
aws-region: us-east-1
- name: Scrape menu
if: env.SKIP_RUN != 'true'
run: python3 src/scraper/scrape_menu.py
# Deploy targets come from Parameter Store, written by Terraform
# (terraform/ssm.tf). They replace the CloudFormation stack outputs this
# job used to read; there is no CloudFormation stack any more.
- name: Get deploy parameters
if: env.SKIP_RUN != 'true'
id: stack
run: |
set -euo pipefail
get_param() {
aws ssm get-parameter --name "$1" --query 'Parameter.Value' --output text
}
API_URL=$(get_param /meal-order-manager/deploy/api-url)
FORM_BUCKET=$(get_param /meal-order-manager/deploy/form-bucket)
DIST_ID=$(get_param /meal-order-manager/deploy/distribution-id)
FORM_URL=$(get_param /meal-order-manager/deploy/form-url)
for v in "$API_URL" "$FORM_BUCKET" "$DIST_ID" "$FORM_URL"; do
if [ -z "$v" ] || [ "$v" = "None" ]; then
echo "A /meal-order-manager/deploy/* parameter is missing; has Terraform been applied?" >&2
exit 1
fi
done
echo "api_url=$API_URL" >> "$GITHUB_OUTPUT"
echo "form_bucket=$FORM_BUCKET" >> "$GITHUB_OUTPUT"
echo "dist_id=$DIST_ID" >> "$GITHUB_OUTPUT"
echo "form_url=$FORM_URL" >> "$GITHUB_OUTPUT"
- name: Get discount settings
if: env.SKIP_RUN != 'true'
id: discount
env:
API_URL: ${{ steps.stack.outputs.api_url }}
run: |
set -euo pipefail
MEALS_PUBLISH_KEY=$(aws ssm get-parameter \
--name /meal-order-manager/publish-key \
--with-decryption \
--query 'Parameter.Value' \
--output text)
SETTINGS=$(MEALS_PUBLISH_KEY="$MEALS_PUBLISH_KEY" python3 scripts/upload_menu.py settings --api-url "$API_URL")
BULK=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["bulk_discount_percent"])' "$SETTINGS")
SUBSIDY=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["company_subsidy_percent"])' "$SETTINGS")
echo "bulk_discount=$BULK" >> "$GITHUB_OUTPUT"
echo "company_subsidy=$SUBSIDY" >> "$GITHUB_OUTPUT"
- name: Get Google Client ID
if: env.SKIP_RUN != 'true'
id: google
run: |
GOOGLE_CLIENT_ID=$(aws ssm get-parameter \
--name /meal-order-manager/google-client-id \
--query 'Parameter.Value' \
--output text)
if [ "$GOOGLE_CLIENT_ID" = "None" ] || [ -z "$GOOGLE_CLIENT_ID" ]; then
echo "Google client ID is required for cloud form generation" >&2
exit 1
fi
echo "client_id=$GOOGLE_CLIENT_ID" >> "$GITHUB_OUTPUT"
- name: Generate order form
if: env.SKIP_RUN != 'true'
env:
BULK_DISCOUNT: ${{ steps.discount.outputs.bulk_discount }}
COMPANY_SUBSIDY: ${{ steps.discount.outputs.company_subsidy }}
GOOGLE_CLIENT_ID: ${{ steps.google.outputs.client_id }}
run: |
# Relative /api paths so the form stays same-origin on CloudFront
# after the ALB origin swap. Do not bake the ALB DNS into HTML.
python3 src/server/generate_form.py \
--bulk-discount "$BULK_DISCOUNT" \
--company-subsidy "$COMPANY_SUBSIDY" \
--google-client-id "$GOOGLE_CLIENT_ID"
- name: Publish menu through API
if: env.SKIP_RUN != 'true'
env:
API_URL: ${{ steps.stack.outputs.api_url }}
run: |
set -euo pipefail
MEALS_PUBLISH_KEY=$(aws ssm get-parameter \
--name /meal-order-manager/publish-key \
--with-decryption \
--query 'Parameter.Value' \
--output text)
MEALS_PUBLISH_KEY="$MEALS_PUBLISH_KEY" python3 scripts/upload_menu.py publish --api-url "$API_URL"
- name: Upload form to S3
if: env.SKIP_RUN != 'true'
env:
FORM_BUCKET: ${{ steps.stack.outputs.form_bucket }}
run: |
WEEK=$(date +%Y-W%U)
aws s3 cp "output/order-form-$WEEK.html" \
"s3://${FORM_BUCKET}/index.html" \
--content-type "text/html" \
--cache-control "no-cache"
aws s3 cp "output/order-form-$WEEK.html" \
"s3://${FORM_BUCKET}/archive/$WEEK.html" \
--content-type "text/html"
- name: Invalidate CloudFront cache
if: env.SKIP_RUN != 'true'
env:
DIST_ID: ${{ steps.stack.outputs.dist_id }}
run: |
aws cloudfront create-invalidation \
--distribution-id "$DIST_ID" \
--paths "/index.html"
- name: Notify Slack
if: env.SKIP_RUN != 'true'
env:
FORM_URL: ${{ steps.stack.outputs.form_url }}
run: python3 scripts/notify_slack.py "$FORM_URL"

1
.mergify.yml Normal file
View file

@ -0,0 +1 @@
extends: .github

View file

@ -1,32 +0,0 @@
# Same Redocly recommended ruleset as internal-portal (DEV-223 / DEV-289).
# Recommended operation-2xx-response does not count 302. Login-style redirects
# succeed with 302, so that rule is replaced by operation-2xx-or-3xx-response
# at error. operation-4xx-response is promoted to error so missing 4xx fails CI.
extends:
- recommended
rules:
operation-2xx-response: off
operation-4xx-response: error
rule/operation-2xx-or-3xx-response:
subject:
type: Responses
message: Operation must define a 2XX or 3XX response.
severity: error
assertions:
requireAny:
- "200"
- "201"
- "202"
- "204"
- "301"
- "302"
- "303"
- "307"
- "308"
- "2XX"
- "3XX"
apis:
meals@v1:
root: openapi.yaml

View file

@ -9,8 +9,8 @@
"justification": "The meals API ALB is the CloudFront HTTP origin for orders.seahaven.com. TLS and WAF terminate at CloudFront. Restricting the security group to the CloudFront managed prefix list would block GitHub-hosted weekly-menu HMAC publish, which must call the origin with X-Meals-Publish-Key. Application gates are HMAC on /api/publish, Cognito or Google Bearer on admin, and public submit only. Accepted as the HTTP-origin design for PLAT-215; TLS on the ALB is a follow-up." "justification": "The meals API ALB is the CloudFront HTTP origin for orders.seahaven.com. TLS and WAF terminate at CloudFront. Restricting the security group to the CloudFront managed prefix list would block GitHub-hosted weekly-menu HMAC publish, which must call the origin with X-Meals-Publish-Key. Application gates are HMAC on /api/publish, Cognito or Google Bearer on admin, and public submit only. Accepted as the HTTP-origin design for PLAT-215; TLS on the ALB is a follow-up."
}, },
{ {
"id": "checkov-CKV_AWS_111-42", "id": "checkov-CKV_AWS_111-40",
"justification": "LINE SHIFT ONLY: dropping the workflow_ref trust condition shifts github_deploy from 49 to 42. The permission document is unchanged. Original justification: githubdeploy-meal-order-manager needs ecr:GetAuthorizationToken and ecs:RegisterTaskDefinition, both of which AWS documents as Resource *. Trust is pinned to environment:dev or environment:prod and job_workflow_ref on org cd-hcp-fargate.yaml@*. iam:PassRole is limited to the ECS task and execution roles. ECR push and SSM parameter reads are already resource-scoped." "justification": "githubdeploy-meal-order-manager needs ecr:GetAuthorizationToken and ecs:RegisterTaskDefinition, both of which AWS documents as Resource *. Trust is pinned to environment:dev or environment:prod plus job_workflow_ref on deploy-api.yaml at refs/heads/main or refs/tags/v*. iam:PassRole is limited to the ECS task and execution roles. ECR push and SSM parameter reads are already resource-scoped."
} }
] ]
} }

View file

@ -12,6 +12,10 @@ Use one of: `feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, `refactor/`, `rele
- **Body sections** (exactly, in order): `Summary`, `Validation`, `Tests`, `Notes`. Use "None." under Notes when empty. - **Body sections** (exactly, in order): `Summary`, `Validation`, `Tests`, `Notes`. Use "None." under Notes when empty.
- State verifiable facts only. Do not justify changes by citing the handbook. No AI-attribution footers. - State verifiable facts only. Do not justify changes by citing the handbook. No AI-attribution footers.
## Security and Cross-Review
- Sensitive surfaces (payment flows, authentication, secrets handling, untrusted input) require security review.
- IAM role, policy, or resource-permission changes require cross-family review. Lambda handler signature changes alone do not.
## CI and Workflow References ## CI and Workflow References
- CI must pass before merge. - CI must pass before merge.
- Org-level reusable workflow refs must be pinned to a full commit SHA with a `# vX.Y.Z` comment. - Org-level reusable workflow refs must be pinned to a full commit SHA with a `# vX.Y.Z` comment.

View file

@ -1,4 +1,4 @@
FROM python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9 FROM python:3.12-slim
WORKDIR /app WORKDIR /app
COPY src/shared/requirements.txt /tmp/shared-requirements.txt COPY src/shared/requirements.txt /tmp/shared-requirements.txt

View file

@ -10,21 +10,26 @@ Automates weekly meal ordering from [Redefine Meals](https://www.redefinemeals.c
## Architecture ## Architecture
``` ```
EventBridge Scheduler (America/New_York) Employees Monday 7:30am ET Employees (Mon–Thu) Thursday 11:59pm ET
┌──────────────────────────────────────┐ ┌────────────────────┐ ┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Mon 6:55 roster, Mon 7:30 menu │ │ orders.seahaven.com│ │ GitHub Actions │ │ orders.seahaven │ │ EventBridge │
│ Thu 10:00 reminder, Thu 23:59 close │ │ CloudFront + S3 │ │ - Scrape menu │────S3 upload───▶│ .com │ │ Scheduler (ET) │
└──────────────────┬───────────────────┘ └─────────┬──────────┘ │ - HMAC publish │ │ (CloudFront+S3) │──POST───┐ │ → jobs SQS │
│ jobs SQS │ POST /api │ - Slack notify │ └──────────────────┘ │ └─────────┬────────┘
▼ ▼ └─────────────────┘ ▼ │
┌──────────────────────────────────────────────────┐ ┌──────────┐ │
│ Fargate: Flask + SQS worker │ Thu 10am: Slack DM │ ALB + │◀──────────┘
│ menu → DynamoDB │ reminders to employees │ Fargate │
│ form HTML → S3, then invalidate /index.html │ who haven't ordered │ Flask │
└──────────────────────────────────────────────────┘ └────┬─────┘
▼
┌──────────┐
│ DynamoDB │
│ orders │
└──────────┘
``` ```
The production HTTP app is `src/server/app.py` (gunicorn). Menu publish, close, aggregate/PDF, Slack reminder, and roster sync run in the same task from a dedicated SQS consumer (`src/server/worker.py`). Menu publish fetches the Redefine HTML catalog. It does not run a browser. The production HTTP app is `src/server/app.py` (gunicorn). Close, aggregate/PDF, Slack reminder, and roster sync run in the same task from a dedicated SQS consumer (`src/server/worker.py`). Playwright scrape stays in GitHub Actions.
### Form frontend decisions ### Form frontend decisions
@ -40,21 +45,21 @@ the generated HTML, and the generated deployment artifact remains self-contained
| When | What | How | | When | What | How |
|------|------|-----| |------|------|-----|
| Monday 6:55am ET | Sync employee roster from Slack channel membership | EventBridge Scheduler → jobs SQS → Fargate | | Monday 6:55am ET | Sync employee roster from Slack channel membership | EventBridge Scheduler → jobs SQS → Fargate |
| Monday 7:30am ET | Fetch menu, generate form, write menu, upload form to S3, invalidate CloudFront, post link to Slack | EventBridge Scheduler → jobs SQS | | Monday 7:30am ET | Scrape menu, generate form, HMAC-publish menu, upload form to S3, post link to Slack | GitHub Actions cron |
| Mon–Thu | Employees visit `orders.seahaven.com` and submit orders | S3 form → CloudFront `/api/*` → ALB → Flask → DynamoDB | | Mon–Thu | Employees visit `orders.seahaven.com` and submit orders | S3 form → CloudFront `/api/*` → ALB → Flask → DynamoDB |
| Thursday 10am ET | DM employees who haven't ordered yet | EventBridge Scheduler → jobs SQS | | Thursday 10am ET | DM employees who haven't ordered yet | EventBridge Scheduler → jobs SQS |
| Thursday 11:59pm ET | Close form, aggregate orders, write CSV reports + weekly summary PDF, post Redefine order summary to Slack | EventBridge Scheduler → jobs SQS | | Thursday 11:59pm ET | Close form, aggregate orders, write CSV reports + weekly summary PDF, post Redefine order summary to Slack | EventBridge Scheduler → jobs SQS |
### Weekly menu publication ### Weekly menu publication boundary
Monday 7:30am Eastern, EventBridge Scheduler enqueues `publish_menu`. The Fargate worker fetches the Redefine menu HTML, parses the embedded catalog, writes that Eastern-time week's menu to DynamoDB, renders the form, uploads it to the form bucket, invalidates `/index.html`, and posts to Slack. The scheduled GitHub workflow has no DynamoDB permissions. It sends two HMAC
requests with `X-Meals-Publish-Key` from Parameter Store to the ALB:
`scripts/upload_menu.py` remains a manual HMAC fallback for one production Monday:
- `GET /api/publish/settings` returns only the bulk discount and company subsidy. - `GET /api/publish/settings` returns only the bulk discount and company subsidy.
- `POST /api/publish/menu` validates and writes the current Eastern-time week's menu. - `POST /api/publish/menu` validates and writes the current Eastern-time week's menu.
Publish routes are omitted from CloudFront. The generated form uses relative `/api/...` paths so it stays same-origin on `orders.seahaven.com`. Publish routes are omitted from CloudFront. The generated form uses relative
`/api/...` paths so it stays same-origin on `orders.seahaven.com`.
### Reports (written to `meal-order-manager-reports-*` at Thursday close) ### Reports (written to `meal-order-manager-reports-*` at Thursday close)
@ -69,14 +74,12 @@ Publish routes are omitted from CloudFront. The generated form uses relative `/a
Workspace: `meal-order-manager-prod` / `meal-order-manager-dev` (us-east-1) Workspace: `meal-order-manager-prod` / `meal-order-manager-dev` (us-east-1)
- **ECS Fargate** — Flask + gunicorn + SQS job consumer. Desired count 2 in prod, 1 in dev. - **ECS Fargate** — Flask + gunicorn + SQS job consumer. Desired count 2 in prod, 1 in dev.
- **VPC** — Prod attaches to the After Hours VPC (`existing_vpc_id` / `existing_public_subnet_ids` from afterhours-shift-manager outputs). The 10.60 CIDR is unused fallback.
- **HTTP contract** — `openapi.yaml`, linted in CI with `npm run openapi:lint` (Redocly `extends: recommended`, same as internal-portal and afterhours-shift-manager).
- **ALB** — origin for CloudFront `/api` behaviors and weekly-menu HMAC publish. Idle timeout 120s. - **ALB** — origin for CloudFront `/api` behaviors and weekly-menu HMAC publish. Idle timeout 120s.
- **ECR** — API image. GitHub Actions `deploy-api.yaml` owns the image; Terraform ignores `container_definitions`. - **ECR** — API image. GitHub Actions `deploy-api.yaml` owns the image; Terraform ignores `container_definitions`.
- **S3** — `meal-order-manager-form-*` (static form hosting), `meal-order-manager-reports-*` (CSV reports + weekly summary PDF) - **S3** — `meal-order-manager-form-*` (static form hosting), `meal-order-manager-reports-*` (CSV reports + weekly summary PDF)
- **CloudFront** — HTTPS distribution with custom domain `orders.seahaven.com`. API origin is the ALB (HTTP-only). - **CloudFront** — HTTPS distribution with custom domain `orders.seahaven.com`. API origin is the ALB (HTTP-only).
- **DynamoDB** — `meal-order-manager-orders` (orders, menu, roster, config) - **DynamoDB** — `meal-order-manager-orders` (orders, menu, roster, config)
- **SQS** — `meal-order-manager-jobs` (+ DLQ). EventBridge Scheduler in `America/New_York` enqueues menu publish, close, reminder, and roster sync. - **SQS** — `meal-order-manager-jobs` (+ DLQ). EventBridge Scheduler in `America/New_York` enqueues close, reminder, and roster sync.
- **Secrets Manager** — Slack bot token - **Secrets Manager** — Slack bot token
- **CloudWatch Alarms** — ALB 5xx, ECS CPU, jobs DLQ, DynamoDB throttles, all notifying `site-alerts` - **CloudWatch Alarms** — ALB 5xx, ECS CPU, jobs DLQ, DynamoDB throttles, all notifying `site-alerts`
- **HCP Terraform** — workspace `meal-order-manager-<env>` in project `seahaven-<env>`. Working directory `terraform/`. VCS file triggers should be `terraform/**` only after the image deploy workflow owns `src/`. Do not `terraform apply` locally to prod. - **HCP Terraform** — workspace `meal-order-manager-<env>` in project `seahaven-<env>`. Working directory `terraform/`. VCS file triggers should be `terraform/**` only after the image deploy workflow owns `src/`. Do not `terraform apply` locally to prod.
@ -138,11 +141,10 @@ Local `:5050` is the same Flask app as production. DynamoDB is used when AWS cre
python3 -m venv .venv python3 -m venv .venv
source .venv/bin/activate source .venv/bin/activate
pip install -r requirements.txt -r requirements-api.txt pip install -r requirements.txt -r requirements-api.txt
playwright install chromium
PYTHONPATH=src:src/shared python3 -m server.app PYTHONPATH=src:src/shared python3 -m server.app
``` ```
Form tests and `src/scraper/recon.py` need `playwright install chromium`. Menu publish does not.
### Deploy to AWS ### Deploy to AWS
Image deploys are GitHub Actions `deploy-api.yaml` (push to `main` → dev, GitHub Release → prod). Infrastructure applies through HCP Terraform. First apply of the new `tf-managed` IAM policies needs the hcptf-bootstrap window. Image deploys are GitHub Actions `deploy-api.yaml` (push to `main` → dev, GitHub Release → prod). Infrastructure applies through HCP Terraform. First apply of the new `tf-managed` IAM policies needs the hcptf-bootstrap window.
@ -182,14 +184,14 @@ python3 src/aggregator/aggregate.py # generate CSV reports
``` ```
meal-order-manager/ meal-order-manager/
├── .github/workflows/ ├── .github/workflows/
│ ├── weekly-menu.yml # Monday cron: scrape + HMAC publish + notify
│ ├── deploy-api.yaml # Image CD to Fargate │ ├── deploy-api.yaml # Image CD to Fargate
│ └── ci.yml # PR checks (lint, pytest, template JS, terraform, ci-complete) │ ├── ci.yml # PR checks
│ └── ci-terraform.yaml # terraform fmt / validate
├── terraform/ # HCP Terraform (cluster, ALB, ECR, jobs queue) ├── terraform/ # HCP Terraform (cluster, ALB, ECR, jobs queue)
├── openapi.yaml # Employee HTTP contract (Redocly recommended)
├── .redocly.yaml
├── Dockerfile ├── Dockerfile
├── src/ ├── src/
│ ├── scraper/ # HTML menu parser (recon scripts still use Playwright) │ ├── scraper/ # Playwright menu scraper
│ ├── server/ # Flask API, form generator, job handlers │ ├── server/ # Flask API, form generator, job handlers
│ ├── aggregator/ # Order aggregation + CSV reports │ ├── aggregator/ # Order aggregation + CSV reports
│ └── shared/shared/ # db, secrets, slack, pdf helpers │ └── shared/shared/ # db, secrets, slack, pdf helpers

View file

@ -1,665 +0,0 @@
openapi: 3.1.0
info:
title: Meal Order Manager
version: 0.1.0
description: >
Flask HTTP API on ECS Fargate behind orders.seahaven.com. The internal
portal SPA calls menu, submit, and admin with a Cognito ID token from
GET /api/auth/meals-token. Weekly-menu GitHub Actions uses HMAC publish
routes that CloudFront does not expose. JSON errors are currently
`{ error: string }`. Health matches the portal BFF `{ stage, sha }`.
Lint with the same Redocly `extends: recommended` config as
internal-portal and afterhours-shift-manager.
contact:
name: Sea Haven Engineering
license:
name: Proprietary
identifier: LicenseRef-SeaHaven
servers:
- url: /
description: orders.seahaven.com CloudFront / local Flask :5050
tags:
- name: Runtime
description: Unauthenticated health
- name: Menu
description: Public weekly menu and form status
- name: Orders
description: Employee submit and own-order lookup
- name: Admin
description: Admin order edit and summary PDF
- name: Publish
description: Weekly-menu HMAC publish (not on CloudFront)
paths:
/api/health:
get:
operationId: getHealth
tags: [Runtime]
summary: Runtime health
security: []
responses:
"200":
description: Process is up
content:
application/json:
schema:
$ref: "#/components/schemas/Health"
"403":
$ref: "#/components/responses/StringError"
/api/menu/{week}:
get:
operationId: getMenu
tags: [Menu]
summary: Published menu for a week
security: []
parameters:
- $ref: "#/components/parameters/MenuWeekPath"
responses:
"200":
description: Menu payload
content:
application/json:
schema:
$ref: "#/components/schemas/MenuPayload"
"400":
$ref: "#/components/responses/StringError"
"404":
$ref: "#/components/responses/StringError"
/api/form-status/{week}:
get:
operationId: getFormStatus
tags: [Menu]
summary: Open or closed for a week
security: []
parameters:
- $ref: "#/components/parameters/MenuWeekPath"
responses:
"200":
description: Form status
content:
application/json:
schema:
$ref: "#/components/schemas/FormStatus"
"400":
$ref: "#/components/responses/StringError"
/api/submit-order:
post:
operationId: submitOrder
tags: [Orders]
summary: Place or replace this week's order
security:
- portalCognito: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SubmitBody"
responses:
"200":
description: Order saved
content:
application/json:
schema:
$ref: "#/components/schemas/SubmitResult"
"400":
$ref: "#/components/responses/StringError"
"403":
$ref: "#/components/responses/StringError"
"404":
$ref: "#/components/responses/StringError"
"410":
$ref: "#/components/responses/StringError"
"429":
$ref: "#/components/responses/StringError"
"503":
$ref: "#/components/responses/StringError"
/api/orders/{week}:
get:
operationId: getMyOrder
tags: [Orders]
summary: Caller's order only
security:
- portalCognito: []
parameters:
- $ref: "#/components/parameters/OrderWeekPath"
responses:
"200":
description: Empty list when the caller has no order
content:
application/json:
schema:
$ref: "#/components/schemas/MyOrders"
"400":
$ref: "#/components/responses/StringError"
"403":
$ref: "#/components/responses/StringError"
"503":
$ref: "#/components/responses/StringError"
/api/admin/orders:
get:
operationId: adminListOrders
tags: [Admin]
summary: List weeks or one week's orders
security:
- portalCognito: []
parameters:
- name: week
in: query
schema:
type: string
responses:
"200":
description: Weeks list or week detail
content:
application/json:
schema:
$ref: "#/components/schemas/AdminOrdersResponse"
"403":
$ref: "#/components/responses/StringError"
put:
operationId: adminUpdateOrder
tags: [Admin]
summary: Recalculate and replace an order
security:
- portalCognito: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AdminUpdateBody"
responses:
"200":
description: Updated
content:
application/json:
schema:
$ref: "#/components/schemas/AdminMutation"
"400":
$ref: "#/components/responses/StringError"
"403":
$ref: "#/components/responses/StringError"
"404":
$ref: "#/components/responses/StringError"
"503":
$ref: "#/components/responses/StringError"
delete:
operationId: adminDeleteOrder
tags: [Admin]
summary: Delete an order
security:
- portalCognito: []
parameters:
- name: week
in: query
required: true
schema:
type: string
- name: email
in: query
required: true
schema:
type: string
responses:
"200":
description: Deleted
content:
application/json:
schema:
$ref: "#/components/schemas/AdminMutation"
"400":
$ref: "#/components/responses/StringError"
"403":
$ref: "#/components/responses/StringError"
"404":
$ref: "#/components/responses/StringError"
/api/admin/summary-pdf:
get:
operationId: adminSummaryPdf
tags: [Admin]
summary: Presigned summary PDF URL
security:
- portalCognito: []
parameters:
- name: week
in: query
required: true
schema:
type: string
responses:
"200":
description: Short-lived URL
content:
application/json:
schema:
$ref: "#/components/schemas/SummaryPdf"
"400":
$ref: "#/components/responses/StringError"
"403":
$ref: "#/components/responses/StringError"
"404":
$ref: "#/components/responses/StringError"
"500":
$ref: "#/components/responses/StringError"
/api/roster:
get:
operationId: getRoster
tags: [Orders]
summary: Name and email list used by the static form
security: []
responses:
"200":
description: Roster
content:
application/json:
schema:
$ref: "#/components/schemas/FormRoster"
"403":
$ref: "#/components/responses/StringError"
/api/publish/settings:
get:
operationId: publishSettings
tags: [Publish]
summary: Bulk discount and subsidy for scrape
security:
- publishKey: []
responses:
"200":
description: Pricing fields only
content:
application/json:
schema:
$ref: "#/components/schemas/PublishSettings"
"403":
$ref: "#/components/responses/StringError"
/api/publish/menu:
post:
operationId: publishMenu
tags: [Publish]
summary: Write this week's scraped menu
security:
- publishKey: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PublishMenuBody"
responses:
"200":
description: Published
content:
application/json:
schema:
$ref: "#/components/schemas/PublishMenuResult"
"400":
$ref: "#/components/responses/StringError"
"403":
$ref: "#/components/responses/StringError"
components:
securitySchemes:
portalCognito:
type: http
scheme: bearer
bearerFormat: JWT
description: Portal Cognito ID token. GIS google_id_token is also accepted on submit until cutover.
publishKey:
type: apiKey
in: header
name: X-Meals-Publish-Key
parameters:
MenuWeekPath:
name: week
in: path
required: true
description: "`current` or `YYYY-WNN`. Other values are 400."
schema:
type: string
minLength: 1
OrderWeekPath:
name: week
in: path
required: true
description: "`YYYY-WNN` or `YYYY-MM-DD`. `current` is 400."
schema:
type: string
minLength: 1
responses:
StringError:
description: Current meals JSON error
content:
application/json:
schema:
$ref: "#/components/schemas/StringErrorBody"
schemas:
Health:
type: object
additionalProperties: false
required: [stage, sha]
properties:
stage:
type: string
minLength: 1
description: Workspace stage (`dev`, `prod`, or `local`)
sha:
type: string
minLength: 1
description: Git SHA or `unknown` locally
StringErrorBody:
type: object
additionalProperties: false
required: [error]
properties:
error:
type: string
minLength: 1
Meal:
type: object
additionalProperties: true
required: [name, price]
properties:
name:
type: string
price:
type: number
calories:
type: [string, number, "null"]
protein:
type: [string, number, "null"]
description:
type: [string, "null"]
dietary_tags:
type: [array, "null"]
items:
type: string
image_url:
type: [string, "null"]
is_new:
type: boolean
MenuPayload:
type: object
additionalProperties: false
required:
- week
- form_status
- meal_count
- meals
- bulk_discount_percent
- company_subsidy_percent
properties:
week:
type: string
form_status:
type: string
scraped_at:
type: [string, "null"]
menu_url:
type: [string, "null"]
meal_count:
type: integer
meals:
type: array
items:
$ref: "#/components/schemas/Meal"
bulk_discount_percent:
type: number
company_subsidy_percent:
type: number
order_deadline:
type: string
FormStatus:
type: object
additionalProperties: false
required: [week, status]
properties:
week:
type: string
status:
type: string
reopen_at:
type: integer
SubmitItem:
type: object
additionalProperties: true
required: [name, quantity]
properties:
name:
type: string
quantity:
type: number
retail_price:
type: number
SubmitBody:
type: object
additionalProperties: true
required: [items]
properties:
items:
type: array
items:
$ref: "#/components/schemas/SubmitItem"
google_id_token:
type: string
SubmitResult:
type: object
additionalProperties: false
required: [status]
properties:
status:
type: string
message:
type: string
total:
type: number
MyOrders:
type: object
additionalProperties: false
required: [week, orders]
properties:
week:
type: string
orders:
type: array
items:
type: object
additionalProperties: false
required: [employee_email]
properties:
employee_email:
type: string
AdminWeek:
type: object
additionalProperties: true
required: [week]
properties:
week:
type: string
form_status:
type: string
meal_count:
type: integer
order_count:
type: integer
AdminOrderItem:
type: object
additionalProperties: false
required: [name, quantity, retail_price, price, subtotal]
properties:
name:
type: string
quantity:
type: integer
retail_price:
type: number
price:
type: number
subtotal:
type: number
AdminOrder:
type: object
additionalProperties: false
required: [employee_name, employee_email, items, total, submitted_at]
properties:
employee_name:
type: string
employee_email:
type: string
items:
type: array
items:
$ref: "#/components/schemas/AdminOrderItem"
total:
type: number
submitted_at:
type: string
AdminWeeks:
type: object
additionalProperties: false
required: [weeks]
properties:
weeks:
type: array
items:
$ref: "#/components/schemas/AdminWeek"
AdminWeekOrders:
type: object
additionalProperties: false
required: [week, orders, total_employees, grand_total]
properties:
week:
type: string
orders:
type: array
items:
$ref: "#/components/schemas/AdminOrder"
total_employees:
type: integer
grand_total:
type: number
AdminOrdersResponse:
oneOf:
- $ref: "#/components/schemas/AdminWeeks"
- $ref: "#/components/schemas/AdminWeekOrders"
AdminUpdateBody:
type: object
additionalProperties: false
required: [week, email, items]
properties:
week:
type: string
email:
type: string
items:
type: array
items:
$ref: "#/components/schemas/SubmitItem"
AdminMutation:
type: object
additionalProperties: false
required: [status, week]
properties:
status:
type: string
week:
type: string
email:
type: string
total:
type: number
SummaryPdf:
type: object
additionalProperties: false
required: [week, url]
properties:
week:
type: string
url:
type: string
format: uri
FormRoster:
type: object
additionalProperties: false
required: [employees]
properties:
employees:
type: array
items:
type: object
additionalProperties: false
required: [name, email]
properties:
name:
type: string
email:
type: string
PublishSettings:
type: object
additionalProperties: false
required: [bulk_discount_percent, company_subsidy_percent]
properties:
bulk_discount_percent:
type: number
company_subsidy_percent:
type: number
PublishMenuBody:
type: object
additionalProperties: true
required: [meals]
properties:
meals:
type: array
minItems: 1
items:
$ref: "#/components/schemas/Meal"
scraped_at:
type: string
menu_url:
type: string
PublishMenuResult:
type: object
additionalProperties: false
required: [status, week, meal_count]
properties:
status:
type: string
week:
type: string
meal_count:
type: integer

46
package-lock.json generated
View file

@ -9,10 +9,9 @@
"version": "1.0.0", "version": "1.0.0",
"devDependencies": { "devDependencies": {
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@redocly/cli": "2.57.0", "eslint": "10.10.0",
"eslint": "10.11.0", "globals": "17.12.0",
"globals": "17.13.0", "prettier": "3.9.8"
"prettier": "3.9.9"
} }
}, },
"node_modules/@cacheable/memory": { "node_modules/@cacheable/memory": {
@ -257,21 +256,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@redocly/cli": {
"version": "2.57.0",
"resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.57.0.tgz",
"integrity": "sha512-1d5fVyUaYlMNgCHUoyE2vUyxBhs/jmOHgsX/kFmfWzESw4f/G/OV/SU9E55rU7aUNmw9rHj1vXmL6yUdIn+KKQ==",
"dev": true,
"license": "MIT",
"bin": {
"openapi": "bin/cli.js",
"redocly": "bin/cli.js"
},
"engines": {
"node": ">=22.12.0 || >=20.19.0 <21.0.0",
"npm": ">=10"
}
},
"node_modules/@types/esrecurse": { "node_modules/@types/esrecurse": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@ -344,9 +328,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.12", "version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -424,9 +408,9 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "10.11.0", "version": "10.10.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz",
"integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
@ -659,9 +643,9 @@
} }
}, },
"node_modules/globals": { "node_modules/globals": {
"version": "17.13.0", "version": "17.12.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz",
"integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -906,9 +890,9 @@
} }
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.9.9", "version": "3.9.8",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz",
"integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", "integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {

View file

@ -6,14 +6,12 @@
"check:templates": "npm run lint:templates && npm run format:templates", "check:templates": "npm run lint:templates && npm run format:templates",
"format:templates": "prettier --check \"src/server/templates/*.js\"", "format:templates": "prettier --check \"src/server/templates/*.js\"",
"format:templates:write": "prettier --write \"src/server/templates/*.js\"", "format:templates:write": "prettier --write \"src/server/templates/*.js\"",
"lint:templates": "eslint \"src/server/templates/*.js\"", "lint:templates": "eslint \"src/server/templates/*.js\""
"openapi:lint": "redocly lint --config .redocly.yaml openapi.yaml"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@redocly/cli": "2.57.0", "eslint": "10.10.0",
"eslint": "10.11.0", "globals": "17.12.0",
"globals": "17.13.0", "prettier": "3.9.8"
"prettier": "3.9.9"
} }
} }

View file

@ -1,7 +1,6 @@
flask==3.1.3 flask==3.1.3
gunicorn==26.2.0 gunicorn==23.0.0
boto3==1.43.107 boto3==1.43.97
sentry-sdk==2.71.0
jinja2==3.1.6 jinja2==3.1.6
fpdf2==2.8.9 fpdf2==2.8.8
PyJWT[crypto]==2.15.1 PyJWT[crypto]==2.14.0

View file

@ -1,4 +1,4 @@
playwright==1.63.0 playwright==1.63.0
flask==3.1.3 flask==3.1.3
boto3==1.43.107 boto3==1.43.97
jinja2==3.1.6 jinja2==3.1.6

View file

@ -1,271 +0,0 @@
"""Fill a Redefine Meals guest cart from an admin order-list CSV.
Opens a visible browser, adds each matched meal, then stops on the cart page.
Log in and check out in that window. Press Enter when finished, or close the
window. The script does not store a password and does not open checkout itself.
python3 scripts/fill_redefine_cart.py order-list-2026-W39.csv
"""
from __future__ import annotations
import argparse
import sys
import threading
from pathlib import Path
from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from playwright.sync_api import Error as PlaywrightError # noqa: E402
from playwright.sync_api import sync_playwright # noqa: E402
from scraper.fill_cart import ( # noqa: E402
FillCartError,
FillPlan,
build_plan,
cart_lines,
error_text,
quantity_for,
)
from scraper.parse_menu import ( # noqa: E402
MenuParseError,
extract_catalog_products,
fetch_menu_html,
)
DEFAULT_MENU_URL = "https://www.redefinemeals.com/menu"
_CART_JS = """
async ({ method, path, body }) => {
const headers = {
Accept: "application/json",
"X-Requested-With": "XMLHttpRequest",
};
const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]*)/);
if (match) {
headers["X-XSRF-TOKEN"] = decodeURIComponent(match[1]);
}
const init = { method, credentials: "same-origin", headers };
if (body !== null && body !== undefined) {
headers["Content-Type"] = "application/json";
init.body = JSON.stringify(body);
}
const response = await fetch(path, init);
const text = await response.text();
let data = null;
if (text) {
try {
data = JSON.parse(text);
} catch (error) {
data = { message: text.slice(0, 200) };
}
}
return { ok: response.ok, status: response.status, data };
}
"""
def main(argv: list[str] | None = None) -> int:
sys.stdout.reconfigure(line_buffering=True)
sys.stderr.reconfigure(line_buffering=True)
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("csv_path", type=Path, help="Admin order-list CSV")
parser.add_argument("--menu-url", default=DEFAULT_MENU_URL)
args = parser.parse_args(argv)
try:
csv_text = args.csv_path.read_text(encoding="utf-8-sig")
menu_html = fetch_menu_html(args.menu_url)
products = extract_catalog_products(menu_html)
plan = build_plan(csv_text, products)
except UnicodeDecodeError:
print("CSV must be UTF-8.", file=sys.stderr)
return 1
except (OSError, FillCartError, MenuParseError) as exc:
print(exc, file=sys.stderr)
return 1
_print_plan(plan)
if not plan.adds:
print("Nothing matched. Not opening a browser.")
return 1 if plan.skipped else 0
return _fill(plan, args.menu_url)
def _fill(plan: FillPlan, menu_url: str) -> int:
with sync_playwright() as playwright:
try:
browser = playwright.chromium.launch(headless=False)
except PlaywrightError as exc:
print(f"Browser failed: {exc}", file=sys.stderr)
print(
"Install the browser with: playwright install chromium",
file=sys.stderr,
)
return 1
return _fill_browser(browser, plan, menu_url)
def _fill_browser(browser, plan: FillPlan, menu_url: str) -> int:
try:
try:
page = browser.new_page()
except PlaywrightError as exc:
print(f"Failed: {exc}")
print(f"Added 0. Failed 1. Skipped {len(plan.skipped)}.")
return 1
return _fill_page(page, plan, menu_url)
finally:
try:
browser.close()
except PlaywrightError:
pass
def _fill_page(page, plan: FillPlan, menu_url: str) -> int:
failed: list[str] = []
added = 0
ready = False
try:
page.goto(menu_url, wait_until="domcontentloaded", timeout=60000)
cart = _cart_request(page, "GET", "/api/cart")
existing = cart_lines(cart)
if existing:
print("The cart already has items:")
for line in existing:
print(f" {line.name} x{line.quantity}")
if not _confirm_clear():
print("Exiting without adding.")
return 1
cleared = _cart_request(page, "POST", "/api/cart/clear", {})
if cart_lines(cleared):
print("The cart still has items after clear. Exiting without adding.")
return 1
posted = []
page_closed = False
for index, item in enumerate(plan.adds):
try:
_cart_request(
page,
"POST",
"/api/cart/add",
{
"uuid": item.uuid,
"quantity": item.quantity,
"properties": None,
},
)
except RuntimeError as exc:
failed.append(f"{item.name}: {exc}")
print(f"Failed: {item.name}: {exc}")
continue
except PlaywrightError as exc:
failed.append(f"{item.name}: {exc}")
print(f"Failed: {item.name}: {exc}")
for rest in plan.adds[index + 1 :]:
message = f"{rest.name}: not attempted"
failed.append(message)
print(f"Failed: {message}")
page_closed = True
break
posted.append(item)
if not page_closed:
try:
current = cart_lines(_cart_request(page, "GET", "/api/cart"))
except RuntimeError as exc:
failed.append(f"Could not read the cart: {exc}")
print(f"Failed: Could not read the cart: {exc}")
else:
for item in posted:
found = quantity_for(current, item.uuid)
if found != item.quantity:
message = (
f"{item.name}: requested {item.quantity}, cart has {found}"
)
failed.append(message)
print(f"Failed: {message}")
else:
added += 1
origin = _origin(page.url or menu_url)
page.goto(f"{origin}/cart", wait_until="domcontentloaded", timeout=60000)
ready = True
except (RuntimeError, PlaywrightError) as exc:
failed.append(str(exc))
print(f"Failed: {exc}")
print(f"Added {added}. Failed {len(failed)}. Skipped {len(plan.skipped)}.")
if ready:
_wait_for_review(page)
return 1 if failed or plan.skipped else 0
def _cart_request(page, method: str, path: str, body: object | None = None) -> object:
result = page.evaluate(_CART_JS, {"method": method, "path": path, "body": body})
if not isinstance(result, dict) or not result.get("ok"):
status = result.get("status") if isinstance(result, dict) else 0
data = result.get("data") if isinstance(result, dict) else None
raise RuntimeError(error_text(int(status or 0), data))
return result.get("data")
def _confirm_clear() -> bool:
if not sys.stdin.isatty():
print("Refusing to clear a cart without a typed yes.")
return False
answer = input("Type yes to clear the cart and add this order: ")
return answer.strip().casefold() == "yes"
def _wait_for_review(page) -> None:
print(
"Cart is ready. Log in and check out in this browser window.\n"
"Press Enter here after you are done. Closing the window also stops the script."
)
finished = threading.Event()
def _wait_for_enter() -> None:
try:
input()
except EOFError:
pass
finished.set()
if sys.stdin.isatty():
threading.Thread(target=_wait_for_enter, daemon=True).start()
while not finished.is_set():
try:
closed = page.is_closed()
except PlaywrightError:
return
if closed:
return
try:
page.wait_for_timeout(250)
except PlaywrightError:
return
def _print_plan(plan: FillPlan) -> None:
if plan.ignored_zero:
print(f"Ignored {plan.ignored_zero} items with quantity 0.")
if plan.adds:
print("Adding:")
for item in plan.adds:
print(f" {item.name} x{item.quantity}")
if plan.skipped:
print("Skipped:")
for line in plan.skipped:
print(f" {line.name}: {line.reason}")
def _origin(url: str) -> str:
parts = urlsplit(url)
return f"{parts.scheme}://{parts.netloc}"
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,203 +0,0 @@
"""Match an admin order-list CSV to the Redefine menu catalog.
The browser script uses this plan. Nothing here contacts the site.
"""
from __future__ import annotations
import csv
import io
from dataclasses import dataclass
_FORMULA_PREFIX = set("=+-@\t\r")
class FillCartError(ValueError):
"""The order-list CSV cannot be planned."""
@dataclass(frozen=True)
class OrderLine:
name: str
quantity: int
@dataclass(frozen=True)
class PlannedAdd:
name: str
quantity: int
uuid: str
@dataclass(frozen=True)
class SkippedLine:
name: str
reason: str
@dataclass(frozen=True)
class FillPlan:
adds: tuple[PlannedAdd, ...]
skipped: tuple[SkippedLine, ...]
ignored_zero: int
@dataclass(frozen=True)
class CartLine:
name: str
quantity: int
product_uuid: str | None
def build_plan(csv_text: str, products: list) -> FillPlan:
parsed = _parse_order_list(csv_text)
adds, skipped = _match(parsed.lines, products)
return FillPlan(
adds=tuple(adds),
skipped=parsed.skipped + tuple(skipped),
ignored_zero=parsed.ignored_zero,
)
def cart_lines(cart: object) -> list[CartLine]:
if not isinstance(cart, dict):
return []
items = cart.get("items")
if not isinstance(items, list):
return []
lines = []
for item in items:
if not isinstance(item, dict):
continue
product = item.get("product") if isinstance(item.get("product"), dict) else {}
name = str(product.get("name") or item.get("name") or "Unknown item").strip()
product_uuid = product.get("uuid") or item.get("uuid")
lines.append(
CartLine(
name=name or "Unknown item",
quantity=_as_int(item.get("quantity")),
product_uuid=str(product_uuid) if product_uuid else None,
)
)
return lines
def quantity_for(lines: list[CartLine], product_uuid: str) -> int:
return sum(line.quantity for line in lines if line.product_uuid == product_uuid)
def error_text(status: int, data: object) -> str:
"""Short cart-API failure text. Vendor bodies can include stack traces."""
message = ""
if isinstance(data, dict):
raw = data.get("message")
if isinstance(raw, str):
message = " ".join(raw.split())
if message:
return f"HTTP {status}: {message[:200]}"
return f"HTTP {status}"
@dataclass(frozen=True)
class _ParsedCsv:
lines: tuple[OrderLine, ...]
skipped: tuple[SkippedLine, ...]
ignored_zero: int
def _parse_order_list(csv_text: str) -> _ParsedCsv:
text = csv_text.lstrip("\ufeff")
if not text.strip():
raise FillCartError("CSV is empty")
reader = csv.DictReader(io.StringIO(text))
if reader.fieldnames is None:
raise FillCartError("CSV must have Item and Quantity columns")
fields = {(name or "").strip().casefold(): name for name in reader.fieldnames}
if "item" not in fields or "quantity" not in fields:
raise FillCartError("CSV must have Item and Quantity columns")
totals: dict[str, int] = {}
display: dict[str, str] = {}
order: list[str] = []
skipped: list[SkippedLine] = []
ignored_zero = 0
for row in reader:
raw_name = row.get(fields["item"]) or ""
name = _unescape_item(str(raw_name).strip())
raw_qty = str(row.get(fields["quantity"]) or "").strip()
if not name and not raw_qty:
continue
if not name:
skipped.append(SkippedLine("(blank)", "missing a name"))
continue
if not raw_qty.isdigit():
skipped.append(SkippedLine(name, f"invalid quantity {raw_qty!r}"))
continue
quantity = int(raw_qty)
if quantity == 0:
ignored_zero += 1
continue
key = name.casefold()
if key not in totals:
order.append(key)
display[key] = name
totals[key] = 0
totals[key] += quantity
lines = tuple(OrderLine(display[key], totals[key]) for key in order)
return _ParsedCsv(lines=lines, skipped=tuple(skipped), ignored_zero=ignored_zero)
def _unescape_item(name: str) -> str:
if len(name) > 1 and name[0] == "'" and name[1] in _FORMULA_PREFIX:
return name[1:]
return name
def _match(
lines: tuple[OrderLine, ...], products: list
) -> tuple[list[PlannedAdd], list[SkippedLine]]:
available: dict[str, list[tuple[str, str]]] = {}
unavailable: set[str] = set()
for product in products:
if not isinstance(product, dict):
continue
name = str(product.get("name") or "").strip()
if not name:
continue
key = name.casefold()
if product.get("available") is False:
unavailable.add(key)
continue
uuid = str(product.get("uuid") or "").strip()
if not uuid:
continue
available.setdefault(key, []).append((name, uuid))
adds: list[PlannedAdd] = []
skipped: list[SkippedLine] = []
for line in lines:
key = line.name.casefold()
matches = available.get(key, [])
if len(matches) == 1:
adds.append(PlannedAdd(matches[0][0], line.quantity, matches[0][1]))
elif len(matches) > 1:
skipped.append(SkippedLine(line.name, "matches more than one menu item"))
elif key in unavailable:
skipped.append(SkippedLine(line.name, "unavailable on the menu"))
else:
skipped.append(SkippedLine(line.name, "not on the menu"))
return adds, skipped
def _as_int(value: object) -> int:
if isinstance(value, bool) or value is None:
return 0
if isinstance(value, int):
return value
text = str(value).strip()
if text.isdigit() or (text.startswith("-") and text[1:].isdigit()):
return int(text)
return 0

View file

@ -1,176 +0,0 @@
"""Parse the Redefine Meals menu from the HTML catalog embedded on the page.
The menu document includes an <orders-page> element whose :products and
:newest-ids attributes are JSON. That replaces the Playwright DOM scrape.
"""
from __future__ import annotations
import html
import json
import re
import urllib.error
import urllib.request
from datetime import datetime
from zoneinfo import ZoneInfo
EASTERN = ZoneInfo("America/New_York")
PRODUCTS_MARKER = ":products='"
NEWEST_MARKER = ":newest-ids='"
_TAG_RE = re.compile(r"<[^>]+>")
_SPACE_RE = re.compile(r"\s+")
_USER_AGENT = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
)
class MenuParseError(RuntimeError):
"""The menu page did not contain a usable catalog."""
def fetch_menu_html(url: str, *, timeout: float = 30) -> str:
request = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read().decode("utf-8", "replace")
except urllib.error.URLError as exc:
raise MenuParseError(f"Failed to fetch {url}") from exc
def fetch_menu(url: str, *, timeout: float = 30) -> dict:
page = fetch_menu_html(url, timeout=timeout)
return parse_menu_html(page, menu_url=url)
def extract_catalog_products(page: str) -> list:
"""Return the raw product objects embedded on the menu page."""
products = _extract_json_attr(page, PRODUCTS_MARKER)
if not isinstance(products, list):
raise MenuParseError(":products must be a JSON array")
if not products:
raise MenuParseError("Menu catalog is empty")
return products
def parse_menu_html(page: str, *, menu_url: str, scraped_at: str | None = None) -> dict:
products = _extract_json_attr(page, PRODUCTS_MARKER)
newest = _extract_json_attr(page, NEWEST_MARKER)
if not isinstance(products, list):
raise MenuParseError(":products must be a JSON array")
if not isinstance(newest, list):
raise MenuParseError(":newest-ids must be a JSON array")
if not products:
raise MenuParseError("Menu catalog is empty")
newest_ids = {str(item) for item in newest}
meals = []
for product in products:
if not isinstance(product, dict):
raise MenuParseError("Each catalog entry must be an object")
if product.get("available") is False:
continue
meals.append(_meal_from_product(product, newest_ids))
if not meals:
raise MenuParseError("Scraped 0 meals")
return {
"scraped_at": scraped_at or datetime.now(EASTERN).isoformat(),
"menu_url": menu_url,
"meal_count": len(meals),
"meals": meals,
}
def _extract_json_attr(page: str, marker: str):
index = page.find(marker)
if index < 0:
raise MenuParseError(f"Menu page is missing {marker}")
raw = html.unescape(page[index + len(marker) :])
try:
value, _end = json.JSONDecoder().raw_decode(raw)
except json.JSONDecodeError as exc:
raise MenuParseError(f"Menu page has invalid JSON after {marker}") from exc
return value
def _meal_from_product(product: dict, newest_ids: set[str]) -> dict:
name = str(product.get("name") or "").strip()
if not name:
raise MenuParseError("A catalog entry is missing a name")
details = product.get("details") if isinstance(product.get("details"), dict) else {}
concerns = product.get("dietary_concerns") or []
if not isinstance(concerns, list):
raise MenuParseError(f"{name} dietary_concerns must be a list")
return {
"name": name,
"price": _price(product),
"calories": _calories(details),
"protein": _protein(details),
"dietary_tags": [str(tag).strip() for tag in concerns if str(tag).strip()],
"image_url": _image_url(product),
"is_new": str(product.get("id")) in newest_ids,
"description": _plain_text(product.get("description")),
}
def _price(product: dict) -> float:
raw = (
product.get("sale_price")
if product.get("on_sale") is True
else product.get("price")
)
name = str(product.get("name") or "meal")
if isinstance(raw, bool) or raw is None or str(raw).strip() == "":
raise MenuParseError(f"{name} is missing a price")
try:
price = float(raw)
except (TypeError, ValueError) as exc:
raise MenuParseError(f"{name} has an invalid price") from exc
if price < 0:
raise MenuParseError(f"{name} has a negative price")
return price
def _calories(details: dict) -> int | None:
raw = details.get("calories")
if raw is None or str(raw).strip() == "":
return None
text = str(raw).strip().lower().removesuffix("cal").strip()
try:
return int(text)
except ValueError:
return None
def _protein(details: dict) -> str | None:
raw = details.get("protein")
if raw is None or str(raw).strip() == "":
return None
text = str(raw).strip()
if text.lower().endswith("g"):
return text
return f"{text}g"
def _image_url(product: dict) -> str | None:
media = product.get("media_urls")
images = media.get("images") if isinstance(media, dict) else None
if not images or not isinstance(images, list):
return None
first = images[0]
if not isinstance(first, dict):
return None
return first.get("original") or first.get("thumbnail")
def _plain_text(value) -> str | None:
if value is None:
return None
text = html.unescape(str(value))
text = _SPACE_RE.sub(" ", _TAG_RE.sub(" ", text)).strip()
return text or None

View file

@ -1,14 +1,17 @@
"""Fetch the Redefine Meals menu and write menu JSON for local form generation.""" """
Redefine Meals menu scraper.
Navigates to the menu page using a headless browser, waits for the
Vue.js SPA to render, and extracts structured meal data from the DOM.
Also intercepts network requests to detect any JSON API that could
replace the browser scrape in the future.
"""
import json import json
import sys import sys
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from playwright.sync_api import sync_playwright, TimeoutError as PwTimeout
try:
from scraper.parse_menu import MenuParseError, fetch_menu
except ImportError:
from parse_menu import MenuParseError, fetch_menu
CONFIG_PATH = Path(__file__).resolve().parents[2] / "config.json" CONFIG_PATH = Path(__file__).resolve().parents[2] / "config.json"
@ -18,8 +21,236 @@ def load_config():
return json.load(f) return json.load(f)
def scrape_menu(url: str, **_kwargs) -> dict: def scrape_menu(url: str, *, headless: bool = True, timeout_ms: int = 60_000) -> dict:
return fetch_menu(url) """
Returns {
"scraped_at": ISO timestamp,
"menu_url": str,
"api_endpoints_found": [str],
"meals": [ { name, price, calories, protein, dietary_tags,
image_url, is_new, description } ]
}
Raises RuntimeError if the page fails to load or no meals are found.
"""
api_endpoints = []
with sync_playwright() as p:
browser = p.chromium.launch(headless=headless)
context = browser.new_context(
user_agent=(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
)
)
page = context.new_page()
def on_response(response):
ct = response.headers.get("content-type", "")
if "json" in ct and "/api/" in response.url and "cart" not in response.url:
api_endpoints.append(response.url)
page.on("response", on_response)
try:
page.goto(url, wait_until="networkidle", timeout=timeout_ms)
except PwTimeout:
browser.close()
raise RuntimeError(f"Timed out loading {url}")
page.wait_for_timeout(3000)
articles = page.query_selector_all("article.editorial_card")
if not articles:
browser.close()
raise RuntimeError(
"No meal cards found on page. The site layout may have changed. "
"Run recon.py to inspect the current structure."
)
meals = []
for article in articles:
meal = _extract_card(article)
if meal:
meals.append(meal)
# Try to get descriptions via Quick View modals
_enrich_with_descriptions(page, articles, meals)
browser.close()
if not meals:
raise RuntimeError("Scraped 0 meals — extraction selectors are likely broken.")
for meal in meals:
meal["dietary_tags"] = _clean_tags(meal["dietary_tags"])
return {
"scraped_at": datetime.now().isoformat(),
"menu_url": url,
"api_endpoints_found": api_endpoints,
"meal_count": len(meals),
"meals": meals,
}
def _extract_card(article) -> dict | None:
try:
name_el = article.query_selector("h2.meal_title")
if not name_el:
return None
name = name_el.inner_text().strip()
price_el = article.query_selector(".meal_price")
price_text = price_el.inner_text().strip() if price_el else ""
price = _parse_price(price_text)
cal_el = article.query_selector(".card_macros_brief")
calories = None
protein = None
if cal_el:
macros_text = cal_el.inner_text()
calories, protein = _parse_macros(macros_text)
tag_els = article.query_selector_all(".diet_mini_tag")
dietary_tags = [
t.inner_text().strip().title() for t in tag_els if t.inner_text().strip()
]
img_el = article.query_selector("img.main_meal_img")
image_url = img_el.get_attribute("src") if img_el else None
is_new = article.query_selector(".new_badge_pulse") is not None
return {
"name": name,
"price": price,
"calories": calories,
"protein": protein,
"dietary_tags": dietary_tags,
"image_url": image_url,
"is_new": is_new,
"description": None,
}
except Exception as e:
print(f" Warning: failed to extract a card: {e}", file=sys.stderr)
return None
def _enrich_with_descriptions(page, articles, meals):
"""Click each meal's Quick View overlay to grab the description."""
for i, article in enumerate(articles):
if i >= len(meals):
break
try:
overlay = article.query_selector(".card_overlay")
if not overlay:
continue
article.query_selector(".card_media_wrap").click()
page.wait_for_timeout(800)
modal = page.query_selector(
".modal.show, [class*='modal'][class*='show'], [class*='quickview']"
)
if not modal:
# Try broader selector
modal = page.query_selector(
"[class*='modal']:not([style*='display: none'])"
)
if modal and modal.is_visible():
desc_el = modal.query_selector(
"[class*='description'], [class*='desc'], .meal_description"
)
if desc_el:
desc_text = desc_el.inner_text().strip()
# Filter out price strings and very short text
if (
desc_text
and len(desc_text) > 10
and not desc_text.startswith("$")
):
meals[i]["description"] = desc_text
# Grab full macro details if available
detail_tags = modal.query_selector_all(
".diet_mini_tag, [class*='lifestyle'] span"
)
for tag_el in detail_tags:
tag_text = tag_el.inner_text().strip().title()
if tag_text and tag_text not in meals[i]["dietary_tags"]:
meals[i]["dietary_tags"].append(tag_text)
# Close modal
close_btn = modal.query_selector(
"button[class*='close'], [aria-label='Close'], .btn-close"
)
if close_btn:
close_btn.click()
else:
page.keyboard.press("Escape")
page.wait_for_timeout(300)
except Exception as e:
print(
f" Warning: Quick View failed for meal {i} ({meals[i]['name']}): {e}",
file=sys.stderr,
)
try:
page.keyboard.press("Escape")
page.wait_for_timeout(300)
except Exception:
pass
KNOWN_TAGS = [
"Gluten Free",
"Dairy Free",
"Grass-Fed",
"Low Carb",
"Keto",
"Vegan",
"Vegetarian",
"Nut Free",
]
def _clean_tags(raw_tags: list[str]) -> list[str]:
"""Split concatenated tags and deduplicate."""
import re
cleaned = set()
for raw in raw_tags:
# Split on known tag boundaries (e.g., "Gluten Freedairy Free" → "Gluten Free", "Dairy Free")
remaining = raw
for known in KNOWN_TAGS:
if known.lower() in remaining.lower():
cleaned.add(known)
remaining = re.sub(
re.escape(known), "", remaining, flags=re.IGNORECASE
).strip()
if remaining and len(remaining) > 2:
cleaned.add(remaining.strip().title())
return sorted(cleaned)
def _parse_price(text: str) -> float | None:
text = text.replace("$", "").replace(",", "").strip()
try:
return float(text)
except ValueError:
return None
def _parse_macros(text: str) -> tuple[int | None, str | None]:
"""Parse '570cal • 39gP' into (570, '39g')."""
import re
cal_match = re.search(r"(\d+)\s*cal", text, re.IGNORECASE)
prot_match = re.search(r"(\d+g?)\s*P", text)
calories = int(cal_match.group(1)) if cal_match else None
protein = prot_match.group(1) if prot_match else None
if protein and not protein.endswith("g"):
protein += "g"
return calories, protein
def main(): def main():
@ -30,30 +261,31 @@ def main():
) )
output_dir.mkdir(exist_ok=True) output_dir.mkdir(exist_ok=True)
print(f"Fetching menu from {url} ...") print(f"Scraping menu from {url} ...")
try:
result = scrape_menu(url) result = scrape_menu(url)
except MenuParseError as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
week_str = datetime.now().strftime("%Y-W%U") week_str = datetime.now().strftime("%Y-W%U")
output_file = output_dir / f"menu-{week_str}.json" output_file = output_dir / f"menu-{week_str}.json"
with open(output_file, "w") as f: with open(output_file, "w") as f:
json.dump(result, f, indent=2) json.dump(result, f, indent=2)
print(f"\nFetched {result['meal_count']} meals") print(f"\nScraped {result['meal_count']} meals")
if result["api_endpoints_found"]:
print("API endpoints detected (potential future shortcut):")
for ep in result["api_endpoints_found"]:
print(f" {ep}")
print(f"Output saved to {output_file}") print(f"Output saved to {output_file}")
# Print summary table
print(f"\n{'Name':<40} {'Price':>7} {'Cal':>5} {'Prot':>5} {'Tags'}") print(f"\n{'Name':<40} {'Price':>7} {'Cal':>5} {'Prot':>5} {'Tags'}")
print("-" * 90) print("-" * 90)
for meal in result["meals"]: for m in result["meals"]:
tags = ", ".join(meal["dietary_tags"]) if meal["dietary_tags"] else "" tags = ", ".join(m["dietary_tags"]) if m["dietary_tags"] else ""
new = " *NEW*" if meal["is_new"] else "" new = " *NEW*" if m["is_new"] else ""
price = f"${meal['price']:.2f}" if meal["price"] is not None else "?" price = f"${m['price']:.2f}" if m["price"] else "?"
cal = str(meal["calories"]) if meal["calories"] else "?" cal = str(m["calories"]) if m["calories"] else "?"
prot = meal["protein"] or "?" prot = m["protein"] or "?"
print(f"{(meal['name'] + new):<40} {price:>7} {cal:>5} {prot:>5} {tags}") print(f"{(m['name'] + new):<40} {price:>7} {cal:>5} {prot:>5} {tags}")
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -13,7 +13,6 @@ import json
from flask import Flask, Response, jsonify, request, send_file from flask import Flask, Response, jsonify, request, send_file
import server.sentry_init # noqa: F401
from server import http_api from server import http_api
CORS_ORIGINS = [ CORS_ORIGINS = [

View file

@ -54,14 +54,12 @@ def generate_form(
bulk_discount: float = 0, bulk_discount: float = 0,
company_subsidy: float = 0, company_subsidy: float = 0,
google_client_id: str = "", google_client_id: str = "",
week: str | None = None,
) -> str: ) -> str:
google_client_id = google_client_id.strip() google_client_id = google_client_id.strip()
api_url = (api_url or "").rstrip("/") api_url = (api_url or "").rstrip("/")
if api_url and not google_client_id: if api_url and not google_client_id:
raise ValueError("Google client ID is required in cloud mode") raise ValueError("Google client ID is required in cloud mode")
if not week:
week = datetime.now().strftime("%Y-W%U") week = datetime.now().strftime("%Y-W%U")
scraped_at = menu.get("scraped_at", "unknown") scraped_at = menu.get("scraped_at", "unknown")
deadline = config.get("order_deadline", "Thursday 11:59 PM") deadline = config.get("order_deadline", "Thursday 11:59 PM")

View file

@ -279,20 +279,15 @@ def lambda_handler(event, context):
return response(405, {"error": "Method not allowed"}) return response(405, {"error": "Method not allowed"})
def _week_from_path(event):
requested_week = (event.get("pathParameters") or {}).get("week", "")
if requested_week == "current":
return current_week(), None
if re.fullmatch(r"\d{4}-W\d{2}", requested_week):
return requested_week, None
return None, response(400, {"error": "week path param must be current or YYYY-WNN"})
def handle_menu(event): def handle_menu(event):
"""Return the published menu in the portal's public MenuPayload shape.""" """Return the published menu in the portal's public MenuPayload shape."""
week, error = _week_from_path(event) requested_week = (event.get("pathParameters") or {}).get("week", "")
if error is not None: if requested_week == "current":
return error week = current_week()
elif re.fullmatch(r"\d{4}-W\d{2}", requested_week):
week = requested_week
else:
return response(400, {"error": "week path param must be current or YYYY-WNN"})
menu = get_menu(week) menu = get_menu(week)
if menu is None: if menu is None:
@ -363,7 +358,7 @@ def _require_publish_key(event) -> dict | None:
def handle_publish_settings(event=None): def handle_publish_settings(event=None):
"""Return only the pricing fields needed by the manual HMAC publish fallback.""" """Return only the pricing fields needed by the weekly-menu workflow."""
denied = _require_publish_key(event or {}) denied = _require_publish_key(event or {})
if denied: if denied:
return denied return denied
@ -628,9 +623,7 @@ def handle_my_orders(event):
def handle_form_status(event): def handle_form_status(event):
week, error = _week_from_path(event) week = event.get("pathParameters", {}).get("week", current_week())
if error is not None:
return error
status = get_form_status(week) status = get_form_status(week)
result = {"week": week, "status": status} result = {"week": week, "status": status}
if status == "closed": if status == "closed":

View file

@ -42,10 +42,6 @@ def run_job(payload: dict) -> dict:
if event_type == "sync_roster": if event_type == "sync_roster":
from server.jobs.sync_roster import lambda_handler from server.jobs.sync_roster import lambda_handler
return lambda_handler(payload, None)
if event_type == "publish_menu":
from server.jobs.publish_menu import lambda_handler
return lambda_handler(payload, None) return lambda_handler(payload, None)
from server.jobs.notify import lambda_handler from server.jobs.notify import lambda_handler

View file

@ -1,131 +0,0 @@
"""Monday menu publish: fetch the catalog, write it, upload the form, notify."""
from __future__ import annotations
import os
import boto3
from scraper.parse_menu import fetch_menu
from server.generate_form import generate_form
from shared.db import current_week, get_settings, put_menu
from shared.secrets import get_parameter
DEFAULT_MENU_URL = "https://www.redefinemeals.com/menu"
DEFAULT_DEADLINE = "Thursday at 11:59 PM"
FORM_BUCKET_PARAM = "/meal-order-manager/deploy/form-bucket"
DISTRIBUTION_ID_PARAM = "/meal-order-manager/deploy/distribution-id"
def lambda_handler(event, context):
del event, context
menu_url = os.environ.get("MENU_URL", DEFAULT_MENU_URL).strip() or DEFAULT_MENU_URL
menu = fetch_menu(menu_url)
week = current_week()
settings = get_settings()
bulk_discount = float(settings.get("bulk_discount_percent") or 0)
company_subsidy = float(settings.get("company_subsidy_percent") or 0)
google_client_id = _google_client_id()
bucket = _configured("FORM_BUCKET", "FORM_BUCKET_PARAM", FORM_BUCKET_PARAM)
distribution_id = _configured(
"DISTRIBUTION_ID", "DISTRIBUTION_ID_PARAM", DISTRIBUTION_ID_PARAM
)
form_url = os.environ.get("FORM_URL", "").strip()
if not form_url:
raise RuntimeError("FORM_URL is required")
html = generate_form(
menu,
{"order_deadline": DEFAULT_DEADLINE, "roster": []},
bulk_discount=bulk_discount,
company_subsidy=company_subsidy,
google_client_id=google_client_id,
week=week,
)
put_menu(week, menu)
_upload_form(bucket, week, html)
_invalidate(distribution_id, week, menu.get("scraped_at") or week)
_notify(form_url)
return {
"status": "published",
"week": week,
"meal_count": menu["meal_count"],
}
def _google_client_id() -> str:
direct = os.environ.get("GOOGLE_CLIENT_ID", "").strip()
if direct:
return direct
param = os.environ.get("GOOGLE_CLIENT_ID_PARAM", "").strip()
if not param:
raise RuntimeError("GOOGLE_CLIENT_ID_PARAM is required")
client_id = get_parameter(param).strip()
if not client_id:
raise RuntimeError("Google client ID is empty")
return client_id
def _configured(env_name: str, param_env: str, default_param: str) -> str:
direct = os.environ.get(env_name, "").strip()
if direct:
return direct
param = os.environ.get(param_env, default_param).strip() or default_param
value = get_parameter(param).strip()
if not value:
raise RuntimeError(f"{env_name} is empty")
return value
def _upload_form(bucket: str, week: str, html: str) -> None:
body = html.encode("utf-8")
s3 = boto3.client("s3")
s3.put_object(
Bucket=bucket,
Key="index.html",
Body=body,
ContentType="text/html",
CacheControl="no-cache",
)
s3.put_object(
Bucket=bucket,
Key=f"archive/{week}.html",
Body=body,
ContentType="text/html",
)
def _invalidate(distribution_id: str, week: str, scraped_at: str) -> None:
reference = f"publish-menu-{week}-{scraped_at}".replace(":", "-")
boto3.client("cloudfront").create_invalidation(
DistributionId=distribution_id,
InvalidationBatch={
"Paths": {"Quantity": 1, "Items": ["/index.html"]},
"CallerReference": reference[:128],
},
)
def _notify(form_url: str) -> None:
from shared.slack import post_channel_message
text = f"This week's meal order is open! Deadline: {DEFAULT_DEADLINE}."
blocks = [
{
"type": "header",
"text": {"type": "plain_text", "text": "Meal Order Open"},
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": (
f"*<{form_url}|Place your order>*\n\n*Deadline:* {DEFAULT_DEADLINE}\n"
),
},
},
]
result = post_channel_message(text, blocks)
if not result.get("ok"):
raise RuntimeError(f"Slack API error: {result.get('error')}")

View file

@ -1,177 +0,0 @@
"""Flask Sentry SDK init for meal-order-manager.
Imported for side effect from ``server.app``. ``init_sentry()`` is a no-op when
the DSN is unset, empty, or the literal ``unset``, so pytest and local
``python -m server.app`` never talk to Sentry. When ``SENTRY_DSN`` is absent,
the DSN is read from SSM via ``SENTRY_DSN_PARAM``. ``ParameterNotFound`` is
treated as unset so a mixed-PR race cannot crash gunicorn.
``before_send`` strips auth, cookies, the publish HMAC header, request bodies,
secrety extras, and exception stack-frame locals.
``include_local_variables=False`` keeps those locals out of the event in the
first place.
"""
from __future__ import annotations
import os
import sentry_sdk
from botocore.exceptions import ClientError
from sentry_sdk.integrations.flask import FlaskIntegration
_HEADER_DROP_NAMES = frozenset(
{
"authorization",
"x-auth-token",
"cookie",
"x-amz-security-token",
"x-slack-signature",
"x-meals-publish-key",
}
)
_DROP_REQUEST_KEYS = frozenset(
{
"body",
"Body",
"data",
"cookies",
"raw_email",
"prompt",
"secret",
"SecretString",
"hmac",
"keys",
}
)
_DROP_EXTRA_NEEDLES = (
"body",
"email",
"prompt",
"secret",
"hmac",
"token",
"mime",
"raw_email",
"password",
"signing",
)
def _drop_header(name):
lower = str(name).lower()
return lower in _HEADER_DROP_NAMES or lower.startswith("x-amz-")
def _scrub_headers(headers):
if isinstance(headers, dict):
return {k: v for k, v in headers.items() if not _drop_header(k)}
if isinstance(headers, list):
kept = []
for pair in headers:
if isinstance(pair, (list, tuple)) and pair and _drop_header(pair[0]):
continue
kept.append(pair)
return kept
return headers
def _stacktraces(event):
traces = []
stacktrace = event.get("stacktrace")
if isinstance(stacktrace, dict):
traces.append(stacktrace)
for section in ("exception", "threads"):
container = event.get(section)
if not isinstance(container, dict):
continue
values = container.get("values")
if not isinstance(values, list):
continue
for item in values:
if not isinstance(item, dict):
continue
inner = item.get("stacktrace")
if isinstance(inner, dict):
traces.append(inner)
return traces
def _strip_stack_locals(event):
"""Drop frame locals. Names like ``raw``/``item`` still hold secrets."""
for stacktrace in _stacktraces(event):
frames = stacktrace.get("frames")
if not isinstance(frames, list):
continue
for frame in frames:
if isinstance(frame, dict):
frame.pop("vars", None)
def _before_send(event, _hint):
request = event.get("request")
if isinstance(request, dict):
headers = request.get("headers")
if headers is not None:
request["headers"] = _scrub_headers(headers)
for key in list(request):
if key in _DROP_REQUEST_KEYS or str(key).lower() in {"body", "data"}:
request.pop(key, None)
extra = event.get("extra")
if isinstance(extra, dict):
for key in list(extra):
lower = str(key).lower()
if any(needle in lower for needle in _DROP_EXTRA_NEEDLES):
extra.pop(key, None)
_strip_stack_locals(event)
return event
def _is_parameter_not_found(exc: Exception) -> bool:
response_data = getattr(exc, "response", {})
if not isinstance(response_data, dict):
return False
return response_data.get("Error", {}).get("Code") == "ParameterNotFound"
def _dsn_from_param() -> str:
param = os.environ.get("SENTRY_DSN_PARAM", "").strip()
if not param:
return ""
from shared.secrets import get_parameter
try:
return get_parameter(param, decrypt=True).strip()
except ClientError as exc:
if _is_parameter_not_found(exc):
return ""
raise
def _resolve_dsn() -> str:
dsn = os.environ.get("SENTRY_DSN", "").strip()
if dsn:
return dsn
return _dsn_from_param()
def init_sentry() -> None:
dsn = _resolve_dsn()
if not dsn or dsn.lower() == "unset":
return
kwargs = {
"dsn": dsn,
"integrations": [FlaskIntegration()],
"send_default_pii": False,
"include_local_variables": False,
"enable_logs": False,
"traces_sample_rate": 0.0,
"before_send": _before_send,
"environment": os.environ.get("STAGE", "").strip() or "local",
}
sha = os.environ.get("GIT_SHA", "").strip()
if sha:
kwargs["release"] = sha
sentry_sdk.init(**kwargs)
init_sentry()

View file

@ -29,7 +29,6 @@ let countdownTimer = null;
let mealsListDelegationBound = false; let mealsListDelegationBound = false;
const GOOGLE_AUTH_MAX_ATTEMPTS = 200; const GOOGLE_AUTH_MAX_ATTEMPTS = 200;
const GOOGLE_SESSION_KEY = "seahaven.meals.googleIdToken";
window.googleCredential = null; window.googleCredential = null;
@ -96,68 +95,6 @@ function waitForGoogleAuth(attempt = 0) {
} }
} }
function decodeJwtPayload(token) {
if (!token || typeof token !== "string") return null;
const parts = token.split(".");
if (parts.length < 2) return null;
try {
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
const json = new TextDecoder().decode(
Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)),
);
return JSON.parse(json);
} catch (e) {
return null;
}
}
function googleTokenIsUsable(token) {
const payload = decodeJwtPayload(token);
if (!payload || !payload.email) return false;
if (typeof payload.exp === "number" && payload.exp * 1000 <= Date.now()) {
return false;
}
if (GOOGLE_CLIENT_ID && payload.aud && payload.aud !== GOOGLE_CLIENT_ID) {
return false;
}
return true;
}
function readStoredGoogleToken() {
try {
return sessionStorage.getItem(GOOGLE_SESSION_KEY);
} catch (e) {
return null;
}
}
function persistGoogleSession(token) {
try {
if (googleTokenIsUsable(token)) {
sessionStorage.setItem(GOOGLE_SESSION_KEY, token);
} else {
sessionStorage.removeItem(GOOGLE_SESSION_KEY);
}
} catch (e) {}
}
function clearGoogleSession() {
try {
sessionStorage.removeItem(GOOGLE_SESSION_KEY);
} catch (e) {}
}
function restoreGoogleSession() {
const token = readStoredGoogleToken();
if (!googleTokenIsUsable(token)) {
if (token) clearGoogleSession();
return false;
}
handleCredentialResponse({ credential: token });
return true;
}
function initGoogleAuth() { function initGoogleAuth() {
google.accounts.id.initialize({ google.accounts.id.initialize({
client_id: GOOGLE_CLIENT_ID, client_id: GOOGLE_CLIENT_ID,
@ -170,19 +107,17 @@ function initGoogleAuth() {
text: "signin_with", text: "signin_with",
width: 300, width: 300,
}); });
if (restoreGoogleSession()) return;
try {
google.accounts.id.prompt();
} catch (e) {}
} }
function handleCredentialResponse(response) { function handleCredentialResponse(response) {
const token = response && response.credential; googleCredential = response.credential;
const payload = decodeJwtPayload(token);
if (!payload || !payload.email) return;
googleCredential = token;
syncGoogleCredential(); syncGoogleCredential();
persistGoogleSession(token); const b64 = response.credential
.split(".")[1]
.replace(/-/g, "+")
.replace(/_/g, "/");
const payloadBytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const payload = JSON.parse(new TextDecoder().decode(payloadBytes));
googleUser = { name: payload.name, email: payload.email }; googleUser = { name: payload.name, email: payload.email };
document.getElementById("auth-overlay").classList.add("is-hidden"); document.getElementById("auth-overlay").classList.add("is-hidden");
@ -214,7 +149,6 @@ function signOut() {
googleCredential = null; googleCredential = null;
googleUser = null; googleUser = null;
syncGoogleCredential(); syncGoogleCredential();
clearGoogleSession();
if ( if (
CONFIG.authMode === "google" && CONFIG.authMode === "google" &&
typeof google !== "undefined" && typeof google !== "undefined" &&

View file

@ -10,10 +10,8 @@ import sys
import time import time
import boto3 import boto3
import sentry_sdk
from server.jobs import run_job from server.jobs import run_job
from server.sentry_init import init_sentry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, stream=sys.stderr) logging.basicConfig(level=logging.INFO, stream=sys.stderr)
@ -29,7 +27,6 @@ def _stop(_signum, _frame) -> None:
def main() -> None: def main() -> None:
signal.signal(signal.SIGTERM, _stop) signal.signal(signal.SIGTERM, _stop)
signal.signal(signal.SIGINT, _stop) signal.signal(signal.SIGINT, _stop)
init_sentry()
queue_url = os.environ.get("JOBS_QUEUE_URL", "").strip() queue_url = os.environ.get("JOBS_QUEUE_URL", "").strip()
if not queue_url: if not queue_url:
@ -60,7 +57,6 @@ def main() -> None:
continue continue
sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt) sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt)
except Exception: except Exception:
sentry_sdk.capture_exception()
logger.exception("job failed; leaving message for retry") logger.exception("job failed; leaving message for retry")

View file

@ -1,3 +1,3 @@
boto3==1.43.107 boto3==1.43.97
fpdf2==2.8.9 fpdf2==2.8.8
PyJWT[crypto]==2.15.1 PyJWT[crypto]==2.14.0

View file

@ -2,71 +2,61 @@
# Manual edits may be lost in future updates. # Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/aws" { provider "registry.terraform.io/hashicorp/aws" {
version = "6.67.0" version = "6.65.0"
constraints = "6.67.0" constraints = "6.65.0"
hashes = [ hashes = [
"h1:01Y50Z+67vWZmsW9e8FQTj9NT/XW+x1n0YGdz2x4BpY=", "h1:/VgIzAOR/v+p135IFsJjYT7q3pA24yE3lZGBV0Otqq0=",
"h1:6dffiL4BGVg+Ac2/64N+svhucYstBnVTTu55hWEhmq8=", "h1:1QFvuV0+K3GieeXSlb7qi9Q334XrsnMP8dCRwpM7fkc=",
"h1:8kRViFkyn96SufnuV3Oi3QmfL+DiyxlhAPcSX2jH0T0=", "h1:36ZBGQTzM6dW8dLQ145loI9yw6/fSbRV+fvLGCeEubc=",
"h1:EAfdlvAeLCxhO9G5nnZ6Ti1zsmQP1aClgS41rneOijY=", "h1:4uHlr+eDGOjf71giwLidIfGsMP6g5x2wkSGP5xq9EpM=",
"h1:EB9ixYOZrSlYD7wtJxf88qwoyyWrlKDKxhzaCLIb3t4=", "h1:9fSxZKfaAGrNSzXIz53IP2EmC1LYdO/fGYl7T87Y36Q=",
"h1:Ksjd+RJVccOFRlbg3gpoJjL7T3SMPHxso2dPzAVTyRE=", "h1:GJCK46UtrJMGSyByH4SiDytbwX11bg79jvTGZ27BGWU=",
"h1:OgdIUAQDtJBxlKjoPgChD1w57vl6hm6PyJHiBYgsgQA=", "h1:H0qzEAMrqydb8eTZdebso8nS/b8w1BNHysP8nmM4pLk=",
"h1:Syy68cIDOz7sXpxhjrkCwNHvmOj9VeaLVTJ/XnUKSuU=", "h1:RjeO6m/SvlhGUCDrwTdj99kJhKl/rC1zE9B5mi3YhVw=",
"h1:Tz5wi4X4Gkj2I1Gif4MOtfrj4JerCz+5KqSi6Xj+n/A=", "h1:Yx8Kv/T/BHVE8S1WEyHsFdu4HcsAQIl5e/831DeoQ5k=",
"h1:XaBXhLvtX5lUYkv5fHKzzMfoZXaIh5zU8hvM4jG2TXI=", "h1:ZFDxAUFzk1A2BPbLgu1LhAJ3erD4BbqZsF25yQobN4o=",
"h1:fOwuAcOFTGOU0GY1m4NHhDgTAdTSiU+9qZ+REdp5HIU=", "h1:anUZ356aBWvbHzQalF036qNKO+RISPmq6ycIL4OdXxk=",
"h1:gyduBRrLO8EiRf8zA1aiLRoWydrUMw+TG0pUbOXo1g4=", "h1:fhsSsZmfNFf4wErbcsmu1/ek1/TVUy7NCuMYeOpA1aE=",
"h1:iDfjW95J79sAMaOxeln73bKerm9SBemvLTrKepODumw=", "h1:l+w5eqL9UqpiVZ2ll0r+YvWAPjIL/WtqV8xqfH1+apM=",
"h1:xH+es0QQOteWlNptLqZzI6k8y94Aq/11S7lozotvO2c=", "h1:nt0kyMKN9kDNXKHOejaAo7LQIemP3tyntYjxHY32P0M=",
"h1:zLmFaFw5LptpWftHL3O6+7uykOH/0F9AmyVQ7V6kslY=", "h1:o2tj5YHQU1QNgANW2YAbjj0opl0BRJZl8W9trjYsqdA=",
"zh:111d5686a1f4ccbc888bb5e2229308bcdd9149898c6af97969fcdfb0e7bd2aa0", "zh:15b5bd81119965363893197b3b6065bdf46888b93c536623fd113b5505c375be",
"zh:21b3b7693bd9754c039fd546f03ed09e7510c6189aa5ee37176f144cf8dd5f95", "zh:16409fd045116a31b28adce98fcaaa56a7c01487369713e4a2a04af1cfa96fa3",
"zh:25e03c7f025ff4851889537605aa560d2e39bd738b33a5204b8037eb164b475f", "zh:422ea20ef4be8e5b942118d1da61cbde818cadb512ec1132306d65120f983917",
"zh:2817a046f1060e25bf04b0eb78f4e251130bb92dc82ec1264ce156dc9bf78e67", "zh:42cda6703a6a51585c2cb2b8b3ab3a7c80a3ee08838138be8ecaa6b97b1d74d8",
"zh:2d318d674c3ec9dbd97ddb10b12ad4827be4f508c43ba2ae1e0523baa9e367e3", "zh:718a880d81bfd9af7e297ed3d7bf98d1febebe8b9ebe3333854a4c17f2c4de09",
"zh:2f07ab356718267299e10b6072472ded29d82753883027bf43bcd687ac72feb2", "zh:74e538a8ff4ea27b2040be426cdd2b952725883f5b45c8c03b27eff7d82b40f8",
"zh:32307e67f83bc0dc94d38cfcd23d782166a09e0e975e2a5aa7d7a3e7ca5d3da8", "zh:876bf62e56a41e0c7a514652e22a41246e7c1d67be3b2c1b68553fa3a8dae6d7",
"zh:4ce94853264097dd7f4542b3cfd18d2b98b06d23321db45d5e384ea275a57f63", "zh:8d571e06d78b91b28faa7fafee99dee920d4f7a50f07ec9cd21695a385bcc0d5",
"zh:869656c41cc7c7412f213e488f98167cff61deafcb8cf245d25d056e8dfdc263", "zh:93154e33f4cbd39825a92a230642082e7b2b8b058c27cf93588ee6824aa1c294",
"zh:8fd8c814e9d8edf152552047db23bf5b5d63f22e6b0b5d47b2af851376e99349", "zh:935f9523c940dc5795ce8afac37aa8a2ed06c0012196ab39b1de2ea26acc129e",
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
"zh:9cbc02ceef9bd469da497a1e7065ff984bdacfbe919ac3cce804a86c13b6e2c6", "zh:9bffe18e907e04d98d7d6b9a6a4c2381448e365441df423e90e1eeaf3a79fd2f",
"zh:9ea55dda2767acfc1f6337fc7d29b1d4d79d6f8f04871f8ad99a6078d2865994", "zh:9ddfdefef226c8ff3c8de03d8df23ab3199fed9f0684618a62489e0e90a21cab",
"zh:d7149df0819fb57a160489db45d33951765b8f0118daa3b4cd549a0135bc97a1", "zh:9eab3abf041fe8e1ce5959fda9c20ddfaf331b7c29ff707e914451abce7841af",
"zh:e5819937bb7043d08c9829b32d52d9fb55a5b9a4d8d55d550d47a3d7392db546", "zh:ed749702f6c56b26a390a52bfd31d3bdf7f0af800bc16244276ca71d6f541e87",
"zh:f93bc38dbc0ad53842303f30eec7a22c525c4d56209b54ec33a8d375cfc4e4fd", "zh:f304df223a0bc3e840a806a5dffb75e6b2b75c879053dc4ebd0228484923ee59",
] ]
} }
provider "registry.terraform.io/hashicorp/random" { provider "registry.terraform.io/hashicorp/random" {
version = "3.9.1" version = "3.8.1"
constraints = "3.9.1" constraints = "3.8.1"
hashes = [ hashes = [
"h1:0rmWNCsP90oLS9KtLiqcNmkRzsaOZD95THXyuGYUfOM=", "h1:Eexl06+6J+s75uD46+WnZtpJZYRVUMB0AiuPBifK6Jc=",
"h1:4AN630toDK+4Is2MQcbGA37yR56nIEDPO0qv8ivZD2M=", "h1:fdfOl1HabDT42XLH8qjmfTbVZpgQZ5lyOyOa+GQhm0w=",
"h1:7uiStw0Rl9KOdX5UNMG/sp9nyadoD4LZekQTiYlYPhE=", "h1:u8AKlWVDTH5r9YLSeswoVEjiY72Rt4/ch7U+61ZDkiQ=",
"h1:9RiO3l/4iDguiVMryazTvf8ZAOAwbS0LPWl/XRo2El4=", "zh:08dd03b918c7b55713026037c5400c48af5b9f468f483463321bd18e17b907b4",
"h1:AjDEYpTXfyc3CupsrJYjgxCYnqxdpCLPkaLKPiw9WTA=", "zh:0eee654a5542dc1d41920bbf2419032d6f0d5625b03bd81339e5b33394a3e0ae",
"h1:PYbnOqRuGn4c0/Ae1f7yOS/0zvmXNHFJRZjuF4KECnM=", "zh:229665ddf060aa0ed315597908483eee5b818a17d09b6417a0f52fd9405c4f57",
"h1:PlW+UZ4EElQF3NQwf41KQwavFujab3Czc51zu9dyVM8=", "zh:2469d2e48f28076254a2a3fc327f184914566d9e40c5780b8d96ebf7205f8bc0",
"h1:VGeIpAn+nL6i8a6Y1W7XIq+Z6XFIWu5al4f2PNageoo=", "zh:37d7eb334d9561f335e748280f5535a384a88675af9a9eac439d4cfd663bcb66",
"h1:g40qr7yDmIpaur4SsK5BcOda3HSo1RJ6zHVMqN4EJ+0=", "zh:741101426a2f2c52dee37122f0f4a2f2d6af6d852cb1db634480a86398fa3511",
"h1:nozfr4CZq73d4HjubKJundX/8A+Mj282oS/hyNfjUPU=",
"h1:uFgaD8lhFrHzFm88V3/+wypq5AzCUdzXR60vLSlZ2cI=",
"h1:xxRd4yd9LvPiDqeiGQj7FhZHwD08tDcIToAAePfoahE=",
"zh:05f4734c1f0be840b711b3eff259ebc5fca436784c728955b1678078466f48d7",
"zh:0b91bf19371d012434eba1deeb6aab77158def9b39601dcbd94450b3974a2a26",
"zh:0ee6eacd47ec00183d55d726a4b6c4ce951a199f944bf22f1aa58392ebdfa7a2",
"zh:19388a4074b76a89a43a6c8328d7ae8ee2e7de3d346af51e80d3e6d3d12925f1",
"zh:23e74d48c5e2ac2e823fd527f49fee9db37d32a1990c9e3bf126ead697b843eb",
"zh:3cabf7fbd096c520064aae3aba61aba670af83ab91291a71fa1b1332929c2b7f",
"zh:5c0a3b8af0be60be4eca12ddee385cfa8babc1ec8e98cdf9de2f2274c73eabfa",
"zh:60b4f8a8ef18f52bf8e19215229dae408bee732825964092db7c989fd2de4097",
"zh:7359015acfedcbd6366f2329c854cf8d3c8ca5cd0faa89d2d37db358d6eba6c5",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:7b38758402f0e13a1071162da28994023cd2ac676e54af350c9ffd8dfa73fa7b", "zh:a902473f08ef8df62cfe6116bd6c157070a93f66622384300de235a533e9d4a9",
"zh:7c7fbb8895eb75bb4de1f933e98553bd99c8d048c89a925ddba490aa5a67f7dc", "zh:b85c511a23e57a2147355932b3b6dce2a11e856b941165793a0c3d7578d94d05",
"zh:8c2b8c6a7ccdec16b73e2fb9f3700ea097f58c592571e4c5de60c93d2301732c", "zh:c5172226d18eaac95b1daac80172287b69d4ce32750c82ad77fa0768be4ea4b8",
"zh:dab4434dba34aad569b0bc243c2d3f3ff86dd7740def373f2a49816bd2ff819b",
"zh:f49fd62aa8c5525a5c17abd51e27ca5e213881d58882fd42fec4a545b53c9699",
] ]
} }

View file

@ -69,13 +69,6 @@ check "dev_has_no_custom_domain" {
} }
} }
check "prod_reuses_afterhours_vpc" {
assert {
condition = !local.is_prod || var.existing_vpc_id != ""
error_message = "Prod must set existing_vpc_id to the afterhours VPC. Do not mint 10.60."
}
}
check "existing_vpc_pair" { check "existing_vpc_pair" {
assert { assert {
condition = (var.existing_vpc_id == "") == (length(var.existing_public_subnet_ids) == 0) condition = (var.existing_vpc_id == "") == (length(var.existing_public_subnet_ids) == 0)

View file

@ -150,7 +150,6 @@ locals {
{ name = "PORTAL_COGNITO_AUDIENCE_PARAM", value = aws_ssm_parameter.portal_cognito_audience.name }, { name = "PORTAL_COGNITO_AUDIENCE_PARAM", value = aws_ssm_parameter.portal_cognito_audience.name },
{ name = "PORTAL_COGNITO_TRUST_PARAM", value = aws_ssm_parameter.portal_cognito_trust.name }, { name = "PORTAL_COGNITO_TRUST_PARAM", value = aws_ssm_parameter.portal_cognito_trust.name },
{ name = "PUBLISH_KEY_PARAM", value = aws_ssm_parameter.publish_key.name }, { name = "PUBLISH_KEY_PARAM", value = aws_ssm_parameter.publish_key.name },
{ name = "SENTRY_DSN_PARAM", value = aws_ssm_parameter.sentry_dsn.name },
{ name = "JOBS_QUEUE_URL", value = aws_sqs_queue.jobs.id }, { name = "JOBS_QUEUE_URL", value = aws_sqs_queue.jobs.id },
{ name = "CHECKCOMPONENTS_QUEUE_URL", value = var.checkcomponents_queue_url }, { name = "CHECKCOMPONENTS_QUEUE_URL", value = var.checkcomponents_queue_url },
{ name = "AWS_DEFAULT_REGION", value = var.aws_region }, { name = "AWS_DEFAULT_REGION", value = var.aws_region },

View file

@ -225,8 +225,6 @@ data "aws_iam_policy_document" "hcptf_scoped_iam" {
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/githubdeploy-meal-order-manager"] resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/githubdeploy-meal-order-manager"]
} }
# Kept so this apply can delete githubdeploy-meal-order-manager-weekly-menu.
# Drop the statement after that role is gone.
statement { statement {
sid = "WriteDeployRoles" sid = "WriteDeployRoles"
effect = "Allow" effect = "Allow"

View file

@ -38,23 +38,6 @@ data "aws_iam_policy_document" "ecs_task_boundary" {
resources = ["${aws_s3_bucket.reports.arn}/*"] resources = ["${aws_s3_bucket.reports.arn}/*"]
} }
statement {
sid = "FormObjects"
effect = "Allow"
actions = ["s3:PutObject"]
resources = [
"${aws_s3_bucket.form.arn}/index.html",
"${aws_s3_bucket.form.arn}/archive/*.html",
]
}
statement {
sid = "InvalidateForm"
effect = "Allow"
actions = ["cloudfront:CreateInvalidation"]
resources = [aws_cloudfront_distribution.form.arn]
}
statement { statement {
sid = "JobsQueue" sid = "JobsQueue"
effect = "Allow" effect = "Allow"

View file

@ -1,5 +1,4 @@
# GitHub Actions OIDC role for the thin deploy-api.yaml caller of # GitHub Actions OIDC role for .github/workflows/deploy-api.yaml.
# org reusable cd-hcp-fargate.yaml.
data "aws_iam_policy_document" "github_deploy_assume" { data "aws_iam_policy_document" "github_deploy_assume" {
statement { statement {
@ -27,13 +26,12 @@ data "aws_iam_policy_document" "github_deploy_assume" {
] ]
} }
# AWS STS GitHub condition keys include job_workflow_ref, not workflow_ref.
# A workflow_ref condition fail-closes AssumeRoleWithWebIdentity.
condition { condition {
test = "StringLike" test = "StringLike"
variable = "token.actions.githubusercontent.com:job_workflow_ref" variable = "token.actions.githubusercontent.com:job_workflow_ref"
values = [ values = [
"Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@*", "Sea-Haven-Industries/meal-order-manager/.github/workflows/deploy-api.yaml@refs/heads/main",
"Sea-Haven-Industries/meal-order-manager/.github/workflows/deploy-api.yaml@refs/tags/v*",
] ]
} }
} }

View file

@ -0,0 +1,113 @@
# GitHub Actions OIDC role for .github/workflows/weekly-menu.yml.
#
# Trust is pinned three ways (aud, sub to main, job_workflow_ref to the
# weekly-menu workflow at main) so no other workflow in the repo can assume it.
# Permissions mirror the mgmt github-oidc-deploy-roles weekly-menu role, retargeted
# to prod resources and without form-api-key (SigV4 publish path).
#
# OIDC provider ARN is literal (not a data source): hcptf-meal-order-manager-plan
# lacks iam:GetOpenIDConnectProvider, and the provider is account-stable.
data "aws_iam_policy_document" "weekly_menu_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [local.github_oidc_provider_arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:Sea-Haven-Industries/meal-order-manager:ref:refs/heads/main"]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:job_workflow_ref"
values = ["Sea-Haven-Industries/meal-order-manager/.github/workflows/weekly-menu.yml@refs/heads/main"]
}
}
}
resource "aws_iam_role" "weekly_menu" {
name = "githubdeploy-meal-order-manager-weekly-menu"
path = "/tf-managed/"
description = "GitHub Actions weekly-menu scrape/publish for meal-order-manager"
assume_role_policy = data.aws_iam_policy_document.weekly_menu_assume.json
max_session_duration = 3600
# Not a Lambda execution role. Config omits permissions_boundary so a later
# apply will not PutRolePermissionsBoundary the Lambda ceiling back. Live still
# has seahaven-lambda-execution-boundary; omitting without ignore_changes would
# plan DeleteRolePermissionsBoundary, which hcptf-meal-order-manager is denied
# (DenyBoundaryTampering). Ignore the attribute so this apply does not touch
# the ceiling. An administrator deletes the live attachment, then a follow-up
# drops this lifecycle after refresh-only updates state to null.
lifecycle {
ignore_changes = [permissions_boundary]
}
}
data "aws_iam_policy_document" "weekly_menu" {
statement {
sid = "SlackBotSecret"
effect = "Allow"
actions = [
"secretsmanager:GetSecretValue",
]
resources = [var.slack_bot_secret_arn]
}
statement {
sid = "DeployAndAppParams"
effect = "Allow"
actions = [
"ssm:GetParameter",
]
resources = [
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/deploy/api-url",
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/deploy/form-bucket",
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/deploy/distribution-id",
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/deploy/form-url",
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.google_client_id_param}",
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.slack_channel_param}",
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/publish-key",
]
}
statement {
sid = "FormObjects"
effect = "Allow"
actions = [
"s3:PutObject",
]
resources = [
"${aws_s3_bucket.form.arn}/index.html",
"${aws_s3_bucket.form.arn}/archive/*.html",
]
}
statement {
sid = "InvalidateForm"
effect = "Allow"
actions = [
"cloudfront:CreateInvalidation",
]
resources = [aws_cloudfront_distribution.form.arn]
}
}
resource "aws_iam_role_policy" "weekly_menu" {
name = "weekly-menu-publish"
role = aws_iam_role.weekly_menu.id
policy = data.aws_iam_policy_document.weekly_menu.json
}

View file

@ -7,9 +7,8 @@ locals {
github_oidc_provider_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com" github_oidc_provider_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com"
# Created only when existing_vpc_id is empty. Prod attaches to afterhours 10.70. # Created only when existing_vpc_id is empty. 10.60 is unused in 011934824531.
# 10.60 is the unused fallback CIDR, not a second prod VPC. manage_vpc = var.existing_vpc_id == ""
manage_vpc = var.existing_vpc_id == "" && !local.is_prod
vpc_cidr = "10.60.0.0/16" vpc_cidr = "10.60.0.0/16"
public_subnet_cidrs = ["10.60.0.0/24", "10.60.1.0/24"] public_subnet_cidrs = ["10.60.0.0/24", "10.60.1.0/24"]

View file

@ -43,6 +43,11 @@ output "orders_table_name" {
value = aws_dynamodb_table.orders.name value = aws_dynamodb_table.orders.name
} }
output "weekly_menu_role_arn" {
description = "OIDC role ARN for .github/workflows/weekly-menu.yml (repo secret AWS_WEEKLY_MENU_ROLE_ARN)."
value = aws_iam_role.weekly_menu.arn
}
output "github_deploy_role_arn" { output "github_deploy_role_arn" {
description = "OIDC role ARN for .github/workflows/deploy-api.yaml (Environment DEPLOY_ROLE_ARN)." description = "OIDC role ARN for .github/workflows/deploy-api.yaml (Environment DEPLOY_ROLE_ARN)."
value = aws_iam_role.github_deploy.arn value = aws_iam_role.github_deploy.arn
@ -52,13 +57,3 @@ output "ecs_task_role_arn" {
description = "ECS task role; paychex-checkcomponents queue policy must allow this ARN." description = "ECS task role; paychex-checkcomponents queue policy must allow this ARN."
value = aws_iam_role.ecs_task.arn value = aws_iam_role.ecs_task.arn
} }
output "vpc_id" {
description = "VPC the ALB and Fargate tasks run in. Prod attaches to afterhours."
value = local.vpc_id
}
output "public_subnet_ids" {
description = "Public subnet IDs for the ALB and Fargate tasks."
value = local.public_subnet_ids
}

View file

@ -17,11 +17,6 @@ locals {
schedule = "cron(55 6 ? * MON *)" schedule = "cron(55 6 ? * MON *)"
event = "sync_roster" event = "sync_roster"
} }
publish-menu = {
description = "Publish the weekly menu Monday 7:30am Eastern"
schedule = "cron(30 7 ? * MON *)"
event = "publish_menu"
}
} }
} }

View file

@ -4,17 +4,6 @@
# created and rotated out-of-band because it varies per environment; data.tf # created and rotated out-of-band because it varies per environment; data.tf
# reads it. Do not turn that lookup into a resource. # reads it. Do not turn that lookup into a resource.
resource "aws_ssm_parameter" "sentry_dsn" {
name = "${local.ssm_prefix}/sentry-dsn"
type = "SecureString"
value = "unset"
description = "Sentry DSN for meal-order-manager. PutParameter writes the live value; Terraform ignores it. Empty or unset disables the SDK."
lifecycle {
ignore_changes = [value]
}
}
resource "aws_ssm_parameter" "slack_channel_id" { resource "aws_ssm_parameter" "slack_channel_id" {
name = local.slack_channel_param name = local.slack_channel_param
type = "String" type = "String"
@ -50,8 +39,9 @@ resource "aws_ssm_parameter" "portal_cognito_trust" {
# Deploy-time lookups # Deploy-time lookups
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# #
# Deploy targets for the image workflow and the publish_menu job. # These replace the CloudFormation stack outputs that
# There is no CloudFormation stack. # .github/workflows/weekly-menu.yml used to read, so the job can resolve its
# deploy targets without a CloudFormation stack.
resource "aws_ssm_parameter" "deploy_api_url" { resource "aws_ssm_parameter" "deploy_api_url" {
name = "${local.ssm_prefix}/deploy/api-url" name = "${local.ssm_prefix}/deploy/api-url"
@ -99,19 +89,19 @@ resource "aws_ssm_parameter" "deploy_form_bucket" {
name = "${local.ssm_prefix}/deploy/form-bucket" name = "${local.ssm_prefix}/deploy/form-bucket"
type = "String" type = "String"
value = aws_s3_bucket.form.id value = aws_s3_bucket.form.id
description = "S3 bucket holding the order form; upload target for the publish_menu job" description = "S3 bucket holding the order form; sync target for the weekly-menu deploy job"
} }
resource "aws_ssm_parameter" "deploy_distribution_id" { resource "aws_ssm_parameter" "deploy_distribution_id" {
name = "${local.ssm_prefix}/deploy/distribution-id" name = "${local.ssm_prefix}/deploy/distribution-id"
type = "String" type = "String"
value = aws_cloudfront_distribution.form.id value = aws_cloudfront_distribution.form.id
description = "CloudFront distribution ID; cache-invalidation target for the publish_menu job" description = "CloudFront distribution ID; cache-invalidation target for the weekly-menu deploy job"
} }
resource "aws_ssm_parameter" "deploy_form_url" { resource "aws_ssm_parameter" "deploy_form_url" {
name = "${local.ssm_prefix}/deploy/form-url" name = "${local.ssm_prefix}/deploy/form-url"
type = "String" type = "String"
value = local.form_url value = local.form_url
description = "Public order form URL; linked from the publish_menu Slack post" description = "Public order form URL; reported by the weekly-menu deploy job"
} }

View file

@ -93,7 +93,7 @@ variable "checkcomponents_queue_arn" {
} }
variable "existing_vpc_id" { variable "existing_vpc_id" {
description = "When set, place the ALB and Fargate tasks in this VPC instead of creating one. Prod attaches to the afterhours VPC." description = "When set, place the ALB and Fargate tasks in this VPC instead of creating one."
type = string type = string
default = "" default = ""
} }

View file

@ -4,11 +4,11 @@ terraform {
required_providers { required_providers {
aws = { aws = {
source = "hashicorp/aws" source = "hashicorp/aws"
version = "6.67.0" version = "6.65.0"
} }
random = { random = {
source = "hashicorp/random" source = "hashicorp/random"
version = "3.9.1" version = "3.8.1"
} }
} }

View file

@ -4,7 +4,7 @@ data "aws_availability_zones" "available" {
} }
data "aws_vpc" "existing" { data "aws_vpc" "existing" {
count = var.existing_vpc_id == "" ? 0 : 1 count = local.manage_vpc ? 0 : 1
id = var.existing_vpc_id id = var.existing_vpc_id
} }
@ -13,17 +13,6 @@ data "aws_subnet" "existing_public" {
id = each.value id = each.value
} }
resource "terraform_data" "prod_requires_afterhours_vpc" {
input = var.existing_vpc_id
lifecycle {
precondition {
condition = !local.is_prod || var.existing_vpc_id != ""
error_message = "Prod must set existing_vpc_id to the afterhours VPC. Do not mint 10.60."
}
}
}
resource "aws_vpc" "this" { resource "aws_vpc" "this" {
count = local.manage_vpc ? 1 : 0 count = local.manage_vpc ? 1 : 0
@ -35,13 +24,6 @@ resource "aws_vpc" "this" {
Name = "${local.project}-vpc" Name = "${local.project}-vpc"
} }
lifecycle {
precondition {
condition = !local.is_prod
error_message = "Prod must set existing_vpc_id to the afterhours VPC. Do not mint 10.60."
}
}
# First apply updates the live hcptf apply role before CreateVpc. # First apply updates the live hcptf apply role before CreateVpc.
depends_on = [ depends_on = [
aws_iam_role_policy_attachments_exclusive.hcptf_apply, aws_iam_role_policy_attachments_exclusive.hcptf_apply,
@ -98,7 +80,7 @@ resource "aws_route_table_association" "public" {
} }
locals { locals {
vpc_id = local.manage_vpc ? aws_vpc.this[0].id : try(data.aws_vpc.existing[0].id, var.existing_vpc_id) vpc_id = local.manage_vpc ? aws_vpc.this[0].id : data.aws_vpc.existing[0].id
public_subnet_ids = local.manage_vpc ? aws_subnet.public[*].id : var.existing_public_subnet_ids public_subnet_ids = local.manage_vpc ? aws_subnet.public[*].id : var.existing_public_subnet_ids
} }

View file

@ -3,16 +3,12 @@
import os import os
import sys import sys
import pytest
# Several Lambda handlers construct boto3.client(...) at module load. On a CI # Several Lambda handlers construct boto3.client(...) at module load. On a CI
# runner with no AWS config this raises NoRegionError during test collection # runner with no AWS config this raises NoRegionError during test collection
# (a real region is read from ~/.aws/config locally, masking it). Set a default # (a real region is read from ~/.aws/config locally, masking it). Set a default
# region before any import. Client construction is offline; real calls are mocked. # region before any import. Client construction is offline; real calls are mocked.
os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1") os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1")
os.environ.setdefault("AWS_REGION", "us-east-1") os.environ.setdefault("AWS_REGION", "us-east-1")
os.environ.pop("SENTRY_DSN", None)
os.environ.pop("SENTRY_DSN_PARAM", None)
# Add the repo root so `import functions.<name>.handler` resolves under a bare # Add the repo root so `import functions.<name>.handler` resolves under a bare
# `pytest` invocation. `python -m pytest` injects the CWD automatically, but CI # `pytest` invocation. `python -m pytest` injects the CWD automatically, but CI
@ -29,12 +25,3 @@ sys.path.insert(0, os.path.abspath(_src_dir))
# without requiring a real Lambda layer or .aws-sam build. # without requiring a real Lambda layer or .aws-sam build.
_shared_layer_dir = os.path.join(_src_dir, "shared") _shared_layer_dir = os.path.join(_src_dir, "shared")
sys.path.insert(0, os.path.abspath(_shared_layer_dir)) sys.path.insert(0, os.path.abspath(_shared_layer_dir))
@pytest.fixture(autouse=True)
def _clear_sentry_dsn_env():
os.environ.pop("SENTRY_DSN", None)
os.environ.pop("SENTRY_DSN_PARAM", None)
yield
os.environ.pop("SENTRY_DSN", None)
os.environ.pop("SENTRY_DSN_PARAM", None)

View file

@ -1,9 +0,0 @@
<!DOCTYPE html>
<html>
<body>
<orders-page
:products='[{"id":2238,"name":"Korean Steak Bowl","description":"<div>Shaved ribeye &amp; rice.</div>","type":"meal","price":"12.49","on_sale":false,"sale_price":"0.00","media_urls":{"images":[{"original":"https://example.com/korean.png"}]},"dietary_concerns":["Gluten Free","Dairy Free"],"available":true,"details":{"calories":"590","protein":"50"}},{"id":9,"name":"Sale Bowl","description":"<div>On sale.</div>","price":"14.00","on_sale":true,"sale_price":"9.50","media_urls":{"images":[{"original":"https://example.com/sale.png"}]},"dietary_concerns":[],"available":true,"details":{"calories":"400cal","protein":"30g"}},{"id":3,"name":"Hidden Bowl","description":"<div>Unavailable.</div>","price":"5.00","on_sale":false,"sale_price":"0.00","media_urls":{"images":[]},"dietary_concerns":[],"available":false,"details":{"calories":"100","protein":"10"}}]'
:newest-ids='[2238]'
></orders-page>
</body>
</html>

View file

@ -1,265 +0,0 @@
"""CSV parsing and menu matching for the Redefine cart filler."""
import importlib.util
from pathlib import Path
from playwright.sync_api import Error as PlaywrightError
from scraper.fill_cart import build_plan, cart_lines, error_text, quantity_for
from scraper.parse_menu import extract_catalog_products
_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "fill_redefine_cart.py"
_spec = importlib.util.spec_from_file_location("fill_redefine_cart", _SCRIPT)
fill_redefine_cart = importlib.util.module_from_spec(_spec)
assert _spec.loader is not None
_spec.loader.exec_module(fill_redefine_cart)
PRODUCTS = [
{
"name": "Korean Steak Bowl",
"uuid": "korean-id",
"available": True,
},
{
"name": "Sale Bowl",
"uuid": "sale-id",
"available": True,
},
{
"name": "Hidden Bowl",
"uuid": "hidden-id",
"available": False,
},
{
"name": "Twin Bowl",
"uuid": "twin-a",
"available": True,
},
{
"name": "twin bowl",
"uuid": "twin-b",
"available": True,
},
{
"name": "No Id Bowl",
"uuid": "",
"available": True,
},
]
def test_build_plan_matches_names_and_skips_the_rest():
csv_text = "\n".join(
[
"Item,Quantity",
"korean steak bowl,4",
"Sale Bowl,2",
"Sale Bowl,3",
"Hidden Bowl,1",
"Twin Bowl,8",
"Missing Bowl,1",
"No Id Bowl,1",
"=cmd,1",
"Zero Bowl,0",
"Bad Qty,nope",
]
)
# The admin exporter prefixes formula-like names with an apostrophe.
csv_text = csv_text.replace("=cmd", "'=cmd")
plan = build_plan(csv_text, PRODUCTS)
assert [(item.name, item.quantity, item.uuid) for item in plan.adds] == [
("Korean Steak Bowl", 4, "korean-id"),
("Sale Bowl", 5, "sale-id"),
]
assert [(line.name, line.reason) for line in plan.skipped] == [
("Bad Qty", "invalid quantity 'nope'"),
("Hidden Bowl", "unavailable on the menu"),
("Twin Bowl", "matches more than one menu item"),
("Missing Bowl", "not on the menu"),
("No Id Bowl", "not on the menu"),
("=cmd", "not on the menu"),
]
assert plan.ignored_zero == 1
def test_build_plan_reads_quoted_fields_and_a_byte_order_mark():
csv_text = '\ufeffItem,Quantity\r\n"Bowl, Large",2\r\n'
products = [{"name": "Bowl, Large", "uuid": "bowl", "available": True}]
plan = build_plan(csv_text, products)
assert plan.adds[0].name == "Bowl, Large"
assert plan.adds[0].quantity == 2
assert plan.skipped == ()
def test_build_plan_requires_the_admin_columns():
try:
build_plan("Meal,Qty\nSoup,1\n", PRODUCTS)
except ValueError as exc:
assert "Item and Quantity" in str(exc)
else:
raise AssertionError("expected a column error")
def test_extract_catalog_products_reads_the_menu_attribute():
page = """
<orders-page
:products='[{"name":"Korean Steak Bowl","uuid":"korean-id","available":true}]'
:newest-ids='[]'
></orders-page>
"""
assert extract_catalog_products(page)[0]["uuid"] == "korean-id"
def test_cart_lines_use_the_product_uuid_and_quantity():
cart = {
"items": [
{
"uuid": "line-1",
"quantity": "4",
"product": {"name": "Korean Steak Bowl", "uuid": "korean-id"},
}
]
}
lines = cart_lines(cart)
assert lines[0].name == "Korean Steak Bowl"
assert lines[0].product_uuid == "korean-id"
assert quantity_for(lines, "korean-id") == 4
assert quantity_for(lines, "line-1") == 0
class _CartPage:
def __init__(self, responses):
self._responses = list(responses)
def goto(self, url, **kwargs):
return None
def evaluate(self, script, payload):
response = self._responses.pop(0)
if isinstance(response, Exception):
raise response
return response
def test_main_rejects_a_non_utf8_csv(tmp_path, capsys):
path = tmp_path / "order.csv"
path.write_bytes(b"Item,Quantity\nCaf\xe9 Bowl,1\n")
code = fill_redefine_cart.main([str(path)])
error = capsys.readouterr().err
assert code == 1
assert error.strip() == "CSV must be UTF-8."
assert "Traceback" not in error
def test_fill_browser_prints_a_summary_when_the_page_cannot_open(capsys):
plan = build_plan("Item,Quantity\nKorean Steak Bowl,1\n", PRODUCTS)
class ClosedBrowser:
def __init__(self):
self.closed = False
def new_page(self):
raise PlaywrightError("browser has been closed")
def close(self):
self.closed = True
browser = ClosedBrowser()
code = fill_redefine_cart._fill_browser(
browser, plan, "https://www.redefinemeals.com/menu"
)
output = capsys.readouterr().out
assert code == 1
assert browser.closed
assert "Traceback" not in output
assert "Added 0. Failed 1. Skipped 0." in output
def test_fill_page_prints_a_summary_when_the_window_closes_mid_add(capsys):
plan = build_plan("Item,Quantity\nKorean Steak Bowl,1\n", PRODUCTS)
page = _CartPage(
[
{"ok": True, "status": 200, "data": {"items": []}},
PlaywrightError("Target page, context or browser has been closed"),
]
)
code = fill_redefine_cart._fill_page(
page, plan, "https://www.redefinemeals.com/menu"
)
output = capsys.readouterr().out
assert code == 1
assert "Traceback" not in output
assert "Added 0. Failed 1. Skipped 0." in output
assert "Cart is ready" not in output
def test_fill_page_counts_items_left_when_the_window_closes(capsys):
plan = build_plan(
"Item,Quantity\nKorean Steak Bowl,1\nSale Bowl,2\n",
PRODUCTS,
)
page = _CartPage(
[
{"ok": True, "status": 200, "data": {"items": []}},
PlaywrightError("Target page, context or browser has been closed"),
]
)
code = fill_redefine_cart._fill_page(
page, plan, "https://www.redefinemeals.com/menu"
)
output = capsys.readouterr().out
assert code == 1
assert "Korean Steak Bowl:" in output
assert "Sale Bowl: not attempted" in output
assert "Added 0. Failed 2. Skipped 0." in output
def test_fill_page_prints_a_summary_when_the_first_cart_read_fails(capsys):
plan = build_plan("Item,Quantity\nKorean Steak Bowl,1\n", PRODUCTS)
page = _CartPage(
[
{
"ok": False,
"status": 500,
"data": {"message": "cart unavailable", "trace": [{"file": "x"}]},
}
]
)
code = fill_redefine_cart._fill_page(
page, plan, "https://www.redefinemeals.com/menu"
)
output = capsys.readouterr().out
assert code == 1
assert "HTTP 500: cart unavailable" in output
assert "x" not in output.split("Failed:", 1)[-1]
assert "Added 0. Failed 1. Skipped 0." in output
def test_error_text_keeps_the_message_and_drops_the_trace():
body = {
"message": "Product is not available.",
"exception": "HttpException",
"file": "/home/app/secret.php",
"trace": [{"file": "/home/app/secret.php"}],
}
text = error_text(422, body)
assert text == "HTTP 422: Product is not available."
assert "secret.php" not in text

View file

@ -75,8 +75,9 @@ def _render_for_browser(*, google: bool = False) -> str:
return html return html
def _google_credential(extra: dict | None = None) -> str: def _google_credential() -> str:
payload_obj = { payload = json.dumps(
{
"name": "Test Admin", "name": "Test Admin",
"email": "test-admin@example.com", "email": "test-admin@example.com",
"picture": ( "picture": (
@ -84,11 +85,8 @@ def _google_credential(extra: dict | None = None) -> str:
"<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40'/>" "<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40'/>"
), ),
} }
if extra: ).encode()
payload_obj.update(extra) encoded = base64.urlsafe_b64encode(payload).decode().rstrip("=")
encoded = (
base64.urlsafe_b64encode(json.dumps(payload_obj).encode()).decode().rstrip("=")
)
return f"e30.{encoded}.signature" return f"e30.{encoded}.signature"
@ -158,11 +156,9 @@ class TestGenerateFormStructural:
assert "x-api-key" not in html assert "x-api-key" not in html
def test_cloud_configuration_has_no_form_api_key(self): def test_cloud_configuration_has_no_form_api_key(self):
workflow = (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").read_text()
http_api = (REPO_ROOT / "src" / "server" / "http_api.py").read_text() http_api = (REPO_ROOT / "src" / "server" / "http_api.py").read_text()
publish_job = ( for text in (workflow, http_api):
REPO_ROOT / "src" / "server" / "jobs" / "publish_menu.py"
).read_text()
for text in (http_api, publish_job):
assert "FORM_APIKEY" not in text assert "FORM_APIKEY" not in text
assert "form-api-key" not in text assert "form-api-key" not in text
assert "x-api-key" not in text assert "x-api-key" not in text
@ -173,30 +169,19 @@ class TestGenerateFormStructural:
assert "SUBMIT_RATE_PER_SEC = 5.0" in http_api assert "SUBMIT_RATE_PER_SEC = 5.0" in http_api
assert "def _allow_submit()" in http_api assert "def _allow_submit()" in http_api
def test_manual_publish_stays_hmac_and_scheduled_job_is_in_process(self): def test_weekly_menu_uses_hmac_publish_without_dynamodb(self):
workflow = (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").read_text()
script = (REPO_ROOT / "scripts" / "upload_menu.py").read_text() script = (REPO_ROOT / "scripts" / "upload_menu.py").read_text()
locals_tf = (REPO_ROOT / "terraform" / "locals.tf").read_text() locals_tf = (REPO_ROOT / "terraform" / "locals.tf").read_text()
scheduler = (REPO_ROOT / "terraform" / "scheduler.tf").read_text()
publish_job = (
REPO_ROOT / "src" / "server" / "jobs" / "publish_menu.py"
).read_text()
for route in ("/api/publish/settings", "/api/publish/menu"): for route in ("/api/publish/settings", "/api/publish/menu"):
assert route in script assert route in script
assert 'authorizer = "HMAC"' in locals_tf assert 'authorizer = "HMAC"' in locals_tf
assert "X-Meals-Publish-Key" in script assert "X-Meals-Publish-Key" in script
assert "scripts/upload_menu.py settings" in workflow
assert "scripts/upload_menu.py publish" in workflow
assert "aws dynamodb" not in workflow
assert 'boto3.resource("dynamodb")' not in script assert 'boto3.resource("dynamodb")' not in script
assert 'event = "publish_menu"' in scheduler
assert 'schedule = "cron(30 7 ? * MON *)"' in scheduler
assert "put_menu" in publish_job
assert not (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").exists()
def test_explicit_week_is_embedded_in_the_form(self):
menu, config = _load_fixtures()
html = generate_form(menu, config, week="2026-W38")
config_blob = _extract_config(html)
assert config_blob["week"] == "2026-W38"
assert "/api/form-status/2026-W38" in html
def test_local_and_google_render(self): def test_local_and_google_render(self):
local = _render(google=False) local = _render(google=False)
@ -425,7 +410,6 @@ class TestGenerateFormPlaywright:
id: { id: {
initialize() {}, initialize() {},
renderButton() {}, renderButton() {},
prompt() {},
disableAutoSelect() {}, disableAutoSelect() {},
}, },
}, },
@ -949,7 +933,6 @@ class TestGenerateFormGooglePlaywright:
id: { id: {
initialize() {}, initialize() {},
renderButton() {}, renderButton() {},
prompt() {},
disableAutoSelect() {}, disableAutoSelect() {},
}, },
}, },
@ -1024,105 +1007,6 @@ class TestGenerateFormGooglePlaywright:
finally: finally:
browser.close() browser.close()
def test_refresh_restores_google_session(self, tmp_path):
sync_api = pytest.importorskip("playwright.sync_api")
sync_playwright = sync_api.sync_playwright
out = tmp_path / "google-session-form.html"
out.write_text(_render_for_browser(google=True))
credential = _google_credential({"exp": 2_000_000_000})
with sync_playwright() as p:
try:
browser = p.chromium.launch(headless=True)
except Exception as exc:
raise RuntimeError(
"Chromium is required for form Playwright tests. "
"Run: playwright install --with-deps chromium"
) from exc
try:
page = browser.new_page()
page.add_init_script(
"""window.google = {
accounts: {
id: {
initialize() {},
renderButton() {},
prompt() {},
disableAutoSelect() {},
},
},
};"""
)
page.route(
"https://accounts.google.com/gsi/client",
lambda route: route.abort(),
)
_mock_form_routes(page)
page.goto(out.as_uri(), wait_until="domcontentloaded")
page.evaluate(
"(token) => handleCredentialResponse({ credential: token })",
credential,
)
page.wait_for_function(
"""() => {
const app = document.getElementById('app');
return app && !app.classList.contains('is-hidden');
}"""
)
assert page.evaluate(
"() => sessionStorage.getItem('seahaven.meals.googleIdToken')"
)
page.reload(wait_until="domcontentloaded")
_mock_form_routes(page)
page.wait_for_function(
"""() => {
const app = document.getElementById('app');
const overlay = document.getElementById('auth-overlay');
const email = document.getElementById('user-email');
return app && !app.classList.contains('is-hidden')
&& overlay && overlay.classList.contains('is-hidden')
&& email && email.textContent === 'test-admin@example.com';
}"""
)
page.evaluate("signOut()")
assert (
page.evaluate(
"() => sessionStorage.getItem('seahaven.meals.googleIdToken')"
)
is None
)
page.reload(wait_until="domcontentloaded")
_mock_form_routes(page)
page.wait_for_function(
"""() => {
const overlay = document.getElementById('auth-overlay');
const app = document.getElementById('app');
return overlay && !overlay.classList.contains('is-hidden')
&& app && app.classList.contains('is-hidden');
}"""
)
page.evaluate(
"(token) => sessionStorage.setItem('seahaven.meals.googleIdToken', token)",
_google_credential({"exp": 1}),
)
page.reload(wait_until="domcontentloaded")
_mock_form_routes(page)
page.wait_for_function(
"""() => {
const overlay = document.getElementById('auth-overlay');
const app = document.getElementById('app');
return overlay && !overlay.classList.contains('is-hidden')
&& app && app.classList.contains('is-hidden')
&& !sessionStorage.getItem('seahaven.meals.googleIdToken');
}"""
)
finally:
browser.close()
@pytest.fixture(scope="class") @pytest.fixture(scope="class")
@classmethod @classmethod
def signed_in_admin_page(cls, tmp_path_factory): def signed_in_admin_page(cls, tmp_path_factory):
@ -1152,7 +1036,6 @@ class TestGenerateFormGooglePlaywright:
id: { id: {
initialize() {}, initialize() {},
renderButton() {}, renderButton() {},
prompt() {},
disableAutoSelect() {}, disableAutoSelect() {},
}, },
}, },

View file

@ -1,47 +0,0 @@
"""OpenAPI 3.1 + Redocly recommended, matching internal-portal (DEV-289)."""
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_openapi_uses_redocly_recommended():
redocly = (ROOT / ".redocly.yaml").read_text()
package = (ROOT / "package.json").read_text()
spec = (ROOT / "openapi.yaml").read_text()
ci = (ROOT / ".github" / "workflows" / "ci.yml").read_text()
assert "extends:" in redocly
assert "- recommended" in redocly
assert "operation-2xx-response: off" in redocly
assert "operation-4xx-response: error" in redocly
assert "rule/operation-2xx-or-3xx-response" in redocly
assert "severity: error" in redocly
health = spec.split("/api/health:", 1)[1].split("\n /", 1)[0]
assert '"403":' in health
assert '"400":' not in health
roster = spec.split("/api/roster:", 1)[1].split("\n /", 1)[0]
assert '"403":' in roster
assert '"400":' not in roster
form_status = spec.split("/api/form-status/{week}:", 1)[1].split("\n /", 1)[0]
assert '"400":' in form_status
menu = spec.split("/api/menu/{week}:", 1)[1].split("\n /", 1)[0]
orders = spec.split("/api/orders/{week}:", 1)[1].split("\n /", 1)[0]
assert "MenuWeekPath" in menu
assert "MenuWeekPath" in form_status
assert "OrderWeekPath" in orders
assert "WeekPath" not in spec.split("components:", 1)[1].split("MenuWeekPath", 1)[0]
assert "`current` or `YYYY-WNN`" in spec
assert "`YYYY-WNN` or `YYYY-MM-DD`" in spec
meal = spec.split(" Meal:", 1)[1].split("\n MenuPayload:", 1)[0]
assert 'type: [string, number, "null"]' in meal
assert 'type: [string, "null"]' in meal
assert 'menu_url:\n type: [string, "null"]' in spec
assert "root: openapi.yaml" in redocly
assert '"openapi:lint"' in package
dev = json.loads(package)["devDependencies"]
assert dev["@redocly/cli"]
assert "npm run openapi:lint" in ci
assert "openapi: 3.1.0" in spec
assert "required: [stage, sha]" in spec
assert "{ error: string }" in spec or "`{ error: string }`" in spec

View file

@ -1,66 +0,0 @@
"""Parser for the catalog embedded on the Redefine menu page."""
from pathlib import Path
import pytest
from scraper.parse_menu import MenuParseError, extract_catalog_products, parse_menu_html
FIXTURE = Path(__file__).resolve().parent / "fixtures" / "menu_page.html"
def test_parse_menu_html_maps_catalog_fields():
page = FIXTURE.read_text()
menu = parse_menu_html(
page,
menu_url="https://www.redefinemeals.com/menu",
scraped_at="2026-09-21T07:30:00-04:00",
)
assert menu["meal_count"] == 2
assert menu["scraped_at"] == "2026-09-21T07:30:00-04:00"
korean, sale = menu["meals"]
assert korean["name"] == "Korean Steak Bowl"
assert korean["price"] == 12.49
assert korean["calories"] == 590
assert korean["protein"] == "50g"
assert korean["dietary_tags"] == ["Gluten Free", "Dairy Free"]
assert korean["image_url"] == "https://example.com/korean.png"
assert korean["is_new"] is True
assert korean["description"] == "Shaved ribeye & rice."
assert sale["name"] == "Sale Bowl"
assert sale["price"] == 9.50
assert sale["calories"] == 400
assert sale["protein"] == "30g"
assert sale["is_new"] is False
assert sale["description"] == "On sale."
def test_parse_menu_html_rejects_a_missing_catalog():
with pytest.raises(MenuParseError, match="missing :products"):
parse_menu_html("<html></html>", menu_url="https://example.com/menu")
def test_extract_catalog_products_rejects_an_empty_catalog():
page = "<orders-page :products='[]' :newest-ids='[]'></orders-page>"
with pytest.raises(MenuParseError, match="empty"):
extract_catalog_products(page)
def test_parse_menu_html_rejects_an_empty_catalog():
page = "<orders-page :products='[]' :newest-ids='[]'></orders-page>"
with pytest.raises(MenuParseError, match="empty"):
parse_menu_html(page, menu_url="https://example.com/menu")
def test_parse_menu_html_rejects_a_meal_without_a_price():
page = """
<orders-page
:products='[{"id":1,"name":"Broken","price":null,"on_sale":false,"available":true}]'
:newest-ids='[]'
></orders-page>
"""
with pytest.raises(MenuParseError, match="missing a price"):
parse_menu_html(page, menu_url="https://example.com/menu")

View file

@ -1,145 +0,0 @@
"""publish_menu writes the menu, then the form, then Slack."""
from unittest.mock import MagicMock, patch
import pytest
from server.jobs import publish_menu
from server.jobs import run_job
MENU = {
"scraped_at": "2026-09-21T07:30:00-04:00",
"menu_url": "https://www.redefinemeals.com/menu",
"meal_count": 1,
"meals": [{"name": "Korean Steak Bowl", "price": 12.49}],
}
ENV = {
"MENU_URL": "https://www.redefinemeals.com/menu",
"GOOGLE_CLIENT_ID": "client.apps.googleusercontent.com",
"FORM_BUCKET": "meal-order-manager-form-test",
"DISTRIBUTION_ID": "E123",
"FORM_URL": "https://orders.seahaven.com",
"TABLE_NAME": "meal-order-manager-orders",
}
def _clients(s3, cloudfront):
def client(name, **_kwargs):
if name == "s3":
return s3
if name == "cloudfront":
return cloudfront
raise AssertionError(name)
return client
@patch("shared.slack.post_channel_message", return_value={"ok": True})
@patch("server.jobs.publish_menu.put_menu")
@patch(
"server.jobs.publish_menu.get_settings",
return_value={"bulk_discount_percent": 10, "company_subsidy_percent": 50},
)
@patch("server.jobs.publish_menu.generate_form", return_value="<html>form</html>")
@patch("server.jobs.publish_menu.current_week", return_value="2026-W38")
@patch("server.jobs.publish_menu.fetch_menu", return_value=MENU)
@patch("server.jobs.publish_menu.boto3.client")
def test_publish_menu_uploads_after_the_menu_write(
mock_client,
mock_fetch,
mock_week,
mock_generate,
mock_settings,
mock_put,
mock_slack,
):
s3 = MagicMock()
cloudfront = MagicMock()
mock_client.side_effect = _clients(s3, cloudfront)
order = []
mock_put.side_effect = lambda *args, **kwargs: order.append("menu")
s3.put_object.side_effect = lambda **kwargs: order.append(kwargs["Key"])
cloudfront.create_invalidation.side_effect = lambda **kwargs: order.append(
"invalidate"
)
mock_slack.side_effect = lambda *args, **kwargs: (
order.append("slack") or {"ok": True}
)
with patch.dict("os.environ", ENV, clear=False):
result = publish_menu.lambda_handler({}, None)
assert result == {"status": "published", "week": "2026-W38", "meal_count": 1}
mock_fetch.assert_called_once_with("https://www.redefinemeals.com/menu")
mock_week.assert_called_once()
mock_settings.assert_called_once()
mock_generate.assert_called_once()
assert mock_generate.call_args.kwargs["week"] == "2026-W38"
assert mock_generate.call_args.kwargs["google_client_id"] == ENV["GOOGLE_CLIENT_ID"]
assert mock_generate.call_args.kwargs["bulk_discount"] == 10
assert mock_generate.call_args.kwargs["company_subsidy"] == 50
mock_put.assert_called_once_with("2026-W38", MENU)
assert s3.put_object.call_count == 2
index = s3.put_object.call_args_list[0].kwargs
archive = s3.put_object.call_args_list[1].kwargs
assert index["Bucket"] == ENV["FORM_BUCKET"]
assert index["Key"] == "index.html"
assert index["CacheControl"] == "no-cache"
assert archive["Key"] == "archive/2026-W38.html"
invalidation = cloudfront.create_invalidation.call_args.kwargs
assert invalidation["DistributionId"] == "E123"
assert invalidation["InvalidationBatch"]["Paths"]["Items"] == ["/index.html"]
mock_slack.assert_called_once()
assert mock_slack.call_args.args[0] == (
"This week's meal order is open! Deadline: Thursday at 11:59 PM."
)
assert mock_slack.call_args.args[1][1]["text"]["text"] == (
f"*<{ENV['FORM_URL']}|Place your order>*\n\n*Deadline:* Thursday at 11:59 PM\n"
)
assert order == [
"menu",
"index.html",
"archive/2026-W38.html",
"invalidate",
"slack",
]
@patch("shared.slack.post_channel_message", return_value={"ok": True})
@patch("server.jobs.publish_menu.put_menu")
@patch("server.jobs.publish_menu.get_settings", return_value={})
@patch("server.jobs.publish_menu.generate_form", return_value="<html>form</html>")
@patch("server.jobs.publish_menu.current_week", return_value="2026-W38")
@patch("server.jobs.publish_menu.fetch_menu", return_value=MENU)
@patch("server.jobs.publish_menu.boto3.client")
def test_publish_menu_does_not_notify_when_upload_fails(
mock_client,
_fetch,
_week,
_generate,
_settings,
mock_put,
mock_slack,
):
s3 = MagicMock()
s3.put_object.side_effect = RuntimeError("s3 down")
mock_client.side_effect = _clients(s3, MagicMock())
with patch.dict("os.environ", ENV, clear=False):
with pytest.raises(RuntimeError, match="s3 down"):
publish_menu.lambda_handler({}, None)
mock_put.assert_called_once()
mock_slack.assert_not_called()
def test_run_job_dispatches_publish_menu():
with patch(
"server.jobs.publish_menu.lambda_handler", return_value={"status": "published"}
) as handler:
result = run_job({"event": "publish_menu"})
assert result == {"status": "published"}
handler.assert_called_once()

View file

@ -1,220 +0,0 @@
"""sentry_init: DSN no-op, FlaskIntegration, and before_send scrub."""
from unittest.mock import patch
from botocore.exceptions import ClientError
from sentry_sdk.integrations.flask import FlaskIntegration
import server.sentry_init as sentry_mod
_FAKE_DSN = "https://key@o1.ingest.sentry.io/1"
def _parameter_not_found():
return ClientError(
{"Error": {"Code": "ParameterNotFound", "Message": "not found"}},
"GetParameter",
)
def test_unset_dsn_does_not_init():
with (
patch.dict("os.environ", {}, clear=False),
patch("sentry_sdk.init") as mocked,
):
# Ensure both sources are absent even if a prior test set them.
import os
os.environ.pop("SENTRY_DSN", None)
os.environ.pop("SENTRY_DSN_PARAM", None)
sentry_mod.init_sentry()
mocked.assert_not_called()
def test_empty_dsn_does_not_init(monkeypatch):
monkeypatch.setenv("SENTRY_DSN", "")
monkeypatch.delenv("SENTRY_DSN_PARAM", raising=False)
with patch("sentry_sdk.init") as mocked:
sentry_mod.init_sentry()
mocked.assert_not_called()
def test_literal_unset_dsn_does_not_init(monkeypatch):
monkeypatch.setenv("SENTRY_DSN", "unset")
with patch("sentry_sdk.init") as mocked:
sentry_mod.init_sentry()
mocked.assert_not_called()
def test_set_dsn_inits_flask_integration(monkeypatch):
monkeypatch.setenv("SENTRY_DSN", _FAKE_DSN)
monkeypatch.setenv("STAGE", "dev")
monkeypatch.setenv("GIT_SHA", "abc123def")
with patch("sentry_sdk.init") as mocked:
sentry_mod.init_sentry()
mocked.assert_called_once()
kwargs = mocked.call_args.kwargs
assert kwargs["dsn"] == _FAKE_DSN
assert kwargs["send_default_pii"] is False
assert kwargs["include_local_variables"] is False
assert kwargs["enable_logs"] is False
assert kwargs["traces_sample_rate"] == 0.0
assert kwargs["before_send"] is sentry_mod._before_send
assert kwargs["environment"] == "dev"
assert kwargs["release"] == "abc123def"
integrations = kwargs["integrations"]
assert len(integrations) == 1
assert isinstance(integrations[0], FlaskIntegration)
def test_missing_stage_defaults_environment_to_local(monkeypatch):
monkeypatch.setenv("SENTRY_DSN", _FAKE_DSN)
monkeypatch.delenv("STAGE", raising=False)
monkeypatch.delenv("GIT_SHA", raising=False)
with patch("sentry_sdk.init") as mocked:
sentry_mod.init_sentry()
kwargs = mocked.call_args.kwargs
assert kwargs["environment"] == "local"
assert "release" not in kwargs
def test_sentry_dsn_param_fetches_from_ssm(monkeypatch):
monkeypatch.delenv("SENTRY_DSN", raising=False)
monkeypatch.setenv("SENTRY_DSN_PARAM", "/meal-order-manager/sentry-dsn")
monkeypatch.setenv("STAGE", "dev")
monkeypatch.setenv("GIT_SHA", "deadbeef")
with (
patch("shared.secrets.get_parameter", return_value=_FAKE_DSN) as mock_get,
patch("sentry_sdk.init") as mocked,
):
sentry_mod.init_sentry()
mock_get.assert_called_once_with("/meal-order-manager/sentry-dsn", decrypt=True)
mocked.assert_called_once()
assert mocked.call_args.kwargs["dsn"] == _FAKE_DSN
assert isinstance(mocked.call_args.kwargs["integrations"][0], FlaskIntegration)
def test_sentry_dsn_param_unset_value_does_not_init(monkeypatch):
monkeypatch.delenv("SENTRY_DSN", raising=False)
monkeypatch.setenv("SENTRY_DSN_PARAM", "/meal-order-manager/sentry-dsn")
with (
patch("shared.secrets.get_parameter", return_value="unset"),
patch("sentry_sdk.init") as mocked,
):
sentry_mod.init_sentry()
mocked.assert_not_called()
def test_sentry_dsn_param_not_found_does_not_init(monkeypatch):
monkeypatch.delenv("SENTRY_DSN", raising=False)
monkeypatch.setenv("SENTRY_DSN_PARAM", "/meal-order-manager/sentry-dsn")
with (
patch("shared.secrets.get_parameter", side_effect=_parameter_not_found()),
patch("sentry_sdk.init") as mocked,
):
sentry_mod.init_sentry()
mocked.assert_not_called()
def test_before_send_strips_auth_and_publish_key_headers():
event = {
"request": {
"headers": {
"Authorization": "Bearer secret",
"X-Meals-Publish-Key": "hmac-secret",
"X-Auth-Token": "tok",
"Cookie": "session=abc",
"X-Amz-Date": "20260101T000000Z",
"Content-Type": "application/json",
},
"url": "https://example.invalid/api/submit-order",
}
}
out = sentry_mod._before_send(event, {})
assert out["request"]["headers"] == {"Content-Type": "application/json"}
assert out["request"]["url"] == "https://example.invalid/api/submit-order"
def test_before_send_strips_list_headers():
event = {
"request": {
"headers": [
("Authorization", "Bearer secret"),
("X-Meals-Publish-Key", "hmac-secret"),
("Content-Type", "application/json"),
]
}
}
out = sentry_mod._before_send(event, {})
assert out["request"]["headers"] == [("Content-Type", "application/json")]
def test_before_send_drops_body_and_secret_keys():
event = {
"request": {
"body": '{"google_id_token":"ya29.secret"}',
"data": {"google_id_token": "ya29.secret"},
"method": "POST",
},
"extra": {
"google_id_token": "ya29.secret",
"publish_hmac": "aabbcc",
"bot_token": "xoxb-secret",
"week": "2026-W38",
},
}
out = sentry_mod._before_send(event, {})
assert "body" not in out["request"]
assert "data" not in out["request"]
assert out["request"]["method"] == "POST"
assert "google_id_token" not in out["extra"]
assert "publish_hmac" not in out["extra"]
assert "bot_token" not in out["extra"]
assert out["extra"]["week"] == "2026-W38"
def test_before_send_drops_exception_and_thread_frame_locals():
event = {
"exception": {
"values": [
{
"stacktrace": {
"frames": [
{
"function": "handler",
"vars": {
"google_id_token": "ya29.secret",
"SecretString": "aabbcc",
},
}
]
}
}
]
},
"threads": {
"values": [
{
"stacktrace": {
"frames": [
{
"function": "_require_publish_key",
"vars": {"provided": "hmac-secret"},
}
]
}
}
]
},
"stacktrace": {
"frames": [{"function": "get_secret", "vars": {"item": {"token": "x"}}}]
},
}
out = sentry_mod._before_send(event, {})
assert "vars" not in out["exception"]["values"][0]["stacktrace"]["frames"][0]
assert "vars" not in out["threads"]["values"][0]["stacktrace"]["frames"][0]
assert "vars" not in out["stacktrace"]["frames"][0]
assert (
out["exception"]["values"][0]["stacktrace"]["frames"][0]["function"]
== "handler"
)

View file

@ -1446,33 +1446,6 @@ def test_form_status_open(mock_week, mock_status):
assert "reopen_at" not in body, "reopen_at should NOT be present when form is open" assert "reopen_at" not in body, "reopen_at should NOT be present when form is open"
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
def test_form_status_current_maps_to_current_week(mock_week, mock_status):
from submit_order_handler import lambda_handler
event = _make_event(
method="GET", path="/form-status/current", path_parameters={"week": "current"}
)
status, body = _parse_response(lambda_handler(event, None))
assert status == 200, f"Expected 200, got {status}: {body}"
assert body["week"] == "2026-W20"
mock_status.assert_called_once_with("2026-W20")
@patch("submit_order_handler.get_form_status")
def test_form_status_rejects_invalid_week(mock_status):
from submit_order_handler import lambda_handler
event = _make_event(
method="GET", path="/form-status/nope", path_parameters={"week": "nope"}
)
status, body = _parse_response(lambda_handler(event, None))
assert status == 400, f"Expected 400, got {status}: {body}"
assert "week path param" in body["error"]
mock_status.assert_not_called()
@patch("submit_order_handler.get_form_status", return_value="closed") @patch("submit_order_handler.get_form_status", return_value="closed")
@patch("submit_order_handler.current_week", return_value="2026-W20") @patch("submit_order_handler.current_week", return_value="2026-W20")
def test_form_status_closed_reopen_at(mock_week, mock_status): def test_form_status_closed_reopen_at(mock_week, mock_status):
@ -2164,24 +2137,6 @@ def test_my_orders_empty_when_none(mock_looks_like, mock_portal, mock_get):
assert body == {"week": "2026-W36", "orders": []} assert body == {"week": "2026-W36", "orders": []}
@patch("submit_order_handler.get_order")
@patch(
"submit_order_handler._verify_portal_token",
return_value={
"name": "Portal Employee",
"email": "portal.employee@seahavenind.com",
},
)
@patch("submit_order_handler.looks_like_cognito_token", return_value=True)
def test_my_orders_rejects_current_week(mock_looks_like, mock_portal, mock_get):
status, body = _parse_response(
submit_order_handler.lambda_handler(_orders_event(week="current"), None)
)
assert status == 400
assert "YYYY-WNN" in body["error"]
mock_get.assert_not_called()
def test_my_orders_requires_bearer(): def test_my_orders_requires_bearer():
status, body = _parse_response( status, body = _parse_response(
submit_order_handler.lambda_handler(_orders_event(token=""), None) submit_order_handler.lambda_handler(_orders_event(token=""), None)

View file

@ -1,18 +0,0 @@
"""githubdeploy OIDC trust pins the org reusable with AWS-supported claims."""
from pathlib import Path
IAM = Path(__file__).resolve().parents[1] / "terraform" / "iam_github_deploy.tf"
def test_github_deploy_trust_uses_org_reusable_and_caller():
text = IAM.read_text()
assert "token.actions.githubusercontent.com:sub" in text
assert "repo:Sea-Haven-Industries/meal-order-manager:environment:dev" in text
assert "repo:Sea-Haven-Industries/meal-order-manager:environment:prod" in text
assert (
"Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@*" in text
)
assert "token.actions.githubusercontent.com:job_workflow_ref" in text
assert "token.actions.githubusercontent.com:workflow_ref" not in text
assert "cd-hcp-spa.yaml" not in text

View file

@ -5,14 +5,6 @@ from pathlib import Path
IAM = Path(__file__).resolve().parents[1] / "terraform" / "iam.tf" IAM = Path(__file__).resolve().parents[1] / "terraform" / "iam.tf"
def test_task_boundary_can_publish_the_order_form():
text = IAM.read_text()
assert 'sid = "FormObjects"' in text
assert "cloudfront:CreateInvalidation" in text
assert "${aws_s3_bucket.form.arn}/index.html" in text
assert not (IAM.parent / "iam_github_weekly_menu.tf").exists()
def test_checkcomponents_send_omitted_when_queue_arn_empty(): def test_checkcomponents_send_omitted_when_queue_arn_empty():
text = IAM.read_text() text = IAM.read_text()
assert "compact([var.checkcomponents_queue_arn])" not in text assert "compact([var.checkcomponents_queue_arn])" not in text

View file

@ -1,40 +0,0 @@
"""Sentry DSN is an SSM SecureString; the task receives the parameter name."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TERRAFORM = ROOT / "terraform"
def _read(name: str) -> str:
return (TERRAFORM / name).read_text()
def test_sentry_dsn_is_secure_string_stub():
ssm_tf = _read("ssm.tf")
assert 'resource "aws_ssm_parameter" "sentry_dsn"' in ssm_tf
assert 'name = "${local.ssm_prefix}/sentry-dsn"' in ssm_tf
assert 'type = "SecureString"' in ssm_tf
assert 'value = "unset"' in ssm_tf
assert "ignore_changes = [value]" in ssm_tf
assert 'data "aws_ssm_parameter" "sentry_dsn_value"' not in ssm_tf
def test_ecs_task_receives_sentry_dsn_parameter_name():
ecs_tf = _read("ecs.tf")
assert ecs_tf.count("SENTRY_DSN_PARAM") == 1
assert "aws_ssm_parameter.sentry_dsn.name" in ecs_tf
assert "SENTRY_DSN " not in ecs_tf
def test_terraform_does_not_embed_a_sentry_dsn():
for path in TERRAFORM.glob("*.tf"):
text = path.read_text()
assert "ingest.sentry.io" not in text
assert "SENTRY_DSN =" not in text
def test_deploy_api_injects_sentry_dsn_param():
workflow = (ROOT / ".github/workflows/deploy-api.yaml").read_text()
assert "extra-task-env:" in workflow
assert '"SENTRY_DSN_PARAM":"/meal-order-manager/sentry-dsn"' in workflow

View file

@ -21,9 +21,7 @@ def test_meals_owns_a_vpc_instead_of_looking_up_default():
assert "count = local.manage_vpc ? 1 : 0" in vpc assert "count = local.manage_vpc ? 1 : 0" in vpc
assert 'variable "existing_vpc_id"' in variables assert 'variable "existing_vpc_id"' in variables
assert 'variable "existing_public_subnet_ids"' in variables assert 'variable "existing_public_subnet_ids"' in variables
assert ( assert 'manage_vpc = var.existing_vpc_id == ""' in locals_tf
'manage_vpc = var.existing_vpc_id == "" && !local.is_prod' in locals_tf
)
assert 'data "aws_vpc" "default"' not in ecs assert 'data "aws_vpc" "default"' not in ecs
assert "data.aws_vpc.default" not in ecs assert "data.aws_vpc.default" not in ecs
assert "data.aws_subnets.default" not in ecs assert "data.aws_subnets.default" not in ecs
@ -35,11 +33,3 @@ def test_meals_owns_a_vpc_instead_of_looking_up_default():
assert 'check "existing_subnets_in_vpc"' in data assert 'check "existing_subnets_in_vpc"' in data
assert "from = aws_vpc.this" in vpc assert "from = aws_vpc.this" in vpc
assert "to = aws_vpc.this[0]" in vpc assert "to = aws_vpc.this[0]" in vpc
outputs = _read("outputs.tf")
assert 'output "vpc_id"' in outputs
assert "value = local.vpc_id" in outputs
assert 'output "public_subnet_ids"' in outputs
assert 'check "prod_reuses_afterhours_vpc"' in data
assert "prod_requires_afterhours_vpc" in vpc
assert "Do not mint 10.60." in vpc
assert 'count = var.existing_vpc_id == "" ? 0 : 1' in vpc

View file

@ -51,20 +51,3 @@ def test_worker_deletes_completed_jobs(mock_run, mock_client):
sqs.delete_message.assert_called_once_with( sqs.delete_message.assert_called_once_with(
QueueUrl="https://sqs.example/jobs", ReceiptHandle="rh-1" QueueUrl="https://sqs.example/jobs", ReceiptHandle="rh-1"
) )
@patch("server.worker.sentry_sdk.capture_exception")
@patch("server.worker.boto3.client")
@patch("server.worker.run_job")
def test_worker_captures_job_failures(mock_run, mock_client, mock_capture):
sqs = MagicMock()
mock_client.return_value = sqs
sqs.receive_message.side_effect = _one_message_then_stop({"event": "close"})
mock_run.side_effect = RuntimeError("boom")
with patch.dict("os.environ", {"JOBS_QUEUE_URL": "https://sqs.example/jobs"}):
worker._running = True
worker.main()
mock_capture.assert_called_once()
sqs.delete_message.assert_not_called()