# Sea Haven Door Unlock API ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white) ![Terraform](https://img.shields.io/badge/HCP-Terraform-7B42BC?logo=terraform&logoColor=white) ![CI](https://github.com/Sea-Haven-Industries/seahaven-door-unlock-api/actions/workflows/ci.yaml/badge.svg) AWS Lambda middleware that allows Yealink desk phones to unlock the front door and manage lockdown profiles via LenelS2 Elements. Target account is **seahaven-prod** (`011934824531`) under HCP Terraform workspace `seahaven-door-unlock-api-prod`. The workspace working directory is `terraform/`. VCS file triggers use `trigger-patterns = [terraform/**/*, lambda/**/*]` because `terraform/build_packages.sh` bundles handlers from `lambda/`. A `lambda/`-only merge must still queue a run. ``` Yealink T54W/T57W/T58W → HTTPS GET (?token=) → API Gateway (token authorizer) → Lambda → LenelS2 Elements API ``` Phones keep `https://doorunlock.seahaven.com`. Cutover is a Route 53 A-record flip in the mgmt zone (`Z06652411XKH89KTZD3XA`). DNS is not managed in this Terraform. ## Architecture - **API Gateway (HTTP API)** — `GET /unlock`, `GET /lockdown`, `GET /lockdown/status` with throttling (5 burst / 2 sustained req/sec) - **Token authorizer Lambda** — a REQUEST-type Lambda authorizer validates the `?token=` query-string value (the same shared secret the phones already send) against the `/seahaven/door-unlock/auth-token` SSM parameter, so unauthenticated callers are rejected at the gateway (401/403) before any handler runs. Identity source is `$request.querystring.token`; results are cached 5 minutes. Fail-closed. The handlers also re-validate the token as defense-in-depth. API Gateway invokes the authorizer through a Lambda resource policy, not `AuthorizerCredentialsArn`. - **Unlock Lambda** — validates a shared auth token, calls the Elements `TemporaryUnlock` command - **Lockdown Lambda** — toggles lockdown profiles (start/stop) and checks status, returns Yealink XML TextScreen responses - **Lockdown Poller Lambda** — polls the public Elements API every minute. No VPC. - **BLF sync Lambda** — daily 09:00 UTC EventBridge job that writes 3CX department BLFs - **SSM Parameter Store** — Elements API key, auth token, and door ID (created out of band; Terraform never reads SecureString values) - **Secrets Manager** — 3CX XAPI credentials (`afterhours-shift-manager/3cx-*`), referenced by ARN only - **Custom Domain** — `doorunlock.seahaven.com` via an out-of-band ACM certificate in prod. The API Gateway domain mapping is attached at DNS cutover (`attach_custom_domain`). Route 53 stays in mgmt. Until then, proof uses the execute-api URL. - **CloudWatch alarms** — one error alarm per Lambda (unlock, lockdown, authorizer, poller, blf-sync); each fires on `Errors > 0` and notifies the prod `site-alerts` SNS topic (ALARM state only) ## Infrastructure (HCP Terraform) All live infrastructure is defined in `terraform/` and applied from HCP Terraform workspace `seahaven-door-unlock-api-prod` (manual apply). The AWS provider is pinned at `6.58.0`. Functions run Node 24 arm64 under path `/tf-managed/` with permissions boundary `seahaven-lambda-execution-boundary-seahaven-door-unlock-api`. CDK sources (`bin/`, `lib/`) remain in the repo as the mgmt rollback target until that stack is deleted. GitHub Actions CDK deploy is frozen (`.github/workflows/deploy.yaml.frozen`). ### Layout ``` terraform/ # HCP Terraform (working directory) lambda/ ├── unlock/unlock-handler.ts ├── lockdown/lockdown-handler.ts ├── poller/lockdown-poller.ts ├── authorizer/authorizer-handler.ts └── blf-sync/blf-sync-handler.ts ``` HCP plan workers may not have Node. `terraform/build_packages_external.sh` bootstraps Node 24 if needed, then esbuild-bundles the five handlers during plan. Packages upload through `aws_s3_object.content_base64` because plan and apply run on different workers. EventBridge schedules are created **disabled** (`enable_schedules = false`) until live-path proof and DNS cutover. Do not put secret values in Terraform, `*.tfvars`, chat, or PRs. Create SSM and Secrets Manager objects out of band; Terraform uses names and ARNs only. ## Documentation The canonical map of Sea Haven's AWS infrastructure lives in Confluence. - **[AWS Architecture Map](https://seahaven.atlassian.net/wiki/spaces/IT/pages/1540098)** (Confluence, IT space, page 1540098) - **[Door Unlock API](https://seahaven.atlassian.net/wiki/spaces/IT/pages/55640066)** (ops page) ## Lockdown Profiles Two lockdown profiles are configured: | Profile | Elements ID | Line Key | |---------|-------------|----------| | Bohemia - Whole Building | `4b4a3e6b-c903-4cce-8cd6-288612bf0542` | 3 | | Ronkonkoma - Whole Building | `ff9876bc-c54f-472e-aef9-d2bffd4b7cf7` | 4 | Pressing the line key toggles the lockdown on/off and displays the current status on the phone screen. **Known limitation:** Line key LED color does not currently change to reflect lockdown status. The T58W's XML Browser key type (17) does not support persistent LED color changes via Push XML or Execute commands — LED commands are transient and immediately overridden by the phone's key type management. ## SSM Parameters | Parameter | Type | Description | |-----------|------|-------------| | `/seahaven/door-unlock/elements-api-key` | SecureString | LenelS2 Elements API key | | `/seahaven/door-unlock/auth-token` | SecureString | Shared secret embedded in the Yealink DSS key URL | | `/seahaven/door-unlock/door-id` | String | Elements device ID for the front door reader | Phone LED/Push XML is not in the live path. `/seahaven/door-unlock/phone-ips` and `door-unlock-api/phone-password` are not used by this Terraform. ## Secrets Manager | Secret | Description | |--------|-------------| | `afterhours-shift-manager/3cx-domain` | 3CX XAPI hostname | | `afterhours-shift-manager/3cx-client-id` | 3CX XAPI client id | | `afterhours-shift-manager/3cx-client-secret` | 3CX XAPI client secret | ## CI/CD - **`.github/workflows/ci.yaml`** — on pull requests to `main`, runs TypeScript tests. CDK synth is off. - **`.github/workflows/ci-terraform.yaml`** — on pull requests that touch `terraform/`, runs `terraform fmt`, `init -backend=false`, and `validate`. - **HCP Terraform** — workspace `seahaven-door-unlock-api-prod` in project `seahaven-prod`. Working directory `terraform/`. `trigger-patterns = [terraform/**/*, lambda/**/*]`. Manual apply. Auto-apply stays off until the stack is sealed. Do not run `cdk deploy` against prod. The GitHub CDK deploy workflow is frozen. ## Phone Configuration Configure DSS keys on the Yealink T54W/T57W/T58W (via phone web UI or 3CX): - **Key 2 — Unlock Door** - Type: URL - Value: `https://doorunlock.seahaven.com/unlock?token=` - **Keys 3-4 — Lockdown Toggle** - Type: XML Browser (17) - Value: `https://doorunlock.seahaven.com/lockdown?token=&profile=bohemia|ronkonkoma` ## 3CX Provisioning Templates Custom 3CX templates are included with door unlock and lockdown URLs hardcoded. | Template | Model | Key 2 | Keys 3-4 | Display | |----------|-------|-------|----------|---------| | `yealinkT54W-door-unlock.ph.xml` | T54W | Unlock Door | Managed by 3CX BLF | Dim after 5 min, never sleep | | `yealinkT54W-door-unlock-with-sp.ph.xml` | T54W | Unlock Door | SP1-3 via BLF sync | Dim after 5 min, never sleep | | `yealinkT57W-door-unlock-with-sp.ph.xml` | T57W | Unlock Door | SP1-3 via BLF sync | Dim after 5 min, never sleep | | `yealinkT58W-door-unlock.ph.xml` | T58W | Unlock Door | Lockdown Toggle (Bohemia/Ronkonkoma) | Default T58W display settings | Department colleague BLFs are not encoded in these templates. A scheduled Lambda (`door-unlock-api-blf-sync`) writes each Yealink user's 3CX BLF list from that user's first non-DEFAULT 3CX department, excluding the phone's own extension. Extension 100 is always included, even when the XAPI Users list omits it. Unlock and lockdown URL keys stay hardcoded in the template. Shared parking on the T54W+SP and T57W+SP templates is written by the sync job as 3CX SharedParking BLFs. | Template | Reserved (never write) | Sync-owned parking | Own line | Managed department BLFs | Personal | | --- | --- | --- | --- | --- | --- | | `yealinkT54W-door-unlock.ph.xml` | `blf2` | none | `blf1` | `blf3`–`blf12` | `blf13+` | | `yealinkT54W-door-unlock-with-sp.ph.xml` | `blf2` | `blf3`–`blf5` (SP1–SP3) | `blf1` | `blf6`–`blf15` | `blf16+` | | `yealinkT57W-door-unlock-with-sp.ph.xml` | `blf2` | `blf3`–`blf5` (SP1–SP3) | `blf1` | `blf6`–`blf15` | `blf16+` | | `yealinkT58W-door-unlock.ph.xml` | `blf2`–`blf4` | none | `blf1` | `blf5`–`blf14` | `blf15+` | The job authenticates to 3CX XAPI with the existing `afterhours-shift-manager/3cx-*` Secrets Manager values. Invoke `door-unlock-api-blf-sync` with `DRY_RUN=true` for a proposed-XML log and no writes. Set `SMOKE_EXTENSION` to PATCH a single extension. The daily EventBridge rule runs at `09:00 UTC` (05:00 ET during EDT).