Files
api/services/nginx/app/modules/bird/bird.md
T
Jeppe B a442e70744 Add secure Bird gateway for Pleno Control Plane (#332)
Add the Bird Control Plane gateway, signed webhook ingestion, policy-gated writes, fail-closed production auto-activation, and RSA-OAEP bootstrap credential flow.
2026-07-29 19:59:20 +02:00

284 lines
13 KiB
Markdown

# Bird Module
This module provides Bird API integration for voice calls and number management.
## Control Plane integration
The Bird module is the credential and public-webhook authority for Pleno
Control Plane. Control Plane must never receive the Bird access key. The
integration is disabled by default and has three independently protected
surfaces:
- `GET /bird/health` is an end-user-permission-protected, read-only replacement
for the outbound-call connection test.
- `/bird/control-plane/v1/*` accepts only the configured
`control_plane_token` bearer token and exposes explicitly listed read
operations. It is not a generic Bird proxy.
- `POST /bird/webhooks/notifications` accepts only Bird notifications signed
against the exact configured public HTTPS URL. It checks the replay window
and durably deduplicates both Bird request ID and signature before returning
success.
- `POST /bird/flows/evaluate` accepts a timestamp-bound HMAC in
`x-pleno-flow-timestamp` and `x-pleno-flow-signature`. It returns only
deterministic `tag`, `assign`, `snooze`, or `close` decisions from a valid
versioned policy.
Required configuration is:
- `workspaceId`: canonical workspace identifier. While migrating, an empty
value falls back to legacy `workplaceId`.
- `allowed_channel_ids_json`: canonical JSON array of explicitly approved
channel IDs. Its empty default falls back to legacy `channelId`; malformed
or non-empty invalid configuration fails closed.
- `control_plane_enabled=false` and secret `control_plane_token`.
- secret `webhook_signing_key`, exact `webhook_public_url`, and
`webhook_replay_window_seconds=300`.
- `flow_enabled=false`, secret `flow_shared_secret`, and a valid
`flow_policy_json` document.
- `operations_actions_enabled=false` for confirmed typed voice actions.
- `outbound_messages_enabled=false`, `participantId`, and an immutable
`template_policy_json` allowlist for confirmed conversation replies.
The gateway intentionally does not expose conversation creation, number
deletion, physical gate actions, arbitrary recipients, attachments, or a
generic proxy. Outbound references are durably reserved before a provider
request; an ambiguous outcome must be inspected through
`/bird/control-plane/v1/messages/by-reference` and is never blindly retried.
Credentials and write switches can only be changed by the guarded CLI startup
path or its explicit break-glass command, never by a web request.
### Schema deployment and preflight
The durable webhook and outbound-action ledgers use checked-in schema version
`1`. Schema changes are never run from a web request or worker. Production
startup applies and verifies the schema automatically; these commands remain
available for manual preflight and break-glass recovery:
```bash
php scripts/bird-control-plane-schema.php check
php scripts/bird-control-plane-schema.php apply --yes
php scripts/bird-control-plane-schema.php check
```
`apply` is CLI-only and requires the explicit `--yes` guard. The status endpoint
publishes read-only `schema: {ready, version, expectedVersion, missing}` state.
Ledger-dependent reads, webhook ingestion, messages, and operational actions
fail closed with HTTP `503` and code `bird_schema_not_ready` until the preflight
is ready. Production auto-activation applies and verifies the schema before
enabling any Bird write switch; the manual command remains a diagnostic and
break-glass path.
### Fail-closed production auto-activation
The Coolify production container runs activation before PHP-FPM or nginx. It
first transactionally disables bootstrap readiness and all three write
switches. It then canonicalizes the existing legacy workspace/channel
configuration, applies and checks schema version `1`, validates configured
channels and conversations, resolves the access-key participant, pins the
public webhook URL to
`https://api.truckwash.io:4433/bird/webhooks/notifications`, and reconciles and
verifies webhooks while writes remain dark. Only then does one transaction
enable bootstrap readiness, the Control Plane, outbound messaging, and
operational-action switches, followed by final readiness checks.
It preserves existing valid credentials; otherwise it generates distinct
48-byte random Control Plane and webhook secrets inside the container. The
webhook key remains backend-only.
The Control Plane token is sealed with the committed RSA-3072 public key using
RSA-OAEP-SHA256 with MGF1-SHA256. OpenSSL receives plaintext only over child
stdin, never argv. Only the ciphertext, `RSA-OAEP-256` algorithm identifier,
SPKI SHA-256 fingerprint
`6dc63c6ffe33b8de0b1396d7f529f56aea0a685ef98168016161cf721ddc8c21`,
monotonic token version, and UTC update timestamp are bootstrap-visible.
Existing ciphertext is retained only when its internal token hash and all
metadata remain valid, so normal deployments do not rotate a working token.
Webhook subscription reconciliation runs before service readiness. The
reconciler first requires Bird's `available-webhooks` response to advertise the
`conversations` service, both `conversation.created` and
`conversation.updated`, and `channelId` filtering for each. It prefers the
documented organization/workspace list after deriving a single UUID-like
organization ID from workspace-consistent channel/conversation metadata.
When no organization ID is discoverable, the read-only preflight may probe the
existing workspace-scoped subscription list endpoint. That fallback is
accepted only when it returns an explicit `results`, `items`, or `data`
collection with bounded pagination. Any unsupported response, ambiguous page,
or provider error becomes `organization_id_required` before a POST or PATCH.
The reconciler creates one exact subscription for each event and allowlisted
channel, or patches only an existing subscription with the exact Pleno URL,
event, and sole channel filter. It never deletes or mutates unrelated
subscriptions.
Any schema, provider, encryption, subscription, or final readiness failure
transactionally restores bootstrap readiness and all three write switches to
false, then exits container startup before PHP-FPM/nginx starts. Flow and
template automation remain disabled unless their existing versioned policies
are explicit, non-empty, and structurally valid; Flow also requires an existing
strong shared secret.
### Local Control Plane credential bootstrap
The unauthenticated `GET /bird/control-plane/v1/bootstrap` response is
`Cache-Control: no-store` and contains only the fixed validated ciphertext
envelope. Unavailable state always returns the same `404 Not found` response.
The endpoint never returns plaintext, hashes, provider credentials, or the
backend-only webhook key.
On the Control Plane host, run:
```bash
scripts/bird-control-plane-bootstrap-local.sh
```
The script is pinned to `https://api.truckwash.io:4433` and accepts no URL
override. It validates the exact algorithm, public-key fingerprint, fixed
ciphertext shape, version, and timestamp, decrypts using the local `0600`
private key, and writes only a temporary `0600` candidate. It immediately proves
the bearer against the authenticated status endpoint and atomically retains it
as `/home/jeppe/.openclaw/credentials/bird.gateway-token` only after success.
## Endpoints
### Voice calls
- `POST /bird/voice/calls`
- Permission: `modules_bird_voice_calls_create`
- Required: `workspaceId`, `channelId`
- Body: passthrough to Bird call create API (except `workspaceId` and `channelId`).
- `GET /bird/voice/calls`
- Permission: `modules_bird_voice_calls_list`
- Required query: `workspaceId`, `channelId`
- Query params are passed through to Bird.
- `GET /bird/voice/calls/{id}`
- Permission: `modules_bird_voice_calls_get`
- Required query: `workspaceId`, `channelId`
- `POST /bird/voice/calls/{id}/hangup`
- Permission: `modules_bird_voice_calls_hangup`
- Required: `workspaceId`, `channelId`
- `POST /bird/voice/calls/test-outbound`
- Permission: `modules_bird_voice_calls_test_outbound`
- Required: `workspaceId`, `channelId`
- Places an outbound call to the fixed test number `+45 42 33 11 28` (`+4542331128`), polls call state, and sends hangup when state is `accepted` or `ongoing`.
- Optional tuning params:
- `pollIntervalSeconds` (default `2`)
- `maxPollSeconds` (default `30`)
- `hangupCause`
- `POST /bird/voice/calls/webhook/inbound`
- Permission: `modules_bird_voice_call_webhooks_trigger`
- Stateful inbound IVR webhook for phone-controlled department gates.
- On the initial request, the webhook accepts the live inbound call through Bird before returning the first gather command.
- Supports both request formats:
- Native-flow mode: top-level `{ callId, channelId, workspaceId }` for the initial fetch, then `{ callId, channelId, workspaceId, keys }` for selections.
- Compatibility mode: `{ payload, request, waitConditions }`, which returns a raw Bird `callCommand` gather envelope.
- Uses `department_gates` rows with `config.type=PHONE_CALL` as the source of truth for which departments and entrance/exit gates are offered.
- Department menu order follows `departments.order_priority`.
- Supports multi-digit department selections such as `10#`.
- Always prompts for both department selection and gate selection, even when only one valid option exists.
- Uses compact gate numbering:
- both gates available: `1=entrance`, `2=exit`
- entrance only: `1=entrance`
- exit only: `1=exit`
### Bird Flow Builder setup
- Preferred native-flow setup:
- `Voice` trigger
- `Answer call`
- `Async HTTP request` (initial IVR state fetch)
- `Gather digits from a call`
- `Async HTTP request` (submit selected keys)
- If the second HTTP response returns `completed=false`, run another `Gather digits from a call` with the returned prompt and gather settings.
- If the second HTTP response returns `completed=true`, end the flow or optionally say the returned `message`.
- The caller must be connected in Bird before the HTTP webhook step runs. If the flow omits `Answer call`, the inbound call can keep ringing until Bird times it out.
- Initial HTTP step:
- URL: `https://api.truckwash.io:4433/bird/voice/calls/webhook/inbound`
- Content type: `application/json`
- Body:
```json
{
"callId": "{{callId}}",
"channelId": "{{channelId}}",
"workspaceId": "{{workspaceId}}"
}
```
- Example native-flow response:
```json
{
"requestId": "request-123",
"callId": "4015cf84-8028-46a1-a0d9-9213e5bf4f09",
"status": "gather",
"completed": false,
"stage": "department_select",
"prompt": "Choose department. Press 1 for Roskilde. Press 2 for Demo.",
"gather": {
"input": "dtmf",
"maxNumKeys": 1,
"endKey": "#",
"timeout": 30,
"retries": 3,
"say": {
"locale": "en-US",
"voice": "female",
"text": "Choose department. Press 1 for Roskilde. Press 2 for Demo."
}
}
}
```
- Use the initial HTTP step output to configure `Gather digits from a call`:
- Prompt/text from `prompt` or `gather.say.text`
- `maxNumKeys`, `endKey`, `timeout`, and `retries` from `gather`
- TTS `locale` and `voice` from `gather.say`
- Selection HTTP step:
- POST the same `callId`, `channelId`, and `workspaceId` plus the gathered digits as `keys`.
- Example body:
```json
{
"callId": "{{callId}}",
"channelId": "{{channelId}}",
"workspaceId": "{{workspaceId}}",
"keys": "{{gatheredKeys}}"
}
```
- Bird's current Voice API examples use BCP-47 locales such as `en-US` and TTS voices such as `female`/`male`. Avoid undocumented values like `alice`.
- Compatibility mode:
- The webhook still supports the older `{ payload, request, waitConditions }` contract and returns a raw `202 Accepted` Bird `callCommand` gather envelope.
- That mode is not suitable for Bird Flow Builder `Async HTTP request`, because Bird treats the response as data rather than executing the returned call command.
### Numbers
- `GET /bird/numbers`
- Permission: `modules_bird_numbers_list`
- Optional query: `workspaceId` (if not configured)
- Query params are passed through to Bird.
- `GET /bird/numbers/{id}`
- Permission: `modules_bird_numbers_get`
- Optional query: `workspaceId` (if not configured)
- `DELETE /bird/numbers/{id}`
- Permission: `modules_bird_numbers_delete`
- Optional query/body: `workspaceId` (if not configured)
- Releases/deletes a number if supported by Bird account policy.
## Logging
Bird requests are logged through `logs_o` with module `bird`, including:
- Request start (`BIRD_HTTP_REQUEST`)
- Response status (`BIRD_HTTP_RESPONSE`)
- API or transport errors (`BIRD_HTTP_ERROR`, `BIRD_HTTP_CURL_ERROR`)
- Test outbound flow events (`BIRD_TEST_OUTBOUND_CALL_*`)
## Notes
- Bird credentials and base URL are configured in Bird module config.
- Route handlers enforce workspace/channel requirements before outbound API calls.