# Sea Haven — Desk Phone Door Unlock via LenelS2 Elements ## Project Plan for Claude Code --- ## Overview Build a lightweight AWS middleware that allows a Yealink T58A desk phone to unlock the front door controlled by LenelS2 Elements. The user presses a dedicated button (DSS key) on the Yealink, which fires an HTTPS GET request to an API Gateway endpoint. A Lambda function receives the request, authenticates to the LenelS2 Elements API, and sends a door unlock command. **Flow:** `Yealink T58A (DSS key press) → HTTPS GET → API Gateway → Lambda → Elements API → Door Unlocks` --- ## Architecture ``` ┌──────────────┐ HTTPS GET ┌──────────────────┐ HTTPS POST ┌─────────────────────┐ │ Yealink T58A │ ───────────────► │ API Gateway │ ──────────────► │ LenelS2 Elements │ │ DSS Key: │ ?token=xxxxx │ + Lambda │ api-key header │ api.elementssecure │ │ "Unlock Door"│ │ (us-east-1) │ │ .com │ └──────────────┘ └──────────────────┘ └─────────────────────┘ │ ▼ ┌──────────────┐ │ Secrets Mgr │ │ or SSM Param │ │ (API keys) │ └──────────────┘ ``` --- ## Components to Build ### 1. AWS CDK Stack (TypeScript — matches existing LedgerFlow stack) **Stack name:** `SeaHavenDoorUnlockStack` **Region:** `us-east-1` (matches existing infra) #### Resources: - **API Gateway (HTTP API)** — a single GET endpoint at `/unlock` - **Lambda function (Node.js 20.x)** — handles the request - **SSM Parameter Store or Secrets Manager** — stores the Elements API key and the shared bearer token for Yealink auth - **Optional: Route 53 custom domain** — e.g., `doorunlock.seahaven.com` using the existing wildcard ACM cert and hosted zone #### API Gateway Configuration: - HTTP API (not REST API — simpler, cheaper) - Single route: `GET /unlock` - No CORS needed (Yealink sends a plain GET, not a browser request) - Throttling: 5 requests/second burst, 2 requests/second sustained (matches Elements rate limit of 2 req/sec) ### 2. Lambda Function Logic ``` // Pseudocode for the Lambda handler 1. Extract query parameter `token` from the request 2. Validate `token` against stored shared secret (from SSM/Secrets Manager) - If invalid → return 403 3. Retrieve Elements API key from SSM/Secrets Manager 4. Identify which door/reader to unlock (hardcoded or passed as param) 5. Call the Elements API to execute the door unlock command - Endpoint: https://api.elementssecure.com/... - Header: api-key: - Method: POST (or whatever the Elements API docs specify for door commands) 6. Return 200 with a simple success message (or error details) ``` #### Key Design Decisions: - **Auth from the Yealink:** Use a simple shared secret as a query parameter (`?token=`). The Yealink DSS URL key supports specifying a full URL including query params. HTTPS ensures the token is encrypted in transit. - **Idempotency:** The door unlock is inherently idempotent (unlocking an already-unlocking door is harmless), so no special handling needed. - **Logging:** Log every unlock attempt to CloudWatch with timestamp, source IP, and success/failure for audit trail. - **Rate limiting:** API Gateway throttling prevents abuse. Optionally add a cooldown (e.g., ignore requests within 5 seconds of the last successful unlock). ### 3. Yealink T58A Configuration This is manual config done in the Yealink web UI — not code. Documenting here for completeness. 1. Log into the Yealink web interface at `http://` 2. Navigate to **DSSKey → Line Key** (or **Programmable Key**) 3. Configure an available key: - **Type:** URL - **Label:** Unlock Door - **Value:** `https://doorunlock.seahaven.com/unlock?token=` 4. Click **Confirm** to save **Security note:** Under **Features → General Information**, the Yealink has an "Action URI" trusted IP list. This is for *incoming* action URIs (remote control of the phone). It's not relevant here since the phone is the one *initiating* the HTTP GET. No additional Yealink security config needed for outbound requests. --- ## Prerequisites — Adam Needs to Do Before Claude Code ### From LenelS2 Elements: 1. **Enable the Elements API** — Go to the Elements Marketplace and request/enable API access if not already done 2. **Generate an API key** — From the Marketplace, create an API key. This key will be passed as an `api-key` header in requests to `api.elementssecure.com` 3. **Identify the door/reader ID** — In the Elements portal, find the device ID for the front door reader that needs to be unlocked. You'll need this for the API call. 4. **Review the API docs** — Go to `https://api.elementssecure.com/docs` and look for: - A door unlock or device command endpoint (likely under a `/doors` or `/devices` or `/commands` path) - The exact HTTP method, URL path, and request body format for a momentary unlock - Note the exact endpoint path and required parameters 5. **Test the API manually** — Use Postman or curl to confirm you can unlock the door via the API before building the Lambda. Example: ``` curl -X POST https://api.elementssecure.com/v1/doors//unlock \ -H "api-key: " \ -H "Content-Type: application/json" ``` (The exact path/method will come from the API docs — this is just an example) ### From AWS (should mostly be in place already): 6. **Confirm the seahaven.com Route 53 hosted zone ID** — for custom domain setup 7. **Confirm the wildcard ACM certificate ARN** — `*.seahaven.com` cert in us-east-1 8. **Generate a shared secret token** — a long random string (32+ characters) that will be embedded in the Yealink URL and validated by the Lambda. Generate with: `openssl rand -hex 32` --- ## Information to Provide to Claude Code When starting the Claude Code session, provide the following: ``` Project: Sea Haven Door Unlock Middleware Stack: AWS CDK (TypeScript), Lambda (Node.js 20.x) Region: us-east-1 Build an AWS CDK stack called SeaHavenDoorUnlockStack that creates: 1. An HTTP API Gateway with a single GET /unlock route 2. A Lambda function (Node.js 20.x) that: - Validates a `token` query parameter against a secret stored in SSM Parameter Store - On valid token, calls the LenelS2 Elements API to unlock the front door - Elements API base URL: https://api.elementssecure.com - Elements API auth: pass `api-key` header with the stored API key - Elements API endpoint for door unlock: [FILL IN from API docs] - Door/reader ID: [FILL IN from Elements portal] - Logs all attempts to CloudWatch - Returns 200 on success, 403 on bad token, 502 on Elements API failure 3. SSM Parameter Store parameters: - /seahaven/door-unlock/elements-api-key (SecureString) - /seahaven/door-unlock/auth-token (SecureString) - /seahaven/door-unlock/door-id (String) 4. Custom domain: doorunlock.seahaven.com - Route 53 hosted zone: [FILL IN hosted zone ID] - ACM certificate: [FILL IN wildcard cert ARN] 5. API Gateway throttling: 5 burst, 2 sustained requests/sec After deployment, the Yealink phone will call: GET https://doorunlock.seahaven.com/unlock?token= Additional requirements: - Add a 5-second cooldown between successful unlocks (store last unlock time in Lambda global scope or a simple DynamoDB TTL item) - Include proper error handling and structured JSON logging - No external npm dependencies beyond AWS SDK v3 (bundled with Lambda runtime) ``` --- ## Post-Deployment Steps 1. **Store the secrets in SSM:** ```bash aws ssm put-parameter --name "/seahaven/door-unlock/elements-api-key" \ --value "" --type SecureString --region us-east-1 aws ssm put-parameter --name "/seahaven/door-unlock/auth-token" \ --value "" --type SecureString --region us-east-1 aws ssm put-parameter --name "/seahaven/door-unlock/door-id" \ --value "" --type String --region us-east-1 ``` 2. **Test the endpoint:** ```bash # Should return 403 curl https://doorunlock.seahaven.com/unlock?token=wrong-token # Should unlock the door curl https://doorunlock.seahaven.com/unlock?token= ``` 3. **Configure the Yealink T58A** (see section above) 4. **Test end-to-end:** Press the DSS key on the Yealink and verify the door unlocks --- ## Security Considerations - **HTTPS everywhere** — Yealink → API Gateway is TLS, Lambda → Elements API is TLS - **Shared secret in URL** — acceptable because HTTPS encrypts the full URL including query parameters. The token is never logged by API Gateway (use `$context.requestId` not `$context.path` in access logs). Only the Lambda sees the token for validation. - **Rate limiting** — API Gateway throttle + Lambda cooldown prevent brute force and accidental double-taps - **IP restriction (optional enhancement)** — if the Yealink phones have static IPs on the LAN and the office has a static public IP, you can add an API Gateway resource policy to restrict access to that IP only. This makes the shared token a second factor rather than the only factor. - **Audit trail** — every unlock attempt (success or failure) is logged to CloudWatch with timestamp and source IP - **API key rotation** — the Elements API key and shared token are in SSM Parameter Store, so rotation requires updating the SSM value and (for the shared token) the Yealink DSS key URL --- ## Estimated AWS Cost - API Gateway HTTP API: $1.00 per million requests (negligible for door unlocks) - Lambda: free tier covers 1M requests/month (you'll use maybe 100) - SSM Parameter Store: free for standard parameters, $0.05/parameter/month for SecureString via advanced - Route 53: already have the hosted zone ($0.50/month existing) - **Total incremental cost: effectively $0/month** --- ## Files Claude Code Should Create ``` seahaven-door-unlock/ ├── bin/ │ └── app.ts # CDK app entry point ├── lib/ │ └── door-unlock-stack.ts # CDK stack definition ├── lambda/ │ └── unlock-handler.ts # Lambda function code ├── cdk.json ├── tsconfig.json ├── package.json └── README.md ``` --- ## Open Items (Requires Your Input) | Item | What's Needed | Where to Find It | |------|--------------|-----------------| | Elements API door unlock endpoint | Exact path, method, and body for unlocking a door | https://api.elementssecure.com/docs | | Elements door/reader ID | The device ID for your front door | Elements portal → Devices | | Elements API key | Your API key for auth | Elements Marketplace | | Route 53 hosted zone ID | Your seahaven.com zone | AWS Console → Route 53 | | ACM wildcard cert ARN | Your *.seahaven.com cert | AWS Console → ACM (us-east-1) | | Shared secret token | Generate a random token | Run: `openssl rand -hex 32` |