Compare commits

...

91 commits
v2.0.0 ... main

Author SHA1 Message Date
renovate[bot]
4589ffd8e0
chore(deps): update sea-haven-industries/.github action to v1.0.19 (#98)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-28 16:06:45 +00:00
renovate[bot]
e15ff6824c
chore(deps): update npm minor and patch (#99)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-28 16:06:41 +00:00
Adam Moussa
5d53280ddc
docs(agents): drop security review gates (#97)
Agents no longer treat a security review or a cross-family review as a merge gate.
2026-09-26 17:18:00 -04:00
Adam Moussa
3f91831666
chore(ci): remove unused Mergify stub (#96) 2026-09-22 18:41:29 +00:00
renovate[bot]
5c2f9627d0
fix(deps): update npm minor and patch (#94)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-21 16:02:44 +00:00
Adam Moussa
e51c5241c1
chore(ci): add npmrc to allow remote package installs (#95) 2026-09-21 15:58:41 +00:00
renovate[bot]
90b94ad091
chore(deps): update terraform aws to v6.65.0 (#93)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-21 15:47:28 +00:00
renovate[bot]
50564bdffb
chore(deps): update sea-haven-industries/.github action to v1.0.11 (#91)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-14 17:46:53 +00:00
renovate[bot]
44681ca2c8
chore(deps): update terraform minor and patch (#92)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-14 17:46:35 +00:00
Adam Moussa
9063d0f438
docs(readme): record HCP VCS trigger patterns (PLAT-183) (#90) 2026-09-10 21:00:45 +00:00
renovate[bot]
e4083558e1
fix(deps): update npm minor and patch (#89)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-09 18:45:04 +00:00
renovate[bot]
e12d671e1f
chore(deps): update terraform aws to v6.63.0 (#88)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-09 18:44:39 +00:00
Adam Moussa
c23f990c6f
feat(phones): add T57W door-unlock-with-sp template (#87)
* feat(phones): add T57W door-unlock-with-sp template

* feat: add firmware dir and add latest 3cx supported yealink firmware
2026-09-03 22:56:04 +00:00
Adam Moussa
b43335b4a7
feat(iam): import hcptf roles into app Terraform (PLAT-146) (#86)
* feat(iam): import hcptf roles into app Terraform (PLAT-146)

Move the existing hcptf pair into this repo so app Terraform owns prod IAM after the substrate handoff.

* fix(iam): add apply-role IAM list permissions (PLAT-146)

IamReadOnly omitted ListRoleTags and ListInstanceProfilesForRole needed after detaching the substrate guardrail.
2026-09-02 21:47:15 +00:00
renovate[bot]
ad74f02ec4
chore(deps): update terraform aws to v6.62.0 (#85)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-31 22:41:03 +00:00
renovate[bot]
57dc807618
chore(deps): update sea-haven-industries/.github action to v1.0.10 (#84)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-31 22:40:47 +00:00
Adam Moussa
61f13bddfe
fix(terraform): defer api domain until dns cutover (#83)
API Gateway custom domain names are unique per region across accounts, so prod cannot create doorunlock.seahaven.com while mgmt still holds it.
2026-08-27 23:06:52 +00:00
Adam Moussa
cdd810001d
fix(terraform): bootstrap node from gzip tarball (#82)
HCP Linux workers do not ship xz, so the plan-time Node download must use the gzip distro tarball.
2026-08-27 22:25:04 +00:00
Adam Moussa
c43bee214c
feat(terraform): add hcp terraform for prod door-unlock-api (PLAT-76) (#81)
* feat(terraform): add hcp terraform for prod door-unlock-api

Move deploy off frozen CDK CD onto an HCP workspace that recreates the HTTP API, five Lambdas, disabled EventBridge rules, and alarms in seahaven-prod without a poller VPC.

* docs(readme): link the door unlock api ops page

* fix(terraform): invoke package build via bash

HCP launches the external data source with bash, so the inner build script should not depend on the git executable bit.
2026-08-27 22:03:12 +00:00
Adam Moussa
bbc113497a
feat(3cx): sync office department blfs via xapi (PLAT-116) (#80)
Some checks failed
Deploy / deploy (push) Has been cancelled
* feat(3cx): sync office department BLFs via XAPI

Keep unlock and lockdown keys in the templates and write colleague plus shared-parking BLFs per extension so phones skip their own line.

* fix(3cx): preserve parking BLF IDs and fail the job on PATCH errors
2026-08-27 14:58:55 -04:00
renovate[bot]
39414156ef
fix(deps): update npm minor and patch (#78)
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-08-25 15:55:33 +00:00
Adam Moussa
6481f0c23a
chore(deps): remove dependabot version updates (#79)
Renovate is the version-update bot. GitHub Dependabot alerts stay.
2026-08-25 11:51:15 -04:00
renovate[bot]
39013e8a65
chore(deps): update sea-haven-industries/.github action to v1.0.8 (#77) 2026-08-25 15:42:11 +00:00
Adam Moussa
fe89bc6649
chore(ci): remove pr policy workflow caller (#74)
Some checks are pending
Deploy / deploy (push) Waiting to run
2026-08-24 15:12:36 -04:00
Adam Moussa
fd459b12b6
chore(ci): switch auto-merge from seahaven-bot to Mergify (#73)
Some checks are pending
Deploy / deploy (push) Waiting to run
2026-08-24 13:53:15 -04:00
Adam Moussa
a00730675a
ci: enable squash auto-merge on ready PRs (PLAT-108) (#71)
Some checks failed
Deploy / deploy (push) Has been cancelled
* ci: enable squash auto-merge on ready PRs

* fix(ci): serialize auto-merge enable and ignore already-enabled
2026-08-21 23:34:40 +00:00
Adam Moussa
0929d2fb6c
ci: add merge_group trigger for required ci / ci (#70)
Some checks are pending
Deploy / deploy (push) Waiting to run
2026-08-21 17:42:31 -04:00
dependabot[bot]
5d5439acf7
chore(deps): bump the minor-and-patch group with 4 updates (#69)
Some checks failed
Deploy / deploy (push) Has been cancelled
Bumps the minor-and-patch group with 4 updates: [@aws-sdk/client-ssm](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ssm), [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk), [esbuild](https://github.com/evanw/esbuild) and [tsx](https://github.com/privatenumber/tsx).


Updates `@aws-sdk/client-ssm` from 3.1106.0 to 3.1111.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ssm/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1111.0/clients/client-ssm)

Updates `aws-cdk` from 2.1135.1 to 2.1136.0
- [Release notes](https://github.com/aws/aws-cdk-cli/releases)
- [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1136.0/packages/aws-cdk)

Updates `esbuild` from 0.28.1 to 0.28.2
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.28.1...v0.28.2)

Updates `tsx` from 4.23.11 to 4.23.12
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.11...v4.23.12)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-ssm"
  dependency-version: 3.1111.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-cdk
  dependency-version: 2.1136.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: esbuild
  dependency-version: 0.28.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: tsx
  dependency-version: 4.23.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Adam Moussa <166072409+amoussa1229@users.noreply.github.com>
2026-08-18 17:16:21 +00:00
dependabot[bot]
fea155279a
chore(deps): bump the minor-and-patch group with 4 updates (#68)
Bumps the minor-and-patch group with 4 updates: [Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml](https://github.com/sea-haven-industries/.github), [Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml](https://github.com/sea-haven-industries/.github), [Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml](https://github.com/sea-haven-industries/.github) and [Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml](https://github.com/sea-haven-industries/.github).


Updates `Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml` from 1.0.6 to 1.0.7
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](7ac3528750...e5691d8a7f)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml` from 1.0.6 to 1.0.7
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](7ac3528750...e5691d8a7f)

Updates `Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml` from 1.0.6 to 1.0.7
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](7ac3528750...e5691d8a7f)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml` from 1.0.6 to 1.0.7
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](7ac3528750...e5691d8a7f)

---
updated-dependencies:
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml
  dependency-version: 1.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml
  dependency-version: 1.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml
  dependency-version: 1.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml
  dependency-version: 1.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-18 13:14:22 -04:00
dependabot[bot]
4e84f52379
chore(deps): bump the minor-and-patch group across 1 directory with 5 updates (#65)
Some checks are pending
Deploy / deploy (push) Waiting to run
* chore(deps): bump the minor-and-patch group across 1 directory with 5 updates

Bumps the minor-and-patch group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [aws-cdk-lib](https://github.com/aws/aws-cdk/tree/HEAD/packages/aws-cdk-lib) | `2.262.1` | `2.263.0` |
| [constructs](https://github.com/aws/constructs) | `10.7.1` | `10.8.1` |
| [@aws-sdk/client-ssm](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ssm) | `3.1096.0` | `3.1106.0` |
| [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk) | `2.1133.0` | `2.1135.1` |
| [tsx](https://github.com/privatenumber/tsx) | `4.23.1` | `4.23.11` |



Updates `aws-cdk-lib` from 2.262.1 to 2.263.0
- [Release notes](https://github.com/aws/aws-cdk/releases)
- [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md)
- [Commits](https://github.com/aws/aws-cdk/commits/v2.263.0/packages/aws-cdk-lib)

Updates `constructs` from 10.7.1 to 10.8.1
- [Release notes](https://github.com/aws/constructs/releases)
- [Commits](https://github.com/aws/constructs/compare/v10.7.1...v10.8.1)

Updates `@aws-sdk/client-ssm` from 3.1096.0 to 3.1106.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ssm/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1106.0/clients/client-ssm)

Updates `aws-cdk` from 2.1133.0 to 2.1135.1
- [Release notes](https://github.com/aws/aws-cdk-cli/releases)
- [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1135.1/packages/aws-cdk)

Updates `tsx` from 4.23.1 to 4.23.11
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.1...v4.23.11)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-ssm"
  dependency-version: 3.1106.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-cdk
  dependency-version: 2.1135.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-cdk-lib
  dependency-version: 2.263.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: constructs
  dependency-version: 10.8.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: tsx
  dependency-version: 4.23.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: bump aws-cdk-lib to 2.265.0

Signed-off-by: Adam Moussa <adam@seahavenind.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Adam Moussa <adam@seahavenind.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Adam Moussa <adam@seahavenind.com>
Co-authored-by: Adam Moussa <166072409+amoussa1229@users.noreply.github.com>
2026-08-17 18:03:50 -04:00
dependabot[bot]
d650b4b945
chore(deps): bump the minor-and-patch group (#66)
Some checks failed
Deploy / deploy (push) Has been cancelled
Bumps the minor-and-patch group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml](https://github.com/sea-haven-industries/.github) | `1.0.3` | `1.0.6` |
| [Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml](https://github.com/sea-haven-industries/.github) | `1.0.3` | `1.0.6` |
| [Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml](https://github.com/sea-haven-industries/.github) | `1.0.3` | `1.0.6` |
| [Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml](https://github.com/sea-haven-industries/.github) | `1.0.3` | `1.0.6` |
| [Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml](https://github.com/sea-haven-industries/.github) | `1.0.5` | `1.0.6` |

Updates `Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml` from 1.0.3 to 1.0.6
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](3f74677422...7ac3528750)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml` from 1.0.3 to 1.0.6
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](3f74677422...7ac3528750)

Updates `Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml` from 1.0.3 to 1.0.6
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](3f74677422...7ac3528750)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml` from 1.0.3 to 1.0.6
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](3f74677422...7ac3528750)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml` from 1.0.5 to 1.0.6
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](9c1ecf9428...7ac3528750)

---
updated-dependencies:
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml
  dependency-version: 1.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml
  dependency-version: 1.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml
  dependency-version: 1.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml
  dependency-version: 1.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml
  dependency-version: 1.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 12:00:24 -04:00
Adam Moussa
67a577bf90
ci: update PR policy workflow to v1.0.7 (#67)
Signed-off-by: Adam Moussa <adam@seahavenind.com>
2026-08-11 11:42:41 -04:00
1e0ed68a11
ci: add org PR policy caller
Refs: PLAT-62
2026-08-11 11:15:08 -04:00
dependabot[bot]
ab7dd977ac
build(deps): bump the minor-and-patch group with 4 updates (#62)
Some checks failed
Deploy / deploy (push) Has been cancelled
Bumps the minor-and-patch group with 4 updates: [Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml](https://github.com/sea-haven-industries/.github), [Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml](https://github.com/sea-haven-industries/.github), [Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml](https://github.com/sea-haven-industries/.github) and [Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml](https://github.com/sea-haven-industries/.github).


Updates `Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml` from 1.0.2 to 1.0.3
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](0170a57c0d...3f74677422)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml` from 1.0.2 to 1.0.3
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](0170a57c0d...3f74677422)

Updates `Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml` from 1.0.2 to 1.0.3
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](0170a57c0d...3f74677422)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml` from 1.0.2 to 1.0.3
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](0170a57c0d...3f74677422)

---
updated-dependencies:
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml
  dependency-version: 1.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml
  dependency-version: 1.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml
  dependency-version: 1.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml
  dependency-version: 1.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 11:02:19 -04:00
Adam Moussa
3787a82eb1
ci(deps): pin org reusable workflows to v1.0.2 (#61)
Some checks failed
Deploy / deploy (push) Has been cancelled
* ci(deps): pin org reusable workflows to v1.0.2

* style(ci): normalize workflow block spacing
2026-07-28 18:16:43 -04:00
dependabot[bot]
8e43dc3805
build(deps): bump the minor-and-patch group with 3 updates (#60)
Some checks are pending
Deploy / deploy (push) Waiting to run
Bumps the minor-and-patch group with 3 updates: [aws-cdk-lib](https://github.com/aws/aws-cdk/tree/HEAD/packages/aws-cdk-lib), [@aws-sdk/client-ssm](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ssm) and [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk).


Updates `aws-cdk-lib` from 2.262.0 to 2.262.1
- [Release notes](https://github.com/aws/aws-cdk/releases)
- [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md)
- [Commits](https://github.com/aws/aws-cdk/commits/v2.262.1/packages/aws-cdk-lib)

Updates `@aws-sdk/client-ssm` from 3.1091.0 to 3.1096.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ssm/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1096.0/clients/client-ssm)

Updates `aws-cdk` from 2.1132.0 to 2.1133.0
- [Release notes](https://github.com/aws/aws-cdk-cli/releases)
- [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1133.0/packages/aws-cdk)

---
updated-dependencies:
- dependency-name: aws-cdk-lib
  dependency-version: 2.262.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@aws-sdk/client-ssm"
  dependency-version: 3.1096.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-cdk
  dependency-version: 2.1133.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 11:51:51 -04:00
Adam Moussa
211a00a013
chore(security): resolve open dependabot and code scanning alerts (#59)
Some checks failed
Deploy / deploy (push) Has been cancelled
* ci: add least-privilege permissions blocks to workflow callers
Resolves code scanning alerts #3 and #4 (actions/missing-workflow-permissions). Both callable workflows only need contents: read; the dependency-review callable already declares it internally, this caps the caller token to match.

Signed-off-by: Adam Moussa <adam@seahavenind.com>

* build(deps): bump aws-cdk-lib to 2.262.0 to patch brace-expansion

aws-cdk-lib 2.261.0 bundles brace-expansion 5.0.6, which is vulnerable to CVE-2026-13149 (GHSA-3jxr-9vmj-r5cp), an exponential-time DoS in expand(). This was the only path pulling the vulnerable package into the tree. 2.262.0 vendors the patched 5.0.7, resolving Dependabot alert 7.

Because brace-expansion arrives bundled inside the aws-cdk-lib tarball rather than resolved by npm, the aws-cdk-lib bump is the only way to move it.

Signed-off-by: Adam Moussa <adam@seahavenind.com>

* refactor(cdk): drop unreferenced ssm value parameters

fromStringParameterName injects an AWS::SSM::Parameter::Value
CloudFormation parameter to carry the parameter's value, but DoorId
and PhoneIps are only used for grantRead, which builds the ARN from
the name string — so DoorIdParameter and PhoneIpsParameter sat
unreferenced in the template (flagged W2001 by the CloudFormation
validator newly bundled in aws-cdk-lib 2.262.0).

Switching to fromStringParameterAttributes with forceDynamicReference
resolves the value lazily via an SSM dynamic reference, emitting
nothing when unused. Verified the synthesized template is identical
apart from the removed Parameters entries and the CDKMetadata
analytics hash — no IAM or resource changes.

Also re-points the four line-keyed semgrep detect-child-process
suppressions (adjudicated FPs, INFRA-105) to the shifted line
numbers; the findings themselves are unchanged.

Signed-off-by: Adam Moussa <adam@seahavenind.com>

---------

Signed-off-by: Adam Moussa <adam@seahavenind.com>
2026-07-23 20:24:23 +00:00
dependabot[bot]
c662688ae3
build(deps): bump the minor-and-patch group with 3 updates (#58)
Some checks failed
Deploy / deploy (push) Has been cancelled
Bumps the minor-and-patch group with 3 updates: [constructs](https://github.com/aws/constructs), [@aws-sdk/client-ssm](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ssm) and [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk).


Updates `constructs` from 10.6.0 to 10.7.1
- [Release notes](https://github.com/aws/constructs/releases)
- [Commits](https://github.com/aws/constructs/compare/v10.6.0...v10.7.1)

Updates `@aws-sdk/client-ssm` from 3.1086.0 to 3.1091.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ssm/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1091.0/clients/client-ssm)

Updates `aws-cdk` from 2.1130.0 to 2.1132.0
- [Release notes](https://github.com/aws/aws-cdk-cli/releases)
- [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1132.0/packages/aws-cdk)

---
updated-dependencies:
- dependency-name: constructs
  dependency-version: 10.7.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@aws-sdk/client-ssm"
  dependency-version: 3.1091.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-cdk
  dependency-version: 2.1132.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 15:33:15 -04:00
dependabot[bot]
bf51bf7aa2
build(deps-dev): bump the minor-and-patch group with 3 updates (#57)
Some checks failed
Deploy / deploy (push) Has been cancelled
Bumps the minor-and-patch group with 3 updates: [@aws-sdk/client-ssm](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ssm), [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk) and [tsx](https://github.com/privatenumber/tsx).


Updates `@aws-sdk/client-ssm` from 3.1082.0 to 3.1086.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ssm/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1086.0/clients/client-ssm)

Updates `aws-cdk` from 2.1129.0 to 2.1130.0
- [Release notes](https://github.com/aws/aws-cdk-cli/releases)
- [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1130.0/packages/aws-cdk)

Updates `tsx` from 4.23.0 to 4.23.1
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.0...v4.23.1)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-ssm"
  dependency-version: 3.1086.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-cdk
  dependency-version: 2.1130.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: tsx
  dependency-version: 4.23.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-14 11:30:30 -04:00
Adam Moussa
f2bc576dfd
chore(security): add repo-local suppressions for adjudicated FPs (#56)
Some checks are pending
Deploy / deploy (push) Waiting to run
Moves proof-or-kill-verified false positives (CDK synth-time esbuild execSync x4;
CDK LogRetention IAM boilerplate; Yealink provisioning-template token placeholders)
from machine-level to a tracked repo-local .security-review/suppressions.json so
the Open SWE daily-report automation resolves them. Machine-level copy retained
until merge. INFRA-105.
2026-07-13 14:30:55 -04:00
Adam Moussa
07247d4044
Document the CDK app in the README (#55)
Some checks failed
Deploy / deploy (push) Has been cancelled
The README covered the runtime architecture but never described the
CDK app itself — the infrastructure-as-code component that cdk.json
represents. Add an "Infrastructure (CDK)" section documenting the
project layout, the app entry point, the DoorUnlockStack resources,
cdk.json (the tsx-based app command and context), and synth/diff/deploy
commands. Also list the per-Lambda CloudWatch error alarms the stack
defines under Architecture.
2026-07-10 16:07:24 -04:00
Adam Moussa
c2c45562e4
build(deps): migrate CDK app ts-node->tsx, adopt typescript 7 (INFRA-183) (#54)
Some checks failed
Deploy / deploy (push) Has been cancelled
ts-node 10.x is incompatible with the TypeScript 7 compiler API, causing
cdk synth to fail with "Cannot read properties of undefined (reading
'fileExists')". Switch the cdk.json app command to tsx and bump
typescript to ~7.0.2. Supersedes Dependabot PR #53.
2026-07-08 17:48:18 -04:00
dependabot[bot]
0d40c471b0
Bump the minor-and-patch group with 2 updates (#52)
Bumps the minor-and-patch group with 2 updates: [@aws-sdk/client-ssm](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ssm) and [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `@aws-sdk/client-ssm` from 3.1080.0 to 3.1082.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ssm/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1082.0/clients/client-ssm)

Updates `@types/node` from 24.13.2 to 24.13.3
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-ssm"
  dependency-version: 3.1082.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 24.13.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-08 17:03:08 -04:00
Adam Moussa
dec88eac5d
ci: add github-actions to dependabot coverage (INFRA-130) (#51)
Some checks are pending
Deploy / deploy (push) Waiting to run
2026-07-08 16:53:48 -04:00
dependabot[bot]
97402aac9b
Bump @aws-sdk/client-ssm in the minor-and-patch group (#50)
Some checks failed
Deploy / deploy (push) Has been cancelled
Bumps the minor-and-patch group with 1 update: [@aws-sdk/client-ssm](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ssm).


Updates `@aws-sdk/client-ssm` from 3.1079.0 to 3.1080.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ssm/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1080.0/clients/client-ssm)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-ssm"
  dependency-version: 3.1080.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 12:35:42 -04:00
Adam Moussa
097b8a3fbf
chore(ci): SHA-pin org reusable-workflow caller refs (INFRA-50) (#49)
Some checks are pending
Deploy / deploy (push) Waiting to run
2026-07-06 18:27:54 -04:00
Adam Moussa
3283e62ff2
docs: link Confluence AWS Architecture Map (INFRA-53) (#48)
Some checks are pending
Deploy / deploy (push) Waiting to run
2026-07-06 17:44:18 -04:00
Adam Moussa
a86465d0e2
security: door-unlock token rotation runbook + executed cutover (INFRA-105) (#47)
Some checks are pending
Deploy / deploy (push) Waiting to run
* security: placeholder committed door-unlock token + rotation runbook (INFRA-105)

The live door-system auth token was hard-coded in the Yealink Push-XML
provisioning templates (both /unlock and /lockdown) and present in git
history since 2c9b863. Replace it with __DOOR_UNLOCK_TOKEN__ so future
templates carry no secret; add RUNBOOK-token-rotation.md covering the
rotation, phone re-provisioning, and history scrub.

Rotation + history scrub are NOT performed here — staged for a scheduled
phone re-provisioning window. The old token is compromised until rotated.

* security: mark INFRA-105 rotation executed; correct false cache-expiry claim

The runbook claimed the old token stops working ~5 min after SSM rotation.
/sh-security-review (2026-07-06) confirmed this is false: the authorizer and
both handlers cache the token in module scope with no TTL, so warm containers
honor the old token until recycled (unbounded). Add the mandatory forced
cold-start step, mark the cutover EXECUTED (token rotated to SSM v3, history
scrubbed + force-pushed, Lambdas recycled), stop embedding partial token bytes,
and note the accepted refs/pull/* residual. Design fix (cache TTL) tracked.
2026-07-06 16:54:32 -04:00
dependabot[bot]
2a20b68584 Bump the minor-and-patch group with 3 updates (#46) 2026-07-04 16:35:11 -04:00
seahaven-openswe[bot]
3e869ff373 chore: upgrade Lambda runtime nodejs22.x to nodejs24.x (#45) 2026-07-04 01:25:45 -04:00
dependabot[bot]
21eaa2b5ab Bump @aws-sdk/client-ssm in the minor-and-patch group (#43)
Bumps the minor-and-patch group with 1 update: [@aws-sdk/client-ssm](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ssm).


Updates `@aws-sdk/client-ssm` from 3.1075.0 to 3.1076.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ssm/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1076.0/clients/client-ssm)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-ssm"
  dependency-version: 3.1076.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 19:46:18 -04:00
dependabot[bot]
00786aaae4 Bump the minor-and-patch group across 1 directory with 3 updates (#39)
Bumps the minor-and-patch group with 3 updates in the / directory: [aws-cdk-lib](https://github.com/aws/aws-cdk/tree/HEAD/packages/aws-cdk-lib), [@aws-sdk/client-ssm](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ssm) and [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk).


Updates `aws-cdk-lib` from 2.259.0 to 2.260.0
- [Release notes](https://github.com/aws/aws-cdk/releases)
- [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md)
- [Commits](https://github.com/aws/aws-cdk/commits/v2.260.0/packages/aws-cdk-lib)

Updates `@aws-sdk/client-ssm` from 3.1070.0 to 3.1075.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ssm/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1075.0/clients/client-ssm)

Updates `aws-cdk` from 2.1127.0 to 2.1128.1
- [Release notes](https://github.com/aws/aws-cdk-cli/releases)
- [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1128.1/packages/aws-cdk)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-ssm"
  dependency-version: 3.1075.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-cdk
  dependency-version: 2.1128.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-cdk-lib
  dependency-version: 2.260.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-24 15:55:04 -04:00
Adam Moussa
769c712bcb Pin @types/node to runtime major (Node 22) (#41)
The Lambdas in this stack run on the nodejs22.x runtime, but @types/node
had drifted to ^25 via Dependabot. A too-new types major still compiles,
so the mismatch passed CI while describing APIs absent at runtime.

Repin to ^22 to match the Lambda runtime and add a scoped Dependabot
ignore for @types/node semver-major bumps so the alignment can only be
broken deliberately, alongside a runtime upgrade. Minor/patch within the
major still flow. Sanctioned exception to the no-blanket-ignore rule
(engineering-handbook github-standards Pinning Principle).
2026-06-24 15:01:13 -04:00
dependabot[bot]
5f3b963d00 Bump the minor-and-patch group across 1 directory with 4 updates (#38)
Bumps the minor-and-patch group with 4 updates in the / directory: [aws-cdk-lib](https://github.com/aws/aws-cdk/tree/HEAD/packages/aws-cdk-lib), [@aws-sdk/client-ssm](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ssm), [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk).


Updates `aws-cdk-lib` from 2.258.1 to 2.259.0
- [Release notes](https://github.com/aws/aws-cdk/releases)
- [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md)
- [Commits](https://github.com/aws/aws-cdk/commits/v2.259.0/packages/aws-cdk-lib)

Updates `@aws-sdk/client-ssm` from 3.1064.0 to 3.1070.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ssm/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1070.0/clients/client-ssm)

Updates `@types/node` from 25.9.2 to 25.9.3
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `aws-cdk` from 2.1126.0 to 2.1127.0
- [Release notes](https://github.com/aws/aws-cdk-cli/releases)
- [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1127.0/packages/aws-cdk)

---
updated-dependencies:
- dependency-name: aws-cdk-lib
  dependency-version: 2.259.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@aws-sdk/client-ssm"
  dependency-version: 3.1070.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.9.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-cdk
  dependency-version: 2.1127.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 11:15:33 -04:00
dependabot[bot]
fae171f907 Bump esbuild from 0.28.0 to 0.28.1 (#36)
Bumps [esbuild](https://github.com/evanw/esbuild) from 0.28.0 to 0.28.1.
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.28.0...v0.28.1)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.28.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 17:20:25 -04:00
Adam Moussa
1edf1c8b93 Repo hygiene: PR labeler + README badges (INFRA-56/57) (#35) 2026-06-11 14:13:45 -04:00
dependabot[bot]
8d314db257 Bump the minor-and-patch group with 3 updates (#33)
Bumps the minor-and-patch group with 3 updates: [aws-cdk-lib](https://github.com/aws/aws-cdk/tree/HEAD/packages/aws-cdk-lib), [@aws-sdk/client-ssm](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ssm) and [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `aws-cdk-lib` from 2.258.0 to 2.258.1
- [Release notes](https://github.com/aws/aws-cdk/releases)
- [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md)
- [Commits](https://github.com/aws/aws-cdk/commits/v2.258.1/packages/aws-cdk-lib)

Updates `@aws-sdk/client-ssm` from 3.1062.0 to 3.1064.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ssm/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1064.0/clients/client-ssm)

Updates `@types/node` from 25.9.1 to 25.9.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: aws-cdk-lib
  dependency-version: 2.258.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@aws-sdk/client-ssm"
  dependency-version: 3.1064.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.9.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 18:11:12 -04:00
Adam Moussa
86f078de72 Add CloudWatch Errors alarms to all door-unlock Lambdas (#34)
Physical access control has no error visibility — a Lambda failure
could leave unlock/lockdown/authorizer silently broken. Add ALARM-only
Errors alarms (Sum > 0, 5-min period, NOT_BREACHING) for all four
Lambdas, routed to the cross-stack site-alerts SNS topic encrypted
with alias/seahaven-alarm-topics. No OK/recovery actions per org
convention.

Refs: INFRA-101
2026-06-10 14:38:15 -04:00
Adam Moussa
4265ad90fe Gateway token authorizer + finish CI/CD migration (INFRA-99, INFRA-2) (#32)
* feat: add gateway token authorizer to door-unlock API (INFRA-99)

All three routes (GET /unlock, /lockdown, /lockdown/status) were
AuthorizationType NONE — auth relied solely on each handler checking
the ?token= query param. Add a REQUEST-type HTTP API Lambda authorizer
(door-unlock-api-authorizer) that validates the SAME ?token= value the
Yealink XML Browser keys already send, against the existing
/seahaven/door-unlock/auth-token SSM SecureString, and attach it to all
three routes.

Transparent to the phones: identity source is $request.querystring.token
(exactly what the type-17 XML Browser keys send via GET), simple response
{isAuthorized}, fail-closed, 5-min results cache. Token is cached in
module scope so warm invocations skip SSM.

GET is kept (not switched to POST): the Yealink type-17 XML Browser keys
are GET-only and render the returned Yealink XML — they cannot issue a
POST body or custom headers. POST is therefore deferred to avoid bricking
the door keys.

Handlers retain their own token check as defense-in-depth. Purely
additive change set; no existing Lambda or integration is modified.

* chore: complete CI/CD migration to GitHub Actions (INFRA-2)

GitHub Actions (ci.yaml + deploy.yaml via the Sea Haven reusable
workflows) is the proven deploy path. Remove the now-orphaned
buildspec.yml and update the README CI/CD and architecture sections.

The legacy CodePipeline was already deleted (2026-06-05); the leftover
CodeBuild project seahaven-door-unlock-api-build and its IAM role
seahaven-door-unlock-api-codebuild have now also been decommissioned.
2026-06-08 18:03:30 -04:00
Adam Moussa
fe04a199de fix: use constant-time auth token comparison (INFRA-21) (#30)
Replace plain token !== secrets.authToken checks in the unlock and
lockdown handlers with crypto.timingSafeEqual, guarding for unequal
buffer lengths first (timingSafeEqual throws on different lengths).
Prevents timing side-channel leakage of the auth token. Handler
signatures, event shape, and return contract are unchanged.
2026-06-05 17:26:12 -04:00
dependabot[bot]
0f27b2553a Bump aws-cdk-lib in the minor-and-patch group across 1 directory (#28)
Bumps the minor-and-patch group with 1 update in the / directory: [aws-cdk-lib](https://github.com/aws/aws-cdk/tree/HEAD/packages/aws-cdk-lib).


Updates `aws-cdk-lib` from 2.257.0 to 2.258.0
- [Release notes](https://github.com/aws/aws-cdk/releases)
- [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md)
- [Commits](https://github.com/aws/aws-cdk/commits/v2.258.0/packages/aws-cdk-lib)

---
updated-dependencies:
- dependency-name: aws-cdk-lib
  dependency-version: 2.258.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 14:47:13 -04:00
Adam Moussa
565d4af4fd fix: raise unlock Lambda timeout to 20s (#29)
The door-unlock-api-unlock function hit its 10,000ms timeout on
2026-06-05 at 18:00 UTC during a cold start combined with a slow
LenelS2 Elements API call (REPORT: Duration 10000.00 ms, Status:
timeout). A concurrent attempt succeeded in 3639ms, confirming the
call path is healthy but lacks margin under cold-start + slow-API
conditions.

Raise the UnlockHandler timeout from 10s to 20s for additional
headroom. LockdownHandler (15s) and the poller (75s) are unchanged.
2026-06-05 14:31:00 -04:00
Adam Moussa
1a3eb51628 chore(deps): remove blanket aws-cdk-lib dependabot ignore (#27)
Per handbook Pinning Principle: exact pins are kept current by Dependabot version updates gated by CI + dependency review. Blanket ignores let pins rot (see today's fast-uri incident).
2026-06-05 13:56:52 -04:00
Adam Moussa
912a860462 fix(deps): pin aws-cdk-lib to ==2.257.0 (#26)
* fix(deps): re-pin aws-cdk-lib to ==2.253.1

* fix(deps): pin aws-cdk-lib to 2.257.0 (exact)
2026-06-05 13:01:30 -04:00
Adam Moussa
118ea41e87 Add dependency-review caller workflow (#25)
* Add dependency-review caller workflow

Add a pull_request-triggered caller that invokes the org-level
callable-dependency-review workflow to scan dependency changes and
fail on high-severity advisories.

* chore: retrigger checks

* chore: retrigger dep review (post-fix)

* chore: add .env to .gitignore
2026-06-05 12:34:08 -04:00
dependabot[bot]
09f301f482 Bump the minor-and-patch group with 2 updates (#24)
Bumps the minor-and-patch group with 2 updates: [@aws-sdk/client-ssm](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ssm) and [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk).


Updates `@aws-sdk/client-ssm` from 3.1059.0 to 3.1061.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ssm/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1061.0/clients/client-ssm)

Updates `aws-cdk` from 2.1125.0 to 2.1126.0
- [Release notes](https://github.com/aws/aws-cdk-cli/releases)
- [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1126.0/packages/aws-cdk)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-ssm"
  dependency-version: 3.1061.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-cdk
  dependency-version: 2.1126.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 00:27:55 +00:00
dependabot[bot]
e18b085af3 Bump the minor-and-patch group across 1 directory with 3 updates (#22)
Bumps the minor-and-patch group with 3 updates in the / directory: [aws-cdk-lib](https://github.com/aws/aws-cdk/tree/HEAD/packages/aws-cdk-lib), [@aws-sdk/client-ssm](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ssm) and [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk).


Updates `aws-cdk-lib` from 2.255.0 to 2.257.0
- [Release notes](https://github.com/aws/aws-cdk/releases)
- [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md)
- [Commits](https://github.com/aws/aws-cdk/commits/v2.257.0/packages/aws-cdk-lib)

Updates `@aws-sdk/client-ssm` from 3.1050.0 to 3.1059.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ssm/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1059.0/clients/client-ssm)

Updates `aws-cdk` from 2.1123.0 to 2.1125.0
- [Release notes](https://github.com/aws/aws-cdk-cli/releases)
- [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1125.0/packages/aws-cdk)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-ssm"
  dependency-version: 3.1054.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-cdk
  dependency-version: 2.1124.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-cdk-lib
  dependency-version: 2.257.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-03 00:06:10 +00:00
Adam Moussa
b0b2444799 Add API access logging (audit Day 3: M-18) (#23)
Access logging to /aws/apigateway/door-unlock-api (90d) on the HTTP API default
stage. Throttling (5 burst / 2 rps) was already present.
2026-06-02 17:42:13 -04:00
dependabot[bot]
1aef1ca16c Bump the minor-and-patch group with 4 updates (#21)
Bumps the minor-and-patch group with 4 updates: [aws-cdk-lib](https://github.com/aws/aws-cdk/tree/HEAD/packages/aws-cdk-lib), [@aws-sdk/client-ssm](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ssm), [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk).


Updates `aws-cdk-lib` from 2.253.1 to 2.255.0
- [Release notes](https://github.com/aws/aws-cdk/releases)
- [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md)
- [Commits](https://github.com/aws/aws-cdk/commits/v2.255.0/packages/aws-cdk-lib)

Updates `@aws-sdk/client-ssm` from 3.1045.0 to 3.1050.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ssm/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1050.0/clients/client-ssm)

Updates `@types/node` from 25.7.0 to 25.9.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `aws-cdk` from 2.1121.0 to 2.1123.0
- [Release notes](https://github.com/aws/aws-cdk-cli/releases)
- [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1123.0/packages/aws-cdk)

---
updated-dependencies:
- dependency-name: aws-cdk-lib
  dependency-version: 2.255.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@aws-sdk/client-ssm"
  dependency-version: 3.1050.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.9.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-cdk
  dependency-version: 2.1123.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 00:32:00 +00:00
dependabot[bot]
c4567a213c Bump @types/node from 25.6.2 to 25.7.0 in the minor-and-patch group (#20)
Bumps the minor-and-patch group with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `@types/node` from 25.6.2 to 25.7.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.7.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-12 20:22:16 +00:00
dependabot[bot]
8b2a1d001d Bump the minor-and-patch group across 1 directory with 3 updates (#16)
Bumps the minor-and-patch group with 3 updates in the / directory: [aws-cdk-lib](https://github.com/aws/aws-cdk/tree/HEAD/packages/aws-cdk-lib), [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk).


Updates `aws-cdk-lib` from 2.252.0 to 2.253.1
- [Release notes](https://github.com/aws/aws-cdk/releases)
- [Changelog](https://github.com/aws/aws-cdk/blob/v2.253.1/CHANGELOG.v2.alpha.md)
- [Commits](https://github.com/aws/aws-cdk/commits/v2.253.1/packages/aws-cdk-lib)

Updates `@types/node` from 25.6.0 to 25.6.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `aws-cdk` from 2.1120.0 to 2.1121.0
- [Release notes](https://github.com/aws/aws-cdk-cli/releases)
- [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1121.0/packages/aws-cdk)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.6.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-cdk
  dependency-version: 2.1121.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-cdk-lib
  dependency-version: 2.253.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 22:27:19 +00:00
Adam Moussa
b5e75f8306 Add GitHub Actions deploy workflow (#18)
* Add GitHub Actions deploy workflow (OIDC)

* Add permissions block for OIDC token exchange

* Add concurrency control to prevent parallel deploys
2026-05-08 17:08:22 -04:00
Adam Moussa
c3d1c399f2 Add CI workflow (#17)
* Add CI workflow

* Fix TypeScript compilation for CI

Add types: ["node"] to tsconfig so tsc resolves Node.js globals
(console, process, __dirname). Add @aws-sdk/client-ssm and
source-map-support as devDependencies for type resolution.
2026-05-08 16:03:06 -04:00
Adam Moussa
1849d3db50 Update Dependabot: remove assignees, group minor/patch updates (#15) 2026-05-08 14:23:20 -04:00
Adam Moussa
222e6fd49d Remove wrapper workflow — using required workflow via org ruleset (#14) 2026-05-06 19:59:02 -04:00
dependabot[bot]
dc992880cc Bump esbuild from 0.25.0 to 0.28.0 (#8)
Bumps [esbuild](https://github.com/evanw/esbuild) from 0.25.0 to 0.28.0.
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2025.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.0...v0.28.0)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.28.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-06 19:24:24 -04:00
dependabot[bot]
0e9943fadc Bump typescript from 5.7.3 to 6.0.3 (#7)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.7.3 to 6.0.3.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.7.3...v6.0.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-06 19:11:15 -04:00
dependabot[bot]
1b778197e2 Bump @types/node from 22.19.17 to 25.6.0 (#10)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.19.17 to 25.6.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.6.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Adam Moussa <166072409+amoussa1229@users.noreply.github.com>
2026-05-06 18:22:57 -04:00
Adam Moussa
07f81be535 Add Claude Code review workflow (#13) 2026-05-06 18:12:11 -04:00
Adam Moussa
ae8060f5c9 Merge pull request #12 from Sea-Haven-Industries/feature/dependabot-auto-assign
Auto-assign Dependabot PRs
2026-05-02 17:28:06 -04:00
Adam Moussa
23c860187f Auto-assign Dependabot PRs to amoussa1229 2026-05-02 17:26:13 -04:00
Adam Moussa
0c7f154c17 Merge pull request #11 from Sea-Haven-Industries/dependabot/npm_and_yarn/aws-cdk-2.1120.0
Bump aws-cdk from 2.1118.4 to 2.1120.0
2026-05-02 17:23:34 -04:00
Adam Moussa
d0fd4a5e8b Merge pull request #9 from Sea-Haven-Industries/dependabot/npm_and_yarn/aws-cdk-lib-2.252.0
Bump aws-cdk-lib from 2.250.0 to 2.252.0
2026-05-02 17:23:31 -04:00
dependabot[bot]
3f1060a104 Bump aws-cdk from 2.1118.4 to 2.1120.0
Bumps [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk) from 2.1118.4 to 2.1120.0.
- [Release notes](https://github.com/aws/aws-cdk-cli/releases)
- [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1120.0/packages/aws-cdk)

---
updated-dependencies:
- dependency-name: aws-cdk
  dependency-version: 2.1120.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 21:17:32 +00:00
dependabot[bot]
b1f5c54829 Bump aws-cdk-lib from 2.250.0 to 2.252.0
Bumps [aws-cdk-lib](https://github.com/aws/aws-cdk/tree/HEAD/packages/aws-cdk-lib) from 2.250.0 to 2.252.0.
- [Release notes](https://github.com/aws/aws-cdk/releases)
- [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md)
- [Commits](https://github.com/aws/aws-cdk/commits/v2.252.0/packages/aws-cdk-lib)

---
updated-dependencies:
- dependency-name: aws-cdk-lib
  dependency-version: 2.252.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 21:17:20 +00:00
Adam Moussa
97d47bd37b Merge pull request #6 from Sea-Haven-Industries/feature/add-dependabot-config
Add Dependabot version update configuration
2026-05-02 17:15:41 -04:00
Adam Moussa
e7b4ae272b Add Dependabot version update configuration 2026-05-02 17:14:19 -04:00
Adam Moussa
4068a4c34e Merge pull request #5 from Sea-Haven-Industries/feature/fix-stack-name
Rename stack to kebab-case
2026-05-01 18:57:14 -04:00
Adam Moussa
49639436f7 Rename stack to kebab-case
Requires deleting the old SeaHavenDoorUnlockStack before deploying.
2026-05-01 18:56:59 -04:00
Adam Moussa
ccbc98962b Add lockdown mode and CI/CD pipeline (#3)
* Add lockdown profile toggle endpoints with T58W linekey support

Add a new Lambda handler that toggles Elements lockdown profiles
(Bohemia and Ronkonkoma) via the Elements API, with status
verification before and after each toggle. Returns Yealink XML
to control linekey LEDs (green=inactive, red=locked down).

Also brings both Lambda handlers into compliance with system
standards: Node 22.x runtime, arm64 architecture, 60-day log
retention, and kebab-case function names.

* Add lockdown poller Lambda and fix lockdown handler responses

- Add VPC-connected poller Lambda that monitors lockdown status via
  Elements API every 15 seconds (4 polls per 1-min EventBridge schedule)
- Handle Elements API rate limits (429) with retry-after support
- Fix lockdown handler to use TextScreen XML instead of Execute XML
  (Execute shows globe icon on T58W, TextScreen renders properly)
- Fix Elements API status parsing to be case-insensitive
- Trust toggle action instead of re-checking status (eventual consistency)
- Configure push_xml.server = any in T58W template for Push XML support
- Clear action_url.setup_completed (poller replaces boot-time check)
- Update README with lockdown architecture and known LED limitation

Note: T58W line key LED color does not change to reflect lockdown
status. Execute LED commands are transient on the T58W - the phone's
XML Browser key type immediately overrides them.

* Add buildspec for CodePipeline CI/CD

* Update README with CI/CD pipeline details
2026-05-01 18:52:37 -04:00
dependabot[bot]
dbdb87ca4b Bump esbuild from 0.24.2 to 0.25.0 (#1)
Bumps [esbuild](https://github.com/evanw/esbuild) from 0.24.2 to 0.25.0.
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.24.2...v0.25.0)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.25.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-28 18:04:03 -04:00
53 changed files with 26956 additions and 435 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

15
.github/workflows/ci.yaml vendored Normal file
View file

@ -0,0 +1,15 @@
name: CI
on:
pull_request:
branches: [main]
merge_group:
permissions:
contents: read
jobs:
ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19
with:
run-cdk-synth: false
run-tests: true

10
.github/workflows/dependency-review.yml vendored Normal file
View file

@ -0,0 +1,10 @@
name: Dependency Review
on:
pull_request:
permissions:
contents: read
jobs:
review:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19

20
.github/workflows/deploy.yaml.frozen vendored Normal file
View file

@ -0,0 +1,20 @@
# Frozen for PLAT-76. HCP Terraform is the sole deploy path.
# Do not restore this workflow; the mgmt CDK stack is the rollback target
# until DNS cutover and stack delete.
name: Deploy
on:
workflow_dispatch: # CD frozen for PLAT-76; do not restore push-to-main
permissions:
id-token: write
contents: read
concurrency:
group: deploy
cancel-in-progress: false
jobs:
deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
secrets:
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}

13
.github/workflows/labeler.yml vendored Normal file
View file

@ -0,0 +1,13 @@
name: Labeler
on:
pull_request:
branches: [main]
permissions:
contents: read
pull-requests: write
issues: write
jobs:
label:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19

7
.gitignore vendored
View file

@ -3,3 +3,10 @@ cdk.out/
*.js *.js
*.d.ts *.d.ts
*.js.map *.js.map
.env
.env.*
terraform/build/
.terraform/
*.tfvars

1
.npmrc Normal file
View file

@ -0,0 +1 @@
allow-remote=all

View file

@ -0,0 +1,40 @@
{
"suppressions": [
{
"id": "semgrep-detect-child-process-61",
"justification": "False positive. CDK local-bundling tryBundle(outputDir) in lib/door-unlock-stack.ts. execSync runs esbuild at cdk-synth time; outputDir is supplied by the CDK framework (staging temp dir) and the other path segments are repo-relative constants. No untrusted input, build-time only on a trusted host, never runs at request time. Verified proof-or-kill 2026-07-13. INFRA-105. Re-pointed from line 55 on 2026-07-23: fromStringParameterName cleanup shifted lines in lib/door-unlock-stack.ts; finding unchanged."
},
{
"id": "semgrep-detect-child-process-90",
"justification": "False positive. Same as semgrep-detect-child-process-55: CDK local-bundling esbuild execSync with framework-supplied synth-time outputDir. Build-time only. INFRA-105. Re-pointed from line 84 on 2026-07-23: fromStringParameterName cleanup shifted lines in lib/door-unlock-stack.ts; finding unchanged."
},
{
"id": "semgrep-detect-child-process-128",
"justification": "False positive. Same as semgrep-detect-child-process-55: CDK local-bundling esbuild execSync with framework-supplied synth-time outputDir. Build-time only. INFRA-105. Re-pointed from line 122 on 2026-07-23: fromStringParameterName cleanup shifted lines in lib/door-unlock-stack.ts; finding unchanged."
},
{
"id": "semgrep-detect-child-process-210",
"justification": "False positive. Same as semgrep-detect-child-process-55: CDK local-bundling esbuild execSync with framework-supplied synth-time outputDir. Build-time only. INFRA-105. Re-pointed from line 202 on 2026-07-23: fromStringParameterName cleanup shifted lines in lib/door-unlock-stack.ts; finding unchanged."
},
{
"id": "semgrep-detect-child-process-262",
"justification": "False positive. Same as the other CDK local-bundling esbuild execSync findings in lib/door-unlock-stack.ts. tryBundle(outputDir) for door-unlock-api-blf-sync; outputDir is supplied by the CDK framework at synth time; remaining path segments are repo-relative constants. No untrusted input, build-time only, never runs at request time. PLAT-116."
},
{
"id": "checkov-CKV_AWS_111-234",
"justification": "False positive. CDK-generated LogRetention custom-resource role (cdk.out synth output). logs:PutRetentionPolicy/DeleteRetentionPolicy on Resource:* is inherent to the aws-cdk LogRetention singleton construct (log-group names are not known at synth time). Accepted CDK boilerplate, not hand-written IAM. INFRA-105."
},
{
"id": "gitleaks-generic-api-key-5193",
"justification": "False positive. A provisioning-template token placeholder (the literal __DOOR_UNLOCK_TOKEN__) in Yealink T54W templates \u2014 not a secret. The real token was rotated in SSM (INFRA-105, param v3 2026-07-06) and the historical live token was removed by the git-filter-repo history scrub; only the placeholder remains."
},
{
"id": "gitleaks-generic-api-key-900",
"justification": "False positive. Historical blob of a Yealink provisioning template. After the INFRA-105 history scrub this line holds the __DOOR_UNLOCK_TOKEN__ placeholder only; the pre-scrub live token was rotated in SSM 2026-07-06 and is invalid."
},
{
"id": "gitleaks-generic-api-key-3097",
"justification": "False positive. A __DOOR_UNLOCK_TOKEN__ placeholder in a Yealink provisioning template (unlock linekey) \u2014 not a secret. Live token rotated in SSM 2026-07-06; history scrubbed via git-filter-repo (INFRA-105)."
}
]
}

32
AGENTS.md Normal file
View file

@ -0,0 +1,32 @@
# Sea Haven Org Governance
> Full engineering standards: [engineering-handbook](https://github.com/Sea-Haven-Industries/engineering-handbook).
## Branching and PRs
- Branch prefixes: `feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, `refactor/`, `release/`
- PR titles: `type(scope): description (DEV-123)` — Jira key required (DEV/PLAT/SEC)
- PR body sections (exact order): **Summary**, **Validation**, **Tests**, **Notes**
- Route work: DEV (product), PLAT (infra/platform), SEC (security)
## Commits
- Conventional Commits: `type(scope): description`
- Allowed types: `feat fix docs style refactor perf test build ci chore revert release`
- No AI-attribution footers
## Secrets and Security
- Secrets in AWS Secrets Manager only — never in code, env vars, logs, or commits
- Non-secret config in SSM Parameter Store
## CI and SHA Pins
Pin every GitHub Actions ref to a full commit SHA with an inline version comment:
```yaml
uses: actions/checkout@abc123def456 # v4.1.0
```
The deterministic global pre-push security hook must not be bypassed (`--no-verify` requires
explicit approval). Linting stays in CI; do not gate on it locally.

123
README.md
View file

@ -1,17 +1,73 @@
# Sea Haven Door Unlock API # Sea Haven Door Unlock API
AWS Lambda middleware that allows a Yealink T54W desk phone to unlock the front door controlled by LenelS2 Elements. Press a DSS key on the phone, and the door unlocks. ![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 → HTTPS GET → API Gateway → Lambda → LenelS2 Elements API → Door Unlocks 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 ## Architecture
- **API Gateway (HTTP API)** — single `GET /unlock` endpoint with throttling (5 burst / 2 sustained req/sec) - **API Gateway (HTTP API)** — `GET /unlock`, `GET /lockdown`, `GET /lockdown/status` with throttling (5 burst / 2 sustained req/sec)
- **Lambda (Node.js 20.x)** — validates a shared auth token, calls the Elements `TemporaryUnlock` command - **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`.
- **SSM Parameter Store** — stores the Elements API key, auth token, and door device ID - **Unlock Lambda** — validates a shared auth token, calls the Elements `TemporaryUnlock` command
- **Custom Domain** — `doorunlock.seahaven.com` via Route 53 + ACM wildcard cert - **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 ## SSM Parameters
@ -21,27 +77,54 @@ Yealink T54W → HTTPS GET → API Gateway → Lambda → LenelS2 Elements API
| `/seahaven/door-unlock/auth-token` | SecureString | Shared secret embedded in the Yealink DSS key URL | | `/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 | | `/seahaven/door-unlock/door-id` | String | Elements device ID for the front door reader |
## Deployment 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.
```bash ## Secrets Manager
npm install
npx cdk deploy | 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 ## Phone Configuration
Configure a DSS key on the Yealink T54W (via phone web UI or 3CX): Configure DSS keys on the Yealink T54W/T57W/T58W (via phone web UI or 3CX):
- **Type:** URL - **Key 2 — Unlock Door**
- **Label:** Unlock Door - Type: URL
- **Value:** `https://doorunlock.seahaven.com/unlock?token=<auth-token-value>` - Value: `https://doorunlock.seahaven.com/unlock?token=<auth-token>`
- **Keys 3-4 — Lockdown Toggle**
- Type: XML Browser (17)
- Value: `https://doorunlock.seahaven.com/lockdown?token=<auth-token>&profile=bohemia|ronkonkoma`
## 3CX Provisioning Templates ## 3CX Provisioning Templates
Custom 3CX templates are included with the door unlock URL hardcoded on line key 2. Custom 3CX templates are included with door unlock and lockdown URLs hardcoded.
| Template | Model | Line Key 2 | Keys 3+ | Display | | 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.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 | Shared Parking SP1-3 | 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 |
| `yealinkT58W-door-unlock.ph.xml` | T58W | Unlock Door | Managed by 3CX BLF | Default T58W display settings | | `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).

113
RUNBOOK-token-rotation.md Normal file
View file

@ -0,0 +1,113 @@
# Door-Unlock Token Rotation & History Scrub — INFRA-105
> **Status: EXECUTED 2026-07-06.** Token rotated (SSM `/seahaven/door-unlock/auth-token` v3,
> 16:23 ET), all phones re-provisioned, git history scrubbed with `git filter-repo` and
> force-pushed (main + this branch + all tags), and the three token-validating Lambdas
> force-recycled to flush the never-expiring in-memory token cache (see step 1 note). The
> previously committed token is invalid and no longer present on any origin ref.
>
> **Residual (accepted):** GitHub-controlled read-only `refs/pull/*` refs still reference
> pre-scrub commits (only GitHub Support can purge); harmless since the token is rotated.
## Background
The Yealink Push-XML provisioning templates hard-coded the live door-system auth token in
plaintext, in both the `/unlock` and `/lockdown` query strings, across:
- `yealinkT58W-door-unlock.ph.xml`
- `yealinkT54W-door-unlock.ph.xml`
- `yealinkT54W-door-unlock-with-sp.ph.xml`
- `yealinkT57W-door-unlock-with-sp.ph.xml`
The token is the exact secret the API Gateway authorizer (`lambda/authorizer/authorizer-handler.ts`)
validates against SSM SecureString `/seahaven/door-unlock/auth-token` via `timingSafeEqual`.
It is present in git history since the first template commit `6d5f665`. Anyone with repo read
access (or history) could unlock the door or trigger building lockdown over the internet.
This branch replaces the token with the placeholder `__DOOR_UNLOCK_TOKEN__`. The real value is
injected at provisioning time and must never be committed again.
## Cutover procedure (run in a maintenance window)
### 1. Rotate the token
```sh
NEW_TOKEN=$(openssl rand -hex 32)
aws ssm put-parameter \
--name /seahaven/door-unlock/auth-token \
--type SecureString --overwrite \
--value "$NEW_TOKEN" --region us-east-1
# CRITICAL: the SSM overwrite ALONE does NOT invalidate the old token.
# The authorizer + unlock + lockdown handlers cache the token in module scope with
# NO TTL (authorizer-handler.ts getAuthToken, unlock/lockdown loadSecrets), so any warm
# Lambda container keeps honoring the OLD token until AWS recycles it — unbounded, up to
# hours on a low-traffic API. You MUST force a cold start to guarantee invalidation:
for fn in door-unlock-api-authorizer door-unlock-api-unlock door-unlock-api-lockdown; do
aws lambda update-function-configuration --region us-east-1 \
--function-name "$fn" --description "token-rotation $(date -u +%Y-%m-%dT%H%M%SZ)"
done
# (The API Gateway authorizer result cache is a separate 5-min TTL keyed on the presented
# token string; stale ALLOW entries for the old token expire within 5 min on their own.)
```
> **Design debt (INFRA-105 follow-up):** give the module-scope token cache a short TTL (or
> read SSM per-invocation) so future rotations are self-healing and this manual cold-start
> step is unnecessary. Tracked as a confirmed HIGH from the 2026-07-06 `/sh-security-review`.
### 2. Re-provision all Yealink phones
Render each template with the new token (do NOT commit the rendered output):
```sh
for f in yealink*.ph.xml; do
sed "s/__DOOR_UNLOCK_TOKEN__/$NEW_TOKEN/g" "$f" > "/tmp/rendered-$f"
done
```
Push `/tmp/rendered-*` to the phones via the 3CX/Yealink provisioning path. Verify one phone's
`/unlock` key works against the new token before doing the rest. Delete the rendered files after.
> The token must physically live on the phones — type-17 URL keys can't send headers — so the
> rendered XML always contains the secret. Keep it out of source control and off shared storage.
### 3. Scrub git history
```sh
# git-filter-repo (preferred)
# Put the old 64-hex token value in a gitignored/temp file, never inline in this doc:
git filter-repo --replace-text <(printf '%s==>__DOOR_UNLOCK_TOKEN__\n' "$OLD_TOKEN")
# then force-push every ref; coordinate with anyone holding clones (they must re-clone)
git push --force --all && git push --force --tags
```
> **Do it in a mirror clone** (`git clone --mirror`), scrub there, and force-push, rather than
> filter-repo'ing your working clone. Then re-sync your local clone: delete stale local tags and
> `git fetch --tags --force`, and delete any local branches whose tips predate the scrub (they
> retain the old blob even after the scrub). Verify: `for r in $(git for-each-ref
> --format='%(refname)'); do git grep -q "$OLD_TOKEN" "$r" && echo "HIT $r"; done`.
After force-push, the old token is gone from history but is **already compromised** — rotation
(step 1, including the forced Lambda cold start) is what actually invalidates it, not the scrub.
Note `refs/pull/*` on GitHub are read-only and cannot be force-updated — they retain the old
blob until GitHub Support purges them; acceptable once the token is rotated.
## Gates before merge/deploy
- **/sh-security-review** — auth surface, required. Run 2026-07-06: gate = BLOCK on 5 confirmed
pre-existing auth-design HIGHs (never-expiring token cache ×2, no replay/IP restriction,
lockdown toggle de-escalation, unlock-during-lockdown). None are introduced by this branch,
which adds only this runbook. The residual-secret concern (SC-01) was REFUTED post-scrub.
Tracked for follow-up; see the project memory. Merge decision is Adam's given the block is on
legacy design, not this diff.
- **GPT-4.1 cross-review** — only if a handler/IAM change accompanies this (placeholdering alone does not).
- Deploy-then-merge per handbook; the pre-push scanner hook passes with machine-level suppressions
for the placeholder/CDK-bundling/LogRetention false-positives (see
`~/.config/sea-haven/security-review/seahaven-door-unlock-api/suppressions.json`).
## Follow-up design fix
Treat the provisioning XML as a generated/secret artifact: keep only the placeholdered template
in git, render with the SSM value at provisioning time. Consider a small provisioning script in
this repo that pulls the token from SSM and renders, so the secret is never written to disk longer
than the push requires.

View file

@ -5,6 +5,6 @@ import { DoorUnlockStack } from "../lib/door-unlock-stack";
const app = new cdk.App(); const app = new cdk.App();
new DoorUnlockStack(app, "door-unlock-api", { new DoorUnlockStack(app, "door-unlock-api", {
stackName: "SeaHavenDoorUnlockStack", stackName: "seahaven-door-unlock-api",
env: { account: "328440206208", region: "us-east-1" }, env: { account: "328440206208", region: "us-east-1" },
}); });

47
cdk.context.json Normal file
View file

@ -0,0 +1,47 @@
{
"vpc-provider:account=328440206208:filter.vpc-id=vpc-0d3d4b67bd0cf8a68:region=us-east-1:returnAsymmetricSubnets=true": {
"vpcId": "vpc-0d3d4b67bd0cf8a68",
"vpcCidrBlock": "10.20.0.0/16",
"ownerAccountId": "328440206208",
"availabilityZones": [],
"vpnGatewayId": "vgw-073737d44762dffc2",
"subnetGroups": [
{
"name": "Private",
"type": "Private",
"subnets": [
{
"subnetId": "subnet-04e38c507e96f1926",
"cidr": "10.20.30.0/24",
"availabilityZone": "us-east-1a",
"routeTableId": "rtb-06a2f56f492b9b4de"
},
{
"subnetId": "subnet-0a0b4fc6f296dfba5",
"cidr": "10.20.40.0/24",
"availabilityZone": "us-east-1b",
"routeTableId": "rtb-01e152fe5cabca7d6"
}
]
},
{
"name": "Public",
"type": "Public",
"subnets": [
{
"subnetId": "subnet-0eea820effe1b3ae5",
"cidr": "10.20.10.0/24",
"availabilityZone": "us-east-1a",
"routeTableId": "rtb-0f2232493a5c43fe8"
},
{
"subnetId": "subnet-0012f5895182c1580",
"cidr": "10.20.20.0/24",
"availabilityZone": "us-east-1b",
"routeTableId": "rtb-0f2232493a5c43fe8"
}
]
}
]
}
}

View file

@ -1,5 +1,5 @@
{ {
"app": "npx ts-node bin/app.ts", "app": "npx tsx bin/app.ts",
"watch": { "watch": {
"include": ["**"], "include": ["**"],
"exclude": [ "exclude": [

Binary file not shown.

View file

@ -0,0 +1,62 @@
import {
SSMClient,
GetParameterCommand,
} from "@aws-sdk/client-ssm";
import { timingSafeEqual } from "node:crypto";
const ssm = new SSMClient({});
function tokensMatch(provided: string, expected: string): boolean {
const a = Buffer.from(provided);
const b = Buffer.from(expected);
// timingSafeEqual throws on unequal-length buffers; check length first.
return a.length === b.length && timingSafeEqual(a, b);
}
let cachedAuthToken: string | undefined;
async function getAuthToken(): Promise<string> {
if (cachedAuthToken) return cachedAuthToken;
const res = await ssm.send(
new GetParameterCommand({
Name: process.env.AUTH_TOKEN_PARAM!,
WithDecryption: true,
})
);
cachedAuthToken = res.Parameter!.Value!;
return cachedAuthToken;
}
/**
* API Gateway HTTP API (payload v2.0) REQUEST Lambda authorizer.
*
* Validates the SAME `?token=` query-string parameter the Yealink XML Browser
* keys already send (type-17 keys issue a plain GET and cannot send headers or
* a POST body), so this authorizer is transparent to the phones. An
* unauthenticated or wrong-token request is now rejected at the gateway with
* 401/403 before any handler Lambda is invoked.
*
* Returns the simple-response shape ({ isAuthorized }) which the routes are
* configured for (enableSimpleResponses: true).
*/
export async function handler(event: {
queryStringParameters?: Record<string, string>;
}): Promise<{ isAuthorized: boolean }> {
const token = event.queryStringParameters?.token;
if (!token) {
return { isAuthorized: false };
}
let expected: string;
try {
expected = await getAuthToken();
} catch (err) {
console.error(
JSON.stringify({ action: "authorize", status: "error", reason: "ssm_failure", error: String(err) })
);
// Fail closed: deny if the token cannot be loaded.
return { isAuthorized: false };
}
return { isAuthorized: tokensMatch(token, expected) };
}

View file

@ -0,0 +1,192 @@
import {
SecretsManagerClient,
GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";
import {
ALWAYS_INCLUDE_EXTENSIONS,
addAlwaysIncludedColleagues,
groupUsersByDepartment,
isEligibleColleague,
primaryDepartmentName,
splitPeerName,
type ColleagueRef,
type SyncUser,
} from "./department";
import { mergeDepartmentBlfs } from "./merge";
import { resolveTemplateId, SLOT_CONTRACT } from "./slot-contract";
import { ThreeCxClient } from "./three-cx-client";
const secrets = new SecretsManagerClient({});
export interface BlfSyncEvent {
dryRun?: boolean;
smokeExtension?: string;
}
function parseSecretString(raw: string | undefined): string {
if (!raw) {
throw new Error("empty secret");
}
try {
const parsed = JSON.parse(raw) as unknown;
return typeof parsed === "string" ? parsed : raw;
} catch {
return raw;
}
}
async function readSecret(name: string): Promise<string> {
const res = await secrets.send(new GetSecretValueCommand({ SecretId: name }));
return parseSecretString(res.SecretString);
}
function resolveTemplate(user: SyncUser) {
const phones = user.Phones ?? [];
for (const phone of phones) {
const id = resolveTemplateId(phone.TemplateName, phone.Name);
if (id) {
return id;
}
}
return undefined;
}
export async function handler(event: BlfSyncEvent = {}) {
const dryRun = event.dryRun ?? process.env.DRY_RUN !== "false";
const smokeExtension = (event.smokeExtension ?? process.env.SMOKE_EXTENSION ?? "").trim();
const domain = await readSecret(process.env.THREE_CX_DOMAIN_SECRET!);
const clientId = await readSecret(process.env.THREE_CX_CLIENT_ID_SECRET!);
const clientSecret = await readSecret(process.env.THREE_CX_CLIENT_SECRET_SECRET!);
const client = new ThreeCxClient({ domain, clientId, clientSecret });
const users = await client.listUsers<SyncUser>();
const byDept = groupUsersByDepartment(users);
const extraColleagues: ColleagueRef[] = [];
for (const number of ALWAYS_INCLUDE_EXTENSIONS) {
const already = users.find((u) => u.Number === number);
if (already && isEligibleColleague(already)) {
extraColleagues.push({
id: already.Id,
number: already.Number,
firstName: already.FirstName ?? "",
lastName: already.LastName ?? "",
});
continue;
}
const peer = await client.getPeerByNumber(number);
if (!peer) {
console.log(JSON.stringify({ action: "blf_sync_skip_extra", reason: "peer_not_found", extension: number }));
continue;
}
const names = splitPeerName(peer.Name);
extraColleagues.push({
id: peer.Id,
number: peer.Number,
firstName: names.firstName,
lastName: names.lastName,
});
}
let patched = 0;
let unchanged = 0;
let skipped = 0;
let failed = 0;
for (const user of users) {
const templateId = resolveTemplate(user);
if (!templateId) {
skipped += 1;
console.log(JSON.stringify({
action: "blf_sync_skip",
reason: "unknown_template",
extension: user.Number,
}));
continue;
}
const dept = primaryDepartmentName(user);
if (!dept) {
skipped += 1;
console.log(JSON.stringify({
action: "blf_sync_skip",
reason: "no_department",
extension: user.Number,
}));
continue;
}
const colleagues = addAlwaysIncludedColleagues(
(byDept.get(dept) ?? [])
.filter(isEligibleColleague)
.map((u) => ({
id: u.Id,
number: u.Number,
firstName: u.FirstName ?? "",
lastName: u.LastName ?? "",
})),
extraColleagues
);
const result = mergeDepartmentBlfs({
currentXml: user.Blfs,
selfExtension: user.Number,
colleagues,
contract: SLOT_CONTRACT[templateId],
});
const logBase = {
extension: user.Number,
department: dept,
templateId,
placed: result.placed,
overflow: result.overflow,
changed: result.changed,
};
if (!result.changed) {
unchanged += 1;
console.log(JSON.stringify({ action: "blf_sync_unchanged", ...logBase }));
continue;
}
if (dryRun || (smokeExtension && user.Number !== smokeExtension)) {
console.log(JSON.stringify({
action: dryRun ? "blf_sync_dry_run" : "blf_sync_skipped_not_smoke",
...logBase,
proposedBlfs: result.xml,
}));
continue;
}
const write = await client.patchUserBlfs(user.Id, result.xml);
if (write.status >= 200 && write.status < 300) {
patched += 1;
console.log(JSON.stringify({ action: "blf_sync_patched", ...logBase, status: write.status }));
} else {
failed += 1;
console.error(JSON.stringify({
action: "blf_sync_patch_failed",
...logBase,
status: write.status,
}));
}
}
const summary = {
action: "blf_sync_complete",
dryRun,
smokeExtension: smokeExtension || undefined,
visibleUsers: users.length,
departments: [...byDept.keys()],
patched,
unchanged,
skipped,
failed,
};
console.log(JSON.stringify(summary));
if (failed > 0) {
throw new Error(`blf sync patch failed for ${failed} user(s)`);
}
return summary;
}

View file

@ -0,0 +1,222 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { blfsEqual, parseBlfs, serializeBlfs } from "./blf-xml";
import { addAlwaysIncludedColleagues, isEligibleColleague, primaryDepartmentName, splitPeerName } from "./department";
import { mergeDepartmentBlfs } from "./merge";
import { resolveTemplateId, SLOT_CONTRACT } from "./slot-contract";
const colleagues = [
{ id: 29, number: "100", firstName: "Adam", lastName: "Moussa" },
{ id: 33, number: "111", firstName: "Alyssa", lastName: "Ficarra" },
{ id: 38, number: "114", firstName: "Ashley", lastName: "Fedner" },
{ id: 66, number: "115", firstName: "Derrick", lastName: "Smith" },
{ id: 68, number: "113", firstName: "Sarah", lastName: "May" },
{ id: 69, number: "116", firstName: "Cindy", lastName: "Vallecillo" },
];
test("resolveTemplateId matches longest Sea Haven template first", () => {
assert.equal(
resolveTemplateId("yealinkT54W-door-unlock-with-sp.ph.xml"),
"t54w-door-unlock-with-sp"
);
assert.equal(
resolveTemplateId("yealinkT57W-door-unlock-with-sp.ph.xml"),
"t57w-door-unlock-with-sp"
);
assert.equal(resolveTemplateId("yealinkT54W-door-unlock.ph.xml"), "t54w-door-unlock");
assert.equal(resolveTemplateId("yealinkT58W-door-unlock.ph.xml"), "t58w-door-unlock");
assert.equal(resolveTemplateId("yealinkT54W.ph.xml"), undefined);
assert.equal(resolveTemplateId("yealinkT57W.ph.xml"), undefined);
});
test("T57W+SP slot contract matches T54W+SP", () => {
assert.deepEqual(
SLOT_CONTRACT["t57w-door-unlock-with-sp"],
SLOT_CONTRACT["t54w-door-unlock-with-sp"]
);
});
test("parseBlfs reads Line self-close and extension BLFs", () => {
const xml =
'<PhoneDevice><BLFS><BLF ID="-1" BLFNo="1" BLFType="Line" BLFTypeID="6" /><BLF ID="91" BLFNo="6" BLFType="BLF" BLFTypeID="0">112</BLF></BLFS></PhoneDevice>';
assert.deepEqual(parseBlfs(xml), [
{ id: "-1", blfNo: 1, blfType: "Line", blfTypeId: "6", value: "" },
{ id: "91", blfNo: 6, blfType: "BLF", blfTypeId: "0", value: "112" },
]);
});
test("merge skips self and sorts by extension", () => {
const result = mergeDepartmentBlfs({
currentXml: "<PhoneDevice><BLFS/></PhoneDevice>",
selfExtension: "114",
colleagues,
contract: SLOT_CONTRACT["t54w-door-unlock-with-sp"],
});
const managed = parseBlfs(result.xml).filter((e) => e.blfNo >= 6);
assert.deepEqual(
managed.map((e) => e.value),
["100", "111", "113", "115", "116"]
);
assert.equal(result.overflow, 0);
assert.equal(result.placed, 5);
assert.equal(managed.some((e) => e.value === "114"), false);
});
test("merge never writes reserved T54W+SP unlock key 2", () => {
const current =
'<PhoneDevice><BLFS><BLF ID="33" BLFNo="1" BLFType="BLF" BLFTypeID="0">111</BLF><BLF ID="91" BLFNo="2" BLFType="BLF" BLFTypeID="0">112</BLF><BLF ID="38" BLFNo="3" BLFType="BLF" BLFTypeID="0">114</BLF></BLFS></PhoneDevice>';
const result = mergeDepartmentBlfs({
currentXml: current,
selfExtension: "113",
colleagues,
contract: SLOT_CONTRACT["t54w-door-unlock-with-sp"],
});
const entries = parseBlfs(result.xml);
assert.equal(entries.some((e) => e.blfNo === 2), false);
assert.equal(entries.find((e) => e.blfNo === 1)?.blfType, "Line");
});
test("merge writes SP1-SP3 on T54W+SP keys 3-5", () => {
const result = mergeDepartmentBlfs({
currentXml: "<PhoneDevice><BLFS/></PhoneDevice>",
selfExtension: "111",
colleagues,
contract: SLOT_CONTRACT["t54w-door-unlock-with-sp"],
});
const parking = parseBlfs(result.xml).filter((e) => e.blfNo >= 3 && e.blfNo <= 5);
assert.deepEqual(
parking.map((e) => ({ blfNo: e.blfNo, type: e.blfType, value: e.value })),
[
{ blfNo: 3, type: "SharedParking", value: "SP1" },
{ blfNo: 4, type: "SharedParking", value: "SP2" },
{ blfNo: 5, type: "SharedParking", value: "SP3" },
]
);
});
test("merge preserves personal BLFs outside the managed range", () => {
const current =
'<PhoneDevice><BLFS><BLF ID="-1" BLFNo="1" BLFType="Line" BLFTypeID="6" /><BLF ID="9" BLFNo="16" BLFType="BLF" BLFTypeID="0">215</BLF></BLFS></PhoneDevice>';
const result = mergeDepartmentBlfs({
currentXml: current,
selfExtension: "116",
colleagues,
contract: SLOT_CONTRACT["t54w-door-unlock-with-sp"],
});
const personal = parseBlfs(result.xml).find((e) => e.blfNo === 16);
assert.equal(personal?.value, "215");
assert.equal(personal?.id, "9");
});
test("merge caps overflow at the managed slot count", () => {
const many = Array.from({ length: 12 }, (_, i) => ({
id: 200 + i,
number: String(300 + i),
firstName: "User",
lastName: `Z${String(i).padStart(2, "0")}`,
}));
const result = mergeDepartmentBlfs({
currentXml: "<PhoneDevice><BLFS/></PhoneDevice>",
selfExtension: "111",
colleagues: many,
contract: SLOT_CONTRACT["t54w-door-unlock"],
});
assert.equal(result.placed, 10);
assert.equal(result.overflow, 2);
const managed = parseBlfs(result.xml).filter((e) => e.blfNo >= 3 && e.blfNo <= 12);
assert.equal(managed.length, 10);
assert.equal(managed[0]?.blfNo, 3);
assert.equal(managed[9]?.blfNo, 12);
});
test("merge is a no-op when XML already matches", () => {
const first = mergeDepartmentBlfs({
currentXml: "<PhoneDevice><BLFS/></PhoneDevice>",
selfExtension: "116",
colleagues,
contract: SLOT_CONTRACT["t54w-door-unlock-with-sp"],
});
const second = mergeDepartmentBlfs({
currentXml: first.xml,
selfExtension: "116",
colleagues,
contract: SLOT_CONTRACT["t54w-door-unlock-with-sp"],
});
assert.equal(first.changed, true);
assert.equal(second.changed, false);
assert.equal(blfsEqual(first.xml, second.xml), true);
});
test("merge keeps assigned shared-parking IDs so later runs are a no-op", () => {
const first = mergeDepartmentBlfs({
currentXml: "<PhoneDevice><BLFS/></PhoneDevice>",
selfExtension: "116",
colleagues,
contract: SLOT_CONTRACT["t54w-door-unlock-with-sp"],
});
const assigned = serializeBlfs(
parseBlfs(first.xml).map((entry) =>
entry.blfType === "SharedParking" || entry.blfType === "Line"
? { ...entry, id: String(900 + entry.blfNo) }
: entry
)
);
const second = mergeDepartmentBlfs({
currentXml: assigned,
selfExtension: "116",
colleagues,
contract: SLOT_CONTRACT["t54w-door-unlock-with-sp"],
});
assert.equal(second.changed, false);
const parking = parseBlfs(second.xml).filter((e) => e.blfNo >= 3 && e.blfNo <= 5);
assert.deepEqual(
parking.map((e) => e.id),
["903", "904", "905"]
);
});
test("serializeBlfs normalizes empty lists", () => {
assert.equal(serializeBlfs([]), "<PhoneDevice><BLFS></BLFS></PhoneDevice>");
assert.equal(blfsEqual("<PhoneDevice><BLFS/></PhoneDevice>", serializeBlfs([])), true);
});
test("primaryDepartmentName prefers Office over DEFAULT", () => {
assert.equal(
primaryDepartmentName({
Id: 1,
Number: "111",
Groups: [{ Id: 28, Name: "DEFAULT" }, { Id: 142, Name: "Office" }],
}),
"Office"
);
assert.equal(
primaryDepartmentName({
Id: 2,
Number: "201",
Groups: [{ Id: 28, Name: "DEFAULT" }],
}),
undefined
);
});
test("addAlwaysIncludedColleagues injects ext 100 when missing", () => {
const with100 = addAlwaysIncludedColleagues(colleagues, [
{ id: 29, number: "100", firstName: "Adam", lastName: "Moussa" },
]);
assert.equal(with100.some((c) => c.number === "100"), true);
const again = addAlwaysIncludedColleagues(with100, [
{ id: 29, number: "100", firstName: "Adam", lastName: "Moussa" },
]);
assert.equal(again.filter((c) => c.number === "100").length, 1);
});
test("splitPeerName uses last token as last name", () => {
assert.deepEqual(splitPeerName("Adam Moussa"), { firstName: "Adam", lastName: "Moussa" });
});
test("isEligibleColleague skips queues and voicemail", () => {
assert.equal(isEligibleColleague({ Id: 1, Number: "111", FirstName: "A", LastName: "B" }), true);
assert.equal(isEligibleColleague({ Id: 2, Number: "801", FirstName: "After", LastName: "Hours" }), false);
assert.equal(isEligibleColleague({ Id: 3, Number: "201", FirstName: "Voicemail" }), false);
assert.equal(isEligibleColleague({ Id: 4, Number: "scheduler" }), false);
});

View file

@ -0,0 +1,63 @@
export interface BlfEntry {
id: string;
blfNo: number;
blfType: string;
blfTypeId: string;
value: string;
}
const BLF_TAG =
/<BLF\b([^>/]*)(?:\s*\/>|>([\s\S]*?)<\/BLF>)/gi;
function attr(attrs: string, name: string): string {
const match = attrs.match(new RegExp(`\\b${name}="([^"]*)"`, "i"));
return match?.[1] ?? "";
}
export function parseBlfs(xml: string | undefined | null): BlfEntry[] {
if (!xml) {
return [];
}
const entries: BlfEntry[] = [];
for (const match of xml.matchAll(BLF_TAG)) {
const attrs = match[1] ?? "";
const blfNo = Number.parseInt(attr(attrs, "BLFNo"), 10);
if (!Number.isFinite(blfNo)) {
continue;
}
entries.push({
id: attr(attrs, "ID") || "-1",
blfNo,
blfType: attr(attrs, "BLFType") || "BLF",
blfTypeId: attr(attrs, "BLFTypeID") || "0",
value: (match[2] ?? "").trim(),
});
}
return entries;
}
function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
export function serializeBlfs(entries: BlfEntry[]): string {
const sorted = [...entries].sort((a, b) => a.blfNo - b.blfNo);
const inner = sorted
.map((entry) => {
const attrs = `ID="${escapeXml(entry.id)}" BLFNo="${entry.blfNo}" BLFType="${escapeXml(entry.blfType)}" BLFTypeID="${escapeXml(entry.blfTypeId)}"`;
if (entry.value === "") {
return `<BLF ${attrs} />`;
}
return `<BLF ${attrs}>${escapeXml(entry.value)}</BLF>`;
})
.join("");
return `<PhoneDevice><BLFS>${inner}</BLFS></PhoneDevice>`;
}
export function blfsEqual(a: string | undefined | null, b: string): boolean {
return serializeBlfs(parseBlfs(a)) === serializeBlfs(parseBlfs(b));
}

View file

@ -0,0 +1,95 @@
export interface UserGroup {
Id?: number;
Name?: string;
}
export interface SyncUser {
Id: number;
Number: string;
FirstName?: string;
LastName?: string;
PrimaryGroupId?: number;
Groups?: UserGroup[];
Blfs?: string;
Phones?: Array<{
TemplateName?: string;
Name?: string;
}>;
}
const QUEUE_EXTENSION_MIN = 800;
export const ALWAYS_INCLUDE_EXTENSIONS = ["100"] as const;
export interface ColleagueRef {
id: number;
number: string;
firstName: string;
lastName: string;
}
export function splitPeerName(name: string | undefined): { firstName: string; lastName: string } {
const parts = (name ?? "").trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
return { firstName: "", lastName: "" };
}
if (parts.length === 1) {
return { firstName: parts[0], lastName: "" };
}
return { firstName: parts.slice(0, -1).join(" "), lastName: parts[parts.length - 1] };
}
export function addAlwaysIncludedColleagues(
colleagues: ColleagueRef[],
extras: ColleagueRef[]
): ColleagueRef[] {
const next = [...colleagues];
for (const extra of extras) {
if (!next.some((c) => c.number === extra.number)) {
next.push(extra);
}
}
return next;
}
export function isEligibleColleague(user: SyncUser): boolean {
if (!/^\d+$/.test(user.Number)) {
return false;
}
if (Number.parseInt(user.Number, 10) >= QUEUE_EXTENSION_MIN) {
return false;
}
const first = (user.FirstName ?? "").trim();
const last = (user.LastName ?? "").trim();
if (!first && !last) {
return false;
}
const full = `${first} ${last}`.trim().toLowerCase();
if (full === "voicemail") {
return false;
}
return true;
}
export function primaryDepartmentName(user: SyncUser): string | undefined {
const groups = user.Groups ?? [];
const named = groups.find((g) => (g.Name ?? "").toUpperCase() !== "DEFAULT" && (g.Name ?? "").trim() !== "");
if (named?.Name) {
return named.Name;
}
return undefined;
}
export function groupUsersByDepartment(users: SyncUser[]): Map<string, SyncUser[]> {
const byDept = new Map<string, SyncUser[]>();
for (const user of users) {
const dept = primaryDepartmentName(user);
if (!dept) {
continue;
}
const list = byDept.get(dept) ?? [];
list.push(user);
byDept.set(dept, list);
}
return byDept;
}

102
lambda/blf-sync/merge.ts Normal file
View file

@ -0,0 +1,102 @@
import {
type BlfEntry,
parseBlfs,
serializeBlfs,
} from "./blf-xml";
import {
type SlotContract,
isManagedSlot,
isReservedSlot,
isSharedParkingSlot,
managedSlotCount,
} from "./slot-contract";
export interface Colleague {
id: number;
number: string;
firstName: string;
lastName: string;
}
export interface MergeResult {
xml: string;
changed: boolean;
overflow: number;
placed: number;
}
export function sortColleagues(colleagues: Colleague[]): Colleague[] {
return [...colleagues].sort((a, b) => a.number.localeCompare(b.number, "en", { numeric: true }));
}
export function mergeDepartmentBlfs(input: {
currentXml: string | undefined | null;
selfExtension: string;
colleagues: Colleague[];
contract: SlotContract;
}): MergeResult {
const current = parseBlfs(input.currentXml);
const others = sortColleagues(
input.colleagues.filter((c) => c.number !== input.selfExtension)
);
const slotCount = managedSlotCount(input.contract);
const placedColleagues = others.slice(0, slotCount);
const overflow = Math.max(0, others.length - slotCount);
const preserved = current.filter((entry) => {
if (entry.blfNo === input.contract.ownLine) {
return false;
}
if (isReservedSlot(input.contract, entry.blfNo)) {
return false;
}
if (isSharedParkingSlot(input.contract, entry.blfNo)) {
return false;
}
if (isManagedSlot(input.contract, entry.blfNo)) {
return false;
}
return true;
});
const existingOwnLine = current.find((entry) => entry.blfNo === input.contract.ownLine);
const ownLine: BlfEntry = {
id: existingOwnLine?.blfType === "Line" ? existingOwnLine.id : "-1",
blfNo: input.contract.ownLine,
blfType: "Line",
blfTypeId: "6",
value: "",
};
const parking: BlfEntry[] = input.contract.sharedParking.map((slot) => {
const existing = current.find(
(entry) =>
entry.blfNo === slot.blfNo &&
entry.blfType === "SharedParking" &&
entry.value === slot.value
);
return {
id: existing?.id ?? "-1",
blfNo: slot.blfNo,
blfType: "SharedParking",
blfTypeId: existing?.blfTypeId ?? "3",
value: slot.value,
};
});
const managed: BlfEntry[] = placedColleagues.map((colleague, index) => ({
id: String(colleague.id),
blfNo: input.contract.managedStart + index,
blfType: "BLF",
blfTypeId: "0",
value: colleague.number,
}));
const nextXml = serializeBlfs([...preserved, ownLine, ...parking, ...managed]);
return {
xml: nextXml,
changed: serializeBlfs(current) !== nextXml,
overflow,
placed: placedColleagues.length,
};
}

View file

@ -0,0 +1,93 @@
export type TemplateId =
| "t54w-door-unlock"
| "t54w-door-unlock-with-sp"
| "t57w-door-unlock-with-sp"
| "t58w-door-unlock";
export interface SharedParkingSlot {
readonly blfNo: number;
readonly value: string;
}
export interface SlotContract {
readonly reserved: readonly number[];
readonly sharedParking: readonly SharedParkingSlot[];
readonly ownLine: number;
readonly managedStart: number;
readonly managedEnd: number;
}
export const SLOT_CONTRACT: Record<TemplateId, SlotContract> = {
"t54w-door-unlock": {
reserved: [2],
sharedParking: [],
ownLine: 1,
managedStart: 3,
managedEnd: 12,
},
"t54w-door-unlock-with-sp": {
reserved: [2],
sharedParking: [
{ blfNo: 3, value: "SP1" },
{ blfNo: 4, value: "SP2" },
{ blfNo: 5, value: "SP3" },
],
ownLine: 1,
managedStart: 6,
managedEnd: 15,
},
"t57w-door-unlock-with-sp": {
reserved: [2],
sharedParking: [
{ blfNo: 3, value: "SP1" },
{ blfNo: 4, value: "SP2" },
{ blfNo: 5, value: "SP3" },
],
ownLine: 1,
managedStart: 6,
managedEnd: 15,
},
"t58w-door-unlock": {
reserved: [2, 3, 4],
sharedParking: [],
ownLine: 1,
managedStart: 5,
managedEnd: 14,
},
};
const TEMPLATE_MATCHERS: { id: TemplateId; needle: string }[] = [
{ id: "t57w-door-unlock-with-sp", needle: "yealinkT57W-door-unlock-with-sp" },
{ id: "t54w-door-unlock-with-sp", needle: "yealinkT54W-door-unlock-with-sp" },
{ id: "t54w-door-unlock", needle: "yealinkT54W-door-unlock" },
{ id: "t58w-door-unlock", needle: "yealinkT58W-door-unlock" },
];
export function resolveTemplateId(...candidates: Array<string | undefined | null>): TemplateId | undefined {
const haystack = candidates.filter(Boolean).join(" ");
if (!haystack) {
return undefined;
}
for (const matcher of TEMPLATE_MATCHERS) {
if (haystack.includes(matcher.needle)) {
return matcher.id;
}
}
return undefined;
}
export function isManagedSlot(contract: SlotContract, blfNo: number): boolean {
return blfNo >= contract.managedStart && blfNo <= contract.managedEnd;
}
export function isReservedSlot(contract: SlotContract, blfNo: number): boolean {
return contract.reserved.includes(blfNo);
}
export function isSharedParkingSlot(contract: SlotContract, blfNo: number): boolean {
return contract.sharedParking.some((slot) => slot.blfNo === blfNo);
}
export function managedSlotCount(contract: SlotContract): number {
return contract.managedEnd - contract.managedStart + 1;
}

View file

@ -0,0 +1,107 @@
export interface ThreeCxConfig {
domain: string;
clientId: string;
clientSecret: string;
}
export class ThreeCxClient {
private accessToken: string | undefined;
private readonly baseUrl: string;
constructor(private readonly config: ThreeCxConfig) {
this.baseUrl = `https://${config.domain.replace(/^https?:\/\//, "")}`;
}
async getAccessToken(): Promise<string> {
if (this.accessToken) {
return this.accessToken;
}
const res = await fetch(`${this.baseUrl}/connect/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
}),
});
if (!res.ok) {
throw new Error(`3CX token request failed: ${res.status}`);
}
const body = (await res.json()) as { access_token?: string };
if (!body.access_token) {
throw new Error("3CX token response missing access_token");
}
this.accessToken = body.access_token;
return this.accessToken;
}
async getJson<T>(path: string): Promise<{ status: number; body: T }> {
const token = await this.getAccessToken();
const res = await fetch(`${this.baseUrl}${path}`, {
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
});
const text = await res.text();
const body = (text ? JSON.parse(text) : {}) as T;
return { status: res.status, body };
}
async patchJson<T>(path: string, payload: unknown): Promise<{ status: number; body: T }> {
const token = await this.getAccessToken();
const res = await fetch(`${this.baseUrl}${path}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const text = await res.text();
const body = (text ? JSON.parse(text) : {}) as T;
return { status: res.status, body };
}
async listUsers<T>(): Promise<T[]> {
const users: T[] = [];
let path =
"/xapi/v1/Users?$top=100&$select=Id,Number,FirstName,LastName,Blfs,PrimaryGroupId&$expand=Groups($select=Id,Name),Phones";
for (let i = 0; i < 20; i++) {
const page = await this.getJson<{ value?: T[]; "@odata.nextLink"?: string }>(path);
if (page.status !== 200) {
throw new Error(`3CX Users list failed: ${page.status}`);
}
users.push(...(page.body.value ?? []));
const next = page.body["@odata.nextLink"];
if (!next) {
break;
}
path = next.replace(this.baseUrl, "");
}
return users;
}
async getPeerByNumber(number: string): Promise<{ Id: number; Number: string; Name?: string } | undefined> {
const encoded = number.replace(/'/g, "''");
const page = await this.getJson<{ value?: Array<{ Id: number; Number: string; Name?: string }> }>(
`/xapi/v1/Peers?$filter=Number eq '${encoded}'&$top=1`
);
if (page.status !== 200) {
return undefined;
}
return page.body.value?.[0];
}
async patchUserBlfs(userId: number, blfs: string): Promise<{ status: number }> {
const path = `/xapi/v1/Users(${userId})`;
const current = await this.getJson<Record<string, unknown>>(path);
if (current.status !== 200) {
return { status: current.status };
}
const payload = { ...current.body };
delete payload["@odata.context"];
payload.Blfs = blfs;
const patched = await this.patchJson<Record<string, unknown>>(path, payload);
return { status: patched.status };
}
}

View file

@ -0,0 +1,171 @@
import {
SSMClient,
GetParameterCommand,
} from "@aws-sdk/client-ssm";
import { timingSafeEqual } from "node:crypto";
const ssm = new SSMClient({});
function tokensMatch(provided: string, expected: string): boolean {
const a = Buffer.from(provided);
const b = Buffer.from(expected);
// timingSafeEqual throws on unequal-length buffers; check length first.
return a.length === b.length && timingSafeEqual(a, b);
}
let cachedAuthToken: string | undefined;
let cachedApiKey: string | undefined;
const LOCKDOWN_PROFILES: Record<string, { id: string; name: string; linekey: number }> = {
bohemia: { id: "4b4a3e6b-c903-4cce-8cd6-288612bf0542", name: "Bohemia - Whole Building", linekey: 3 },
ronkonkoma: { id: "ff9876bc-c54f-472e-aef9-d2bffd4b7cf7", name: "Ronkonkoma - Whole Building", linekey: 4 },
};
const ELEMENTS_BASE_URL = "https://api.elementssecure.com/v1";
async function getParameter(name: string, decrypt: boolean): Promise<string> {
const res = await ssm.send(
new GetParameterCommand({ Name: name, WithDecryption: decrypt })
);
return res.Parameter!.Value!;
}
async function loadSecrets() {
const [authToken, apiKey] = await Promise.all([
cachedAuthToken ?? getParameter(process.env.AUTH_TOKEN_PARAM!, true),
cachedApiKey ?? getParameter(process.env.ELEMENTS_API_KEY_PARAM!, true),
]);
cachedAuthToken = authToken;
cachedApiKey = apiKey;
return { authToken, apiKey };
}
async function getLockdownStatus(lockdownId: string, apiKey: string): Promise<boolean> {
const response = await fetch(`${ELEMENTS_BASE_URL}/lockdowns/${lockdownId}`, {
headers: { "api-key": apiKey },
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Elements status check failed: ${response.status} - ${body}`);
}
const data = await response.json() as Record<string, unknown>;
console.log(JSON.stringify({ action: "lockdown_raw_status", lockdownId, data }));
return String(data.status).toLowerCase() === "active";
}
async function setLockdown(lockdownId: string, apiKey: string, start: boolean): Promise<void> {
const action = start ? "start" : "stop";
const response = await fetch(`${ELEMENTS_BASE_URL}/lockdowns/${lockdownId}/${action}`, {
method: "POST",
headers: {
"api-key": apiKey,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Elements ${action} failed: ${response.status} - ${body}`);
}
}
function textScreenXml(title: string, text: string): string {
return [
`<?xml version="1.0" encoding="UTF-8"?>`,
`<YealinkIPPhoneTextScreen>`,
` <Title>${title}</Title>`,
` <Text>${text}</Text>`,
`</YealinkIPPhoneTextScreen>`,
].join("\n");
}
function xmlResponse(statusCode: number, body: string) {
return {
statusCode,
headers: { "Content-Type": "application/xml" },
body,
};
}
export async function handler(event: {
queryStringParameters?: Record<string, string>;
rawPath?: string;
requestContext?: { http?: { sourceIp?: string } };
}) {
const sourceIp = event.requestContext?.http?.sourceIp ?? "unknown";
const token = event.queryStringParameters?.token;
const profile = event.queryStringParameters?.profile;
const path = event.rawPath ?? "";
if (!token) {
console.log(JSON.stringify({ action: "lockdown", status: "rejected", reason: "missing_token", sourceIp }));
return { statusCode: 403, body: JSON.stringify({ error: "Forbidden" }) };
}
let secrets;
try {
secrets = await loadSecrets();
} catch (err) {
console.error(JSON.stringify({ action: "lockdown", status: "error", reason: "ssm_failure", sourceIp, error: String(err) }));
return xmlResponse(500, textScreenXml("Error", "Internal error"));
}
if (!tokensMatch(token, secrets.authToken)) {
console.log(JSON.stringify({ action: "lockdown", status: "rejected", reason: "invalid_token", sourceIp }));
return { statusCode: 403, body: JSON.stringify({ error: "Forbidden" }) };
}
if (path === "/lockdown/status") {
return handleStatus(secrets.apiKey, sourceIp);
}
return handleToggle(profile, secrets.apiKey, sourceIp);
}
async function handleStatus(apiKey: string, sourceIp: string) {
try {
const lines: string[] = [];
for (const [key, config] of Object.entries(LOCKDOWN_PROFILES)) {
const active = await getLockdownStatus(config.id, apiKey);
lines.push(`${config.name}: ${active ? "LOCKED DOWN" : "Normal"}`);
console.log(JSON.stringify({ action: "lockdown_status", profile: key, active, sourceIp }));
}
return xmlResponse(200, textScreenXml("Lockdown Status", lines.join("\n")));
} catch (err) {
console.error(JSON.stringify({ action: "lockdown_status", status: "error", sourceIp, error: String(err) }));
return xmlResponse(502, textScreenXml("Error", "Unable to check lockdown status"));
}
}
async function handleToggle(profile: string | undefined, apiKey: string, sourceIp: string) {
if (!profile || !LOCKDOWN_PROFILES[profile]) {
return xmlResponse(400, textScreenXml("Error", "Invalid profile"));
}
const config = LOCKDOWN_PROFILES[profile];
try {
const wasActive = await getLockdownStatus(config.id, apiKey);
await setLockdown(config.id, apiKey, !wasActive);
const nowActive = !wasActive;
console.log(JSON.stringify({
action: "lockdown_toggle",
profile,
wasActive,
nowActive,
sourceIp,
}));
const statusText = nowActive ? "LOCKED DOWN" : "Normal";
return xmlResponse(200, textScreenXml(config.name, statusText));
} catch (err) {
console.error(JSON.stringify({ action: "lockdown_toggle", status: "error", profile, sourceIp, error: String(err) }));
return xmlResponse(502, textScreenXml("Error", `Lockdown error: ${config.name}`));
}
}

View file

@ -0,0 +1,86 @@
import {
SSMClient,
GetParameterCommand,
} from "@aws-sdk/client-ssm";
const ssm = new SSMClient({});
let cachedApiKey: string | undefined;
const LOCKDOWN_PROFILES: { key: string; id: string }[] = [
{ key: "bohemia", id: "4b4a3e6b-c903-4cce-8cd6-288612bf0542" },
{ key: "ronkonkoma", id: "ff9876bc-c54f-472e-aef9-d2bffd4b7cf7" },
];
const ELEMENTS_BASE_URL = "https://api.elementssecure.com/v1";
const POLL_COUNT = 4;
const POLL_INTERVAL_MS = 15_000;
async function getSsmParam(name: string, decrypt: boolean): Promise<string> {
const res = await ssm.send(
new GetParameterCommand({ Name: name, WithDecryption: decrypt })
);
return res.Parameter!.Value!;
}
async function loadApiKey(): Promise<string> {
if (!cachedApiKey) {
cachedApiKey = await getSsmParam(process.env.ELEMENTS_API_KEY_PARAM!, true);
}
return cachedApiKey;
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function getLockdownStatus(lockdownId: string, apiKey: string): Promise<boolean> {
const response = await fetch(`${ELEMENTS_BASE_URL}/lockdowns/${lockdownId}`, {
headers: { "api-key": apiKey },
});
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get("retry-after") ?? "2", 10);
await sleep(retryAfter * 1000);
return getLockdownStatus(lockdownId, apiKey);
}
if (!response.ok) {
throw new Error(`Elements status check failed: ${response.status}`);
}
const data = await response.json() as Record<string, unknown>;
return String(data.status).toLowerCase() === "active";
}
export async function handler() {
let apiKey: string;
try {
apiKey = await loadApiKey();
} catch (err) {
console.error(JSON.stringify({ action: "poller_config_error", error: String(err) }));
return;
}
for (let i = 0; i < POLL_COUNT; i++) {
try {
const statuses = [];
for (const profile of LOCKDOWN_PROFILES) {
const active = await getLockdownStatus(profile.id, apiKey);
statuses.push({ profile: profile.key, active });
}
console.log(JSON.stringify({
action: "poller_cycle",
iteration: i + 1,
statuses,
}));
} catch (err) {
console.error(JSON.stringify({ action: "poller_poll_error", iteration: i + 1, error: String(err) }));
}
if (i < POLL_COUNT - 1) {
await sleep(POLL_INTERVAL_MS);
}
}
}

View file

@ -2,9 +2,17 @@ import {
SSMClient, SSMClient,
GetParameterCommand, GetParameterCommand,
} from "@aws-sdk/client-ssm"; } from "@aws-sdk/client-ssm";
import { timingSafeEqual } from "node:crypto";
const ssm = new SSMClient({}); const ssm = new SSMClient({});
function tokensMatch(provided: string, expected: string): boolean {
const a = Buffer.from(provided);
const b = Buffer.from(expected);
// timingSafeEqual throws on unequal-length buffers; check length first.
return a.length === b.length && timingSafeEqual(a, b);
}
let cachedAuthToken: string | undefined; let cachedAuthToken: string | undefined;
let cachedApiKey: string | undefined; let cachedApiKey: string | undefined;
let cachedDoorId: string | undefined; let cachedDoorId: string | undefined;
@ -51,7 +59,7 @@ export async function handler(event: {
return { statusCode: 500, body: JSON.stringify({ error: "Internal error" }) }; return { statusCode: 500, body: JSON.stringify({ error: "Internal error" }) };
} }
if (token !== secrets.authToken) { if (!tokensMatch(token, secrets.authToken)) {
console.log(JSON.stringify({ action: "unlock_attempt", status: "rejected", reason: "invalid_token", sourceIp })); console.log(JSON.stringify({ action: "unlock_attempt", status: "rejected", reason: "invalid_token", sourceIp }));
return { statusCode: 403, body: JSON.stringify({ error: "Forbidden" }) }; return { statusCode: 403, body: JSON.stringify({ error: "Forbidden" }) };
} }

View file

@ -2,11 +2,19 @@ import * as cdk from "aws-cdk-lib";
import * as lambda from "aws-cdk-lib/aws-lambda"; import * as lambda from "aws-cdk-lib/aws-lambda";
import * as apigwv2 from "aws-cdk-lib/aws-apigatewayv2"; import * as apigwv2 from "aws-cdk-lib/aws-apigatewayv2";
import * as integrations from "aws-cdk-lib/aws-apigatewayv2-integrations"; import * as integrations from "aws-cdk-lib/aws-apigatewayv2-integrations";
import * as authorizers from "aws-cdk-lib/aws-apigatewayv2-authorizers";
import * as ssm from "aws-cdk-lib/aws-ssm"; import * as ssm from "aws-cdk-lib/aws-ssm";
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as events from "aws-cdk-lib/aws-events";
import * as targets from "aws-cdk-lib/aws-events-targets";
import * as route53 from "aws-cdk-lib/aws-route53"; import * as route53 from "aws-cdk-lib/aws-route53";
import * as route53Targets from "aws-cdk-lib/aws-route53-targets"; import * as route53Targets from "aws-cdk-lib/aws-route53-targets";
import * as acm from "aws-cdk-lib/aws-certificatemanager"; import * as acm from "aws-cdk-lib/aws-certificatemanager";
import * as logs from "aws-cdk-lib/aws-logs"; import * as logs from "aws-cdk-lib/aws-logs";
import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
import * as cwactions from "aws-cdk-lib/aws-cloudwatch-actions";
import * as sns from "aws-cdk-lib/aws-sns";
import { Construct } from "constructs"; import { Construct } from "constructs";
import * as path from "path"; import * as path from "path";
@ -26,23 +34,31 @@ export class DoorUnlockStack extends cdk.Stack {
{ parameterName: "/seahaven/door-unlock/auth-token" } { parameterName: "/seahaven/door-unlock/auth-token" }
); );
const doorIdParam = ssm.StringParameter.fromStringParameterName( // forceDynamicReference: the stack only needs the parameter for grantRead
// (ARN, built from the name); without it, fromStringParameterName injects
// an unreferenced AWS::SSM::Parameter::Value CloudFormation parameter.
const doorIdParam = ssm.StringParameter.fromStringParameterAttributes(
this, this,
"DoorId", "DoorId",
"/seahaven/door-unlock/door-id" {
parameterName: "/seahaven/door-unlock/door-id",
forceDynamicReference: true,
}
); );
const unlockHandler = new lambda.Function(this, "UnlockHandler", { const unlockHandler = new lambda.Function(this, "UnlockHandler", {
runtime: lambda.Runtime.NODEJS_20_X, functionName: "door-unlock-api-unlock",
runtime: lambda.Runtime.NODEJS_24_X,
architecture: lambda.Architecture.ARM_64,
handler: "unlock-handler.handler", handler: "unlock-handler.handler",
code: lambda.Code.fromAsset(path.join(__dirname, "../lambda"), { code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/unlock"), {
bundling: { bundling: {
image: lambda.Runtime.NODEJS_20_X.bundlingImage, image: lambda.Runtime.NODEJS_24_X.bundlingImage,
local: { local: {
tryBundle(outputDir: string) { tryBundle(outputDir: string) {
const { execSync } = require("child_process"); const { execSync } = require("child_process");
execSync( execSync(
`esbuild ${path.join(__dirname, "../lambda/unlock-handler.ts")} --bundle --platform=node --target=node20 --outfile=${path.join(outputDir, "unlock-handler.js")} --external:@aws-sdk/*` `esbuild ${path.join(__dirname, "../lambda/unlock/unlock-handler.ts")} --bundle --platform=node --target=node24 --outfile=${path.join(outputDir, "unlock-handler.js")} --external:@aws-sdk/*`
); );
return true; return true;
}, },
@ -54,15 +70,225 @@ export class DoorUnlockStack extends cdk.Stack {
AUTH_TOKEN_PARAM: "/seahaven/door-unlock/auth-token", AUTH_TOKEN_PARAM: "/seahaven/door-unlock/auth-token",
DOOR_ID_PARAM: "/seahaven/door-unlock/door-id", DOOR_ID_PARAM: "/seahaven/door-unlock/door-id",
}, },
timeout: cdk.Duration.seconds(10), timeout: cdk.Duration.seconds(20),
memorySize: 128, memorySize: 128,
logRetention: logs.RetentionDays.THREE_MONTHS, logRetention: logs.RetentionDays.TWO_MONTHS,
});
const lockdownHandler = new lambda.Function(this, "LockdownHandler", {
functionName: "door-unlock-api-lockdown",
runtime: lambda.Runtime.NODEJS_24_X,
architecture: lambda.Architecture.ARM_64,
handler: "lockdown-handler.handler",
code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/lockdown"), {
bundling: {
image: lambda.Runtime.NODEJS_24_X.bundlingImage,
local: {
tryBundle(outputDir: string) {
const { execSync } = require("child_process");
execSync(
`esbuild ${path.join(__dirname, "../lambda/lockdown/lockdown-handler.ts")} --bundle --platform=node --target=node24 --outfile=${path.join(outputDir, "lockdown-handler.js")} --external:@aws-sdk/*`
);
return true;
},
},
},
}),
environment: {
ELEMENTS_API_KEY_PARAM: "/seahaven/door-unlock/elements-api-key",
AUTH_TOKEN_PARAM: "/seahaven/door-unlock/auth-token",
},
timeout: cdk.Duration.seconds(15),
memorySize: 128,
logRetention: logs.RetentionDays.TWO_MONTHS,
}); });
elementsApiKeyParam.grantRead(unlockHandler); elementsApiKeyParam.grantRead(unlockHandler);
authTokenParam.grantRead(unlockHandler); authTokenParam.grantRead(unlockHandler);
doorIdParam.grantRead(unlockHandler); doorIdParam.grantRead(unlockHandler);
elementsApiKeyParam.grantRead(lockdownHandler);
authTokenParam.grantRead(lockdownHandler);
// Gateway authorizer (INFRA-99): validates the same `?token=` query-string
// value the Yealink XML Browser keys already send, so it is transparent to
// the phones while rejecting unauthenticated callers at the gateway.
const authorizerHandler = new lambda.Function(this, "AuthorizerHandler", {
functionName: "door-unlock-api-authorizer",
runtime: lambda.Runtime.NODEJS_24_X,
architecture: lambda.Architecture.ARM_64,
handler: "authorizer-handler.handler",
code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/authorizer"), {
bundling: {
image: lambda.Runtime.NODEJS_24_X.bundlingImage,
local: {
tryBundle(outputDir: string) {
const { execSync } = require("child_process");
execSync(
`esbuild ${path.join(__dirname, "../lambda/authorizer/authorizer-handler.ts")} --bundle --platform=node --target=node24 --outfile=${path.join(outputDir, "authorizer-handler.js")} --external:@aws-sdk/*`
);
return true;
},
},
},
}),
environment: {
AUTH_TOKEN_PARAM: authTokenParam.parameterName,
},
// APIGW HTTP API authorizers have a hard 10s limit; keep margin so the
// gateway returns its own 500 rather than racing the Lambda timeout. The
// token is cached in module scope, so warm invocations never hit SSM.
timeout: cdk.Duration.seconds(8),
memorySize: 128,
logRetention: logs.RetentionDays.TWO_MONTHS,
});
authTokenParam.grantRead(authorizerHandler);
const tokenAuthorizer = new authorizers.HttpLambdaAuthorizer(
"DoorUnlockTokenAuthorizer",
authorizerHandler,
{
authorizerName: "door-unlock-api-token-authorizer",
// The Yealink phones send the token in the query string; scope the
// identity source there. A request with no `token` query param is
// rejected by the gateway before the authorizer Lambda is invoked.
identitySource: ["$request.querystring.token"],
responseTypes: [authorizers.HttpLambdaResponseType.SIMPLE],
// Authorizer result caching keyed on the identity source (the token).
// 5 min keeps repeated phone presses fast without a long stale window.
resultsCacheTtl: cdk.Duration.minutes(5),
}
);
const vpc = ec2.Vpc.fromLookup(this, "SeaHavenVpc", {
vpcId: "vpc-0d3d4b67bd0cf8a68",
});
const privateSubnet1 = ec2.Subnet.fromSubnetId(
this, "PrivateSubnet1", "subnet-04e38c507e96f1926"
);
const privateSubnet2 = ec2.Subnet.fromSubnetId(
this, "PrivateSubnet2", "subnet-0a0b4fc6f296dfba5"
);
const pollerSg = new ec2.SecurityGroup(this, "PollerSecurityGroup", {
vpc,
securityGroupName: "door-unlock-api-poller",
description: "Lockdown poller - outbound to Elements API and phone LAN",
allowAllOutbound: false,
});
pollerSg.addEgressRule(
ec2.Peer.anyIpv4(), ec2.Port.tcp(443), "HTTPS to Elements API and SSM via NAT"
);
pollerSg.addEgressRule(
ec2.Peer.ipv4("10.10.0.0/16"), ec2.Port.tcp(443), "HTTPS to phone LAN via VPN"
);
// forceDynamicReference for the same reason as DoorId above.
const phoneIpsParam = ssm.StringParameter.fromStringParameterAttributes(
this, "PhoneIps",
{ parameterName: "/seahaven/door-unlock/phone-ips", forceDynamicReference: true }
);
const phonePasswordSecret = secretsmanager.Secret.fromSecretNameV2(
this, "PhonePassword", "door-unlock-api/phone-password"
);
const pollerHandler = new lambda.Function(this, "LockdownPoller", {
functionName: "door-unlock-api-lockdown-poller",
runtime: lambda.Runtime.NODEJS_24_X,
architecture: lambda.Architecture.ARM_64,
handler: "lockdown-poller.handler",
code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/poller"), {
bundling: {
image: lambda.Runtime.NODEJS_24_X.bundlingImage,
local: {
tryBundle(outputDir: string) {
const { execSync } = require("child_process");
execSync(
`esbuild ${path.join(__dirname, "../lambda/poller/lockdown-poller.ts")} --bundle --platform=node --target=node24 --outfile=${path.join(outputDir, "lockdown-poller.js")} --external:@aws-sdk/*`
);
return true;
},
},
},
}),
environment: {
ELEMENTS_API_KEY_PARAM: "/seahaven/door-unlock/elements-api-key",
PHONE_IPS_PARAM: "/seahaven/door-unlock/phone-ips",
PHONE_PASSWORD_SECRET: "door-unlock-api/phone-password",
},
vpc,
vpcSubnets: { subnets: [privateSubnet1, privateSubnet2] },
securityGroups: [pollerSg],
timeout: cdk.Duration.seconds(75),
memorySize: 128,
logRetention: logs.RetentionDays.TWO_MONTHS,
});
elementsApiKeyParam.grantRead(pollerHandler);
phoneIpsParam.grantRead(pollerHandler);
phonePasswordSecret.grantRead(pollerHandler);
new events.Rule(this, "LockdownPollerSchedule", {
ruleName: "door-unlock-api-lockdown-poller-schedule",
schedule: events.Schedule.rate(cdk.Duration.minutes(1)),
targets: [new targets.LambdaFunction(pollerHandler)],
});
const threeCxDomainSecret = secretsmanager.Secret.fromSecretNameV2(
this, "ThreeCxDomain", "afterhours-shift-manager/3cx-domain"
);
const threeCxClientIdSecret = secretsmanager.Secret.fromSecretNameV2(
this, "ThreeCxClientId", "afterhours-shift-manager/3cx-client-id"
);
const threeCxClientSecret = secretsmanager.Secret.fromSecretNameV2(
this, "ThreeCxClientSecret", "afterhours-shift-manager/3cx-client-secret"
);
const blfSyncHandler = new lambda.Function(this, "BlfSyncHandler", {
functionName: "door-unlock-api-blf-sync",
runtime: lambda.Runtime.NODEJS_24_X,
architecture: lambda.Architecture.ARM_64,
handler: "blf-sync-handler.handler",
code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/blf-sync"), {
bundling: {
image: lambda.Runtime.NODEJS_24_X.bundlingImage,
local: {
tryBundle(outputDir: string) {
const { execSync } = require("child_process");
execSync(
`esbuild ${path.join(__dirname, "../lambda/blf-sync/blf-sync-handler.ts")} --bundle --platform=node --target=node24 --outfile=${path.join(outputDir, "blf-sync-handler.js")} --external:@aws-sdk/*`
);
return true;
},
},
},
}),
environment: {
THREE_CX_DOMAIN_SECRET: "afterhours-shift-manager/3cx-domain",
THREE_CX_CLIENT_ID_SECRET: "afterhours-shift-manager/3cx-client-id",
THREE_CX_CLIENT_SECRET_SECRET: "afterhours-shift-manager/3cx-client-secret",
DRY_RUN: "false",
},
timeout: cdk.Duration.seconds(60),
memorySize: 256,
logRetention: logs.RetentionDays.TWO_MONTHS,
});
threeCxDomainSecret.grantRead(blfSyncHandler);
threeCxClientIdSecret.grantRead(blfSyncHandler);
threeCxClientSecret.grantRead(blfSyncHandler);
// 05:00 ET during EDT (09:00 UTC).
new events.Rule(this, "BlfSyncSchedule", {
ruleName: "door-unlock-api-blf-sync-schedule",
schedule: events.Schedule.cron({ minute: "0", hour: "9" }),
enabled: true,
targets: [new targets.LambdaFunction(blfSyncHandler)],
});
const httpApi = new apigwv2.HttpApi(this, "DoorUnlockApi", { const httpApi = new apigwv2.HttpApi(this, "DoorUnlockApi", {
apiName: "door-unlock-api", apiName: "door-unlock-api",
}); });
@ -73,6 +299,27 @@ export class DoorUnlockStack extends cdk.Stack {
ThrottlingRateLimit: 2, ThrottlingRateLimit: 2,
}); });
// Access logging (audit M-18). Throttling above was already present.
const apiAccessLogGroup = new logs.LogGroup(this, "ApiAccessLogGroup", {
logGroupName: "/aws/apigateway/door-unlock-api",
retention: logs.RetentionDays.THREE_MONTHS,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
defaultStage.addPropertyOverride("AccessLogSettings", {
DestinationArn: apiAccessLogGroup.logGroupArn,
Format: JSON.stringify({
requestId: "$context.requestId",
ip: "$context.identity.sourceIp",
requestTime: "$context.requestTime",
method: "$context.httpMethod",
routeKey: "$context.routeKey",
status: "$context.status",
protocol: "$context.protocol",
responseLength: "$context.responseLength",
integrationError: "$context.integrationErrorMessage",
}),
});
httpApi.addRoutes({ httpApi.addRoutes({
path: "/unlock", path: "/unlock",
methods: [apigwv2.HttpMethod.GET], methods: [apigwv2.HttpMethod.GET],
@ -80,6 +327,26 @@ export class DoorUnlockStack extends cdk.Stack {
"UnlockIntegration", "UnlockIntegration",
unlockHandler unlockHandler
), ),
authorizer: tokenAuthorizer,
});
const lockdownIntegration = new integrations.HttpLambdaIntegration(
"LockdownIntegration",
lockdownHandler
);
httpApi.addRoutes({
path: "/lockdown",
methods: [apigwv2.HttpMethod.GET],
integration: lockdownIntegration,
authorizer: tokenAuthorizer,
});
httpApi.addRoutes({
path: "/lockdown/status",
methods: [apigwv2.HttpMethod.GET],
integration: lockdownIntegration,
authorizer: tokenAuthorizer,
}); });
const hostedZone = route53.HostedZone.fromHostedZoneAttributes( const hostedZone = route53.HostedZone.fromHostedZoneAttributes(
@ -121,5 +388,78 @@ export class DoorUnlockStack extends cdk.Stack {
new cdk.CfnOutput(this, "ApiUrl", { new cdk.CfnOutput(this, "ApiUrl", {
value: `https://doorunlock.seahaven.com/unlock`, value: `https://doorunlock.seahaven.com/unlock`,
}); });
new cdk.CfnOutput(this, "LockdownApiUrl", {
value: `https://doorunlock.seahaven.com/lockdown`,
});
// ── CloudWatch error alarms (INFRA-101) ──────────────────────────────────
// Import the cross-stack site-alerts SNS topic. This topic is managed by
// seahaven-account-baseline and encrypted with alias/seahaven-alarm-topics
// (CMK). Never use alias/aws/sns here — CloudWatch cannot publish to topics
// using the AWS-managed SNS key (silent publish failure).
// ALARM state only; no OK/recovery actions per Sea Haven preference.
const siteAlerts = sns.Topic.fromTopicArn(
this,
"SiteAlerts",
"arn:aws:sns:us-east-1:328440206208:site-alerts"
);
interface AlarmSpec {
readonly id: string;
readonly fn: lambda.Function;
readonly alarmName: string;
readonly description: string;
}
const alarmSpecs: AlarmSpec[] = [
{
id: "UnlockErrors",
fn: unlockHandler,
alarmName: "door-unlock-api-unlock-errors",
description: "door-unlock-api-unlock Lambda is erroring — physical door unlock calls may be failing",
},
{
id: "LockdownErrors",
fn: lockdownHandler,
alarmName: "door-unlock-api-lockdown-errors",
description: "door-unlock-api-lockdown Lambda is erroring — lockdown commands may not be executing",
},
{
id: "AuthorizerErrors",
fn: authorizerHandler,
alarmName: "door-unlock-api-authorizer-errors",
description: "door-unlock-api-authorizer Lambda is erroring — all API requests will be rejected",
},
{
id: "PollerErrors",
fn: pollerHandler,
alarmName: "door-unlock-api-lockdown-poller-errors",
description: "door-unlock-api-lockdown-poller Lambda is erroring — lockdown state polling may be broken",
},
{
id: "BlfSyncErrors",
fn: blfSyncHandler,
alarmName: "door-unlock-api-blf-sync-errors",
description: "door-unlock-api-blf-sync Lambda is erroring — department BLF updates may not be applying",
},
];
for (const spec of alarmSpecs) {
const alarm = new cloudwatch.Alarm(this, spec.id, {
alarmName: spec.alarmName,
alarmDescription: spec.description,
metric: spec.fn.metricErrors({
period: cdk.Duration.minutes(5),
statistic: "Sum",
}),
threshold: 0,
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
evaluationPeriods: 1,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
});
// ALARM-only: addAlarmAction only (no addOkAction / addInsufficientDataAction)
alarm.addAlarmAction(new cwactions.SnsAction(siteAlerts));
}
} }
} }

1383
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,19 +6,25 @@
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"test": "tsx --test lambda/blf-sync/*.test.ts",
"cdk": "cdk", "cdk": "cdk",
"synth": "cdk synth", "synth": "cdk synth",
"deploy": "cdk deploy", "deploy": "cdk deploy",
"diff": "cdk diff" "diff": "cdk diff"
}, },
"devDependencies": { "devDependencies": {
"aws-cdk": "^2.178.0", "@aws-sdk/client-secrets-manager": "^3.1140.0",
"typescript": "~5.7.0", "@aws-sdk/client-ssm": "^3.1140.0",
"@types/node": "^22.0.0", "@types/node": "^24.13.6",
"esbuild": "^0.24.0" "@types/source-map-support": "^0.5.10",
"aws-cdk": "^2.1143.0",
"esbuild": "^0.28.2",
"source-map-support": "^0.5.21",
"tsx": "4.23.15",
"typescript": "~7.0.2"
}, },
"dependencies": { "dependencies": {
"aws-cdk-lib": "^2.178.0", "aws-cdk-lib": "2.270.0",
"constructs": "^10.0.0" "constructs": "^10.8.1"
} }
} }

21
scripts/blf-sync-local.ts Normal file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env node
import { handler, type BlfSyncEvent } from "../lambda/blf-sync/blf-sync-handler";
process.env.THREE_CX_DOMAIN_SECRET ??= "afterhours-shift-manager/3cx-domain";
process.env.THREE_CX_CLIENT_ID_SECRET ??= "afterhours-shift-manager/3cx-client-id";
process.env.THREE_CX_CLIENT_SECRET_SECRET ??= "afterhours-shift-manager/3cx-client-secret";
const args = process.argv.slice(2);
const event: BlfSyncEvent = {
dryRun: !args.includes("--write"),
smokeExtension: args.find((a) => a.startsWith("--smoke="))?.slice("--smoke=".length),
};
if (event.dryRun === false) {
process.env.DRY_RUN = "false";
}
handler(event).catch((err) => {
console.error(String(err));
process.exit(1);
});

104
terraform/.terraform.lock.hcl generated Normal file
View file

@ -0,0 +1,104 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/archive" {
version = "2.8.1"
constraints = "2.8.1"
hashes = [
"h1:KHd+q58PrZfAHh6hThm5b9z8uZ3Fy/g7F1XQTAH4WfA=",
"h1:KfIRyazppfpvRKIW5URe4sIVRPPdcbtt4ddkYd1Bw3Y=",
"h1:Lc8kS9DBvvKbl7klWyHTI406NU4mFHJcEgKN0wUaroM=",
"h1:TKUVBhC4A1uEerXrssAgudziMw3ybiecKswVsH3aDAA=",
"h1:W+SKtC8w0d/RNYlYc0Pz7IHotwlVXXiccFQ3Vs/Ykm8=",
"h1:Z4YQ0fn73Qt5AoFKeBcKYNDuWpnIRM0rVtDzV9pNhWk=",
"h1:aLNmq6dc3cDcqZc8s/8eKtn0I+UQXyJMGrmo4rRFtNw=",
"h1:bQBSVj62u4hNd6xqDLKvuQ8IbZHHmWv2d9YQrSG5QPc=",
"h1:eehhIUcuegkswQDKArYBAhVV9wQmRVMhyYGaD7kHIj0=",
"h1:qy2edUBBRcDC9frArT5EVbuShLx+EuFpb7/O7ZeRoTM=",
"h1:sVkac3fUlYGsTEO4F3x55D9zRm6xW+okSRpxi72cR/M=",
"h1:xVTMBOmMFXyLhO4yhr9an1CaRKcuiA6Wi0P8DAzUVcg=",
"zh:03de290604114a89fcd45c2e5bc7787d5a1ebfc5f964fb5989306bea7a4c79ec",
"zh:0a7d69dc9fbbc48960bc2f04588c8fb1bd92c78a8f306566b7fb17fc4a4f2058",
"zh:4df1f3981379c35f1757da957470f7f7724496b57219da3485177cf1647bdf59",
"zh:50f0e72ba53bfe6e11b03b7fc899e1f72536354381d302a743a274ae2a3f45f4",
"zh:5c4e15a04c98e2a8cafb1cd9632b48ad318051e8462d105b1153006277985c35",
"zh:66069e604bcf5c4af0278e15997d9e6bd755c54fb3801d78885838b889729c5f",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:979765db3f42601870ab377104ba70befb029547b278337ec4ada980e3582de6",
"zh:b165254da774f49945a73fbccc3ef1b63d70ea00a98fa9e14716665ba80ecaad",
"zh:c0bb2697b525da9fec4511f569ed2bd2b42f25d5c1f9fa00f8f3645b50cfc2e9",
"zh:c48f6695d12df0d0fa5231f7c1b8d518a4feae91a733fdd35a5804af3a303831",
"zh:d589954c93f075180c9f4e2ce91780d5edcb56dfd0d3cda8ba08e13c10b52249",
"zh:f5792ed06da65d0daf7ca3711f5399ff78c7cb4400afe53fbce9a926fbde4477",
]
}
provider "registry.terraform.io/hashicorp/aws" {
version = "6.65.0"
constraints = "6.65.0"
hashes = [
"h1:/VgIzAOR/v+p135IFsJjYT7q3pA24yE3lZGBV0Otqq0=",
"h1:1QFvuV0+K3GieeXSlb7qi9Q334XrsnMP8dCRwpM7fkc=",
"h1:36ZBGQTzM6dW8dLQ145loI9yw6/fSbRV+fvLGCeEubc=",
"h1:4uHlr+eDGOjf71giwLidIfGsMP6g5x2wkSGP5xq9EpM=",
"h1:9fSxZKfaAGrNSzXIz53IP2EmC1LYdO/fGYl7T87Y36Q=",
"h1:GJCK46UtrJMGSyByH4SiDytbwX11bg79jvTGZ27BGWU=",
"h1:H0qzEAMrqydb8eTZdebso8nS/b8w1BNHysP8nmM4pLk=",
"h1:RjeO6m/SvlhGUCDrwTdj99kJhKl/rC1zE9B5mi3YhVw=",
"h1:Yx8Kv/T/BHVE8S1WEyHsFdu4HcsAQIl5e/831DeoQ5k=",
"h1:ZFDxAUFzk1A2BPbLgu1LhAJ3erD4BbqZsF25yQobN4o=",
"h1:anUZ356aBWvbHzQalF036qNKO+RISPmq6ycIL4OdXxk=",
"h1:fhsSsZmfNFf4wErbcsmu1/ek1/TVUy7NCuMYeOpA1aE=",
"h1:l+w5eqL9UqpiVZ2ll0r+YvWAPjIL/WtqV8xqfH1+apM=",
"h1:nt0kyMKN9kDNXKHOejaAo7LQIemP3tyntYjxHY32P0M=",
"h1:o2tj5YHQU1QNgANW2YAbjj0opl0BRJZl8W9trjYsqdA=",
"zh:15b5bd81119965363893197b3b6065bdf46888b93c536623fd113b5505c375be",
"zh:16409fd045116a31b28adce98fcaaa56a7c01487369713e4a2a04af1cfa96fa3",
"zh:422ea20ef4be8e5b942118d1da61cbde818cadb512ec1132306d65120f983917",
"zh:42cda6703a6a51585c2cb2b8b3ab3a7c80a3ee08838138be8ecaa6b97b1d74d8",
"zh:718a880d81bfd9af7e297ed3d7bf98d1febebe8b9ebe3333854a4c17f2c4de09",
"zh:74e538a8ff4ea27b2040be426cdd2b952725883f5b45c8c03b27eff7d82b40f8",
"zh:876bf62e56a41e0c7a514652e22a41246e7c1d67be3b2c1b68553fa3a8dae6d7",
"zh:8d571e06d78b91b28faa7fafee99dee920d4f7a50f07ec9cd21695a385bcc0d5",
"zh:93154e33f4cbd39825a92a230642082e7b2b8b058c27cf93588ee6824aa1c294",
"zh:935f9523c940dc5795ce8afac37aa8a2ed06c0012196ab39b1de2ea26acc129e",
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
"zh:9bffe18e907e04d98d7d6b9a6a4c2381448e365441df423e90e1eeaf3a79fd2f",
"zh:9ddfdefef226c8ff3c8de03d8df23ab3199fed9f0684618a62489e0e90a21cab",
"zh:9eab3abf041fe8e1ce5959fda9c20ddfaf331b7c29ff707e914451abce7841af",
"zh:ed749702f6c56b26a390a52bfd31d3bdf7f0af800bc16244276ca71d6f541e87",
"zh:f304df223a0bc3e840a806a5dffb75e6b2b75c879053dc4ebd0228484923ee59",
]
}
provider "registry.terraform.io/hashicorp/external" {
version = "2.4.2"
constraints = "2.4.2"
hashes = [
"h1:4UInMFuK4GNw4uf2vkUwwDtc0CajvJ88BkAE6xLKOa4=",
"h1:8KPRXwNezVs9S/xEpGcKkPNMez+Kv5bKJNfLWivGekY=",
"h1:B8SUNH8ToFJjQwz10rFU8k9soEhnj2OzzTwKrcH9cFI=",
"h1:ERhVaFFS4/WRonirXUJV1DWN+cSTyEKEfs2ZnoP1BgY=",
"h1:Ev3S467Z+WcjiY/MroSWX492k017jYU1eGtZLhXr9x8=",
"h1:O6uC9yKr7swQtc/kHVyaV9yETT20A39oUi5X+k/b290=",
"h1:QP724n6VWM/iitpILCwO079UOlzx6I0Ylh7g8Gwv1Y0=",
"h1:famcgOUn8RzdgcZe+GUptA59vdgh4pXzIu/y9GMX8SU=",
"h1:h5n+iCc1zwT5mKIATjIYS8hcjJxoFAPSNxPsZbZLc3Q=",
"h1:l0Z5YlRsbjRUU0+u4U8BPIDGv7PAszOrNLO9ZIxQrG4=",
"h1:rwlUbh50HZYdRQWKW12nG4+3Giu2rp+mQXjqF0NzmVg=",
"h1:tP4PPkaGoG60uUYEH3bUnYBSbhUmlk2vsh9uJbBmjUU=",
"zh:0b51793be4f66934a3666339e44c01fd56e1c6a56256dfc66d1cb391584b4c2f",
"zh:31cdd9b30e4ec63d130befc89471757ef3937b99a8f6cc006769d215365c5ba8",
"zh:61f86de4a3166cfa5da6800eeba8e6a2e4ab6403fc3d7b396508260b45d3de7d",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:817e8d5946aed6ca692e0bb2f6463c28774ef8fb2fdd3922543a495a249229ea",
"zh:b35f1bd1be09ed1a1620b43ab8cb43fe93407cf419586d53fe965691cc1b4a8e",
"zh:bcb170063ec8b5728bc2a4568bc48f539a1991cdaaf31667aa35568aebc34725",
"zh:c0d2c824cc7c047f26ce793bb0cbb6746f9745d1fc6e630d34a0afb5b92850d1",
"zh:cde68f51089b02db50e2c5a17f6e132dc1ead2fc08d3dd267b8dd54ec58133fa",
"zh:d1f3c497aa41f17e8d61067122f6d94e51ef942ab08be5465489864d900854ab",
"zh:e62568bfc0934b63e14f3547c823b01ed60d7475ff16bf12c0e55d5ac8d98ba7",
"zh:ed8890c29dba2b0ac27afcefa75e7395e27253b7025aaaa4258345fc59dad23e",
"zh:f220c56c7e487f01fd158126066f6525178bbd82805b27205354bc523cc7c413",
]
}

13
terraform/acm.tf Normal file
View file

@ -0,0 +1,13 @@
# ACM certificate for doorunlock.seahaven.com.
#
# The certificate is an out-of-band bootstrap dependency and is deliberately NOT
# created here. It was requested in seahaven-prod ahead of this configuration
# (arn:aws:acm:us-east-1:011934824531:certificate/c972e630-047f-4b6d-ae9b-2a7702cbdfce)
# and validated by DNS in the mgmt hosted zone. Declaring an aws_acm_certificate
# resource as well would request a second certificate for the same domain on
# the first apply, so this configuration only reads the issued one.
data "aws_acm_certificate" "doorunlock" {
domain = var.domain_name
statuses = ["ISSUED"]
most_recent = true
}

44
terraform/alarms.tf Normal file
View file

@ -0,0 +1,44 @@
locals {
alarm_functions = {
unlock = {
function_name = aws_lambda_function.unlock.function_name
description = "door-unlock-api-unlock Lambda is erroring — physical door unlock calls may be failing"
}
lockdown = {
function_name = aws_lambda_function.lockdown.function_name
description = "door-unlock-api-lockdown Lambda is erroring — lockdown commands may not be executing"
}
authorizer = {
function_name = aws_lambda_function.authorizer.function_name
description = "door-unlock-api-authorizer Lambda is erroring — all API requests will be rejected"
}
poller = {
function_name = aws_lambda_function.poller.function_name
description = "door-unlock-api-lockdown-poller Lambda is erroring — lockdown state polling may be broken"
}
blf_sync = {
function_name = aws_lambda_function.blf_sync.function_name
description = "door-unlock-api-blf-sync Lambda is erroring — department BLF updates may not be applying"
}
}
}
resource "aws_cloudwatch_metric_alarm" "lambda_errors" {
for_each = local.alarm_functions
alarm_name = "${each.value.function_name}-errors"
alarm_description = each.value.description
namespace = "AWS/Lambda"
metric_name = "Errors"
statistic = "Sum"
period = 300
evaluation_periods = 1
threshold = 0
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
dimensions = {
FunctionName = each.value.function_name
}
}

135
terraform/apigateway.tf Normal file
View file

@ -0,0 +1,135 @@
resource "aws_apigatewayv2_api" "this" {
name = local.project
protocol_type = "HTTP"
description = "Yealink door unlock and lockdown HTTP API"
}
# Invoke permission is a Lambda resource policy, not AuthorizerCredentialsArn.
# The credentials-role path returned 500 without invoking the authorizer
# (PLAT-102); resource policy matches the route grants.
resource "aws_apigatewayv2_authorizer" "token" {
api_id = aws_apigatewayv2_api.this.id
name = "${local.project}-token-authorizer"
authorizer_type = "REQUEST"
authorizer_uri = aws_lambda_function.authorizer.invoke_arn
authorizer_payload_format_version = "2.0"
authorizer_result_ttl_in_seconds = 300
enable_simple_responses = true
identity_sources = ["$request.querystring.token"]
}
resource "aws_apigatewayv2_integration" "unlock" {
api_id = aws_apigatewayv2_api.this.id
integration_type = "AWS_PROXY"
integration_method = "POST"
integration_uri = aws_lambda_function.unlock.invoke_arn
payload_format_version = "2.0"
timeout_milliseconds = 20000
}
resource "aws_apigatewayv2_integration" "lockdown" {
api_id = aws_apigatewayv2_api.this.id
integration_type = "AWS_PROXY"
integration_method = "POST"
integration_uri = aws_lambda_function.lockdown.invoke_arn
payload_format_version = "2.0"
timeout_milliseconds = 15000
}
resource "aws_apigatewayv2_route" "unlock" {
api_id = aws_apigatewayv2_api.this.id
route_key = "GET /unlock"
target = "integrations/${aws_apigatewayv2_integration.unlock.id}"
authorization_type = "CUSTOM"
authorizer_id = aws_apigatewayv2_authorizer.token.id
}
resource "aws_apigatewayv2_route" "lockdown" {
api_id = aws_apigatewayv2_api.this.id
route_key = "GET /lockdown"
target = "integrations/${aws_apigatewayv2_integration.lockdown.id}"
authorization_type = "CUSTOM"
authorizer_id = aws_apigatewayv2_authorizer.token.id
}
resource "aws_apigatewayv2_route" "lockdown_status" {
api_id = aws_apigatewayv2_api.this.id
route_key = "GET /lockdown/status"
target = "integrations/${aws_apigatewayv2_integration.lockdown.id}"
authorization_type = "CUSTOM"
authorizer_id = aws_apigatewayv2_authorizer.token.id
}
resource "aws_apigatewayv2_stage" "default" {
api_id = aws_apigatewayv2_api.this.id
name = "$default"
auto_deploy = true
access_log_settings {
destination_arn = aws_cloudwatch_log_group.api_access.arn
format = "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"requestTime\":\"$context.requestTime\",\"method\":\"$context.httpMethod\",\"routeKey\":\"$context.routeKey\",\"status\":\"$context.status\",\"protocol\":\"$context.protocol\",\"responseLength\":\"$context.responseLength\",\"integrationError\":\"$context.integrationErrorMessage\"}"
}
default_route_settings {
throttling_burst_limit = 5
throttling_rate_limit = 2
}
depends_on = [
aws_apigatewayv2_route.unlock,
aws_apigatewayv2_route.lockdown,
aws_apigatewayv2_route.lockdown_status,
]
}
resource "aws_lambda_permission" "unlock_route" {
statement_id = "AllowApiGatewayInvokeUnlock"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.unlock.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.this.execution_arn}/*/GET/unlock"
}
resource "aws_lambda_permission" "lockdown_route" {
statement_id = "AllowApiGatewayInvokeLockdown"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.lockdown.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.this.execution_arn}/*/GET/lockdown"
}
resource "aws_lambda_permission" "lockdown_status_route" {
statement_id = "AllowApiGatewayInvokeLockdownStatus"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.lockdown.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.this.execution_arn}/*/GET/lockdown/status"
}
resource "aws_lambda_permission" "authorizer" {
statement_id = "AllowApiGatewayInvokeAuthorizer"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.authorizer.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.this.execution_arn}/authorizers/${aws_apigatewayv2_authorizer.token.id}"
}
resource "aws_apigatewayv2_domain_name" "this" {
count = var.attach_custom_domain ? 1 : 0
domain_name = var.domain_name
domain_name_configuration {
certificate_arn = data.aws_acm_certificate.doorunlock.arn
endpoint_type = "REGIONAL"
security_policy = "TLS_1_2"
}
}
resource "aws_apigatewayv2_api_mapping" "this" {
count = var.attach_custom_domain ? 1 : 0
api_id = aws_apigatewayv2_api.this.id
domain_name = aws_apigatewayv2_domain_name.this[0].id
stage = aws_apigatewayv2_stage.default.id
}

104
terraform/artifacts.tf Normal file
View file

@ -0,0 +1,104 @@
# Lambda packaging.
#
# HCP plan and apply run on separate workers, so a zip written during plan is
# not on disk at apply time. The bytes are therefore carried inside the plan as
# content_base64 on aws_s3_object and uploaded at apply, and the functions
# read from S3 rather than from a local file.
#
# The build itself runs during plan through an external data source:
# local-exec provisioners only run on apply, and archive_file needs build/ to
# already exist when the plan is computed.
data "external" "package_build" {
program = ["bash", "${path.module}/build_packages_external.sh"]
}
resource "aws_s3_bucket" "artifacts" {
bucket = local.artifacts_bucket_name
tags = {
Purpose = "Lambda deployment packages for door-unlock-api"
}
}
resource "aws_s3_bucket_public_access_block" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_ownership_controls" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_versioning" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_lifecycle_configuration" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
rule {
id = "expire-noncurrent-packages"
status = "Enabled"
filter {}
noncurrent_version_expiration {
noncurrent_days = 180
}
}
rule {
id = "abort-incomplete-multipart"
status = "Enabled"
filter {}
abort_incomplete_multipart_upload {
days_after_initiation = 7
}
}
depends_on = [aws_s3_bucket_versioning.artifacts]
}
data "archive_file" "function" {
for_each = local.function_packages
type = "zip"
source_dir = "${path.module}/build/functions/${each.key}"
output_path = "${path.module}/build/packages/${each.key}.zip"
depends_on = [data.external.package_build]
}
resource "aws_s3_object" "function" {
for_each = local.function_packages
bucket = aws_s3_bucket.artifacts.id
key = "functions/${each.key}.zip"
content_base64 = filebase64(data.archive_file.function[each.key].output_path)
source_hash = data.archive_file.function[each.key].output_base64sha256
}

64
terraform/build_packages.sh Executable file
View file

@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Bundle the five TypeScript handlers for HCP plan/apply.
# Runs on the Terraform worker during plan (see artifacts.tf).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
BUILD="${ROOT}/build"
REPO="$(cd "${ROOT}/.." && pwd)"
NODE_PREFIX="${BUILD}/.node"
ensure_node() {
if command -v node >/dev/null 2>&1; then
local major
major="$(node -v | sed 's/^v//;s/\..*//')"
if [ "${major}" -ge 20 ]; then
return
fi
fi
local version="24.11.1"
local os arch tarball
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
case "$(uname -m)" in
x86_64 | amd64) arch="x64" ;;
arm64 | aarch64) arch="arm64" ;;
*)
echo "error: unsupported arch $(uname -m)" >&2
exit 1
;;
esac
tarball="node-v${version}-${os}-${arch}"
mkdir -p "${NODE_PREFIX}"
# HCP Terraform Linux workers do not ship xz. Use the gzip tarball.
curl -fsSL "https://nodejs.org/dist/v${version}/${tarball}.tar.gz" \
| tar -xz -C "${NODE_PREFIX}" --strip-components=1
export PATH="${NODE_PREFIX}/bin:${PATH}"
}
rm -rf "${BUILD}"
mkdir -p "${BUILD}/packages"
ensure_node
if [ ! -d "${REPO}/node_modules/esbuild" ]; then
(cd "${REPO}" && npm ci)
fi
bundle() {
local name="$1"
local src="$2"
local outfile="$3"
mkdir -p "${BUILD}/functions/${name}"
npx --prefix "${REPO}" esbuild "${src}" \
--bundle \
--platform=node \
--target=node24 \
--outfile="${BUILD}/functions/${name}/${outfile}" \
--external:@aws-sdk/*
}
bundle unlock "${REPO}/lambda/unlock/unlock-handler.ts" unlock-handler.js
bundle lockdown "${REPO}/lambda/lockdown/lockdown-handler.ts" lockdown-handler.js
bundle authorizer "${REPO}/lambda/authorizer/authorizer-handler.ts" authorizer-handler.js
bundle poller "${REPO}/lambda/poller/lockdown-poller.ts" lockdown-poller.js
bundle blf_sync "${REPO}/lambda/blf-sync/blf-sync-handler.ts" blf-sync-handler.js

View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Terraform external data source entrypoint. Stdout must be JSON only.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
# artifacts.tf already launches this file with bash, so +x is not required
# here. Invoke the inner script the same way so HCP plan does not depend on
# the git executable bit.
bash "${ROOT}/build_packages.sh" >&2
if command -v sha256sum >/dev/null 2>&1; then
SHA=(sha256sum)
else
SHA=(shasum -a 256)
fi
hash="$(
{
find -P "${ROOT}/build" -type f -print0 2>/dev/null \
| sort -z \
| xargs -0 "${SHA[@]}"
} | "${SHA[@]}" | awk '{print $1}'
)"
printf '{"status":"ok","hash":"%s"}\n' "${hash}"

39
terraform/data.tf Normal file
View file

@ -0,0 +1,39 @@
data "aws_caller_identity" "current" {}
check "correct_account" {
assert {
condition = data.aws_caller_identity.current.account_id == local.account_id
error_message = "This configuration targets account ${local.account_id}, but the credentials resolve to ${data.aws_caller_identity.current.account_id}."
}
}
data "aws_sns_topic" "site_alerts" {
name = "site-alerts"
}
# Non-secret door id. Fetching the value is safe for state and fails the plan
# closed if the OOB parameter is missing. SecureString parameters are never
# read through data sources (that would put secret values in HCP state).
data "aws_ssm_parameter" "door_id" {
name = local.door_id_param
}
check "door_id_present" {
assert {
condition = length(data.aws_ssm_parameter.door_id.value) > 0
error_message = "SSM parameter ${local.door_id_param} is missing or empty; create it out of band before apply."
}
}
# Secret *metadata* only (ARN). Never add aws_secretsmanager_secret_version.
data "aws_secretsmanager_secret" "three_cx_domain" {
name = local.three_cx_domain_secret_name
}
data "aws_secretsmanager_secret" "three_cx_client_id" {
name = local.three_cx_client_id_secret_name
}
data "aws_secretsmanager_secret" "three_cx_client_secret" {
name = local.three_cx_client_secret_secret_name
}

43
terraform/events.tf Normal file
View file

@ -0,0 +1,43 @@
locals {
schedules = {
"lockdown-poller-schedule" = {
description = "Poll LenelS2 lockdown status every minute"
schedule = "rate(1 minute)"
function_arn = aws_lambda_function.poller.arn
function_name = aws_lambda_function.poller.function_name
}
"blf-sync-schedule" = {
description = "Sync 3CX department BLFs daily at 09:00 UTC"
schedule = "cron(0 9 * * ? *)"
function_arn = aws_lambda_function.blf_sync.arn
function_name = aws_lambda_function.blf_sync.function_name
}
}
}
resource "aws_cloudwatch_event_rule" "schedule" {
for_each = local.schedules
name = "${local.project}-${each.key}"
description = each.value.description
schedule_expression = each.value.schedule
state = var.enable_schedules ? "ENABLED" : "DISABLED"
}
resource "aws_cloudwatch_event_target" "schedule" {
for_each = local.schedules
rule = aws_cloudwatch_event_rule.schedule[each.key].name
target_id = "${local.project}-${each.key}"
arn = each.value.function_arn
}
resource "aws_lambda_permission" "schedule" {
for_each = local.schedules
statement_id = "AllowEventBridgeInvoke-${each.key}"
action = "lambda:InvokeFunction"
function_name = each.value.function_name
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.schedule[each.key].arn
}

686
terraform/hcp_iam.tf Normal file
View file

@ -0,0 +1,686 @@
# HCP plan/apply roles imported from seahaven-terraform-substrate (PLAT-146).
# Import, do not recreate. Role names stay hcptf-seahaven-door-unlock-api / hcptf-seahaven-door-unlock-api-plan.
#
# Live seahaven-hcptf-iam-management DenySelfMutation blocks DetachRolePolicy
# and PutRolePolicy on hcptf-* (including this role). Import apply sequence:
# 1. seahaven-org-baseline scripts/create-hcptf-bootstrap-roles.sh
# --account prod --allow-workspace seahaven-door-unlock-api-prod
# 2. Point this workspace's TFC_AWS_* at hcptf-bootstrap /
# hcptf-bootstrap-plan (workspace vars, never a project set).
# 3. One Manual apply (import + detach seahaven-hcptf-iam-management +
# put scoped inline).
# 4. Point TFC_AWS_* back at hcptf-seahaven-door-unlock-api / hcptf-seahaven-door-unlock-api-plan.
# 5. Re-run the script without --allow-workspace to pin trust back to
# iam-bootstrap-prod only.
# seahaven-lambda-execution-boundary remains seahaven-lambda-execution-boundary-seahaven-door-unlock-api.
import {
to = aws_iam_role.hcptf_apply
id = "hcptf-seahaven-door-unlock-api"
}
import {
to = aws_iam_role.hcptf_plan
id = "hcptf-seahaven-door-unlock-api-plan"
}
import {
to = aws_iam_role_policy.hcptf_apply_services
id = "hcptf-seahaven-door-unlock-api:seahaven-door-unlock-api-services"
}
import {
to = aws_iam_role_policy.hcptf_plan_refresh
id = "hcptf-seahaven-door-unlock-api-plan:seahaven-door-unlock-api-plan-refresh"
}
import {
to = aws_iam_role_policy_attachments_exclusive.hcptf_apply
id = "hcptf-seahaven-door-unlock-api"
}
import {
to = aws_iam_role_policy_attachment.hcptf_plan_viewonly
id = "hcptf-seahaven-door-unlock-api-plan/arn:aws:iam::aws:policy/job-function/ViewOnlyAccess"
}
import {
to = aws_iam_role_policy_attachments_exclusive.hcptf_plan
id = "hcptf-seahaven-door-unlock-api-plan"
}
data "aws_iam_policy_document" "hcptf_apply_trust" {
statement {
sid = "HcpApply"
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = ["arn:aws:iam::${local.account_id}:oidc-provider/app.terraform.io"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:aud"
values = ["aws.workload.identity"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:sub"
values = [
"organization:seahaven:project:seahaven-prod:workspace:seahaven-door-unlock-api-prod:run_phase:apply",
]
}
}
}
data "aws_iam_policy_document" "hcptf_plan_trust" {
statement {
sid = "HcpPlan"
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = ["arn:aws:iam::${local.account_id}:oidc-provider/app.terraform.io"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:aud"
values = ["aws.workload.identity"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:sub"
values = [
"organization:seahaven:project:seahaven-prod:workspace:seahaven-door-unlock-api-prod:run_phase:plan",
]
}
}
}
data "aws_iam_policy_document" "hcptf_scoped_iam" {
statement {
sid = "DenyCreatePolicy"
effect = "Deny"
actions = ["iam:CreatePolicy", "iam:CreatePolicyVersion"]
resources = ["*"]
}
statement {
sid = "CreateExecRoleWithBoundary"
effect = "Allow"
actions = ["iam:CreateRole"]
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/door-unlock-api-*"]
condition {
test = "StringLike"
variable = "iam:PermissionsBoundary"
values = [
"arn:aws:iam::${local.account_id}:policy/tf-managed/door-unlock-api-*",
"arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary-seahaven-door-unlock-api"
]
}
}
statement {
sid = "MutateExecRoleWithBoundary"
effect = "Allow"
actions = [
"iam:AttachRolePolicy",
"iam:PutRolePolicy",
"iam:PutRolePermissionsBoundary",
]
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/door-unlock-api-*"]
condition {
test = "StringLike"
variable = "iam:PermissionsBoundary"
values = [
"arn:aws:iam::${local.account_id}:policy/tf-managed/door-unlock-api-*",
"arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary-seahaven-door-unlock-api"
]
}
}
statement {
sid = "WriteExecRoles"
effect = "Allow"
actions = [
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:DetachRolePolicy",
"iam:TagRole",
"iam:UntagRole",
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription",
]
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/door-unlock-api-*"]
}
statement {
sid = "PassExecRolesToLambda"
effect = "Allow"
actions = ["iam:PassRole"]
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/door-unlock-api-*"]
condition {
test = "StringEquals"
variable = "iam:PassedToService"
values = ["lambda.amazonaws.com"]
}
}
statement {
sid = "IamReadOnly"
effect = "Allow"
actions = [
"iam:GetPolicy",
"iam:GetPolicyVersion",
"iam:GetRole",
"iam:GetRolePolicy",
"iam:ListAttachedRolePolicies",
"iam:ListInstanceProfilesForRole",
"iam:ListPolicies",
"iam:ListPolicyVersions",
"iam:ListRolePolicies",
"iam:ListRoles",
"iam:ListRoleTags",
]
resources = ["*"]
}
statement {
sid = "DenySelfMutation"
effect = "Deny"
actions = [
"iam:AttachRolePolicy",
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:DeleteRolePermissionsBoundary",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
"iam:PutRolePermissionsBoundary",
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription",
]
resources = [
"arn:aws:iam::${local.account_id}:role/hcptf-*",
"arn:aws:iam::${local.account_id}:role/github-cfn-execution-role",
"arn:aws:iam::${local.account_id}:role/githubdeploy-*",
"arn:aws:iam::${local.account_id}:role/cdk-hnb659fds-*",
"arn:aws:iam::${local.account_id}:role/OrganizationAccountAccessRole",
"arn:aws:iam::${local.account_id}:role/seahaven-*",
]
}
statement {
sid = "DenyBoundaryTampering"
effect = "Deny"
actions = [
"iam:DeleteRolePermissionsBoundary",
"iam:DeleteUserPermissionsBoundary",
]
resources = [
"arn:aws:iam::${local.account_id}:role/*",
"arn:aws:iam::${local.account_id}:user/*",
]
}
statement {
sid = "DenyBoundaryPolicyEdit"
effect = "Deny"
actions = [
"iam:CreatePolicyVersion",
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:SetDefaultPolicyVersion",
]
resources = ["arn:aws:iam::${local.account_id}:policy/seahaven-*"]
}
}
resource "aws_iam_role_policy" "hcptf_apply_services" {
name = "seahaven-door-unlock-api-services"
role = aws_iam_role.hcptf_apply.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = [
"lambda:*",
]
Resource = [
"arn:aws:lambda:us-east-1:${local.account_id}:function:door-unlock-api-*",
]
Effect = "Allow"
Sid = "LambdaAll"
},
{
Action = [
"lambda:ListFunctions",
"lambda:GetAccountSettings",
]
Resource = "*"
Effect = "Allow"
Sid = "LambdaList"
},
{
Action = [
"events:*",
]
Resource = [
"arn:aws:events:us-east-1:${local.account_id}:rule/door-unlock-api-*",
]
Effect = "Allow"
Sid = "EventBridgeRules"
},
{
Action = [
"logs:CreateLogGroup",
"logs:DeleteLogGroup",
"logs:PutRetentionPolicy",
"logs:DeleteRetentionPolicy",
"logs:TagResource",
"logs:UntagResource",
"logs:ListTagsForResource",
]
Resource = [
"arn:aws:logs:us-east-1:${local.account_id}:log-group:/aws/lambda/door-unlock-api-*",
"arn:aws:logs:us-east-1:${local.account_id}:log-group:/aws/apigateway/door-unlock-api*",
]
Effect = "Allow"
Sid = "CloudWatchLogs"
},
{
Action = [
"logs:DescribeLogGroups",
]
Resource = "*"
Effect = "Allow"
Sid = "CloudWatchLogsDescribe"
},
{
Action = [
"logs:CreateLogDelivery",
"logs:GetLogDelivery",
"logs:UpdateLogDelivery",
"logs:DeleteLogDelivery",
"logs:ListLogDeliveries",
"logs:DescribeResourcePolicies",
]
Resource = "*"
Effect = "Allow"
Sid = "DoorUnlockApiGwAccessLogDelivery"
},
{
Action = [
"s3:*",
]
Resource = [
"arn:aws:s3:::door-unlock-api-artifacts-${local.account_id}",
"arn:aws:s3:::door-unlock-api-artifacts-${local.account_id}/*",
]
Effect = "Allow"
Sid = "StackBuckets"
},
{
Action = [
"apigateway:*",
]
Resource = [
"arn:aws:apigateway:us-east-1::/apis",
"arn:aws:apigateway:us-east-1::/apis/*",
"arn:aws:apigateway:us-east-1::/tags/*",
]
Effect = "Allow"
Sid = "HttpApiManage"
},
{
Action = [
"apigateway:*",
]
Resource = [
"arn:aws:apigateway:us-east-1::/domainnames/doorunlock.seahaven.com",
"arn:aws:apigateway:us-east-1::/domainnames/doorunlock.seahaven.com/*",
]
Effect = "Allow"
Sid = "HttpApiDomain"
},
{
Condition = {
StringEquals = {
"aws:RequestTag/Project" = "seahaven-door-unlock-api"
}
}
Action = [
"acm:RequestCertificate",
]
Resource = "*"
Effect = "Allow"
Sid = "AcmCreate"
},
{
Action = [
"acm:ListCertificates",
"acm:ListTagsForCertificate",
]
Resource = "*"
Effect = "Allow"
Sid = "AcmList"
},
{
Condition = {
StringEquals = {
"aws:ResourceTag/Project" = "seahaven-door-unlock-api"
}
}
Action = [
"acm:DescribeCertificate",
"acm:GetCertificate",
"acm:DeleteCertificate",
"acm:AddTagsToCertificate",
"acm:RemoveTagsFromCertificate",
"acm:RenewCertificate",
]
Resource = "*"
Effect = "Allow"
Sid = "AcmManageTagged"
},
{
Action = [
"ssm:GetParameter",
"ssm:GetParameters",
]
Resource = [
"arn:aws:ssm:us-east-1:${local.account_id}:parameter/seahaven/door-unlock/door-id",
]
Effect = "Allow"
Sid = "DoorUnlockSsm"
},
{
Action = [
"ssm:ListTagsForResource",
]
Resource = [
"arn:aws:ssm:us-east-1:${local.account_id}:parameter/seahaven/door-unlock/*",
]
Effect = "Allow"
Sid = "DoorUnlockSsmTags"
},
{
Action = [
"ssm:DescribeParameters",
]
Resource = "*"
Effect = "Allow"
Sid = "DoorUnlockSsmDescribeParameters"
},
{
Action = [
"secretsmanager:DescribeSecret",
"secretsmanager:GetResourcePolicy",
"secretsmanager:ListSecretVersionIds",
]
Resource = [
"arn:aws:secretsmanager:us-east-1:${local.account_id}:secret:afterhours-shift-manager/3cx-domain-TPwqWP",
"arn:aws:secretsmanager:us-east-1:${local.account_id}:secret:afterhours-shift-manager/3cx-client-id-jzyQXb",
"arn:aws:secretsmanager:us-east-1:${local.account_id}:secret:afterhours-shift-manager/3cx-client-secret-jpO476",
]
Effect = "Allow"
Sid = "DescribeThreeCxSecrets"
},
{
Condition = {
StringEquals = {
"iam:PassedToService" = "apigateway.amazonaws.com"
}
}
Action = [
"iam:PassRole",
]
Resource = [
"arn:aws:iam::${local.account_id}:role/tf-managed/door-unlock-api-*",
]
Effect = "Allow"
Sid = "DoorUnlockPassRoleApiGateway"
},
{
Action = [
"cloudwatch:PutMetricAlarm",
"cloudwatch:DeleteAlarms",
"cloudwatch:DescribeAlarms",
"cloudwatch:TagResource",
"cloudwatch:UntagResource",
"cloudwatch:ListTagsForResource",
]
Resource = [
"arn:aws:cloudwatch:us-east-1:${local.account_id}:alarm:door-unlock-api-*",
]
Effect = "Allow"
Sid = "CloudWatchAlarms"
},
{
Action = [
"sns:Publish",
"sns:GetTopicAttributes",
"sns:ListTagsForResource",
]
Resource = [
"arn:aws:sns:us-east-1:${local.account_id}:site-alerts",
]
Effect = "Allow"
Sid = "SnsPublishSiteAlerts"
},
]
})
}
resource "aws_iam_role_policy" "hcptf_plan_refresh" {
name = "seahaven-door-unlock-api-plan-refresh"
role = aws_iam_role.hcptf_plan.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = [
"iam:GetRole",
"iam:GetRolePolicy",
"iam:ListRolePolicies",
"iam:ListAttachedRolePolicies",
"iam:ListRoleTags",
]
Resource = [
"arn:aws:iam::${local.account_id}:role/tf-managed/door-unlock-api-*",
"arn:aws:iam::${local.account_id}:role/hcptf-seahaven-door-unlock-api",
"arn:aws:iam::${local.account_id}:role/hcptf-seahaven-door-unlock-api-plan",
]
Effect = "Allow"
Sid = "RefreshIamRoles"
},
{
Action = [
"iam:GetPolicy",
"iam:GetPolicyVersion",
]
Resource = "*"
Effect = "Allow"
Sid = "RefreshManagedPolicies"
},
{
Action = [
"events:DescribeRule",
"events:ListTargetsByRule",
"events:ListTagsForResource",
]
Resource = [
"arn:aws:events:us-east-1:${local.account_id}:rule/door-unlock-api-*",
]
Effect = "Allow"
Sid = "RefreshEventBridge"
},
{
Action = [
"lambda:Get*",
"lambda:List*",
]
Resource = [
"arn:aws:lambda:us-east-1:${local.account_id}:function:door-unlock-api-*",
]
Effect = "Allow"
Sid = "RefreshLambda"
},
{
Action = [
"s3:Get*",
"s3:ListBucket",
]
Resource = [
"arn:aws:s3:::door-unlock-api-artifacts-${local.account_id}",
"arn:aws:s3:::door-unlock-api-artifacts-${local.account_id}/*",
]
Effect = "Allow"
Sid = "RefreshBuckets"
},
{
Action = [
"logs:DescribeLogGroups",
"logs:ListTagsForResource",
]
Resource = "*"
Effect = "Allow"
Sid = "RefreshLogs"
},
{
Action = [
"acm:DescribeCertificate",
"acm:ListCertificates",
"acm:ListTagsForCertificate",
"acm:GetCertificate",
]
Resource = "*"
Effect = "Allow"
Sid = "RefreshAcm"
},
{
Action = [
"ssm:GetParameter",
"ssm:GetParameters",
]
Resource = [
"arn:aws:ssm:us-east-1:${local.account_id}:parameter/seahaven/door-unlock/door-id",
]
Effect = "Allow"
Sid = "RefreshDoorUnlockSsm"
},
{
Action = [
"ssm:ListTagsForResource",
]
Resource = [
"arn:aws:ssm:us-east-1:${local.account_id}:parameter/seahaven/door-unlock/*",
]
Effect = "Allow"
Sid = "RefreshDoorUnlockSsmTags"
},
{
Action = [
"ssm:DescribeParameters",
]
Resource = "*"
Effect = "Allow"
Sid = "RefreshSsmDescribeParameters"
},
{
Action = [
"apigateway:GET",
]
Resource = [
"arn:aws:apigateway:us-east-1::/apis/*",
"arn:aws:apigateway:us-east-1::/domainnames/doorunlock.seahaven.com",
"arn:aws:apigateway:us-east-1::/domainnames/doorunlock.seahaven.com/*",
"arn:aws:apigateway:us-east-1::/tags/*",
]
Effect = "Allow"
Sid = "RefreshHttpApi"
},
{
Action = [
"cloudwatch:DescribeAlarms",
"cloudwatch:ListTagsForResource",
]
Resource = "*"
Effect = "Allow"
Sid = "RefreshAlarms"
},
{
Action = [
"secretsmanager:DescribeSecret",
"secretsmanager:GetResourcePolicy",
"secretsmanager:ListSecretVersionIds",
]
Resource = [
"arn:aws:secretsmanager:us-east-1:${local.account_id}:secret:afterhours-shift-manager/3cx-domain-TPwqWP",
"arn:aws:secretsmanager:us-east-1:${local.account_id}:secret:afterhours-shift-manager/3cx-client-id-jzyQXb",
"arn:aws:secretsmanager:us-east-1:${local.account_id}:secret:afterhours-shift-manager/3cx-client-secret-jpO476",
]
Effect = "Allow"
Sid = "RefreshThreeCxSecrets"
},
]
})
}
resource "aws_iam_role" "hcptf_apply" {
name = "hcptf-seahaven-door-unlock-api"
assume_role_policy = data.aws_iam_policy_document.hcptf_apply_trust.json
max_session_duration = 3600
tags = {
Project = "seahaven-door-unlock-api"
Owner = "adam@seahavenind.com"
ManagedBy = "terraform"
}
}
# Empty exclusive set keeps seahaven-hcptf-iam-management detached.
resource "aws_iam_role_policy_attachments_exclusive" "hcptf_apply" {
role_name = aws_iam_role.hcptf_apply.name
policy_arns = []
}
resource "aws_iam_role" "hcptf_plan" {
name = "hcptf-seahaven-door-unlock-api-plan"
assume_role_policy = data.aws_iam_policy_document.hcptf_plan_trust.json
max_session_duration = 3600
tags = {
Project = "seahaven-door-unlock-api"
Owner = "adam@seahavenind.com"
ManagedBy = "terraform"
}
}
resource "aws_iam_role_policy_attachment" "hcptf_plan_viewonly" {
role = aws_iam_role.hcptf_plan.name
policy_arn = "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess"
}
resource "aws_iam_role_policy_attachments_exclusive" "hcptf_plan" {
role_name = aws_iam_role.hcptf_plan.name
policy_arns = [
aws_iam_role_policy_attachment.hcptf_plan_viewonly.policy_arn,
]
}
resource "aws_iam_role_policy" "hcptf_scoped_iam" {
name = "scoped-iam-management"
role = aws_iam_role.hcptf_apply.id
policy = data.aws_iam_policy_document.hcptf_scoped_iam.json
}

157
terraform/iam.tf Normal file
View file

@ -0,0 +1,157 @@
data "aws_iam_policy_document" "lambda_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
data "aws_iam_policy_document" "ssm_elements_and_auth" {
statement {
sid = "ReadElementsAndAuth"
effect = "Allow"
actions = ["ssm:GetParameter"]
resources = [
local.elements_api_key_arn,
local.auth_token_arn,
]
}
}
data "aws_iam_policy_document" "ssm_unlock" {
statement {
sid = "ReadUnlockParams"
effect = "Allow"
actions = ["ssm:GetParameter"]
resources = [
local.elements_api_key_arn,
local.auth_token_arn,
local.door_id_arn,
]
}
}
data "aws_iam_policy_document" "ssm_auth_only" {
statement {
sid = "ReadAuthToken"
effect = "Allow"
actions = ["ssm:GetParameter"]
resources = [local.auth_token_arn]
}
}
data "aws_iam_policy_document" "ssm_elements_only" {
statement {
sid = "ReadElementsApiKey"
effect = "Allow"
actions = ["ssm:GetParameter"]
resources = [local.elements_api_key_arn]
}
}
data "aws_iam_policy_document" "three_cx_secrets" {
statement {
sid = "ReadThreeCxSecrets"
effect = "Allow"
actions = ["secretsmanager:GetSecretValue"]
resources = [
data.aws_secretsmanager_secret.three_cx_domain.arn,
data.aws_secretsmanager_secret.three_cx_client_id.arn,
data.aws_secretsmanager_secret.three_cx_client_secret.arn,
]
}
}
resource "aws_iam_role" "unlock" {
name = "${local.project}-unlock"
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
permissions_boundary = local.boundary_arn
}
resource "aws_iam_role_policy_attachment" "unlock_basic" {
role = aws_iam_role.unlock.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_iam_role_policy" "unlock" {
name = "unlock"
role = aws_iam_role.unlock.id
policy = data.aws_iam_policy_document.ssm_unlock.json
}
resource "aws_iam_role" "lockdown" {
name = "${local.project}-lockdown"
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
permissions_boundary = local.boundary_arn
}
resource "aws_iam_role_policy_attachment" "lockdown_basic" {
role = aws_iam_role.lockdown.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_iam_role_policy" "lockdown" {
name = "lockdown"
role = aws_iam_role.lockdown.id
policy = data.aws_iam_policy_document.ssm_elements_and_auth.json
}
resource "aws_iam_role" "authorizer" {
name = "${local.project}-authorizer"
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
permissions_boundary = local.boundary_arn
}
resource "aws_iam_role_policy_attachment" "authorizer_basic" {
role = aws_iam_role.authorizer.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_iam_role_policy" "authorizer" {
name = "authorizer"
role = aws_iam_role.authorizer.id
policy = data.aws_iam_policy_document.ssm_auth_only.json
}
resource "aws_iam_role" "poller" {
name = "${local.project}-lockdown-poller"
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
permissions_boundary = local.boundary_arn
}
resource "aws_iam_role_policy_attachment" "poller_basic" {
role = aws_iam_role.poller.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_iam_role_policy" "poller" {
name = "lockdown-poller"
role = aws_iam_role.poller.id
policy = data.aws_iam_policy_document.ssm_elements_only.json
}
resource "aws_iam_role" "blf_sync" {
name = "${local.project}-blf-sync"
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
permissions_boundary = local.boundary_arn
}
resource "aws_iam_role_policy_attachment" "blf_sync_basic" {
role = aws_iam_role.blf_sync.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_iam_role_policy" "blf_sync" {
name = "blf-sync"
role = aws_iam_role.blf_sync.id
policy = data.aws_iam_policy_document.three_cx_secrets.json
}

135
terraform/lambda.tf Normal file
View file

@ -0,0 +1,135 @@
resource "aws_lambda_function" "unlock" {
function_name = local.function_packages.unlock.function_name
role = aws_iam_role.unlock.arn
handler = local.function_packages.unlock.handler
runtime = "nodejs24.x"
architectures = ["arm64"]
memory_size = local.function_packages.unlock.memory
timeout = local.function_packages.unlock.timeout
s3_bucket = aws_s3_bucket.artifacts.id
s3_key = aws_s3_object.function["unlock"].key
source_code_hash = data.archive_file.function["unlock"].output_base64sha256
environment {
variables = {
ELEMENTS_API_KEY_PARAM = local.elements_api_key_param
AUTH_TOKEN_PARAM = local.auth_token_param
DOOR_ID_PARAM = local.door_id_param
}
}
depends_on = [
aws_cloudwatch_log_group.function,
aws_iam_role_policy.unlock,
aws_iam_role_policy_attachment.unlock_basic,
]
}
resource "aws_lambda_function" "lockdown" {
function_name = local.function_packages.lockdown.function_name
role = aws_iam_role.lockdown.arn
handler = local.function_packages.lockdown.handler
runtime = "nodejs24.x"
architectures = ["arm64"]
memory_size = local.function_packages.lockdown.memory
timeout = local.function_packages.lockdown.timeout
s3_bucket = aws_s3_bucket.artifacts.id
s3_key = aws_s3_object.function["lockdown"].key
source_code_hash = data.archive_file.function["lockdown"].output_base64sha256
environment {
variables = {
ELEMENTS_API_KEY_PARAM = local.elements_api_key_param
AUTH_TOKEN_PARAM = local.auth_token_param
}
}
depends_on = [
aws_cloudwatch_log_group.function,
aws_iam_role_policy.lockdown,
aws_iam_role_policy_attachment.lockdown_basic,
]
}
resource "aws_lambda_function" "authorizer" {
function_name = local.function_packages.authorizer.function_name
role = aws_iam_role.authorizer.arn
handler = local.function_packages.authorizer.handler
runtime = "nodejs24.x"
architectures = ["arm64"]
memory_size = local.function_packages.authorizer.memory
timeout = local.function_packages.authorizer.timeout
s3_bucket = aws_s3_bucket.artifacts.id
s3_key = aws_s3_object.function["authorizer"].key
source_code_hash = data.archive_file.function["authorizer"].output_base64sha256
environment {
variables = {
AUTH_TOKEN_PARAM = local.auth_token_param
}
}
depends_on = [
aws_cloudwatch_log_group.function,
aws_iam_role_policy.authorizer,
aws_iam_role_policy_attachment.authorizer_basic,
]
}
resource "aws_lambda_function" "poller" {
function_name = local.function_packages.poller.function_name
role = aws_iam_role.poller.arn
handler = local.function_packages.poller.handler
runtime = "nodejs24.x"
architectures = ["arm64"]
memory_size = local.function_packages.poller.memory
timeout = local.function_packages.poller.timeout
s3_bucket = aws_s3_bucket.artifacts.id
s3_key = aws_s3_object.function["poller"].key
source_code_hash = data.archive_file.function["poller"].output_base64sha256
environment {
variables = {
ELEMENTS_API_KEY_PARAM = local.elements_api_key_param
}
}
depends_on = [
aws_cloudwatch_log_group.function,
aws_iam_role_policy.poller,
aws_iam_role_policy_attachment.poller_basic,
]
}
resource "aws_lambda_function" "blf_sync" {
function_name = local.function_packages.blf_sync.function_name
role = aws_iam_role.blf_sync.arn
handler = local.function_packages.blf_sync.handler
runtime = "nodejs24.x"
architectures = ["arm64"]
memory_size = local.function_packages.blf_sync.memory
timeout = local.function_packages.blf_sync.timeout
s3_bucket = aws_s3_bucket.artifacts.id
s3_key = aws_s3_object.function["blf_sync"].key
source_code_hash = data.archive_file.function["blf_sync"].output_base64sha256
environment {
variables = {
THREE_CX_DOMAIN_SECRET = local.three_cx_domain_secret_name
THREE_CX_CLIENT_ID_SECRET = local.three_cx_client_id_secret_name
THREE_CX_CLIENT_SECRET_SECRET = local.three_cx_client_secret_secret_name
DRY_RUN = "false"
}
}
depends_on = [
aws_cloudwatch_log_group.function,
aws_iam_role_policy.blf_sync,
aws_iam_role_policy_attachment.blf_sync_basic,
]
}

65
terraform/locals.tf Normal file
View file

@ -0,0 +1,65 @@
locals {
project = "door-unlock-api"
account_id = "011934824531"
boundary_arn = "arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary-seahaven-door-unlock-api"
artifacts_bucket_name = "${local.project}-artifacts-${local.account_id}"
ssm_prefix = "/seahaven/door-unlock"
elements_api_key_param = "${local.ssm_prefix}/elements-api-key"
auth_token_param = "${local.ssm_prefix}/auth-token"
door_id_param = "${local.ssm_prefix}/door-id"
elements_api_key_arn = "arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.elements_api_key_param}"
auth_token_arn = "arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.auth_token_param}"
door_id_arn = "arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.door_id_param}"
three_cx_domain_secret_name = "afterhours-shift-manager/3cx-domain"
three_cx_client_id_secret_name = "afterhours-shift-manager/3cx-client-id"
three_cx_client_secret_secret_name = "afterhours-shift-manager/3cx-client-secret"
function_packages = {
unlock = {
source = "lambda/unlock/unlock-handler.ts"
outfile = "unlock-handler.js"
handler = "unlock-handler.handler"
function_name = "${local.project}-unlock"
timeout = 20
memory = 128
}
lockdown = {
source = "lambda/lockdown/lockdown-handler.ts"
outfile = "lockdown-handler.js"
handler = "lockdown-handler.handler"
function_name = "${local.project}-lockdown"
timeout = 15
memory = 128
}
authorizer = {
source = "lambda/authorizer/authorizer-handler.ts"
outfile = "authorizer-handler.js"
handler = "authorizer-handler.handler"
function_name = "${local.project}-authorizer"
timeout = 8
memory = 128
}
poller = {
source = "lambda/poller/lockdown-poller.ts"
outfile = "lockdown-poller.js"
handler = "lockdown-poller.handler"
function_name = "${local.project}-lockdown-poller"
timeout = 75
memory = 128
}
blf_sync = {
source = "lambda/blf-sync/blf-sync-handler.ts"
outfile = "blf-sync-handler.js"
handler = "blf-sync-handler.handler"
function_name = "${local.project}-blf-sync"
timeout = 60
memory = 256
}
}
}

11
terraform/logs.tf Normal file
View file

@ -0,0 +1,11 @@
resource "aws_cloudwatch_log_group" "function" {
for_each = local.function_packages
name = "/aws/lambda/${each.value.function_name}"
retention_in_days = 60
}
resource "aws_cloudwatch_log_group" "api_access" {
name = "/aws/apigateway/${local.project}"
retention_in_days = 90
}

30
terraform/outputs.tf Normal file
View file

@ -0,0 +1,30 @@
output "api_endpoint" {
description = "HTTP API execute-api URL for pre-DNS live-path proof."
value = aws_apigatewayv2_api.this.api_endpoint
}
output "custom_domain_target" {
description = "API Gateway regional domain name. DNS A alias target for doorunlock.seahaven.com at cutover. Null until attach_custom_domain is true."
value = var.attach_custom_domain ? aws_apigatewayv2_domain_name.this[0].domain_name_configuration[0].target_domain_name : null
}
output "custom_domain_hosted_zone_id" {
description = "API Gateway regional hosted zone id for the Route53 alias. Null until attach_custom_domain is true."
value = var.attach_custom_domain ? aws_apigatewayv2_domain_name.this[0].domain_name_configuration[0].hosted_zone_id : null
}
output "artifacts_bucket_name" {
description = "S3 bucket holding Lambda deployment packages."
value = aws_s3_bucket.artifacts.id
}
output "function_arns" {
description = "ARNs of every Lambda function in this configuration."
value = {
unlock = aws_lambda_function.unlock.arn
lockdown = aws_lambda_function.lockdown.arn
authorizer = aws_lambda_function.authorizer.arn
poller = aws_lambda_function.poller.arn
blf_sync = aws_lambda_function.blf_sync.arn
}
}

11
terraform/providers.tf Normal file
View file

@ -0,0 +1,11 @@
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = "seahaven-door-unlock-api"
ManagedBy = "terraform"
Workspace = "seahaven-door-unlock-api-prod"
}
}
}

23
terraform/variables.tf Normal file
View file

@ -0,0 +1,23 @@
variable "aws_region" {
description = "Region every resource in this configuration is created in."
type = string
default = "us-east-1"
}
variable "domain_name" {
description = "Custom domain for the HTTP API. An ISSUED ACM certificate for this domain must already exist in us-east-1 (see acm.tf)."
type = string
default = "doorunlock.seahaven.com"
}
variable "enable_schedules" {
description = "When true, EventBridge rules invoke the poller and BLF sync. Keep false until live-path proof and DNS cutover."
type = bool
default = false
}
variable "attach_custom_domain" {
description = "When true, create the API Gateway custom domain and mapping. Keep false until mgmt releases doorunlock.seahaven.com at DNS cutover; the hostname is unique per region across accounts."
type = bool
default = false
}

26
terraform/versions.tf Normal file
View file

@ -0,0 +1,26 @@
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "6.65.0"
}
archive = {
source = "hashicorp/archive"
version = "2.8.1"
}
external = {
source = "hashicorp/external"
version = "2.4.2"
}
}
cloud {
organization = "seahaven"
workspaces {
name = "seahaven-door-unlock-api-prod"
}
}
}

View file

@ -3,6 +3,7 @@
"target": "ES2022", "target": "ES2022",
"module": "commonjs", "module": "commonjs",
"lib": ["ES2022"], "lib": ["ES2022"],
"types": ["node"],
"declaration": true, "declaration": true,
"strict": true, "strict": true,
"noImplicitAny": true, "noImplicitAny": true,
@ -20,5 +21,5 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"esModuleInterop": true "esModuleInterop": true
}, },
"exclude": ["node_modules", "cdk.out"] "exclude": ["node_modules", "cdk.out", "**/*.test.ts", "scripts"]
} }

View file

@ -5189,33 +5189,49 @@ linekey.1.extension = %%PickupValue%%
linekey.1.type = 0 linekey.1.type = 0
{ENDIF} {ENDIF}
#Configure Line Key2 - Door Unlock (hardcoded) #Configure Line Key2 - Door Unlock (hardcoded)
# do not assign 3CX BLF index 2 — reserved for this URL key
linekey.2.line = 1 linekey.2.line = 1
linekey.2.value = https://doorunlock.seahaven.com/unlock?token=__DOOR_UNLOCK_TOKEN__ linekey.2.value = https://doorunlock.seahaven.com/unlock?token=__DOOR_UNLOCK_TOKEN__
linekey.2.pickup_value = %NULL% linekey.2.pickup_value = %NULL%
linekey.2.type = 17 linekey.2.type = 17
linekey.2.label = Unlock Door linekey.2.label = Unlock Door
linekey.2.extension = %NULL% linekey.2.extension = %NULL%
#Configure Line Key3 - Shared Parking SP1 (hardcoded) #Configure Line Key3
linekey.3.line = 1 # SP1 is written by door-unlock-api-blf-sync as 3CX BLF index 3
linekey.3.value = SP1 {IF blf3}
linekey.3.pickup_value = %NULL% linekey.3.line = %%Line%%
linekey.3.type = 10 linekey.3.value = %%type%%
linekey.3.label = SP 1 linekey.3.pickup_value = %%PickupValue%%
linekey.3.extension = %NULL% linekey.3.type = %%DKtype%%
#Configure Line Key4 - Shared Parking SP2 (hardcoded) linekey.3.label = %%label%%
linekey.4.line = 1 linekey.3.extension = %%PickupValue%%
linekey.4.value = SP2 {ELSE}
linekey.4.pickup_value = %NULL% linekey.3.type = 0
linekey.4.type = 10 {ENDIF}
linekey.4.label = SP 2 #Configure Line Key4
linekey.4.extension = %NULL% # SP2 is written by door-unlock-api-blf-sync as 3CX BLF index 4
#Configure Line Key5 - Shared Parking SP3 (hardcoded) {IF blf4}
linekey.5.line = 1 linekey.4.line = %%Line%%
linekey.5.value = SP3 linekey.4.value = %%type%%
linekey.5.pickup_value = %NULL% linekey.4.pickup_value = %%PickupValue%%
linekey.5.type = 10 linekey.4.type = %%DKtype%%
linekey.5.label = SP 3 linekey.4.label = %%label%%
linekey.5.extension = %NULL% linekey.4.extension = %%PickupValue%%
{ELSE}
linekey.4.type = 0
{ENDIF}
#Configure Line Key5
# SP3 is written by door-unlock-api-blf-sync as 3CX BLF index 5
{IF blf5}
linekey.5.line = %%Line%%
linekey.5.value = %%type%%
linekey.5.pickup_value = %%PickupValue%%
linekey.5.type = %%DKtype%%
linekey.5.label = %%label%%
linekey.5.extension = %%PickupValue%%
{ELSE}
linekey.5.type = 0
{ENDIF}
#Configure Line Key6 #Configure Line Key6
{IF blf6} {IF blf6}
linekey.6.line = %%Line%% linekey.6.line = %%Line%%

View file

@ -5189,6 +5189,7 @@ linekey.1.extension = %%PickupValue%%
linekey.1.type = 0 linekey.1.type = 0
{ENDIF} {ENDIF}
#Configure Line Key2 - Door Unlock (hardcoded) #Configure Line Key2 - Door Unlock (hardcoded)
# do not assign 3CX BLF index 2 — reserved for this URL key
linekey.2.line = 1 linekey.2.line = 1
linekey.2.value = https://doorunlock.seahaven.com/unlock?token=__DOOR_UNLOCK_TOKEN__ linekey.2.value = https://doorunlock.seahaven.com/unlock?token=__DOOR_UNLOCK_TOKEN__
linekey.2.pickup_value = %NULL% linekey.2.pickup_value = %NULL%

File diff suppressed because it is too large Load diff

View file

@ -897,7 +897,7 @@ voice.headset_send =
#$call_id--The caller ID when in the incoming state, the outgoing state or during conversation. #$call_id--The caller ID when in the incoming state, the outgoing state or during conversation.
#For example, action_url.log_on = http://192.168.1.20/help.xml?mac=$mac #For example, action_url.log_on = http://192.168.1.20/help.xml?mac=$mac
action_url.setup_completed = action_url.setup_completed =
action_url.registered = action_url.registered =
action_url.unregistered = action_url.unregistered =
action_url.register_failed = action_url.register_failed =
@ -979,7 +979,7 @@ zero_touch.network_fail_delay_times =
## Push XML ## ## Push XML ##
####################################################################################### #######################################################################################
push_xml.server = push_xml.server = any
#Enable or disable the phone to display the push XML interface when receiving an incoming call; 0-Disabled (default), 1-Enabled; #Enable or disable the phone to display the push XML interface when receiving an incoming call; 0-Disabled (default), 1-Enabled;
push_xml.block_in_calling = 0 push_xml.block_in_calling = 0
@ -2183,7 +2183,7 @@ features.voice_mail_tone_enable = 1
features.alert_info_tone = features.alert_info_tone =
features.barge_in_via_username.enable = features.barge_in_via_username.enable =
features.flash_url_dsskey_led.enable = features.flash_url_dsskey_led.enable = 1
features.default_account = features.default_account =
#The following parameter only applicable to V80 #The following parameter only applicable to V80
@ -3094,34 +3094,29 @@ linekey.1.extension = %%PickupValue%%
linekey.1.type = 0 linekey.1.type = 0
{ENDIF} {ENDIF}
#Configure Line Key2 - Door Unlock (hardcoded) #Configure Line Key2 - Door Unlock (hardcoded)
# do not assign 3CX BLF index 2 — reserved for this URL key
linekey.2.line = 1 linekey.2.line = 1
linekey.2.value = https://doorunlock.seahaven.com/unlock?token=__DOOR_UNLOCK_TOKEN__ linekey.2.value = https://doorunlock.seahaven.com/unlock?token=__DOOR_UNLOCK_TOKEN__
linekey.2.pickup_value = %NULL% linekey.2.pickup_value = %NULL%
linekey.2.type = 17 linekey.2.type = 17
linekey.2.label = Unlock Door linekey.2.label = Unlock Door
linekey.2.extension = %NULL% linekey.2.extension = %NULL%
#Configure Line Key3 #Configure Line Key3 - Lockdown: Bohemia (hardcoded)
{IF blf3} # do not assign 3CX BLF index 3 — reserved for this URL key
linekey.3.line = %%Line%% linekey.3.line = 1
linekey.3.value = %%type%% linekey.3.value = https://doorunlock.seahaven.com/lockdown?token=__DOOR_UNLOCK_TOKEN__&profile=bohemia
linekey.3.pickup_value = %%PickupValue%% linekey.3.pickup_value = %NULL%
linekey.3.type = %%DKtype%% linekey.3.type = 17
linekey.3.label = %%label%% linekey.3.label = Lockdown BOH
linekey.3.extension = %%PickupValue%% linekey.3.extension = %NULL%
{ELSE} #Configure Line Key4 - Lockdown: Ronkonkoma (hardcoded)
linekey.3.type = 0 # do not assign 3CX BLF index 4 — reserved for this URL key
{ENDIF} linekey.4.line = 1
#Configure Line Key4 linekey.4.value = https://doorunlock.seahaven.com/lockdown?token=__DOOR_UNLOCK_TOKEN__&profile=ronkonkoma
{IF blf4} linekey.4.pickup_value = %NULL%
linekey.4.line = %%Line%% linekey.4.type = 17
linekey.4.value = %%type%% linekey.4.label = Lockdown RNK
linekey.4.pickup_value = %%PickupValue%% linekey.4.extension = %NULL%
linekey.4.type = %%DKtype%%
linekey.4.label = %%label%%
linekey.4.extension = %%PickupValue%%
{ELSE}
linekey.4.type = 0
{ENDIF}
#Configure Line Key5 #Configure Line Key5
{IF blf5} {IF blf5}
linekey.5.line = %%Line%% linekey.5.line = %%Line%%