Live site: townofwiley.gov
The official, bilingual (English/Spanish) website for the Town of Wiley, Colorado — built and operated as a modern, low-cost, serverless municipal platform.
| Area | Stack |
|---|---|
| Frontend | Angular 21 (standalone components, signals, OnPush), PrimeNG, SCSS design tokens |
| Hosting | AWS S3 + CloudFront (OIDC-authenticated GitHub Actions deploys, CloudFront Functions for SPA routing) |
| Content (CMS) | AWS AppSync (GraphQL) + DynamoDB; in-app clerk editor at /admin with Cognito staff auth |
| Backend services | AWS Lambda (weather proxy, payments proxy, contact updates, email alias routing) |
| Quality | Vitest unit tests, Playwright e2e (smoke + regression), Trunk lint/format, WCAG AA accessibility |
| CI/CD | GitHub Actions for production frontend deploy (merge main → S3 + CloudFront); Terraform IaC (infrastructure/terraform/); Ansible removed (docs/DEPLOYMENT_SSOT.md); Ollama advisory (docs/ci-ollama-review.md); ops (docs/ops-observability.md) |
Key design goals: non-technical clerks manage all content in-app (no AWS console required), offline-first content caching for residents, and free-tier-friendly AWS architecture.
- Production deploys: merges to
mainauto-deploy after Site CI passes (S3 + CloudFront via GitHub Actions OIDC). Manualnpm run deploy:siteis break-glass only. - Use short-lived feature branches and merge into
mainonly when the change is build-safe. - Keep deployable app changes in
src/,public/,package*.json,angular.json,tsconfig*, andscripts/generate-runtime-config.mjs. - Keep maintainer-facing docs and runbooks tracked in the repo under
docs/,README.md,CLERK-CMS-GUIDE.md,bot-training/, and related operational paths. - Do not commit local reports, temp logs, or machine-specific artifacts.
- GitHub Actions validates deployable paths (CSP parity, etc.). Production static hosting is S3 + CloudFront (see below).
- GitHub Actions uses targeted caches for npm, Playwright browsers, and Angular CLI build artifacts.
Detailed policy: docs/git-workflow.md
Branch protection and required CI gate: docs/github-branch-protection.md
- Node.js
24.xLTS only for this app (Amplify, GitHub Actions, andpackage.json/ensure-node-versionagree).engines.nodeis>=24.15.0 <25.0.0(with.npmrcengine-strict=true). Repo files pin24.16.0(.nvmrc,.node-version,volta,mise.toml, asdf.tool-versions) — seedocs/NODE_VERSION.mdfor why the pin is an exact patch (not “whatever LTS says today”). Do not use Node 22, 23, 25+, or odd majors — Node 25+ has caused toolchain and native dependency issues with this stack. - Use
nvm install && nvm use(reads.nvmrc),mise install, Volta, asdf, Homebrewnode@24, Windows.\scripts\setup-repo-node.ps1, or Dockernode:24-slim(seedocs/NODE_VERSION.md§ Docker) so your shellnodematches CI.
Homebrew (recommended on macOS) — install the LTS keg and make it the default node (the top-level node formula tracks the latest major, often v25+):
brew install node@24
brew unlink node # only if `node -v` shows v25+ from /opt/homebrew/bin/node
brew link --overwrite --force node@24
hash -r
node -v # expect v24.16.0 (matches .nvmrc)If Homebrew relinks node to a newer major after brew upgrade, run brew unlink node && brew link --overwrite --force node@24 again.
nvm / fnm (from repo root, reads .nvmrc):
nvm install && nvm use
node -v # v24.16.0 (or any 24.15+ satisfying engines)Frontend is statically hosted on S3 + CloudFront (Amplify Hosting app d331voxr1fhoir was permanently deleted after migration).
-
S3 bucket (origin):
townofwiley-static-site(us-east-2) -
CloudFront distribution: ID
E1NZ3XCY5CYR1J→d34qrz3qxoppc5.cloudfront.net- Origin: S3
townofwiley-static-site(OAI; OAC E1UXALBLRIDL2E prepared + bucket policy updated for migration; complete in console if needed) - Viewer request Function:
townofwiley-spa-redirect(handles Angular SPA deep links / 403→index.html rewrite) - Aliases:
townofwiley.gov,www.townofwiley.gov - ACM certificate:
arn:aws:acm:us-east-1:570912405222:certificate/a7d4c19b-070a-478b-9f3a-7203e53fcf90(us-east-1) - Default root: (none; SPA handled by function + pre-generated static route entrypoints)
- Origin: S3
-
DNS (Route 53, zone
Z088746831TMIL67NZ0VFfortownofwiley.gov):townofwiley.gov→ A alias →d34qrz3qxoppc5.cloudfront.netwww.townofwiley.gov→ A alias →d34qrz3qxoppc5.cloudfront.net
-
AWS account:
570912405222only. Default profiletownofwiley(see.vscode/settings.json). -
Build output:
dist/townofwiley-app/browser(same as before;scripts/generate-static-route-entrypoints.mjspopulates route folders + 404.html for SPA). -
Migration status (June 2026): Frontend fully moved from Amplify Hosting (app
d331voxr1fhoirdeleted) to S3 + CloudFront. Site restored and hardened on 2026-06-02 after discovering artifacts were under/browser/prefix instead of bucket root. CustomErrorResponses added to prevent raw S3 errors. See git history around this date for details. -
Deploy steps:
- Automatic (normal): merge to
mainwith deployable app changes → Site CI builds and uploads artifact →deploy-productionsyncs to S3 + CloudFront invalidation. Seedocs/github-actions-production-deploy.md. - Manual (break-glass):
# From repo root with AWS_PROFILE (see scripts/agent-aws-env.sh) npm run deploy:site # or: bash scripts/deploy-static-site.sh # dry-run: npm run deploy:site:dry # GitHub: Actions → Deploy production (manual) on main
Ansible removed (July 2026); use Terraform for IaC and merge-to-
mainfor the public site. SSOT:docs/DEPLOYMENT_SSOT.md. Terraform remains for IaC scaffolding only (not static-site publish):npm run terraform:town:plan.The static-site helper applies tiered Cache-Control (immutable for assets, no-cache for HTML/runtime-config) + invalidation. Critical: Output must be at the S3 bucket root (no
browser/prefix). CloudFront origin has no OriginPath. Current hosting uses managed CachingOptimized policy + custom Response Headers Policy (CSP + security headers from customHttp.yml) + access logging. See manifest for IDs. OAC migration prepared (see SOT). - Automatic (normal): merge to
-
Hardening (applied June 2026): CustomErrorResponses configured on the distribution so 403/404 serve
/index.html(HTTP 200). This prevents raw S3 XML errors from ever being shown to visitors again. -
Runtime config: Served as static
runtime-config.jsfrom the S3 bucket (generated at build or viascripts/generate-runtime-config.mjsusing current env / secrets). -
CSP / headers: Managed at CloudFront (or S3 origin) +
customHttp.ymlkept for dev server parity (ng serve) and historical reference. Seedocs/third-party-csp-registry.md.
SPA routing note: The CloudFront Function + static entrypoints ensure /weather, /notices/foo, etc. serve index.html (200) for client-side routing.
Verification (live):
curl -I https://townofwiley.gov/ # 200 + security headers
curl -I https://townofwiley.gov/weather # 200 (SPA rewrite)
nslookup townofwiley.govOld app ID d331voxr1fhoir, old targets (d3fmdu29qcwosh.cloudfront.net, *.amplifyapp.com) are no longer used. The amplify.yml, many scripts/sync-amplify-* scripts, and docs/AMPLIFY_HOSTING_SOT.md are retained only for build process reference, CSP patterns (now also in CloudFront Response Headers Policy), and legacy context. Hosting sync scripts are no-ops for prod. See deprecation notes in docs/AWS_INFRASTRUCTURE_SOT.md and docs/AMPLIFY_HOSTING_SOT.md. Current: S3+CF with managed cache policy, custom security/CSP headers policy, and logging (see manifest + this section).
- Hosted zone:
townofwiley.gov, IDZ088746831TMIL67NZ0VF - Authoritative NS:
ns-360.awsdns-45.com,ns-1383.awsdns-44.org,ns-1718.awsdns-22.co.uk,ns-530.awsdns-02.net
Custom Lambdas, AppSync, DynamoDB, and remaining backends are defined in the repo and checked against live AWS (account 570912405222, region us-east-2).
| Resource | SSOT in repo |
|---|---|
| Expected Lambdas, DynamoDB, S3, Function URL AuthType | infrastructure/aws-infrastructure.manifest.json |
| Amplify Gen2 / backend env var names (no secrets) | infrastructure/amplify-branch-env.manifest.json (still relevant for runtime-config and any remaining Amplify-managed AppSync/Cognito resources) |
| Hosting build + CSP origins + SPA rules | customHttp.yml (CSP SSOT for dev parity), docs/third-party-csp-registry.md, scripts/generate-static-route-entrypoints.mjs |
| Operator runbook + deploy order | docs/AWS_INFRASTRUCTURE_SOT.md |
| Full product / AP tracker | docs/post-development-inventory.md |
Verify live AWS matches the manifest:
npm run verify:aws-infraDeploy contact / chatbot backends (after code review):
python scripts/deploy-*.py # see individual scriptsRecent infra changes (June 2026):
- Full migration of frontend hosting from Amplify to S3
townofwiley-static-site+ CloudFrontE1NZ3XCY5CYR1J. - Legacy Amplify app
d331voxr1fhoirdeleted. - Wiley Widget (Aurora, NAT, App Runner, separate Amplify app) fully decoupled and removed.
- All WAF rate-limit WebACLs removed; CloudWatch retention minimized to 1 day.
- Costs now free-tier safe. See
docs/aws-cost-optimization-runbook.mdfor details.
The blocked Invoke-RestMethod calls came from the Copilot terminal execution policy in this environment, not from any workspace file in this repository. There are no repo-level Copilot customization or hook files present here to change that behavior.
This repository now includes a repo-local user secrets workflow that keeps plaintext credentials out of git while still making encrypted secrets portable across machines.
Tracked files:
secrets/encrypted/user-secrets.lockbox.jsonstores encrypted secrets that can be committed and synced.secrets/templates/user-secrets.template.jsondocuments the supported secret structure and non-secret metadata.scripts/user-secrets.mjsprovides the lock, unlock, status, and environment import commands.
Gitignored shield:
secrets/local/user-secrets.jsonis the editable plaintext file.secrets/local/.passphrasestores the local encryption passphrase if you do not want to pass it in an environment variable.- The
secrets/localfolder is protected by gitignore so plaintext never enters the repository history.
Commands:
npm run secrets:init
npm run secrets:init:local-passphrase
npm run secrets:status
npm run secrets:unlock
npm run secrets:lock
npm run secrets:lock:prune
npm run secrets:prune-local
npm run secrets:import-envCross-machine usage:
- Pull the repository so the encrypted lockbox is present.
- Provide the same passphrase on the new machine through
TOW_SECRETS_PASSPHRASEor, if you accept the local-at-rest tradeoff,secrets/local/.passphrase. - Run
npm run secrets:unlockto hydrate the local gitignored plaintext file.
Practical workflow:
npm run secrets:init- Set
TOW_SECRETS_PASSPHRASEor, for convenience on one machine, runnpm run secrets:init:local-passphrase. npm run secrets:unlock- Edit or import secrets locally.
npm run secrets:lock:pruneto update the tracked ciphertext and remove local plaintext afterward.
Current security hardening:
package.jsonnow overridesundicito^7.24.5so the dependency tree does not stay pinned to the vulnerable7.22.0version pulled in by@angular/build. This override should be re-evaluated after each Angular major upgrade — runnpm auditto check whether the upstream package has resolved the issue so the override can be removed.
public/runtime-config.js is generated at build time by scripts/generate-runtime-config.mjs and is listed in .gitignore. It must never be committed to the repository because it contains live endpoint URLs pulled from secrets.
Required Amplify environment variables (set in Amplify Console → App settings → Environment variables for the main branch):
| Variable | Purpose |
|---|---|
APPSYNC_CMS_ENDPOINT |
AppSync GraphQL endpoint URL |
APPSYNC_CMS_API_KEY |
AppSync public-read API key |
APPSYNC_CMS_REGION |
AWS region (e.g. us-east-2) |
EASYPEASY_CHAT_URL |
Easy-Peasy bot embed URL |
SEVERE_WEATHER_SIGNUP_API_ENDPOINT |
Lambda Function URL for alert signup |
SEVERE_WEATHER_SIGNUP_ENABLED |
true / false |
LOG_ENDPOINT |
Frontend log ingest endpoint |
CONTACT_UPDATE_API_ENDPOINT |
Lambda Function URL for contact updates (write) |
CONTACT_UPDATE_REVIEW_API_URL |
JWT-protected staff review API for /admin#updates |
CONTACT_UPDATE_REVIEW_PROXY_URL |
Deprecated public proxy (use review API URL instead) |
CLERK_SETUP_AWS_ACCOUNT_ID |
Town AWS account ID shown on the unified /admin CMS hub |
CLERK_SETUP_AMPLIFY_APP_ID |
Amplify app ID used for the /admin CMS hub links |
CLERK_SETUP_AWS_REGION |
AWS region used to build /admin console links |
CLERK_SETUP_AWS_CONSOLE_URL |
Optional direct AWS console URL for the /admin CMS hub |
CLERK_SETUP_STUDIO_URL |
Optional override for the AppSync Queries console URL shown under Advanced (IT) on /admin |
Production builds are strict: npm run prebuild / Amplify / GitHub Actions require every key in infrastructure/amplify-branch-env.manifest.json (requiredForProduction). Missing vars fail the build with a clear error. Local npm start still allows empty values for optional dev work.
Verify live CMS after deploy: npm run verify:runtime-config-cms. API key rotation: docs/appsync-api-key-rotation-runbook.md.
The site can now load the Easy-Peasy chatbot from deployment-time runtime config instead of hardcoding a bot URL into the Angular app shell.
How it works:
public/runtime-config.jsstores the browser-safe chatbot settings.public/easy-peasy-loader.jsinjects the Easy-Peasy widget only when a chatbot URL is configured.npm startandnpm run buildboth regeneratepublic/runtime-config.jsbefore Angular starts.
Configuration sources:
EASYPEASY_CHAT_URL- Optional
EASYPEASY_BUTTON_POSITION secrets/local/user-secrets.json -> chatbot.easyPeasy.chatUrl
Amplify setup:
- Create the bot in Easy-Peasy and copy the bot URL.
- Add
EASYPEASY_CHAT_URLas an Amplify environment variable for themainbranch. - Redeploy. If the value is present, the widget loads automatically on every page.
If no chatbot URL is configured, the site renders normally and no Easy-Peasy script is injected.
Homepage and resident-facing content is stored in AWS AppSync (GraphQL). The old browser-local CMS workflow has been disabled. Amplify Studio / Data Manager was decommissioned June 2026.
Plain-language source of truth for staff:
- Daily editing path: https://townofwiley.gov/admin → task hub → Edit content (in-app forms)
- Staff sign-in:
/admin/login(Cognito) - IT bulk GraphQL: AppSync Queries console — link under Advanced (IT) → Open content editor on
/admin - Legacy
/clerk-setuplinks redirect to/adminand preserve supported tab fragments - Non-technical instructions:
CLERK-CMS-GUIDE.md - Model and route matrix (engineering):
docs/CMS-MODEL-ROUTE-MATRIX.md - AWS / AppSync operations checklist:
docs/CMS-STUDIO-OPERATIONS-CHECKLIST.md - Verify CMS + live site:
docs/CMS-VERIFY-STUDIO.md - Build-time check that the public GraphQL query matches schema auth:
npm run verify:public-cms-query(also runs inprebuild)
The /admin page includes task-guided editing, document publishing uploads, CMS connection test, and IT inventory (AppSync Queries link).
The repo now includes a scheduled site monitor that emails bigessfour@gmail.com when the public site or CMS API stops behaving normally.
What it checks:
https://townofwiley.gov/https://townofwiley.gov/weatherhttps://townofwiley.gov/noticeshttps://townofwiley.gov/meetingshttps://townofwiley.gov/serviceshttps://townofwiley.gov/recordshttps://townofwiley.gov/businesseshttps://townofwiley.gov/newshttps://townofwiley.gov/contacthttps://townofwiley.gov/accessibilityhttps://townofwiley.gov/documentshttps://townofwiley.gov/admin- the AppSync CMS endpoint from
src/amplifyconfiguration.json
Deployment and test scripts:
npm run test:infra:monitor
npm run deploy:site-monitorOperational logging note:
- CloudFront access logs are useful for edge-level request patterns, scanner traffic, and status-code spikes, but they do not prove that the correct resident-facing page content rendered.
- The frontend logger reads
LOG_ENDPOINTintopublic/runtime-config.js, but that value must point to a dedicated log-ingest service. Do not point it at the severe-weather signup API unless that backend explicitly implements a/logroute. - The site monitor is the primary route-level guardrail for catching real public-page regressions.
The public site now defaults to English and exposes a runtime language switch so residents can move between Spanish and English without a rebuild.
Current implementation notes:
- The selected language is persisted in browser storage under
tow-site-language. - The public shell, weather panel chrome, AI assistant chrome, and
/adminoperations route all switch languages at runtime. - Homepage CMS content is localized in the frontend with bundled Spanish fallbacks and known-text mappings layered over the current single-language AppSync models.
- If CMS content changes to brand-new English text that is not yet covered by the translation map or bilingual CMS fields, that field will fall back to English until updated.
Plain-language staff guide:
- See CLERK-CMS-GUIDE.md for the current staff workflow.
Current scope:
- Staff publishing:
/adminin-app forms → AppSync GraphQL (Cognito) - Public read path: AppSync GraphQL API with a runtime-injected read key
- Models in use:
SiteSettings,AlertBanner,Announcement,Event,OfficialContact,LeadershipRosterEntry,Business,PublicDocument,ExternalNewsLink,SiteCopy, plus staff-onlyEmailAlias /adminroute: unified operations hub with task cards, CMS connection proof, contact updates, and IT AppSync Queries link
Runtime configuration sources for the public CMS read path:
APPSYNC_CMS_REGIONAPPSYNC_CMS_ENDPOINTAPPSYNC_CMS_API_KEYsecrets/local/user-secrets.json -> cms.appSync
Operational notes:
- Routine homepage and CMS content should be changed on
/admin, not in source code. - The site falls back to bundled homepage content if AppSync runtime config is missing or the CMS request fails.
- The repo secrets workflow now carries the AppSync endpoint and public read key in the encrypted lockbox for future maintainers.
The public site now includes a resident-facing document hub at /documents.
Current implementation status:
- The homepage records center, transparency actions, selected search results, and meeting-related calls to action now route residents into stable public document destinations instead of generic section anchors.
- The
/documentspage is organized into four resident-facing destinations:- records requests
- meeting documents
- financial documents
- code references
- Meeting agendas and minutes on
/meetingscome from activePublicDocumentrows withsectionId: meeting-documents(optional Spanish fieldstitleEs,summaryEs,statusEs). Upload via/admindocument publishing. - Staff workflow:
docs/CLERK-CMS-GUIDE.mdanddocs/town-document-publishing-guide.md. Legacy HTML guides remain underpublic/documents/archive/as href targets. - Ops one-time seed for former static guides:
npm run seed:public-documents(after schema deploy).
Traceability:
src/app/document-hub/document-links.tssrc/app/document-hub/document-hub.tssrc/app/records-center/records-center.tsdocs/README.md— documentation index and current statusdocs/post-development-inventory.md— post-build audit and AP remediation logdocs/town-document-publishing-guide.mdsrc/app/app.tsdocs/incomplete-items-reference.md
The Town's preferred utility payment rollout path is now Paystar because it best fits the current RVS Mosaics setup and can be incorporated into the AWS Amplify-hosted site with the least friction.
Current implementation status:
- The public payment card still supports billing-help email as the fallback path.
- Hosted Paystar portal links via
resolveQuickPayHref()on/pay-billand/services(seesrc/app/payments/paystar-quick-pay.ts). - CTAs are disabled when
PAYSTAR_PORTAL_URLis empty; no in-browser API or proxy path.
Traceability:
src/app/payments/paystar-config.tssrc/app/payments/paystar-quick-pay.tssrc/app/resident-services/resident-services.tsdocs/incomplete-items-reference.md
Runtime configuration sources:
PAYSTAR_PORTAL_URL(hosted portal URL; defaults inscripts/lib/runtime-config-env.mjs)
Recommended deployment path:
- Set
PAYSTAR_PORTAL_URLto the Town's live Paystar payment page. - Redeploy static site so
runtime-config.jsexposes the portal URL.
Operational note:
- Mapping inside
mapUpstreamJsonToTownLaunchResponseandbuildUpstreamLaunchBodyare scaffolds; update them to match Paystar’s tenant OpenAPI when credentials are available.
The Town mail-routing path should use AWS-managed forwarding rather than personal mailbox rules so townofwiley.gov addresses stay under Town control even when the staff member's current inbox changes.
Selected AWS method:
- Receive inbound town mail through Amazon SES.
- Store the raw inbound message in S3.
- Trigger a Lambda forwarder from the S3 object-created event.
- Look up the destination inbox from a private
EmailAliasrecord (staff-only CMS model). - Forward the message to the staff member's current inbox by SES using a verified Town sender.
Why this is the best fit here:
- It supports alias-style forwarding such as
steve.mckitrick@townofwiley.gov -> bigessfour@gmail.comwithout moving staff into a new mailbox system first. - The routing data is managed on
/admin→ Manage email forwarding (or AppSync Queries for IT). - Public contact cards can stay in
OfficialContact, while forwarding destinations remain private and are never exposed through the public API key. - The Lambda forwarder keeps the logic in AWS, so the Town can later swap destination inboxes without editing Route 53 records or personal Gmail rules.
Important scope note:
- This scaffold is for inbound forwarding first.
- If the Town later wants staff to send mail as
townofwiley.govfrom Gmail or another client, that should be handled separately with SES SMTP or Amazon WorkMail after forwarding is stable. - SES inbound receiving may need to live in an AWS region that supports email receiving even if the rest of the site stays in
us-east-2.
Current live SES status in Ohio:
- The
townofwiley.govdomain identity is verified in Amazon SESus-east-2. - Easy DKIM is active and the Route 53 hosted zone now carries the SES DKIM CNAME records for the domain.
- The SES account in
us-east-2is no longer sandbox-limited. - Current Ohio SES sending quotas are
50,000messages per 24 hours and14messages per second. - The current SES account details in
us-east-2reportMailType=TRANSACTIONALandWebsiteURL=http://townofwiley.gov.
What this means now:
- Outbound Town mail through SES in
us-east-2is available. - The live alias router is now configured to forward mail using
steve.mckitrick@townofwiley.govas the sender. - The remaining mail work is now primarily bucket hardening, rollout of the rest of the alias records, and live end-to-end mail validation.
- The
EmailAliasmodel uses DynamoDB tableEmailAlias-j7b2x3sh7rcezekekkxxiak7hi-mainon the Gen 1 AppSync API. See gen1-production-bindings.json and gen2-decommissioned.md. - The alias router now supports split-region operation so inbound processing can run in an SES-receiving region such as
us-east-1while forwarded outbound mail continues through the verifiedus-east-2SES sender. - The first-pass alias router infrastructure is now deployed with Lambda
TownOfWileyEmailAliasRouter, IAM roleTownOfWileyEmailAliasRouterRole, S3 buckettownofwiley-email-alias-570912405222-us-east-1, and active SES receipt rule setTownOfWileyAliasForwardinginus-east-1. - Route 53 now publishes
townofwiley.gov MX 10 inbound-smtp.us-east-1.amazonaws.comand the change is fully in sync. - The first live
EmailAliasrecord is active forsteve.mckitrick@townofwiley.gov -> bigessfour@gmail.com. - The current AWS principal could not apply
s3:PutBucketPublicAccessBlock, so that bucket-hardening step still needs to be completed by a principal with that permission.
CMS model split:
OfficialContact: public role, label, detail, and public alias email shown on the websiteEmailAlias: private alias-to-destination mapping used only by the forwarding worker
EmailAlias model fields:
aliasAddressdestinationAddressdisplayNameroleLabelactivenotes
Traceability:
amplify/backend/api/townofwiley/schema.graphqlsrc/app/cms-admin/cms-admin.tssrc/app/cms-admin/cms-admin.htmlinfrastructure/email-alias-router/app.pyinfrastructure/email-alias-router/tests/test_app.pyscripts/deploy-email-alias-router.pydocs/town-email-alias-forwarding-runbook.mddocs/incomplete-items-reference.md
Recommended deployment shape:
- Apply S3 public-access-block settings on
townofwiley-email-alias-570912405222-us-east-1with a principal that hass3:PutBucketPublicAccessBlock. - Add the remaining
EmailAliasrecords on/admin→ Manage email forwarding for each Town mailbox alias. - Send live test mail to each alias before staff relies on it.
Repo-backed deployment path:
- Fill in the
mail.aliasForwardingsection insecrets/local/user-secrets.json. - Run
npm run deploy:email-alias-router. - Follow the operator steps in docs/town-email-alias-forwarding-runbook.md.
Required Lambda environment variables:
EMAIL_ALIAS_TABLE- Optional
EMAIL_ALIAS_TABLE_REGIONwhen the EmailAlias table lives outside the Lambda region - Optional
EMAIL_ALIAS_INDEX_NAMEwith defaultbyAliasAddress FORWARDER_FROM- Optional
ALIAS_DOMAINwith defaulttownofwiley.gov
Current first live alias:
- Public alias:
steve.mckitrick@townofwiley.gov - Current destination inbox:
bigessfour@gmail.com
Validation command:
npm run test:infra:mailThe homepage weather panel now supports two modes:
- Direct browser requests to
api.weather.govfor local development and simple fallback behavior. - A Town of Wiley AWS proxy endpoint for production, which is the preferred path because NWS expects a meaningful
User-Agentheader that browsers cannot set.
Runtime configuration sources:
NWS_PROXY_ENDPOINT- Optional
NWS_ALLOW_BROWSER_FALLBACK secrets/local/user-secrets.json -> weather.nws.apiEndpointsecrets/local/user-secrets.json -> weather.nws.allowBrowserFallback
Maintainer reference values for this site:
- Town: Wiley, Colorado
- ZIP code:
81092 - Primary display location used in the UI:
Wiley, CO - Point lookup used by the frontend and proxy:
38.154,-102.72 - Forecast page link used by the UI:
https://forecast.weather.gov/MapClick.php?lat=38.155356&lon=-102.719248 - Forecast zone used for alert filtering:
COZ098 - Zone label from NWS:
Lamar Vicinity / Prowers County - Current design intent: treat
COZ098as the practical Wiley service area, which covers Wiley plus the surrounding area well beyond a 20-mile radius
Why COZ098 matters:
- The severe-weather logic does not try to calculate a literal radius.
- The NWS zone is the official alert boundary used by the site.
- If alerts ever look wrong, verify the zone first before changing code.
- Current alert endpoint pattern:
https://api.weather.gov/alerts/active?zone=COZ098
Core operational files:
- Frontend weather component: src/app/weather-panel/weather-panel.ts
- Frontend weather template: src/app/weather-panel/weather-panel.html
- AWS weather proxy handler: infrastructure/nws-weather-proxy/index.mjs
- Runtime config generator: scripts/generate-runtime-config.mjs
- Local/encrypted secrets workflow: scripts/user-secrets.mjs
Required runtime settings:
- Amplify environment variable:
NWS_PROXY_ENDPOINT - Optional Amplify environment variable:
NWS_ALLOW_BROWSER_FALLBACK - Lambda environment variable:
NWS_USER_AGENT - Optional Lambda environment variable:
NWS_API_KEY - Optional Lambda retry tuning (see
infrastructure/nws-weather-proxy/index.mjs):NWS_RETRY_MAX_ATTEMPTS(default4, min 2 max 8),NWS_RETRY_BASE_MS(default1000, exponential backoff base),NWS_RETRY_MAX_DELAY_MS(default20000, cap per wait includingRetry-Afterfor 429)
Current resident-facing weather UI behavior:
- The weather panel shows the forecast, active alerts, and a severe-weather signup form when
weather.alertSignup.enabledandweather.alertSignup.apiEndpointare present in runtime config. - The signup form posts to
POST /subscriptionson the severe-weather backend and asks residents to confirm before alerts begin. - The resident-facing signup is currently limited to ZIP code
81092because the backend enforces that service area. - The checked-in runtime config currently enables this signup form and points it at the live severe-weather backend, so if the form disappears in production the first thing to verify is whether
public/runtime-config.jswas regenerated with the expected alert-signup block during the build. - The live severe-weather backend sender is now
alerts@townofwiley.gov, which is allowed through the verifiedtownofwiley.govSES domain identity inus-east-2. - Email confirmations are working through SES, and SMS confirmations are now live through Amazon SNS in
us-east-2with transactional SMS delivery enabled for this account. - SES and SNS SMS are separate AWS delivery systems, so both services need to remain configured in the same deployment path.
- The severe-weather backend keeps subscription records in DynamoDB with the subscriber channel, normalized destination, full name, preferred language, ZIP code, status, and confirmation/unsubscribe tokens, so the site is already tracking who signs up.
- Scheduled alert fan-out now isolates individual send failures so one bad destination does not stop the rest of the alert run.
- The developer-only smoke-test token is stored in AWS Secrets Manager under
TownOfWileySevereWeatherDeveloperTestTokenand mirrored in the repo's encrypted secrets locker instead of being hardcoded in Lambda config. - CloudWatch alarms are configured for both the normal alert-trigger event and delivery failures, with SNS topic notifications sent to the configured alarm recipient.
- Reusable developer-only alert smoke tests can be run against the backend with
scripts/send-developer-weather-test.py; that route sends only to the explicit email and SMS recipients you provide and does not fan out to the subscriber table.
Recommended NWS_USER_AGENT format:
TownOfWileyWeather/1.0 (contact: bigessfour@gmail.com)
AWS account and hosting identifiers that future maintainers will need:
- Town of Wiley AWS account ID:
570912405222(production site, Town Lambdas, IAM usercopilot) - Code Platoon AWS account ID:
388691194728(separate from Town; wrong profile →NoSuchEntityforcopilotin IAM) - AWS region:
us-east-2 - Amplify app ID:
d331voxr1fhoir - Amplify app name:
Townofwiley - Production branch:
main - Static build output:
dist/townofwiley-app/browser
Expected runtime behavior:
- If
NWS_PROXY_ENDPOINTis set, the weather panel uses the AWS proxy. - If the proxy fails and browser fallback is enabled, the site retries against public
api.weather.gov(with exponential backoff andRetry-Afteron 429, same policy as the Lambda proxy). - If the proxy fails and browser fallback is disabled, the site shows an error state and links residents to the full forecast page.
Common failure points and what to verify:
- No weather data appears at all:
Check that
public/runtime-config.jscontains the expectedweatherblock after build or deploy. - Proxy returns errors:
Check that the Lambda has
NWS_USER_AGENTset and that the string still includes a valid maintainer contact. - Alerts look too broad or too narrow:
Reconfirm that Wiley is still being mapped to
COZ098and that NWS has not changed the point-to-zone mapping. - Browser works locally but production fails:
Verify
NWS_PROXY_ENDPOINTin Amplify and confirm the deployed proxy URL still responds with JSON. - Tests fail only on mobile: Check the Playwright smoke suite first; mobile interactions are covered there specifically for chat and weather refresh.
Useful manual verification URLs:
- Point metadata:
https://api.weather.gov/points/38.154,-102.72 - Active alerts for Wiley area:
https://api.weather.gov/alerts/active?zone=COZ098 - Public forecast page:
https://forecast.weather.gov/MapClick.php?lat=38.155356&lon=-102.719248 - National forecast maps:
https://www.weather.gov/forecastmaps
Production recommendation:
- Deploy
infrastructure/nws-weather-proxy/index.mjsas an AWS Lambda-backed HTTP endpoint. - Set
NWS_USER_AGENTon that function. - Set
NWS_PROXY_ENDPOINTin Amplify so the Angular app uses the AWS proxy instead of direct browser requests. - Leave browser fallback enabled only if you want a safety net during rollout.
- Amplify → Environment variables:
- NWS_PROXY_ENDPOINT = your-lambda-function-url.lambda-url.us-east-2.on.aws/
- NWS_ALLOW_BROWSER_FALLBACK = true
- Lambda Console → NWS proxy function → Environment variables:
- NWS_USER_AGENT =
townofwiley.gov/1.0 (your-email@domain.com)
- NWS_USER_AGENT =
- Lambda → Function URL → CORS: clear Allow Origins (let code handle it)
- Run: npm run verify:nws-weather-proxy-aws
- Redeploy Amplify (forces runtime-config.js update)
This is the #1 cause of "NWS unavailable" on the live site.
Homepage NWS alert banner:
HomepageWeatherAlertPrimeruses the sameweather.apiEndpointas the weather panel when it is set (so the banner respects the AWS proxy and NWSUser-Agentpolicy). If the proxy fails andallowBrowserFallbackis true, it falls back to the publicapi.weather.govchain. In development builds, proxy or NWS failures log a singleconsole.warnfrom[HomepageWeatherAlertPrimer]to aid debugging.
Verify the deployed Lambda (requires AWS CLI credentials for account 570912405222, region us-east-2):
export AWS_PROFILE=townofwiley
export NWS_WEATHER_LAMBDA_FUNCTION_NAME='your-nws-proxy-lambda-name'
./scripts/verify-nws-weather-proxy-aws.shThis repository assumes townofwiley → 570912405222. Use other profiles (e.g. Code Platoon 388691194728) outside this workspace default; do not reuse them as the Wiley site profile.
The script checks caller identity, NWS_USER_AGENT, and lists function URL configs. If your CLI profile points at another account, the script warns so you can switch profiles before trusting the output.
The repository now includes a Python AWS backend for resident severe weather signups, confirmation links, unsubscribe handling, and scheduled NWS alert fanout for Wiley service area residents.
Core backend files:
- Signup Lambda handler: infrastructure/severe-weather-signup/app.py
- Lambda entrypoint shim: infrastructure/severe-weather-signup/index.py
- Backend tests: infrastructure/severe-weather-signup/tests/test_app.py
- Deployment script: scripts/deploy-severe-weather-backend.py
- Frontend signup form logic: src/app/weather-panel/weather-panel.ts
- Frontend signup form template: src/app/weather-panel/weather-panel.html
Service contract:
- Allowed resident ZIP code:
81092 - NWS alert zone:
COZ098 - Supported notification channels:
emailandsms - Supported alert languages:
enandes - Public routes:
POST /subscriptionsGET /confirmGET /unsubscribeGET /health
- Scheduled route source: EventBridge
rate(5 minutes)by default - Delivery dedupe: same NWS VTEC series (e.g. Heat Advisory CON updates) is suppressed for 6 hours; new series,
NEW/EXT/UPG/CAN, severity upgrades, andmessageType: Alertstill notify immediately
Required AWS resources created by the deploy script:
- Lambda function running on
python3.13 - Lambda Function URL with public unauthenticated access
- DynamoDB subscriptions table
- DynamoDB delivery deduplication table
- EventBridge schedule for repeated alert polling
- IAM role with Lambda basic execution, DynamoDB access, SNS publish, SES send, End User Messaging
SendTextMessage, and Amazon TranslateTranslateText - Toll-free SMS origination via AWS End User Messaging (two-way enabled for STOP/HELP)
Live backend identifiers at last successful deployment:
- Lambda function name:
TownOfWileySevereWeatherBackend - Lambda role:
arn:aws:iam::570912405222:role/TownOfWileySevereWeatherRole - Public Function URL:
https://o2k7thyksa422tsobyokzgnaly0smhzf.lambda-url.us-east-2.on.aws - Subscriptions table:
TownOfWileySevereWeatherSubscriptions - Deliveries table:
TownOfWileySevereWeatherDeliveries - EventBridge rule name:
TownOfWileySevereWeatherPoller - SMS origination (toll-free):
+18666509844 - SMS configuration set:
Alert - Current sender email:
bigessfour@gmail.com - Current notification sender name:
Town of Wiley Alerts - Current NWS user agent:
TownOfWileyWeather/1.0 (contact: bigessfour@gmail.com) - Current allowed ZIP code:
81092 - Current alert zone:
COZ098
Runtime / hosting settings related to alert signup:
SEVERE_WEATHER_SIGNUP_API_ENDPOINT/weather.alertSignup.apiEndpointshould point at the Function URL aboveSEVERE_WEATHER_SIGNUP_ENABLED=true/weather.alertSignup.enabled
Operational warning for future maintainers:
- If email confirmations suddenly stop working, verify the SES identity status for
bigessfour@gmail.cominus-east-2first. - If SMS confirmations fail, verify End User Messaging toll-free registration is
COMPLETE, Lambda envSMS_ORIGINATION_IDENTITY=+18666509844, and IAM allowssms-voice:SendTextMessageon that phone number. - If the Function URL starts returning
403, check both Lambda resource-policy statements for Function URL access before changing app code. - The current IAM user still lacks
events:DescribeRule, so deployment verification from this workspace may not be able to read back the EventBridge rule even when the scheduler itself already exists.
Required runtime and secret settings:
- Lambda environment variables:
SUBSCRIPTIONS_TABLEDELIVERIES_TABLESENDER_EMAILNOTIFICATION_SENDER_NAMEALLOWED_ZIP_CODEALERT_ZONE_CODEPUBLIC_API_BASE_URLNWS_USER_AGENTSMS_ORIGINATION_IDENTITY(default+18666509844)SMS_CONFIGURATION_SET(defaultAlert)- Optional
NWS_API_KEY
- Hosting / runtime-config equivalents:
SEVERE_WEATHER_SIGNUP_API_ENDPOINTSEVERE_WEATHER_SIGNUP_ENABLED
- Repo-local secrets support:
weather.alertSignup.enabledweather.alertSignup.apiEndpointweather.alertSignup.senderEmail
Deployment flow:
- Unlock or import repo-local secrets so AWS credentials, Amplify app ID (if still used), and NWS sender values are available.
- Ensure the sender address you plan to use in
SENDER_EMAILis verified in SES forus-east-2. - Run
npm run deploy:severe-weather-backend(orpython3 scripts/deploy-severe-weather-backend.py). - The script packages the Python backend, creates or updates the Lambda function, creates the Function URL, provisions DynamoDB tables, configures the EventBridge poller, enables two-way SMS on the toll-free number, updates hosting env when applicable, and starts an Amplify release unless skipped.
Optional deployment flags:
python3 scripts/deploy-severe-weather-backend.py --skip-amplify-release
python3 scripts/deploy-severe-weather-backend.py --sender-email alerts@townofwiley.gov
python3 scripts/deploy-severe-weather-backend.py --branch-name main
python3 scripts/deploy-severe-weather-backend.py --sms-origination-identity +18666509844Operational notes:
- The deploy script reads AWS credentials and default metadata from
secrets/local/user-secrets.jsonwhen environment variables are not already set. - The weather signup form now lets residents choose English or Spanish alerts. The backend stores that preference and uses Amazon Translate
translate_textfor Spanish confirmation and alert delivery, while preserving confirmation and unsubscribe URLs. - Email confirmation and alert delivery will remain blocked until the configured SES sender identity is verified.
- SMS confirmation and alert delivery use AWS End User Messaging (
SendTextMessage) from toll-free+18666509844(not classic SNSPublish). Two-way SMS is enabled so carrierSTOP/HELPcan route to SNS topicTownOfWileySevereWeatherSmsInbound. Monthly SMS spend limits still apply.
The weather integration is now covered at three layers:
- Angular browser unit tests for direct NWS, proxy mode, and proxy fallback.
- Node-level proxy tests for the AWS handler.
- Playwright smoke coverage for homepage weather rendering, severe-weather signup, and refresh behavior.
Commands:
npm run test:unit:browser
npm run test:infra
npm run test:infra:alerts
npm run test:e2e:smoke
npm run test:regressionMobile-specific regression coverage now checks:
- Programmatic chat submission on the mobile homepage
- Chat fallback handling when the proxy returns malformed data
- Weather refresh behavior on the mobile homepage without a full page reload
This repository now includes a tracked Git pre-push hook at .githooks/pre-push.
Behavior:
- Runs Trunk formatting across tracked repository files before every push.
- Allows the push to continue only if Trunk leaves the tracked file set unchanged.
- Aborts the push if formatting changed any tracked file so the formatted result can be reviewed and committed first.
One-time setup for each clone:
git config core.hooksPath .githooksManual verification:
trunk fmt --allOperational note:
- The hook requires the Trunk CLI to be installed and on
PATH. - The current repo-local Trunk configuration lives in .trunk/trunk.yaml.
This section tracks post-build remediation. Source of truth: docs/post-development-inventory.md and docs/README.md. AP-03 (Paystar placeholder) merged 2026-05-22.
- Specs documented in
docs/feature-completion-spec.md. - Build in progress: Payments, Docs Hub, Permits/Business Directory.
- Review pending: Tests, manual validation on staging.
- Pending: Expanded checklist and audits.
- Pending: Test coverage improvements.
- Pending: Polish and accessibility fixes.
- Pending: Full validation and log.
Final validation run: Pending. Current scores: Functionality 6/10 → progressing.