wbxcallprov/README.md
jmcqueen 3daf2fcdc5
Some checks failed
CI / verify (push) Has been cancelled
Verify (don't order) phone number; fix DECT location lookup endpoint
Two bugs surfaced by the same failed finalize run for store 7311.

1. addPhoneNumbersToLocation was POSTing to /locations/{id}/numbers,
   which Webex treats as "provision a NEW number" — that triggered
   duplicate PSTN orders against numbers AE had already manually
   ordered through Control Hub, and the provider rejected them with
   ORDER_CREATION_FAILED. AE's workflow is manual ordering, so this
   step now VERIFIES the number's current state in the org via
   GET /telephony/config/numbers?phoneNumber=..., classifies the
   outcome (already here / pending / wrong location / not in org),
   and either succeeds silently or throws with a specific
   Control-Hub fix-it instruction. The bot never triggers a PSTN
   order now.

2. findDectNetworkInLocation was hitting
   GET /telephony/config/locations/{id}/dectNetworks, which Webex
   does not implement (returns HTTP 404 "No static resource ..."
   unconditionally). That silently broke both the finalize
   idempotency pre-check and the /provisionDect fallback, so
   /provisionDect kept showing the "create network" card for stores
   that already had one, then 409'd on the create attempt. Switched
   to the real endpoint GET /telephony/config/dectNetworks with a
   locationId filter. Also added translateCreateDectError to
   rewrite the deeply-nested 27453 "access code in use" 409 blob
   into an actionable "already exists, re-run /provisionDect"
   message.

Extracted both error/state translators as pure exported functions
(classifyNumberAssignment, translateCreateDectError) with unit-test
coverage locking down the exact Webex payload shapes.

README: updated /finalizeStore to reflect the manual-order
expectation and specific fix-it instructions on mismatch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 13:53:38 -04:00

233 lines
10 KiB
Markdown

# wbxStoreProvision
Webex Calling provisioning bot for AEO retail stores. Runs as a Webex bot and
exposes slash commands that create Webex locations, attach phone numbers, upload
greetings, build auto-attendants, and clean up user licensing.
## Requirements
- Node.js 20+ (Docker image uses `node:22-alpine`).
- A Webex bot token, a Webex integration (service account) with the scopes
currently used by admin API calls, a Twilio lookup account, an SIW basic-auth
user, and a Google API key with Address Validation + Time Zone enabled.
- An on-prem host that can reach Store Info Web, Google APIs, and Twilio
Lookup from an IP-whitelisted subnet, to run the remote agent (see
[Remote agent (SIW + Google)](#remote-agent-siw--google) below).
## Local setup
```bash
cp .env.example .env
# Edit .env with real values
npm install
npm start
```
The token-refresh cron reads and writes `config/wbxTokens.json` (path
configurable via `WEBEX_TOKEN_STORE`). Seed this file once with a valid
`access_token`, `refresh_token`, and `expiresOn`; the process will keep it up to
date from then on.
## Scripts
| npm script | What it does |
| -------------------------- | ----------------------------------------------------------- |
| `npm start` | Run the bot. |
| `npm run dev` | Run with `node --watch` for local iteration. |
| `npm run lint` | ESLint (flat config) across the tree. |
| `npm run format` | Prettier write. |
| `npm run generate-911-csv` | Rebuild `buildingFile.csv` + `locationFile.csv` from SIW. |
| `npm run fix-store-phones` | Normalize licensing + default meeting site for store users. |
## Docker
```bash
docker build -t wbxcallprov .
docker run --rm --env-file .env \
-p 8080:8080 \
-v "$(pwd)/config:/app/config" \
wbxcallprov
```
The volume mount preserves `config/wbxTokens.json` across restarts so the
refresh cron doesn't lose state. The `-p 8080:8080` publishes the WebSocket
port so the remote SIW agent can connect back to the bot.
## Remote agent (SIW + Google)
Some services can only be reached from inside the corporate network:
- **Store Info Web** — only accepts connections from internal IPs, and
presents an internal-CA TLS cert Node's default trust store doesn't know
about.
- **Google Maps / Address Validation** — the API key is IP-restricted, so
requests direct from the cloud bot IP get `API_KEY_IP_ADDRESS_BLOCKED
(403)`.
To bridge the gap, the bot hosts a WebSocket server; a small on-prem agent
dials in and proxies HTTP requests back and forth. Both SIW and Google
calls go over this bridge so they originate from the on-prem IP.
The agent itself is intentionally generic — it just proxies whatever
`{method, url, headers, auth, body, insecure?}` payload arrives — and
lives in [`docker/remote-agent/`](docker/remote-agent) as a self-contained
Docker bundle you can build here and ship to the on-prem host. The
`insecure: true` flag is scoped per request. Both SIW and Google set it,
because the corporate network the agent lives on runs SSL-inspecting
proxies that intercept outbound HTTPS with an internal-CA chain — without
the flag Node throws `SELF_SIGNED_CERT_IN_CHAIN` even for Google's public
certs. Trust is delegated to that proxy by policy, so the scoped bypass is
consistent across all proxied traffic.
Once the agent is connected, the bot logs `Remote agent connected` and
`/stageStore` and `/finalizeStore` will succeed. If the agent is not
connected, those commands fail immediately with a preflight message
rather than silently timing out mid-flow.
### Deploying the agent
The `docker/remote-agent/` folder produces a fully offline-installable ZIP
(image tarball + `install.sh` + `docker-compose.yml`). The workflow is the
same as netanalyzer's bundle — same layout, same operator playbook — and
the two bundles use distinct image tags and container names
(`wbxprov-remote-agent` vs `sha-remote-agent`) so a single on-prem host
can run both agents side by side.
Build the bundle (on your workstation, or in CI):
```bash
# Default: linux/amd64 (typical Rocky/RHEL/Ubuntu server)
npm run agent:package
# Or explicitly, with a different target arch:
./docker/remote-agent/package.sh --platform linux/arm64
```
That produces `docker/remote-agent/dist/wbxprov-remote-agent-<version>.zip`.
Transfer it to the on-prem host, then:
```bash
unzip wbxprov-remote-agent-<version>.zip
cd wbxprov-remote-agent-<version>
./install.sh # first run: seeds .env and stops
vi .env # set WS_URL + WS_TOKEN
./install.sh # second run: starts the container
docker compose logs -f # tail the agent's connection status
```
Set `WS_URL` to the bot's public WebSocket endpoint (e.g.
`wss://your-wbxstoreprovision-host:8080`) and `WS_TOKEN` to the same value
you configured for `WS_TOKEN` in the bot's `.env`. See
[`docker/remote-agent/README.md`](docker/remote-agent/README.md) for build
details and [`docker/remote-agent/deploy/README.md`](docker/remote-agent/deploy/README.md)
for the full operator guide (upgrades, troubleshooting, coexistence with
`sha-remote-agent`).
## Bot commands
Registered in [src/commands](src/commands):
- `/stageStore <storeNumber>` — creates the Webex location, enables calling,
configures the schedule, greeting, music-on-hold, voicemail, voice portal,
and attaches the Webex Calling license to the store user. Everything
except the phone number, which has to be purchased separately in Control Hub.
- `/finalizeStore <storeNumber>` — cut-over for a previously staged store:
verifies the store's phone number is already assigned to the location
(it must be manually ordered in Control Hub first — this step never
triggers a new PSTN order), sets caller ID, creates the `Store XXXX`
DECT network (DBS-210) with the per-store default access code, creates
the auto-attendant, and finalizes user licensing. Preflights that the
store was actually staged first, and is idempotent on the DECT network
(skips if one already exists). On phone-number mismatches (pending
order, wrong location, or number missing from the org) finalize aborts
early with a specific Control-Hub fix-it instruction.
- `/provisionDect <storeNumber>` — manage DECT phones for a store: card
for adding basestations by MAC and adding/removing handsets. Every
removal goes through an explicit confirmation card. New stores have
their DECT network created by `/finalizeStore`; this command still
offers a recovery "create network" prompt for legacy stores or if
finalize's DECT step was skipped. All Webex API — no remote agent
required.
- `/provisionPhone <storeNumber>` — manage wired desk phones (Cisco 7841
/ 7821) owned by the store user (resolved via `ae<5-digit>@ae.com`).
The card shows the store user's current wired phones (DECT handsets
are filtered out — those live in `/provisionDect`), with an add form
(MAC + model) and a checkbox list for removal. Adds are idempotent
(MACs already registered are skipped), and every removal goes through
an explicit confirmation card. All Webex API — no remote agent
required.
- `/storeinfo <storeNumber>` — show current Webex info for the store user
(`ae<5-digit>@ae.com`).
- `/userinfo <email>` — show current Webex info for any user by email.
- `/help` — bot's own help output.
Typical provisioning workflow: `/stageStore 499` → purchase a phone number
in Control Hub → `/finalizeStore 499`.
Card confirmations post `attachmentAction` events, dispatched in
[src/commands/attachmentActions.js](src/commands/attachmentActions.js).
## Architecture
```
src/
index.js bot bootstrap, cron, command wiring (~70 lines)
config.js dotenv loading + validation
constants.js org-scoped IDs (route groups, licenses, greetings, ...)
logger.js small level-aware logger
http.js fetch wrapper (rate limit + optional SIW TLS agent)
webex/ all Webex API calls, one module per resource family
integrations/ SIW / Twilio / Google
cards/ Adaptive Card builders
flows/ multi-step provisioning (stage, finalize)
commands/ framework.hears handlers + attachmentAction dispatch
scripts/ one-off maintenance scripts (911 CSV, phone fix-up)
greetings/ WAV files uploaded as location announcements
config/wbxTokens.json persistent service-account token cache (git-ignored)
```
Data flow for a store provisioning:
```mermaid
flowchart LR
User[Webex user] -->|/stageStore 1234| Commands[commands/*]
Commands --> SIW[integrations/siw.js]
Commands --> Users[webex/users.js]
Commands --> Card[cards/storeConfirmationCard.js]
Card -->|confirm| Attachment[attachmentActions.js]
Attachment --> Flow[flows/stageStore.js]
Flow --> Locations[webex/locations.js]
Flow --> Devices[webex/devices.js]
Flow --> Announce[webex/announcements.js]
Flow --> License[webex/licensing.js]
subgraph background
Cron[node-cron every 60s] --> Auth[webex/auth.js]
Auth --> Tokens[(config/wbxTokens.json)]
end
```
## Security notes
- `.env` and `config/wbxTokens.json` are git-ignored. Never commit them.
- `NODE_TLS_REJECT_UNAUTHORIZED=0` is no longer set globally. If SIW's TLS
cert cannot be verified from your host, set `ALLOW_INSECURE_SIW_TLS=true`;
the insecure dispatcher is then scoped to SIW requests only.
- Concurrent Webex token refreshes are collapsed into a single in-flight
refresh in [src/webex/auth.js](src/webex/auth.js).
### One-time secret rotation
The pre-refactor `config.json` and `getMeeting.js` stored real secrets in
plain text on disk. Regardless of git history, treat the following as
**exposed** and rotate them at their source:
- Webex bot token (`WEBEX_BOT_TOKEN`)
- Webex integration client secret + service-account access / refresh tokens
(`WEBEX_SVC_CLIENT_SECRET`, plus everything in `config/wbxTokens.json`)
- Twilio auth token (`TWILIO_AUTH_TOKEN`)
- SIW basic-auth password (`SIW_PASSWORD`)
- Google API key (`GOOGLE_API_KEY`) and any service-account private key that
was previously in `config.json`
After rotating, populate the new values in `.env` and seed a fresh
`config/wbxTokens.json` with the new access/refresh token pair.