From a442e70744af67f094dc4e69d0f5354091c6ebdc Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Wed, 29 Jul 2026 19:59:20 +0200 Subject: [PATCH] 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. --- Dockerfile.coolify-api | 5 + scripts/bird-control-plane-activate.php | 58 + scripts/bird-control-plane-auto-activate.php | 28 + scripts/bird-control-plane-bootstrap-local.sh | 79 ++ scripts/bird-control-plane-schema.php | 32 + scripts/php-ci-test.sh | 3 + services/coolify/api/start.sh | 3 + services/nginx/app/classes/bird.php | 13 - services/nginx/app/modules/bird/bird.md | 136 +++ services/nginx/app/modules/bird/bird_c.php | 76 ++ .../classes/bird_control_plane_activator.php | 586 ++++++++++ .../bird_control_plane_auto_activation.php | 348 ++++++ .../bird_control_plane_bootstrap_sealer.php | 101 ++ .../classes/bird_control_plane_contract.php | 253 +++++ .../bird_control_plane_schema_bootstrap.php | 131 +++ .../classes/bird_control_plane_security.php | 112 ++ .../classes/bird_flow_policy_evaluator.php | 107 ++ .../classes/bird_outbound_message_store.php | 111 ++ .../bird/classes/bird_webhook_event_store.php | 128 +++ .../bird_webhook_subscription_reconciler.php | 551 +++++++++ .../bird_allowed_channel_ids_json_c.php | 29 + .../config/bird_control_plane_enabled_c.php | 29 + .../config/bird_control_plane_token_c.php | 29 + .../bird/config/bird_flow_enabled_c.php | 29 + .../bird/config/bird_flow_policy_json_c.php | 29 + .../bird/config/bird_flow_shared_secret_c.php | 29 + .../bird_operations_actions_enabled_c.php | 29 + .../bird_outbound_messages_enabled_c.php | 29 + .../bird/config/bird_participantId_c.php | 29 + .../config/bird_template_policy_json_c.php | 29 + .../bird/config/bird_webhook_public_url_c.php | 29 + .../bird_webhook_replay_window_seconds_c.php | 29 + .../config/bird_webhook_signing_key_c.php | 29 + .../bird/config/bird_workspaceId_c.php | 31 + .../control-plane-bootstrap-public.pem | 11 + .../app/routes/birdControlPlaneRoute.php | 1002 +++++++++++++++++ .../Bird/BirdConfigSecretRedactionTest.php | 90 ++ .../Bird/BirdControlPlaneActivationTest.php | 424 +++++++ .../Bird/BirdControlPlaneContractTest.php | 183 +++ .../Bird/BirdControlPlaneRouteWiringTest.php | 71 ++ .../Bird/BirdControlPlaneSecurityTest.php | 106 ++ .../Unit/Bird/BirdFlowPolicyEvaluatorTest.php | 58 + .../nginx/app/traits/bird_route_helpers_t.php | 37 +- services/nginx/app/traits/module_config_t.php | 31 +- 44 files changed, 5263 insertions(+), 19 deletions(-) create mode 100644 scripts/bird-control-plane-activate.php create mode 100644 scripts/bird-control-plane-auto-activate.php create mode 100644 scripts/bird-control-plane-bootstrap-local.sh create mode 100755 scripts/bird-control-plane-schema.php create mode 100644 services/nginx/app/modules/bird/classes/bird_control_plane_activator.php create mode 100644 services/nginx/app/modules/bird/classes/bird_control_plane_auto_activation.php create mode 100644 services/nginx/app/modules/bird/classes/bird_control_plane_bootstrap_sealer.php create mode 100644 services/nginx/app/modules/bird/classes/bird_control_plane_contract.php create mode 100644 services/nginx/app/modules/bird/classes/bird_control_plane_schema_bootstrap.php create mode 100644 services/nginx/app/modules/bird/classes/bird_control_plane_security.php create mode 100644 services/nginx/app/modules/bird/classes/bird_flow_policy_evaluator.php create mode 100644 services/nginx/app/modules/bird/classes/bird_outbound_message_store.php create mode 100644 services/nginx/app/modules/bird/classes/bird_webhook_event_store.php create mode 100644 services/nginx/app/modules/bird/classes/bird_webhook_subscription_reconciler.php create mode 100644 services/nginx/app/modules/bird/config/bird_allowed_channel_ids_json_c.php create mode 100644 services/nginx/app/modules/bird/config/bird_control_plane_enabled_c.php create mode 100644 services/nginx/app/modules/bird/config/bird_control_plane_token_c.php create mode 100644 services/nginx/app/modules/bird/config/bird_flow_enabled_c.php create mode 100644 services/nginx/app/modules/bird/config/bird_flow_policy_json_c.php create mode 100644 services/nginx/app/modules/bird/config/bird_flow_shared_secret_c.php create mode 100644 services/nginx/app/modules/bird/config/bird_operations_actions_enabled_c.php create mode 100644 services/nginx/app/modules/bird/config/bird_outbound_messages_enabled_c.php create mode 100644 services/nginx/app/modules/bird/config/bird_participantId_c.php create mode 100644 services/nginx/app/modules/bird/config/bird_template_policy_json_c.php create mode 100644 services/nginx/app/modules/bird/config/bird_webhook_public_url_c.php create mode 100644 services/nginx/app/modules/bird/config/bird_webhook_replay_window_seconds_c.php create mode 100644 services/nginx/app/modules/bird/config/bird_webhook_signing_key_c.php create mode 100644 services/nginx/app/modules/bird/config/bird_workspaceId_c.php create mode 100644 services/nginx/app/modules/bird/resources/control-plane-bootstrap-public.pem create mode 100644 services/nginx/app/routes/birdControlPlaneRoute.php create mode 100644 services/nginx/app/tests/Unit/Bird/BirdConfigSecretRedactionTest.php create mode 100644 services/nginx/app/tests/Unit/Bird/BirdControlPlaneActivationTest.php create mode 100644 services/nginx/app/tests/Unit/Bird/BirdControlPlaneContractTest.php create mode 100644 services/nginx/app/tests/Unit/Bird/BirdControlPlaneRouteWiringTest.php create mode 100644 services/nginx/app/tests/Unit/Bird/BirdControlPlaneSecurityTest.php create mode 100644 services/nginx/app/tests/Unit/Bird/BirdFlowPolicyEvaluatorTest.php diff --git a/Dockerfile.coolify-api b/Dockerfile.coolify-api index a7a2d85d..4fcc59b0 100644 --- a/Dockerfile.coolify-api +++ b/Dockerfile.coolify-api @@ -24,6 +24,7 @@ RUN set -eux; \ libzip-dev \ mariadb-client \ nginx \ + openssl \ pkg-config \ redis-tools \ unzip \ @@ -46,6 +47,8 @@ RUN set -eux; \ rm -rf /var/lib/apt/lists/* COPY services/nginx/app/ /var/www/html/ +COPY scripts/bird-control-plane-activate.php /var/www/html/scripts/bird-control-plane-activate.php +COPY scripts/bird-control-plane-auto-activate.php /var/www/html/scripts/bird-control-plane-auto-activate.php COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf @@ -61,6 +64,8 @@ RUN set -eux; \ fi; \ COMPOSER_ALLOW_SUPERUSER=1 composer dump-autoload --no-dev --optimize --no-interaction -d /var/www/html; \ php -d display_errors=1 -r 'require "/var/www/html/vendor/autoload.php"; exit(interface_exists("Psr\\Http\\Message\\UriInterface") && interface_exists("Psr\\Http\\Message\\StreamInterface") ? 0 : 1);'; \ + php -r 'exit(function_exists("proc_open") && extension_loaded("openssl") ? 0 : 1);'; \ + test "$(openssl pkey -pubin -in /var/www/html/modules/bird/resources/control-plane-bootstrap-public.pem -outform DER | sha256sum | cut -d " " -f 1)" = "6dc63c6ffe33b8de0b1396d7f529f56aea0a685ef98168016161cf721ddc8c21"; \ chown -R www-data:www-data /var/www/html; \ chmod -R 755 /var/www/html diff --git a/scripts/bird-control-plane-activate.php b/scripts/bird-control-plane-activate.php new file mode 100644 index 00000000..0046f521 --- /dev/null +++ b/scripts/bird-control-plane-activate.php @@ -0,0 +1,58 @@ +#!/usr/bin/env php +apply($organizationId) + : $reconciler->check($organizationId); + } else { + $activator = new \bird\classes\bird_control_plane_activator($pdo); + $status = $command === 'apply' ? $activator->apply([ + 'controlPlaneToken' => trim((string)(getenv('BIRD_CONTROL_PLANE_TOKEN') ?: '')), + 'webhookSigningKey' => trim((string)(getenv('BIRD_WEBHOOK_SIGNING_KEY') ?: '')), + 'participantId' => trim((string)(getenv('BIRD_PARTICIPANT_ID') ?: '')), + ]) : $activator->check(); + } + fwrite(STDOUT, json_encode($status, JSON_UNESCAPED_SLASHES) . PHP_EOL); + exit(($status['ready'] ?? false) === true ? 0 : 1); +} catch (Throwable $throwable) { + error_log('[bird-control-plane-activate] Failed: ' . get_class($throwable)); + fwrite(STDOUT, json_encode([ + 'ready' => false, + 'errorCode' => 'bird_activation_failed', + ], JSON_UNESCAPED_SLASHES) . PHP_EOL); + exit(1); +} diff --git a/scripts/bird-control-plane-auto-activate.php b/scripts/bird-control-plane-auto-activate.php new file mode 100644 index 00000000..491a146f --- /dev/null +++ b/scripts/bird-control-plane-auto-activate.php @@ -0,0 +1,28 @@ +#!/usr/bin/env php +run(); + fwrite(STDOUT, json_encode($status, JSON_UNESCAPED_SLASHES) . PHP_EOL); + exit(($status['ready'] ?? false) === true ? 0 : 1); +} catch (Throwable $throwable) { + error_log('[bird-control-plane-auto-activate] Failed: ' . get_class($throwable)); + fwrite(STDOUT, '{"ready":false,"errorCode":"bird_auto_activation_failed"}' . PHP_EOL); + exit(1); +} diff --git a/scripts/bird-control-plane-bootstrap-local.sh b/scripts/bird-control-plane-bootstrap-local.sh new file mode 100644 index 00000000..51c57d0c --- /dev/null +++ b/scripts/bird-control-plane-bootstrap-local.sh @@ -0,0 +1,79 @@ +#!/bin/sh +set -eu + +bootstrap_url='https://api.truckwash.io:4433/bird/control-plane/v1/bootstrap' +status_url='https://api.truckwash.io:4433/bird/control-plane/v1/status' +expected_algorithm='RSA-OAEP-256' +expected_fingerprint='6dc63c6ffe33b8de0b1396d7f529f56aea0a685ef98168016161cf721ddc8c21' +private_key='/home/jeppe/.openclaw/credentials/bird.bootstrap-private.pem' +credential_dir='/home/jeppe/.openclaw/credentials' +destination="$credential_dir/bird.gateway-token" + +umask 077 +mkdir -p "$credential_dir" +envelope_file="$(mktemp "$credential_dir/.bird-bootstrap-envelope.XXXXXX")" +candidate_file="$(mktemp "$credential_dir/.bird-gateway-token.XXXXXX")" +payload_file="$(mktemp "$credential_dir/.bird-bootstrap-payload.XXXXXX")" +status_file="$(mktemp "$credential_dir/.bird-bootstrap-status.XXXXXX")" +cleanup() { + rm -f "$envelope_file" "$candidate_file" "$payload_file" "$status_file" +} +trap cleanup EXIT HUP INT TERM + +test -r "$private_key" +test "$(stat -c '%a' "$private_key")" = '600' + +curl --proto '=https' --tlsv1.2 --fail --silent --show-error \ + --max-time 30 "$bootstrap_url" > "$envelope_file" + +test "$(jq -r '.success // false' "$envelope_file")" = 'true' +test "$(jq -r '.data.algorithm // empty' "$envelope_file")" = "$expected_algorithm" +test "$(jq -r '.data.keyFingerprint // empty' "$envelope_file")" = "$expected_fingerprint" +jq -e '.data | keys == ["algorithm","ciphertext","keyFingerprint","tokenVersion","updatedAt"]' \ + "$envelope_file" >/dev/null +jq -e '.data.tokenVersion | type == "number" and . >= 1 and floor == .' \ + "$envelope_file" >/dev/null +jq -e '.data.updatedAt | type == "string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")' \ + "$envelope_file" >/dev/null +jq -e '.data.ciphertext | type == "string" and length == 512 and test("^[A-Za-z0-9+/]{512}$")' \ + "$envelope_file" >/dev/null + +jq -r '.data.ciphertext' "$envelope_file" \ + | base64 -d \ + | openssl pkeyutl -decrypt -inkey "$private_key" \ + -pkeyopt rsa_padding_mode:oaep \ + -pkeyopt rsa_oaep_md:sha256 \ + -pkeyopt rsa_mgf1_md:sha256 > "$payload_file" + +jq -e '. | keys == ["algorithm","keyFingerprint","token","tokenVersion","updatedAt"]' \ + "$payload_file" >/dev/null +test "$(jq -r '.algorithm // empty' "$payload_file")" = "$expected_algorithm" +test "$(jq -r '.keyFingerprint // empty' "$payload_file")" = "$expected_fingerprint" +test "$(jq -r '.tokenVersion // empty' "$payload_file")" = \ + "$(jq -r '.data.tokenVersion' "$envelope_file")" +test "$(jq -r '.updatedAt // empty' "$payload_file")" = \ + "$(jq -r '.data.updatedAt' "$envelope_file")" +jq -j '.token' "$payload_file" > "$candidate_file" + +test "$(wc -c < "$candidate_file")" = '64' +grep -Eq '^[A-Za-z0-9_-]{64}$' "$candidate_file" +chmod 600 "$candidate_file" + +token="$(cat "$candidate_file")" +{ + printf 'url = "%s"\n' "$status_url" + printf 'proto = "=https"\n' + printf 'tlsv1.2\n' + printf 'fail\nsilent\nshow-error\n' + printf 'max-time = 30\n' + printf 'header = "Authorization: Bearer %s"\n' "$token" +} | curl --config - > "$status_file" +unset token + +jq -e '.success == true and .data.enabled == true and .data.webhookConfigured == true' \ + "$status_file" >/dev/null +mv -f "$candidate_file" "$destination" +chmod 600 "$destination" +trap - EXIT HUP INT TERM +rm -f "$envelope_file" "$payload_file" "$status_file" +printf 'Bird gateway credential bootstrapped and authenticated.\n' diff --git a/scripts/bird-control-plane-schema.php b/scripts/bird-control-plane-schema.php new file mode 100755 index 00000000..568d8552 --- /dev/null +++ b/scripts/bird-control-plane-schema.php @@ -0,0 +1,32 @@ +#!/usr/bin/env php +send_message($slack_debug_message); return [ 'status_code' => (int)$code, 'body' => $resp, @@ -1073,4 +1061,3 @@ class bird implements bird_i } } - diff --git a/services/nginx/app/modules/bird/bird.md b/services/nginx/app/modules/bird/bird.md index e316f87a..22057fe0 100644 --- a/services/nginx/app/modules/bird/bird.md +++ b/services/nginx/app/modules/bird/bird.md @@ -2,6 +2,142 @@ 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 diff --git a/services/nginx/app/modules/bird/bird_c.php b/services/nginx/app/modules/bird/bird_c.php index 7f8d5bda..8f23d31c 100644 --- a/services/nginx/app/modules/bird/bird_c.php +++ b/services/nginx/app/modules/bird/bird_c.php @@ -7,13 +7,41 @@ require_once WD . '/modules/bird/config/bird_enabled_c.php'; require_once WD . '/modules/bird/config/bird_api_key_c.php'; require_once WD . '/modules/bird/config/bird_server_url_c.php'; require_once WD . '/modules/bird/config/bird_workplaceId_c.php'; +require_once WD . '/modules/bird/config/bird_workspaceId_c.php'; require_once WD . '/modules/bird/config/bird_channelId_c.php'; +require_once WD . '/modules/bird/config/bird_allowed_channel_ids_json_c.php'; +require_once WD . '/modules/bird/config/bird_control_plane_enabled_c.php'; +require_once WD . '/modules/bird/config/bird_control_plane_token_c.php'; +require_once WD . '/modules/bird/config/bird_webhook_signing_key_c.php'; +require_once WD . '/modules/bird/config/bird_webhook_public_url_c.php'; +require_once WD . '/modules/bird/config/bird_webhook_replay_window_seconds_c.php'; +require_once WD . '/modules/bird/config/bird_flow_enabled_c.php'; +require_once WD . '/modules/bird/config/bird_flow_shared_secret_c.php'; +require_once WD . '/modules/bird/config/bird_flow_policy_json_c.php'; +require_once WD . '/modules/bird/config/bird_operations_actions_enabled_c.php'; +require_once WD . '/modules/bird/config/bird_outbound_messages_enabled_c.php'; +require_once WD . '/modules/bird/config/bird_template_policy_json_c.php'; +require_once WD . '/modules/bird/config/bird_participantId_c.php'; use bird\config\bird_enabled_c; use bird\config\bird_api_key_c; use bird\config\bird_server_url_c; use bird\config\bird_workplaceId_c; +use bird\config\bird_workspaceId_c; use bird\config\bird_channelId_c; +use bird\config\bird_allowed_channel_ids_json_c; +use bird\config\bird_control_plane_enabled_c; +use bird\config\bird_control_plane_token_c; +use bird\config\bird_webhook_signing_key_c; +use bird\config\bird_webhook_public_url_c; +use bird\config\bird_webhook_replay_window_seconds_c; +use bird\config\bird_flow_enabled_c; +use bird\config\bird_flow_shared_secret_c; +use bird\config\bird_flow_policy_json_c; +use bird\config\bird_operations_actions_enabled_c; +use bird\config\bird_outbound_messages_enabled_c; +use bird\config\bird_template_policy_json_c; +use bird\config\bird_participantId_c; use traits\module_config_t; class bird_c @@ -44,11 +72,31 @@ class bird_c */ public bird_workplaceId_c $workplaceId; + /** + * Canonical Bird workspace identifier. + * @var bird_workspaceId_c + */ + public bird_workspaceId_c $workspaceId; + /** * Default Bird channel identifier * @var bird_channelId_c */ public bird_channelId_c $channelId; + public bird_allowed_channel_ids_json_c $allowed_channel_ids_json; + + public bird_control_plane_enabled_c $control_plane_enabled; + public bird_control_plane_token_c $control_plane_token; + public bird_webhook_signing_key_c $webhook_signing_key; + public bird_webhook_public_url_c $webhook_public_url; + public bird_webhook_replay_window_seconds_c $webhook_replay_window_seconds; + public bird_flow_enabled_c $flow_enabled; + public bird_flow_shared_secret_c $flow_shared_secret; + public bird_flow_policy_json_c $flow_policy_json; + public bird_operations_actions_enabled_c $operations_actions_enabled; + public bird_outbound_messages_enabled_c $outbound_messages_enabled; + public bird_template_policy_json_c $template_policy_json; + public bird_participantId_c $participantId; public function __construct() { @@ -58,12 +106,40 @@ class bird_c bird_api_key_c::class, bird_server_url_c::class, bird_workplaceId_c::class, + bird_workspaceId_c::class, bird_channelId_c::class, + bird_allowed_channel_ids_json_c::class, + bird_control_plane_enabled_c::class, + bird_control_plane_token_c::class, + bird_webhook_signing_key_c::class, + bird_webhook_public_url_c::class, + bird_webhook_replay_window_seconds_c::class, + bird_flow_enabled_c::class, + bird_flow_shared_secret_c::class, + bird_flow_policy_json_c::class, + bird_operations_actions_enabled_c::class, + bird_outbound_messages_enabled_c::class, + bird_template_policy_json_c::class, + bird_participantId_c::class, ]); $this->enabled = new bird_enabled_c(); $this->api_key = new bird_api_key_c(); $this->server_url = new bird_server_url_c(); $this->workplaceId = new bird_workplaceId_c(); + $this->workspaceId = new bird_workspaceId_c(); $this->channelId = new bird_channelId_c(); + $this->allowed_channel_ids_json = new bird_allowed_channel_ids_json_c(); + $this->control_plane_enabled = new bird_control_plane_enabled_c(); + $this->control_plane_token = new bird_control_plane_token_c(); + $this->webhook_signing_key = new bird_webhook_signing_key_c(); + $this->webhook_public_url = new bird_webhook_public_url_c(); + $this->webhook_replay_window_seconds = new bird_webhook_replay_window_seconds_c(); + $this->flow_enabled = new bird_flow_enabled_c(); + $this->flow_shared_secret = new bird_flow_shared_secret_c(); + $this->flow_policy_json = new bird_flow_policy_json_c(); + $this->operations_actions_enabled = new bird_operations_actions_enabled_c(); + $this->outbound_messages_enabled = new bird_outbound_messages_enabled_c(); + $this->template_policy_json = new bird_template_policy_json_c(); + $this->participantId = new bird_participantId_c(); } } diff --git a/services/nginx/app/modules/bird/classes/bird_control_plane_activator.php b/services/nginx/app/modules/bird/classes/bird_control_plane_activator.php new file mode 100644 index 00000000..0f8db833 --- /dev/null +++ b/services/nginx/app/modules/bird/classes/bird_control_plane_activator.php @@ -0,0 +1,586 @@ +,string,array):array|object|null */ + private readonly Closure $providerGet; + + /** + * @param null|callable(array,string,array):array|object|null $providerGet + */ + public function __construct( + private readonly PDO $pdo, + ?callable $providerGet = null + ) { + $this->providerGet = $providerGet !== null + ? Closure::fromCallable($providerGet) + : Closure::fromCallable([self::class, 'providerGet']); + } + + /** + * @return array + */ + public function check(): array + { + $config = $this->configuration(); + $workspaceId = self::canonicalWorkspaceId($config); + $allowedChannelIds = self::canonicalAllowedChannelIds($config); + $provider = $this->providerState($config, $workspaceId, $allowedChannelIds); + $schema = bird_control_plane_schema_bootstrap::check($this->pdo); + $participantId = trim((string)($config['participantId'] ?? '')); + $flowPolicyActive = self::policyActive( + (string)($config['flow_policy_json'] ?? ''), + 'rules' + ); + $templatePolicyActive = self::policyActive( + (string)($config['template_policy_json'] ?? ''), + 'templates' + ); + + $status = [ + 'schema' => $schema, + 'providerReadReady' => $provider['ready'], + 'providerErrorCode' => $provider['errorCode'], + 'workspaceId' => self::sanitizeId($workspaceId), + 'allowedChannelIds' => array_map(self::sanitizeId(...), $allowedChannelIds), + 'allowedChannelCount' => count($allowedChannelIds), + 'participantId' => self::sanitizeId($participantId), + 'participantConfigured' => $participantId !== '', + 'participantCandidateCount' => $provider['participantCandidateCount'], + 'moduleEnabled' => self::trueValue($config['enabled'] ?? ''), + 'providerCredentialConfigured' => trim((string)($config['api_key'] ?? '')) !== '', + 'providerUrlConfigured' => trim((string)($config['server_url'] ?? '')) !== '', + 'controlPlaneCredentialConfigured' => self::secretAcceptable( + (string)($config['control_plane_token'] ?? '') + ), + 'webhookSigningCredentialConfigured' => self::secretAcceptable( + (string)($config['webhook_signing_key'] ?? '') + ), + 'webhookPublicUrlExact' => hash_equals( + self::PUBLIC_WEBHOOK_URL, + trim((string)($config['webhook_public_url'] ?? '')) + ), + 'controlPlaneEnabled' => self::trueValue($config['control_plane_enabled'] ?? ''), + 'outboundMessagesEnabled' => self::trueValue($config['outbound_messages_enabled'] ?? ''), + 'operationsActionsEnabled' => self::trueValue($config['operations_actions_enabled'] ?? ''), + 'flowPolicyActive' => $flowPolicyActive, + 'flowEnabled' => self::trueValue($config['flow_enabled'] ?? '') + && $flowPolicyActive + && self::secretAcceptable((string)($config['flow_shared_secret'] ?? '')), + 'templatePolicyActive' => $templatePolicyActive, + ]; + $status['ready'] = $schema['ready'] + && $status['providerReadReady'] + && $status['moduleEnabled'] + && $status['providerCredentialConfigured'] + && $status['providerUrlConfigured'] + && $workspaceId !== '' + && $allowedChannelIds !== [] + && $status['participantConfigured'] + && $status['controlPlaneCredentialConfigured'] + && $status['webhookSigningCredentialConfigured'] + && $status['webhookPublicUrlExact'] + && $status['controlPlaneEnabled'] + && $status['outboundMessagesEnabled'] + && $status['operationsActionsEnabled']; + return $status; + } + + /** + * @param array{ + * controlPlaneToken:string, + * webhookSigningKey:string, + * participantId:string, + * enableCapabilities?:bool + * } $input + * @return array + */ + public function apply(array $input): array + { + if (PHP_SAPI !== 'cli') { + throw new RuntimeException('Bird Control Plane activation is CLI-only.'); + } + if (!self::secretAcceptable($input['controlPlaneToken'])) { + throw new InvalidArgumentException('BIRD_CONTROL_PLANE_TOKEN must contain at least 32 bytes.'); + } + if (!self::secretAcceptable($input['webhookSigningKey'])) { + throw new InvalidArgumentException('BIRD_WEBHOOK_SIGNING_KEY must contain at least 32 bytes.'); + } + if (hash_equals($input['controlPlaneToken'], $input['webhookSigningKey'])) { + throw new InvalidArgumentException('Bird activation secrets must be distinct.'); + } + + $config = $this->configuration(); + $workspaceId = self::canonicalWorkspaceId($config); + $allowedChannelIds = self::canonicalAllowedChannelIds($config); + $this->requireFoundationalConfiguration($config, $workspaceId, $allowedChannelIds); + + // DDL is explicit and occurs before any feature switch can be enabled. + bird_control_plane_schema_bootstrap::apply($this->pdo); + bird_control_plane_schema_bootstrap::requireReady($this->pdo); + + $provider = $this->providerState($config, $workspaceId, $allowedChannelIds); + if (!$provider['ready']) { + throw new RuntimeException('Bird provider read validation failed.'); + } + $participantId = self::selectParticipantId( + $input['participantId'], + $provider['participantIds'] + ); + + $flowPolicy = self::safePolicy( + (string)($config['flow_policy_json'] ?? ''), + 'rules' + ); + $templatePolicy = self::safePolicy( + (string)($config['template_policy_json'] ?? ''), + 'templates' + ); + $flowEnabled = self::trueValue($config['flow_enabled'] ?? '') + && self::policyActive($flowPolicy, 'rules') + && self::secretAcceptable((string)($config['flow_shared_secret'] ?? '')); + $enableCapabilities = ($input['enableCapabilities'] ?? true) === true; + + $this->pdo->beginTransaction(); + try { + $this->upsert('workspaceId', $workspaceId, 'string'); + $this->upsert( + 'allowed_channel_ids_json', + json_encode($allowedChannelIds, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + 'string' + ); + $this->upsert('participantId', $participantId, 'string'); + $this->upsert('control_plane_token', $input['controlPlaneToken'], 'string'); + $this->upsert('webhook_signing_key', $input['webhookSigningKey'], 'string'); + $this->upsert('webhook_public_url', self::PUBLIC_WEBHOOK_URL, 'string'); + $this->upsert('webhook_replay_window_seconds', '300', 'int'); + $this->upsert('flow_policy_json', $flowPolicy, 'string'); + $this->upsert('template_policy_json', $templatePolicy, 'string'); + $this->upsert('flow_enabled', $flowEnabled ? 'true' : 'false', 'bool'); + $capabilityValue = $enableCapabilities ? 'true' : 'false'; + $this->upsert('control_plane_enabled', $capabilityValue, 'bool'); + $this->upsert('outbound_messages_enabled', $capabilityValue, 'bool'); + $this->upsert('operations_actions_enabled', $capabilityValue, 'bool'); + $this->pdo->commit(); + } catch (Throwable $throwable) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $throwable; + } + + return $this->check(); + } + + /** + * @param array $config + * @param array $allowedChannelIds + * @return array{ready:bool,errorCode:?string,participantIds:array,participantCandidateCount:int} + */ + private function providerState( + array $config, + string $workspaceId, + array $allowedChannelIds + ): array { + try { + $this->requireFoundationalConfiguration($config, $workspaceId, $allowedChannelIds); + $channelsResponse = ($this->providerGet)( + $config, + '/workspaces/' . rawurlencode($workspaceId) . '/channels', + ['limit' => 100] + ); + $providerChannelIds = []; + foreach (bird_control_plane_contract::collectionItems($channelsResponse) as $channel) { + if (is_scalar($channel['id'] ?? null)) { + $providerChannelIds[] = trim((string)$channel['id']); + } + } + foreach ($allowedChannelIds as $channelId) { + if (!in_array($channelId, $providerChannelIds, true)) { + throw new RuntimeException('Allowlisted Bird channel was not returned by the provider.'); + } + } + + $participantIds = []; + foreach ($allowedChannelIds as $channelId) { + $conversations = ($this->providerGet)( + $config, + '/workspaces/' . rawurlencode($workspaceId) . '/conversations', + ['channelId' => $channelId, 'limit' => 100] + ); + foreach (bird_control_plane_contract::collectionItems($conversations) as $conversation) { + foreach (self::accessKeyParticipantIds($conversation) as $participantId) { + $participantIds[$participantId] = true; + } + } + } + $participantIds = array_keys($participantIds); + sort($participantIds); + return [ + 'ready' => true, + 'errorCode' => null, + 'participantIds' => $participantIds, + 'participantCandidateCount' => count($participantIds), + ]; + } catch (Throwable $throwable) { + return [ + 'ready' => false, + 'errorCode' => self::safeErrorCode($throwable), + 'participantIds' => [], + 'participantCandidateCount' => 0, + ]; + } + } + + /** + * @param array $config + * @param array $allowedChannelIds + */ + private function requireFoundationalConfiguration( + array $config, + string $workspaceId, + array $allowedChannelIds + ): void { + if (!self::trueValue($config['enabled'] ?? '')) { + throw new RuntimeException('Bird module must already be enabled.'); + } + if (trim((string)($config['api_key'] ?? '')) === '') { + throw new RuntimeException('Bird provider credential is missing.'); + } + self::validatedProviderBaseUrl((string)($config['server_url'] ?? '')); + if ($workspaceId === '' || $allowedChannelIds === []) { + throw new RuntimeException('Bird workspace and channel configuration are required.'); + } + } + + /** + * @return array + */ + private function configuration(): array + { + $statement = $this->pdo->prepare( + "SELECT variable, value FROM module_config WHERE module = 'bird'" + ); + $statement->execute(); + $config = []; + foreach ($statement->fetchAll() as $row) { + if (is_array($row) && is_scalar($row['variable'] ?? null)) { + $config[(string)$row['variable']] = is_scalar($row['value'] ?? null) + ? (string)$row['value'] + : ''; + } + } + return $config; + } + + private function upsert(string $variable, string $value, string $type): void + { + $statement = $this->pdo->prepare( + "INSERT INTO module_config (module, variable, value, type) + VALUES ('bird', :variable, :value, :type) + ON DUPLICATE KEY UPDATE value = VALUES(value), type = VALUES(type)" + ); + $statement->execute([ + ':variable' => $variable, + ':value' => $value, + ':type' => $type, + ]); + } + + /** + * @param array $config + * @param array $query + */ + private static function providerGet(array $config, string $endpoint, array $query): array|object|null + { + $baseUrl = self::validatedProviderBaseUrl((string)($config['server_url'] ?? '')); + $url = $baseUrl . $endpoint; + if ($query !== []) { + $url .= '?' . http_build_query($query); + } + $curl = curl_init($url); + if ($curl === false) { + throw new RuntimeException('Bird provider transport initialization failed.'); + } + curl_setopt_array($curl, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_TIMEOUT => 40, + CURLOPT_HTTPHEADER => [ + 'Accept: application/json', + 'Authorization: AccessKey ' . trim((string)($config['api_key'] ?? '')), + ], + ]); + $body = curl_exec($curl); + $status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); + $error = curl_errno($curl); + curl_close($curl); + if ($error !== 0 || !is_string($body) || $status < 200 || $status >= 300) { + throw new RuntimeException('Bird provider read failed.'); + } + if (strlen($body) > 2 * 1024 * 1024) { + throw new RuntimeException('Bird provider read exceeded the response limit.'); + } + $decoded = json_decode($body, true); + if (!is_array($decoded)) { + throw new RuntimeException('Bird provider returned invalid JSON.'); + } + return $decoded; + } + + public static function canonicalWorkspaceId(array $config): string + { + foreach (['workspaceId', 'workplaceId'] as $key) { + $value = trim((string)($config[$key] ?? '')); + if ($value !== '') { + return self::opaqueId($value, 'workspaceId'); + } + } + return ''; + } + + /** + * @return array + */ + public static function canonicalAllowedChannelIds(array $config): array + { + return bird_control_plane_contract::allowedChannelIds( + (string)($config['allowed_channel_ids_json'] ?? ''), + trim((string)($config['channelId'] ?? '')) + ); + } + + /** + * @param array $observedParticipantIds + */ + public static function selectParticipantId( + string $configuredParticipantId, + array $observedParticipantIds + ): string { + $configuredParticipantId = trim($configuredParticipantId); + if ($configuredParticipantId !== '') { + return self::opaqueId($configuredParticipantId, 'participantId'); + } + $observedParticipantIds = array_values(array_unique(array_filter(array_map( + static fn (mixed $id): string => is_scalar($id) ? trim((string)$id) : '', + $observedParticipantIds + )))); + if (count($observedParticipantIds) !== 1) { + throw new RuntimeException( + 'BIRD_PARTICIPANT_ID is required unless exactly one accessKey participant is observed.' + ); + } + return self::opaqueId($observedParticipantIds[0], 'participantId'); + } + + /** + * @return array + */ + public static function accessKeyParticipantIds(array $conversation): array + { + $participants = []; + foreach (['participants', 'featuredParticipants'] as $key) { + if (is_array($conversation[$key] ?? null)) { + $participants = array_merge($participants, $conversation[$key]); + } + } + if (is_array($conversation['lastMessage']['sender'] ?? null)) { + $participants[] = $conversation['lastMessage']['sender']; + } + $ids = []; + foreach ($participants as $participant) { + $participant = is_object($participant) ? (array)$participant : $participant; + if (!is_array($participant) + || strtolower(trim((string)($participant['type'] ?? ''))) !== 'accesskey' + || !is_scalar($participant['id'] ?? null)) { + continue; + } + $id = trim((string)$participant['id']); + if ($id !== '') { + $ids[$id] = true; + } + } + return array_keys($ids); + } + + public static function policyActive(string $raw, string $collectionKey): bool + { + $policy = json_decode($raw, true); + if (!is_array($policy) + || ($policy['version'] ?? null) !== 'v1' + || !is_array($policy[$collectionKey] ?? null) + || $policy[$collectionKey] === []) { + return false; + } + return match ($collectionKey) { + 'rules' => self::validFlowRules($policy[$collectionKey]), + 'templates' => self::validTemplates($policy[$collectionKey]), + default => false, + }; + } + + public static function safePolicy(string $raw, string $collectionKey): string + { + if (self::policyActive($raw, $collectionKey)) { + $policy = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + return json_encode($policy, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } + return json_encode( + ['version' => 'v1', $collectionKey => []], + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES + ); + } + + public static function secretAcceptable(string $value): bool + { + return strlen(trim($value)) >= self::SECRET_MINIMUM_BYTES; + } + + public static function sanitizeId(string $value): string + { + $value = trim($value); + if ($value === '') { + return ''; + } + if (strlen($value) <= 10) { + return substr(hash('sha256', $value), 0, 12); + } + return substr($value, 0, 4) . '...' . substr($value, -4); + } + + private static function validatedProviderBaseUrl(string $value): string + { + $value = rtrim(trim($value), '/'); + $parts = parse_url($value); + if (!is_array($parts) + || strtolower((string)($parts['scheme'] ?? '')) !== 'https' + || trim((string)($parts['host'] ?? '')) === '' + || isset($parts['user']) + || isset($parts['pass']) + || isset($parts['query']) + || isset($parts['fragment']) + || !in_array((string)($parts['path'] ?? ''), ['', '/'], true)) { + throw new RuntimeException('Bird provider URL must be an HTTPS origin.'); + } + return $value; + } + + /** + * @param array $rules + */ + private static function validFlowRules(array $rules): bool + { + $hasEnabledRule = false; + foreach ($rules as $rule) { + if (!is_array($rule) + || !is_bool($rule['enabled'] ?? null) + || !is_string($rule['id'] ?? null) + || trim($rule['id']) === '' + || !is_array($rule['when'] ?? null) + || $rule['when'] === [] + || !is_array($rule['actions'] ?? null) + || $rule['actions'] === []) { + return false; + } + foreach ($rule['when'] as $path => $expected) { + if (!is_string($path) + || $path === '' + || is_array($expected) + || is_object($expected)) { + return false; + } + } + foreach ($rule['actions'] as $action) { + if (!is_array($action) + || !in_array($action['type'] ?? null, ['tag', 'assign', 'snooze', 'close'], true)) { + return false; + } + $value = $action['value'] ?? null; + if ($action['type'] === 'close' ? $value !== null : !is_string($value) || trim($value) === '') { + return false; + } + } + $hasEnabledRule = $hasEnabledRule || $rule['enabled']; + } + return $hasEnabledRule; + } + + /** + * @param array $templates + */ + private static function validTemplates(array $templates): bool + { + $hasEnabledTemplate = false; + foreach ($templates as $template) { + if (!is_array($template) + || !is_bool($template['enabled'] ?? null) + || !self::policyId($template['projectId'] ?? null) + || !self::policyId($template['version'] ?? null) + || !is_string($template['locale'] ?? null) + || trim($template['locale']) === '' + || !is_array($template['parameterKeys'] ?? null)) { + return false; + } + $keys = []; + foreach ($template['parameterKeys'] as $key) { + if (!is_string($key) || trim($key) === '' || isset($keys[$key])) { + return false; + } + $keys[$key] = true; + } + $hasEnabledTemplate = $hasEnabledTemplate || $template['enabled']; + } + return $hasEnabledTemplate; + } + + private static function policyId(mixed $value): bool + { + return is_string($value) + && preg_match('/^[A-Za-z0-9._:-]{1,191}$/', $value) === 1; + } + + private static function opaqueId(string $value, string $field): string + { + if (preg_match('/^[A-Za-z0-9._:-]{1,191}$/', $value) !== 1) { + throw new InvalidArgumentException('Invalid Bird activation ID: ' . $field); + } + return $value; + } + + private static function trueValue(mixed $value): bool + { + return strtolower(trim((string)$value)) === 'true'; + } + + private static function safeErrorCode(Throwable $throwable): string + { + return match (true) { + $throwable instanceof InvalidArgumentException => 'invalid_configuration', + str_contains($throwable->getMessage(), 'participant') => 'participant_unresolved', + str_contains($throwable->getMessage(), 'channel') => 'channel_validation_failed', + str_contains($throwable->getMessage(), 'workspace') => 'workspace_validation_failed', + str_contains($throwable->getMessage(), 'credential') => 'credential_unavailable', + default => 'provider_read_failed', + }; + } +} diff --git a/services/nginx/app/modules/bird/classes/bird_control_plane_auto_activation.php b/services/nginx/app/modules/bird/classes/bird_control_plane_auto_activation.php new file mode 100644 index 00000000..1290465f --- /dev/null +++ b/services/nginx/app/modules/bird/classes/bird_control_plane_auto_activation.php @@ -0,0 +1,348 @@ + */ + public function run(): array + { + $metadata = []; + $organizationId = ''; + $reconciler = null; + + return self::guardedActivation( + fn (): null => $this->disableCapabilities(), + function () use (&$metadata, &$organizationId, &$reconciler): void { + $config = $this->configuration(); + $controlPlaneToken = $this->strongSecret($config['control_plane_token'] ?? '') + ?: self::generatedSecret(); + $webhookSigningKey = $this->strongSecret($config['webhook_signing_key'] ?? '') + ?: self::generatedSecret(); + $metadata = $this->bootstrapMetadata($config, $controlPlaneToken); + + $activation = (new bird_control_plane_activator($this->pdo))->apply([ + 'controlPlaneToken' => $controlPlaneToken, + 'webhookSigningKey' => $webhookSigningKey, + 'participantId' => trim((string)( + getenv('BIRD_PARTICIPANT_ID') + ?: ($config['participantId'] ?? '') + )), + 'enableCapabilities' => false, + ]); + if (!self::stagedActivationReady($activation)) { + throw new RuntimeException('Bird activation staging did not become ready.'); + } + + $this->persistBootstrapMetadata($metadata); + $organizationId = trim((string)( + getenv('BIRD_ORGANIZATION_ID') + ?: ($config['organizationId'] ?? '') + )); + $reconciler = new bird_webhook_subscription_reconciler( + $this->pdo, + null, + false + ); + }, + function () use (&$organizationId, &$reconciler): void { + if (!$reconciler instanceof bird_webhook_subscription_reconciler + || ($reconciler->apply($organizationId)['ready'] ?? false) !== true) { + throw new RuntimeException( + 'Bird webhook reconciliation did not become ready.' + ); + } + }, + fn (): null => $this->enableCapabilities(), + function () use (&$metadata, &$organizationId, &$reconciler): array { + $finalActivation = (new bird_control_plane_activator($this->pdo))->check(); + $finalSubscriptions = $reconciler instanceof bird_webhook_subscription_reconciler + ? $reconciler->check($organizationId) + : ['ready' => false]; + if (($finalActivation['ready'] ?? false) !== true + || ($finalSubscriptions['ready'] ?? false) !== true) { + throw new RuntimeException('Bird startup readiness validation failed.'); + } + return [ + 'ready' => true, + 'schemaReady' => ($finalActivation['schema']['ready'] ?? false) === true, + 'providerReady' => ($finalActivation['providerReadReady'] ?? false) === true, + 'subscriptionsReady' => true, + 'bootstrapReady' => true, + 'tokenVersion' => $metadata['tokenVersion'], + 'tokenRotated' => $metadata['tokenRotated'], + ]; + } + ); + } + + /** + * Executes activation in a fail-closed order and re-disables every capability + * when any staged provider or final readiness check fails. + */ + public static function guardedActivation( + callable $disable, + callable $stage, + callable $reconcile, + callable $enable, + callable $finalCheck + ): mixed { + $disable(); + try { + $stage(); + $reconcile(); + $enable(); + return $finalCheck(); + } catch (Throwable $throwable) { + try { + $disable(); + } catch (Throwable) { + // Preserve the original failure; startup will still abort. + } + throw $throwable; + } + } + + /** @param array $status */ + public static function stagedActivationReady(array $status): bool + { + return ($status['schema']['ready'] ?? false) === true + && ($status['providerReadReady'] ?? false) === true + && ($status['moduleEnabled'] ?? false) === true + && ($status['providerCredentialConfigured'] ?? false) === true + && ($status['providerUrlConfigured'] ?? false) === true + && trim((string)($status['workspaceId'] ?? '')) !== '' + && (int)($status['allowedChannelCount'] ?? 0) > 0 + && ($status['participantConfigured'] ?? false) === true + && ($status['controlPlaneCredentialConfigured'] ?? false) === true + && ($status['webhookSigningCredentialConfigured'] ?? false) === true + && ($status['webhookPublicUrlExact'] ?? false) === true + && ($status['controlPlaneEnabled'] ?? true) === false + && ($status['outboundMessagesEnabled'] ?? true) === false + && ($status['operationsActionsEnabled'] ?? true) === false; + } + + /** @return array{algorithm:string,keyFingerprint:string,tokenVersion:int,updatedAt:string,ciphertext:string} */ + public function publicEnvelope(): array + { + $config = $this->configuration(); + $envelope = [ + 'algorithm' => (string)($config[self::ALGORITHM_VARIABLE] ?? ''), + 'keyFingerprint' => (string)($config[self::KEY_ID_VARIABLE] ?? ''), + 'tokenVersion' => (int)($config[self::VERSION_VARIABLE] ?? 0), + 'updatedAt' => (string)($config[self::UPDATED_AT_VARIABLE] ?? ''), + 'ciphertext' => (string)($config[self::CIPHERTEXT_VARIABLE] ?? ''), + ]; + if (($config[self::READY_VARIABLE] ?? '') !== 'true' + || $envelope['algorithm'] !== bird_control_plane_bootstrap_sealer::ALGORITHM + || !hash_equals( + bird_control_plane_bootstrap_sealer::KEY_FINGERPRINT, + $envelope['keyFingerprint'] + ) + || $envelope['tokenVersion'] < 1 + || !self::updatedAtValid($envelope['updatedAt']) + || !bird_control_plane_bootstrap_sealer::ciphertextValid($envelope['ciphertext'])) { + throw new RuntimeException('Bootstrap unavailable.'); + } + return $envelope; + } + + /** + * @param array $config + * @return array{algorithm:string,keyFingerprint:string,tokenVersion:int,updatedAt:string,ciphertext:string,tokenHash:string,tokenRotated:bool} + */ + private function bootstrapMetadata(array $config, string $token): array + { + $tokenHash = hash('sha256', $token); + $existingVersion = max(0, (int)($config[self::VERSION_VARIABLE] ?? 0)); + $existingValid = self::existingBootstrapValid($config, $token); + if ($existingValid) { + return [ + 'algorithm' => bird_control_plane_bootstrap_sealer::ALGORITHM, + 'keyFingerprint' => bird_control_plane_bootstrap_sealer::KEY_FINGERPRINT, + 'tokenVersion' => $existingVersion, + 'updatedAt' => (string)$config[self::UPDATED_AT_VARIABLE], + 'ciphertext' => (string)$config[self::CIPHERTEXT_VARIABLE], + 'tokenHash' => $tokenHash, + 'tokenRotated' => false, + ]; + } + if ($existingVersion >= PHP_INT_MAX) { + throw new RuntimeException('Bootstrap token version exhausted.'); + } + $version = $existingVersion + 1; + $updatedAt = gmdate('Y-m-d\TH:i:s\Z'); + return [ + 'algorithm' => bird_control_plane_bootstrap_sealer::ALGORITHM, + 'keyFingerprint' => bird_control_plane_bootstrap_sealer::KEY_FINGERPRINT, + 'tokenVersion' => $version, + 'updatedAt' => $updatedAt, + 'ciphertext' => $this->sealer->seal(self::sealedPayload( + $token, + $version, + $updatedAt + )), + 'tokenHash' => $tokenHash, + 'tokenRotated' => trim((string)($config['control_plane_token'] ?? '')) === '', + ]; + } + + /** @param array $metadata */ + private function persistBootstrapMetadata(array $metadata): void + { + $this->pdo->beginTransaction(); + try { + $this->upsert(self::TOKEN_HASH_VARIABLE, (string)$metadata['tokenHash'], 'string'); + $this->upsert(self::CIPHERTEXT_VARIABLE, (string)$metadata['ciphertext'], 'string'); + $this->upsert(self::ALGORITHM_VARIABLE, (string)$metadata['algorithm'], 'string'); + $this->upsert(self::KEY_ID_VARIABLE, (string)$metadata['keyFingerprint'], 'string'); + $this->upsert(self::VERSION_VARIABLE, (string)$metadata['tokenVersion'], 'int'); + $this->upsert(self::UPDATED_AT_VARIABLE, (string)$metadata['updatedAt'], 'string'); + $this->pdo->commit(); + } catch (Throwable $throwable) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $throwable; + } + } + + private function disableCapabilities(): null + { + $this->setCapabilityState(false); + return null; + } + + private function enableCapabilities(): null + { + $this->setCapabilityState(true); + return null; + } + + private function setCapabilityState(bool $enabled): void + { + $value = $enabled ? 'true' : 'false'; + $this->pdo->beginTransaction(); + try { + $this->upsert('control_plane_enabled', $value, 'bool'); + $this->upsert('outbound_messages_enabled', $value, 'bool'); + $this->upsert('operations_actions_enabled', $value, 'bool'); + $this->upsert(self::READY_VARIABLE, $value, 'bool'); + $this->pdo->commit(); + } catch (Throwable $throwable) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $throwable; + } + } + + /** @return array */ + private function configuration(): array + { + $statement = $this->pdo->prepare( + "SELECT variable, value FROM module_config WHERE module = 'bird'" + ); + $statement->execute(); + $config = []; + foreach ($statement->fetchAll() as $row) { + if (is_array($row) && is_scalar($row['variable'] ?? null)) { + $config[(string)$row['variable']] = is_scalar($row['value'] ?? null) + ? (string)$row['value'] + : ''; + } + } + return $config; + } + + private function upsert(string $variable, string $value, string $type): void + { + $statement = $this->pdo->prepare( + "INSERT INTO module_config (module, variable, value, type) + VALUES ('bird', :variable, :value, :type) + ON DUPLICATE KEY UPDATE value = VALUES(value), type = VALUES(type)" + ); + $statement->execute([ + ':variable' => $variable, + ':value' => $value, + ':type' => $type, + ]); + } + + private function strongSecret(string $value): ?string + { + return bird_control_plane_activator::secretAcceptable($value) ? trim($value) : null; + } + + private static function generatedSecret(): string + { + return rtrim(strtr(base64_encode(random_bytes(48)), '+/', '-_'), '='); + } + + /** @param array $config */ + public static function existingBootstrapValid(array $config, string $token): bool + { + return hash_equals( + (string)($config[self::TOKEN_HASH_VARIABLE] ?? ''), + hash('sha256', $token) + ) + && ($config[self::ALGORITHM_VARIABLE] ?? '') === + bird_control_plane_bootstrap_sealer::ALGORITHM + && hash_equals( + bird_control_plane_bootstrap_sealer::KEY_FINGERPRINT, + (string)($config[self::KEY_ID_VARIABLE] ?? '') + ) + && (int)($config[self::VERSION_VARIABLE] ?? 0) >= 1 + && self::updatedAtValid((string)($config[self::UPDATED_AT_VARIABLE] ?? '')) + && bird_control_plane_bootstrap_sealer::ciphertextValid( + (string)($config[self::CIPHERTEXT_VARIABLE] ?? '') + ); + } + + public static function sealedPayload(string $token, int $version, string $updatedAt): string + { + return json_encode([ + 'algorithm' => bird_control_plane_bootstrap_sealer::ALGORITHM, + 'keyFingerprint' => bird_control_plane_bootstrap_sealer::KEY_FINGERPRINT, + 'tokenVersion' => $version, + 'updatedAt' => $updatedAt, + 'token' => $token, + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } + + private static function updatedAtValid(string $value): bool + { + if (preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/', $value) !== 1) { + return false; + } + try { + return (new DateTimeImmutable($value))->format('Y-m-d\TH:i:s\Z') === $value; + } catch (Throwable) { + return false; + } + } +} diff --git a/services/nginx/app/modules/bird/classes/bird_control_plane_bootstrap_sealer.php b/services/nginx/app/modules/bird/classes/bird_control_plane_bootstrap_sealer.php new file mode 100644 index 00000000..e7a5af37 --- /dev/null +++ b/services/nginx/app/modules/bird/classes/bird_control_plane_bootstrap_sealer.php @@ -0,0 +1,101 @@ + 300) { + throw new RuntimeException('Invalid bootstrap plaintext.'); + } + if (!hash_equals($this->expectedFingerprint, $this->fingerprint())) { + throw new RuntimeException('Bootstrap public key fingerprint mismatch.'); + } + $ciphertext = $this->runOpenSsl([ + 'openssl', + 'pkeyutl', + '-encrypt', + '-pubin', + '-inkey', + $this->publicKeyPath, + '-pkeyopt', + 'rsa_padding_mode:oaep', + '-pkeyopt', + 'rsa_oaep_md:sha256', + '-pkeyopt', + 'rsa_mgf1_md:sha256', + ], $plaintext); + if (strlen($ciphertext) !== self::CIPHERTEXT_BYTES) { + throw new RuntimeException('Bootstrap ciphertext length mismatch.'); + } + return base64_encode($ciphertext); + } + + public function fingerprint(): string + { + $der = $this->runOpenSsl([ + 'openssl', + 'pkey', + '-pubin', + '-in', + $this->publicKeyPath, + '-outform', + 'DER', + ], ''); + return hash('sha256', $der); + } + + public static function ciphertextValid(string $ciphertext): bool + { + if (strlen($ciphertext) !== 512 + || preg_match('/^[A-Za-z0-9+\/]{512}$/', $ciphertext) !== 1) { + return false; + } + $decoded = base64_decode($ciphertext, true); + return is_string($decoded) && strlen($decoded) === self::CIPHERTEXT_BYTES; + } + + /** + * @param array $command + */ + private function runOpenSsl(array $command, string $stdin): string + { + if (!function_exists('proc_open')) { + throw new RuntimeException('OpenSSL process support is unavailable.'); + } + $pipes = []; + $process = proc_open($command, [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], $pipes, null, null, ['bypass_shell' => true]); + if (!is_resource($process)) { + throw new RuntimeException('OpenSSL process failed to start.'); + } + fwrite($pipes[0], $stdin); + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + fclose($pipes[1]); + stream_get_contents($pipes[2]); + fclose($pipes[2]); + $status = proc_close($process); + if ($status !== 0 || !is_string($stdout)) { + throw new RuntimeException('OpenSSL operation failed.'); + } + return $stdout; + } +} diff --git a/services/nginx/app/modules/bird/classes/bird_control_plane_contract.php b/services/nginx/app/modules/bird/classes/bird_control_plane_contract.php new file mode 100644 index 00000000..26b1b2cd --- /dev/null +++ b/services/nginx/app/modules/bird/classes/bird_control_plane_contract.php @@ -0,0 +1,253 @@ + + */ + public static function allowedChannelIds(string $raw, string $legacyChannelId = ''): array + { + $raw = trim($raw); + if ($raw === '' || $raw === '[]') { + return $legacyChannelId === '' ? [] : [self::opaqueId($legacyChannelId, 'channelId')]; + } + $decoded = json_decode($raw, true); + if (!is_array($decoded) || !array_is_list($decoded)) { + return []; + } + $allowed = []; + foreach ($decoded as $channelId) { + if (!is_string($channelId)) { + return []; + } + try { + $allowed[self::opaqueId($channelId, 'channelId')] = true; + } catch (InvalidArgumentException) { + return []; + } + } + return array_keys($allowed); + } + + /** + * @return array{action:string,resourceId:string,channelId:string,cause:string} + */ + public static function hangup(array $payload, array $allowedChannelIds): array + { + $parameters = is_array($payload['parameters'] ?? null) ? $payload['parameters'] : []; + if (($payload['confirmed'] ?? false) !== true) { + throw new InvalidArgumentException('Explicit confirmation is required'); + } + if (($payload['action'] ?? null) !== 'voice.call.hangup') { + throw new InvalidArgumentException('Unsupported Bird operation action'); + } + + $resourceId = self::opaqueId($payload['resourceId'] ?? null, 'resourceId'); + $channelId = self::opaqueId( + $parameters['channelId'] ?? ($allowedChannelIds[0] ?? ''), + 'channelId' + ); + if (!in_array($channelId, $allowedChannelIds, true)) { + throw new InvalidArgumentException('Bird operation channel is not allowlisted'); + } + $cause = trim((string)($parameters['cause'] ?? 'rejected')); + if (!in_array($cause, self::HANGUP_CAUSES, true)) { + throw new InvalidArgumentException('Invalid hangup cause'); + } + return [ + 'action' => 'voice.call.hangup', + 'resourceId' => $resourceId, + 'channelId' => $channelId, + 'cause' => $cause, + ]; + } + + /** + * @param array{action:string,resourceId:string,channelId:string,cause:string} $operation + */ + public static function operationRequestHash(array $operation): string + { + return hash('sha256', json_encode([ + 'action' => $operation['action'], + 'resourceId' => $operation['resourceId'], + 'parameters' => [ + 'channelId' => $operation['channelId'], + 'cause' => $operation['cause'], + ], + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + } + + public static function assertConversationChannel(array $conversation, array $allowedChannelIds): string + { + $channelId = trim((string)( + $conversation['channelId'] + ?? $conversation['channel']['id'] + ?? $conversation['lastMessage']['channelId'] + ?? '' + )); + if (!in_array($channelId, $allowedChannelIds, true)) { + throw new InvalidArgumentException('Bird conversation channel is not allowlisted'); + } + return $channelId; + } + + /** + * @return array{workspaceId:string,channelId:string} + */ + public static function webhookScope( + array $payload, + string $configuredWorkspaceId, + array $allowedChannelIds + ): array { + $workspaceId = self::payloadString($payload, [ + 'workspaceId', + 'data.workspaceId', + 'conversation.workspaceId', + 'data.conversation.workspaceId', + ]); + $channelId = self::payloadString($payload, [ + 'channelId', + 'data.channelId', + 'conversation.channelId', + 'data.conversation.channelId', + 'message.channelId', + 'data.message.channelId', + ]); + if ($configuredWorkspaceId === '' || !hash_equals($configuredWorkspaceId, $workspaceId)) { + throw new InvalidArgumentException('Bird webhook workspace is not allowlisted'); + } + if (!in_array($channelId, $allowedChannelIds, true)) { + throw new InvalidArgumentException('Bird webhook channel is not allowlisted'); + } + return ['workspaceId' => $workspaceId, 'channelId' => $channelId]; + } + + /** + * @return array + */ + public static function channels( + array|object|null $provider, + int $limit = 100, + ?array $allowedChannelIds = null + ): array + { + $payload = is_object($provider) ? (array)$provider : $provider; + if (!is_array($payload)) { + return []; + } + $candidates = $payload['results'] ?? $payload['items'] ?? $payload['data'] ?? $payload; + if (!is_array($candidates)) { + return []; + } + + $channels = []; + foreach (array_slice(array_values($candidates), 0, max(1, min(100, $limit))) as $channel) { + $channel = is_object($channel) ? (array)$channel : $channel; + if (!is_array($channel)) { + continue; + } + $id = trim((string)($channel['id'] ?? '')); + if ($id === '') { + continue; + } + if ($allowedChannelIds !== null && !in_array($id, $allowedChannelIds, true)) { + continue; + } + $channels[] = [ + 'id' => substr($id, 0, 191), + 'name' => substr(trim((string)($channel['name'] ?? $channel['displayName'] ?? $id)), 0, 255), + 'platform' => substr(trim((string)($channel['platform'] ?? $channel['type'] ?? 'unknown')), 0, 80), + 'status' => substr(trim((string)($channel['status'] ?? 'unknown')), 0, 80), + ]; + } + return $channels; + } + + /** + * @return array> + */ + public static function collectionItems(array|object|null $provider): array + { + $payload = is_object($provider) ? (array)$provider : $provider; + if (!is_array($payload)) { + return []; + } + $items = $payload['results'] ?? $payload['items'] ?? $payload['data'] ?? $payload; + if (!is_array($items) || !array_is_list($items)) { + return []; + } + return array_values(array_filter(array_map( + static fn (mixed $item): mixed => is_object($item) ? (array)$item : $item, + $items + ), 'is_array')); + } + + /** + * @return array{ambiguous:bool,statusCode:int,payload:array} + */ + public static function reservationOutcome(array $record): array + { + $status = trim((string)($record['status'] ?? 'pending')); + $reference = trim((string)($record['reference'] ?? '')); + $ambiguous = in_array($status, ['pending', 'ambiguous'], true) + || ($record['reconciliationRequired'] ?? false) === true; + + if (!$ambiguous) { + return [ + 'ambiguous' => false, + 'statusCode' => 200, + 'payload' => $record, + ]; + } + + return [ + 'ambiguous' => true, + 'statusCode' => 409, + 'payload' => [ + 'code' => 'external_action_ambiguous', + 'message' => 'Bird provider outcome requires reconciliation.', + 'outcomeAmbiguous' => true, + 'reference' => $reference, + 'status' => $status, + 'reconciliationRequired' => true, + 'retrySafe' => false, + ], + ]; + } + + private static function opaqueId(mixed $value, string $field): string + { + if (!is_scalar($value)) { + throw new InvalidArgumentException('Missing or invalid parameter: ' . $field); + } + $value = trim((string)$value); + if (preg_match('/^[A-Za-z0-9._:-]{1,191}$/', $value) !== 1) { + throw new InvalidArgumentException('Missing or invalid parameter: ' . $field); + } + return $value; + } + + private static function payloadString(array $payload, array $paths): string + { + foreach ($paths as $path) { + $value = $payload; + foreach (explode('.', $path) as $segment) { + if (!is_array($value) || !array_key_exists($segment, $value)) { + $value = null; + break; + } + $value = $value[$segment]; + } + if (is_scalar($value) && trim((string)$value) !== '') { + return substr(trim((string)$value), 0, 191); + } + } + return ''; + } +} diff --git a/services/nginx/app/modules/bird/classes/bird_control_plane_schema_bootstrap.php b/services/nginx/app/modules/bird/classes/bird_control_plane_schema_bootstrap.php new file mode 100644 index 00000000..02e80edd --- /dev/null +++ b/services/nginx/app/modules/bird/classes/bird_control_plane_schema_bootstrap.php @@ -0,0 +1,131 @@ +} + */ + public static function check(PDO $pdo): array + { + $missing = []; + foreach (['bird_control_plane_schema_versions', 'bird_webhook_events', 'bird_outbound_messages'] as $table) { + try { + $statement = $pdo->prepare( + 'SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = :table' + ); + $statement->execute([':table' => $table]); + if ((int)$statement->fetchColumn() !== 1) { + $missing[] = 'table:' . $table; + } + } catch (PDOException) { + $missing[] = 'table:' . $table; + } + } + + $version = 0; + if (!in_array('table:bird_control_plane_schema_versions', $missing, true)) { + try { + $statement = $pdo->query( + 'SELECT MAX(version) FROM bird_control_plane_schema_versions' + ); + $version = $statement === false ? 0 : (int)$statement->fetchColumn(); + } catch (PDOException) { + $missing[] = 'version:unreadable'; + } + } + if ($version < self::VERSION) { + $missing[] = 'version:' . self::VERSION; + } + return [ + 'ready' => $missing === [], + 'version' => $version, + 'expectedVersion' => self::VERSION, + 'missing' => array_values(array_unique($missing)), + ]; + } + + public static function requireReady(PDO $pdo): void + { + $status = self::check($pdo); + if (!$status['ready']) { + throw new RuntimeException( + 'bird_schema_not_ready:' . implode(',', $status['missing']) + ); + } + } + + public static function apply(PDO $pdo): void + { + if (PHP_SAPI !== 'cli') { + throw new RuntimeException('Bird Control Plane schema changes are CLI-only.'); + } + foreach (self::queries() as $query) { + $pdo->exec($query); + } + $statement = $pdo->prepare( + 'INSERT INTO bird_control_plane_schema_versions (version, applied_at) + VALUES (:version, CURRENT_TIMESTAMP) + ON DUPLICATE KEY UPDATE applied_at = applied_at' + ); + $statement->execute([':version' => self::VERSION]); + } + + /** + * @return array + */ + public static function queries(): array + { + return [ + "CREATE TABLE IF NOT EXISTS bird_control_plane_schema_versions ( + version INT UNSIGNED NOT NULL PRIMARY KEY, + applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + "CREATE TABLE IF NOT EXISTS bird_webhook_events ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + request_id VARCHAR(191) NOT NULL, + signature_hash CHAR(64) NOT NULL, + event_type VARCHAR(191) NULL, + workspace_id VARCHAR(191) NOT NULL, + channel_id VARCHAR(191) NOT NULL, + payload_json LONGTEXT NOT NULL, + request_timestamp BIGINT NOT NULL, + received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + processed_at TIMESTAMP NULL DEFAULT NULL, + UNIQUE KEY uniq_bird_webhook_request_id (request_id), + UNIQUE KEY uniq_bird_webhook_signature_hash (signature_hash), + KEY idx_bird_webhook_cursor (id), + KEY idx_bird_webhook_received_at (received_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + "CREATE TABLE IF NOT EXISTS bird_outbound_messages ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + reference_id VARCHAR(191) NOT NULL, + conversation_id VARCHAR(191) NOT NULL, + message_kind VARCHAR(32) NOT NULL, + request_hash CHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + provider_message_id VARCHAR(191) NULL, + response_json LONGTEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uniq_bird_outbound_reference (reference_id), + KEY idx_bird_outbound_conversation (conversation_id), + KEY idx_bird_outbound_status (status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + ]; + } +} diff --git a/services/nginx/app/modules/bird/classes/bird_control_plane_security.php b/services/nginx/app/modules/bird/classes/bird_control_plane_security.php new file mode 100644 index 00000000..b9efc7a1 --- /dev/null +++ b/services/nginx/app/modules/bird/classes/bird_control_plane_security.php @@ -0,0 +1,112 @@ + $value) { + if (strtolower((string)$key) === $lower) { + return trim((string)$value); + } + } + + $serverKey = 'HTTP_' . strtoupper(str_replace('-', '_', $name)); + return trim((string)($server[$serverKey] ?? '')); + } +} diff --git a/services/nginx/app/modules/bird/classes/bird_flow_policy_evaluator.php b/services/nginx/app/modules/bird/classes/bird_flow_policy_evaluator.php new file mode 100644 index 00000000..ae0a3725 --- /dev/null +++ b/services/nginx/app/modules/bird/classes/bird_flow_policy_evaluator.php @@ -0,0 +1,107 @@ +>} + */ + public static function evaluate(string $policyJson, array $event): array + { + $policy = json_decode($policyJson, true); + if (!is_array($policy) || ($policy['version'] ?? null) !== 'v1' || !is_array($policy['rules'] ?? null)) { + return self::deny('invalid_or_missing_policy'); + } + + foreach ($policy['rules'] as $rule) { + if (!is_array($rule) + || ($rule['enabled'] ?? false) !== true + || !is_string($rule['id'] ?? null) + || !is_array($rule['when'] ?? null) + || !is_array($rule['actions'] ?? null)) { + continue; + } + + if (!self::matches($event, $rule['when'])) { + continue; + } + + $actions = self::normalizeActions($rule['actions']); + if ($actions === null || $actions === []) { + return self::deny('invalid_rule_actions', 'v1', (string)$rule['id']); + } + + return [ + 'decision' => 'allow', + 'reason' => 'matched_rule', + 'policyVersion' => 'v1', + 'ruleId' => (string)$rule['id'], + 'actions' => $actions, + ]; + } + + return self::deny('no_matching_rule', 'v1'); + } + + private static function matches(array $event, array $conditions): bool + { + foreach ($conditions as $path => $expected) { + if (!is_string($path) || $path === '' || is_array($expected) || is_object($expected)) { + return false; + } + + $actual = $event; + foreach (explode('.', $path) as $segment) { + if (!is_array($actual) || !array_key_exists($segment, $actual)) { + return false; + } + $actual = $actual[$segment]; + } + if ($actual !== $expected) { + return false; + } + } + return $conditions !== []; + } + + private static function normalizeActions(array $actions): ?array + { + $normalized = []; + foreach ($actions as $action) { + if (!is_array($action) || !in_array($action['type'] ?? null, self::ALLOWED_ACTIONS, true)) { + return null; + } + + $type = (string)$action['type']; + $value = $action['value'] ?? null; + if ($type !== 'close' && (!is_string($value) || trim($value) === '')) { + return null; + } + if ($type === 'close' && $value !== null) { + return null; + } + + $normalized[] = $type === 'close' + ? ['type' => 'close'] + : ['type' => $type, 'value' => trim((string)$value)]; + } + return $normalized; + } + + private static function deny( + string $reason, + string $version = '', + ?string $ruleId = null + ): array { + return [ + 'decision' => 'deny', + 'reason' => $reason, + 'policyVersion' => $version, + 'ruleId' => $ruleId, + 'actions' => [], + ]; + } +} diff --git a/services/nginx/app/modules/bird/classes/bird_outbound_message_store.php b/services/nginx/app/modules/bird/classes/bird_outbound_message_store.php new file mode 100644 index 00000000..b5b0a402 --- /dev/null +++ b/services/nginx/app/modules/bird/classes/bird_outbound_message_store.php @@ -0,0 +1,111 @@ +pdo); + } + + public function begin(string $reference, string $conversationId, string $kind, string $requestHash): array + { + $this->ensureSchema(); + try { + $statement = $this->pdo->prepare( + 'INSERT INTO bird_outbound_messages + (reference_id, conversation_id, message_kind, request_hash) + VALUES (:reference, :conversation_id, :kind, :request_hash)' + ); + $statement->execute([ + ':reference' => $reference, + ':conversation_id' => $conversationId, + ':kind' => $kind, + ':request_hash' => $requestHash, + ]); + return ['created' => true, 'record' => $this->find($reference)]; + } catch (PDOException $exception) { + if ((string)$exception->getCode() !== '23000') { + throw $exception; + } + $record = $this->find($reference); + if ($record === null || !hash_equals((string)$record['requestHash'], $requestHash)) { + throw new \RuntimeException('Outbound reference is already used for another request'); + } + return ['created' => false, 'record' => $record]; + } + } + + public function complete(string $reference, string $status, array $response): void + { + $providerId = is_scalar($response['id'] ?? null) ? trim((string)$response['id']) : ''; + $statement = $this->pdo->prepare( + 'UPDATE bird_outbound_messages + SET status = :status, provider_message_id = :provider_id, response_json = :response + WHERE reference_id = :reference' + ); + $statement->execute([ + ':status' => $status, + ':provider_id' => $providerId !== '' ? $providerId : null, + ':response' => json_encode($response, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + ':reference' => $reference, + ]); + } + + public function find(string $reference): ?array + { + $this->ensureSchema(); + $statement = $this->pdo->prepare( + 'SELECT reference_id, conversation_id, message_kind, request_hash, status, + provider_message_id, response_json, created_at, updated_at + FROM bird_outbound_messages WHERE reference_id = :reference LIMIT 1' + ); + $statement->execute([':reference' => $reference]); + $row = $statement->fetch(); + if (!is_array($row)) { + return null; + } + return self::recordFromRow($row); + } + + /** + * @param array $row + * @return array + */ + public static function recordFromRow(array $row): array + { + $response = json_decode((string)($row['response_json'] ?? ''), true); + return [ + 'reference' => (string)$row['reference_id'], + 'conversationId' => (string)$row['conversation_id'], + 'kind' => (string)$row['message_kind'], + 'requestHash' => (string)$row['request_hash'], + 'status' => (string)$row['status'], + 'providerMessageId' => (string)($row['provider_message_id'] ?? ''), + 'messageId' => (string)($row['provider_message_id'] ?? ''), + 'response' => is_array($response) ? $response : null, + 'reconciliationRequired' => in_array( + (string)$row['status'], + ['pending', 'ambiguous'], + true + ), + 'retrySafe' => !in_array( + (string)$row['status'], + ['pending', 'ambiguous'], + true + ), + 'createdAt' => (string)$row['created_at'], + 'updatedAt' => (string)($row['updated_at'] ?? ''), + ]; + } +} diff --git a/services/nginx/app/modules/bird/classes/bird_webhook_event_store.php b/services/nginx/app/modules/bird/classes/bird_webhook_event_store.php new file mode 100644 index 00000000..b843b4da --- /dev/null +++ b/services/nginx/app/modules/bird/classes/bird_webhook_event_store.php @@ -0,0 +1,128 @@ +pdo); + } + + /** + * @return array{inserted:bool,id:int} + */ + public function append( + string $requestId, + string $signatureHash, + string $eventType, + string $workspaceId, + string $channelId, + string $payloadJson, + int $requestTimestamp + ): array { + $this->ensureSchema(); + + try { + $statement = $this->pdo->prepare( + 'INSERT INTO bird_webhook_events + (request_id, signature_hash, event_type, workspace_id, channel_id, payload_json, request_timestamp) + VALUES + (:request_id, :signature_hash, :event_type, :workspace_id, :channel_id, :payload_json, :request_timestamp)' + ); + $statement->execute([ + ':request_id' => $requestId, + ':signature_hash' => $signatureHash, + ':event_type' => $eventType !== '' ? $eventType : null, + ':workspace_id' => $workspaceId, + ':channel_id' => $channelId, + ':payload_json' => $payloadJson, + ':request_timestamp' => $requestTimestamp, + ]); + + return ['inserted' => true, 'id' => (int)$this->pdo->lastInsertId()]; + } catch (PDOException $exception) { + if ((string)$exception->getCode() !== '23000') { + throw $exception; + } + + $statement = $this->pdo->prepare( + 'SELECT id FROM bird_webhook_events + WHERE request_id = :request_id OR signature_hash = :signature_hash + ORDER BY id ASC LIMIT 1' + ); + $statement->execute([ + ':request_id' => $requestId, + ':signature_hash' => $signatureHash, + ]); + $row = $statement->fetch(); + if (!is_array($row)) { + throw $exception; + } + + return ['inserted' => false, 'id' => (int)$row['id']]; + } + } + + /** + * @return array{events:array>,nextCursor:int|null} + */ + public function listAfter(int $cursor, int $limit): array + { + $this->ensureSchema(); + $limit = max(1, min(100, $limit)); + $statement = $this->pdo->prepare( + 'SELECT id, request_id, event_type, workspace_id, channel_id, payload_json, + request_timestamp, received_at + FROM bird_webhook_events + WHERE id > :cursor + ORDER BY id ASC + LIMIT ' . ($limit + 1) + ); + $statement->execute([':cursor' => max(0, $cursor)]); + $rows = $statement->fetchAll(); + $hasMore = count($rows) > $limit; + if ($hasMore) { + array_pop($rows); + } + + $events = array_map(static function (array $row): array { + $payload = json_decode((string)$row['payload_json'], true); + $payload = is_array($payload) ? $payload : []; + $data = is_array($payload['data'] ?? null) ? $payload['data'] : []; + return [ + 'cursor' => (int)$row['id'], + 'requestId' => (string)$row['request_id'], + 'eventType' => (string)($row['event_type'] ?? ''), + 'workspaceId' => (string)($row['workspace_id'] ?? ''), + 'channelId' => (string)($row['channel_id'] ?? ''), + 'requestTimestamp' => (int)$row['request_timestamp'], + 'receivedAt' => (string)$row['received_at'], + 'conversation' => is_array($payload['conversation'] ?? null) + ? $payload['conversation'] + : (is_array($data['conversation'] ?? null) ? $data['conversation'] : null), + 'message' => is_array($payload['message'] ?? null) + ? $payload['message'] + : (is_array($data['message'] ?? null) ? $data['message'] : null), + 'payload' => $payload, + ]; + }, $rows); + + $last = $events === [] ? null : (int)$events[array_key_last($events)]['cursor']; + return [ + 'events' => $events, + 'lastCursor' => $last, + 'nextCursor' => $hasMore ? $last : null, + 'historyComplete' => !$hasMore, + ]; + } +} diff --git a/services/nginx/app/modules/bird/classes/bird_webhook_subscription_reconciler.php b/services/nginx/app/modules/bird/classes/bird_webhook_subscription_reconciler.php new file mode 100644 index 00000000..9cc67566 --- /dev/null +++ b/services/nginx/app/modules/bird/classes/bird_webhook_subscription_reconciler.php @@ -0,0 +1,551 @@ +,string,string,array,?array):array */ + private readonly Closure $providerRequest; + + /** + * @param null|callable(array,string,string,array,?array):array $providerRequest + */ + public function __construct( + private readonly PDO $pdo, + ?callable $providerRequest = null, + private readonly bool $requireControlPlaneEnabled = true + ) { + $this->providerRequest = $providerRequest !== null + ? Closure::fromCallable($providerRequest) + : Closure::fromCallable([self::class, 'providerRequest']); + } + + /** @return array */ + public function check(string $organizationId): array + { + try { + [$config, $workspaceId, $channelIds, $organizationId, $organizationSource] = + $this->context($organizationId); + $available = ($this->providerRequest)( + $config, + 'GET', + '/workspaces/' . rawurlencode($workspaceId) . '/available-webhooks', + [], + null + ); + if (!self::capabilitiesAdvertised($available)) { + throw new RuntimeException('webhook_capabilities_unavailable'); + } + $subscriptions = $this->listSubscriptions( + $config, + $organizationId, + $workspaceId + ); + $targets = self::targets($channelIds); + $exactActiveCount = 0; + foreach ($targets as $target) { + $matches = self::matchingSubscriptions($subscriptions, $target); + if (count($matches) > 1) { + throw new RuntimeException('duplicate_target_subscriptions'); + } + if (count($matches) === 1 + && strtolower((string)($matches[0]['status'] ?? '')) === 'active' + && is_string($matches[0]['signingKey'] ?? null) + && hash_equals( + (string)$config['webhook_signing_key'], + $matches[0]['signingKey'] + )) { + $exactActiveCount++; + } + } + return [ + 'ready' => $exactActiveCount === count($targets), + 'capabilitiesAdvertised' => true, + 'subscriptionListValidated' => true, + 'targetCount' => count($targets), + 'exactActiveCount' => $exactActiveCount, + 'organizationId' => bird_control_plane_activator::sanitizeId($organizationId), + 'organizationProvided' => $organizationSource === 'environment', + 'organizationDiscovered' => $organizationSource === 'provider', + 'workspaceListFallback' => $organizationSource === 'workspace_fallback', + 'workspaceId' => bird_control_plane_activator::sanitizeId($workspaceId), + 'blockerCode' => null, + ]; + } catch (Throwable $throwable) { + return [ + 'ready' => false, + 'capabilitiesAdvertised' => false, + 'subscriptionListValidated' => false, + 'targetCount' => 0, + 'exactActiveCount' => 0, + 'organizationId' => bird_control_plane_activator::sanitizeId($organizationId), + 'organizationProvided' => trim($organizationId) !== '', + 'organizationDiscovered' => false, + 'workspaceListFallback' => false, + 'workspaceId' => '', + 'blockerCode' => self::blockerCode($throwable), + ]; + } + } + + /** @return array */ + public function apply(string $organizationId): array + { + if (PHP_SAPI !== 'cli') { + throw new RuntimeException('webhook_reconciliation_cli_only'); + } + [$config, $workspaceId, $channelIds, $organizationId, $organizationSource] = + $this->context($organizationId); + $available = ($this->providerRequest)( + $config, + 'GET', + '/workspaces/' . rawurlencode($workspaceId) . '/available-webhooks', + [], + null + ); + if (!self::capabilitiesAdvertised($available)) { + throw new RuntimeException('webhook_capabilities_unavailable'); + } + $subscriptions = $this->listSubscriptions($config, $organizationId, $workspaceId); + $signingKey = (string)$config['webhook_signing_key']; + foreach (self::targets($channelIds) as $target) { + $matches = self::matchingSubscriptions($subscriptions, $target); + if (count($matches) > 1) { + throw new RuntimeException('duplicate_target_subscriptions'); + } + $payload = [ + 'service' => 'conversations', + 'event' => $target['event'], + 'eventFilters' => [[ + 'key' => 'channelId', + 'value' => $target['channelId'], + ]], + 'url' => bird_control_plane_activator::PUBLIC_WEBHOOK_URL, + 'signingKey' => $signingKey, + ]; + if ($matches === []) { + $result = ($this->providerRequest)( + $config, + 'POST', + '/workspaces/' . rawurlencode($workspaceId) . '/webhook-subscriptions', + [], + $payload + ); + } else { + $subscriptionId = trim((string)($matches[0]['id'] ?? '')); + if ($subscriptionId === '') { + throw new RuntimeException('subscription_id_missing'); + } + $payload['status'] = 'active'; + $result = ($this->providerRequest)( + $config, + 'PATCH', + '/workspaces/' . rawurlencode($workspaceId) + . '/webhook-subscriptions/' . rawurlencode($subscriptionId), + [], + $payload + ); + } + if (!self::mutationResultExact($result, $target, $signingKey)) { + throw new RuntimeException('subscription_mutation_unverified'); + } + } + $status = $this->check($organizationId); + if ($organizationSource === 'provider') { + $status['organizationProvided'] = false; + $status['organizationDiscovered'] = true; + } elseif ($organizationSource === 'workspace_fallback') { + $status['organizationProvided'] = false; + $status['organizationDiscovered'] = false; + $status['workspaceListFallback'] = true; + } + return $status; + } + + public static function capabilitiesAdvertised(array $response): bool + { + foreach (bird_control_plane_contract::collectionItems($response) as $service) { + if (($service['service'] ?? null) !== 'conversations' + || !is_array($service['events'] ?? null)) { + continue; + } + $supported = []; + foreach ($service['events'] as $event) { + if (is_array($event) + && in_array($event['name'] ?? null, self::EVENTS, true) + && is_array($event['filterKeys'] ?? null) + && in_array('channelId', $event['filterKeys'], true)) { + $supported[(string)$event['name']] = true; + } + } + return count($supported) === count(self::EVENTS); + } + return false; + } + + /** + * @param array> $subscriptions + * @param array{event:string,channelId:string} $target + * @return array> + */ + public static function matchingSubscriptions(array $subscriptions, array $target): array + { + return array_values(array_filter( + $subscriptions, + static fn (array $subscription): bool => + ($subscription['service'] ?? null) === 'conversations' + && ($subscription['event'] ?? null) === $target['event'] + && ($subscription['url'] ?? null) === bird_control_plane_activator::PUBLIC_WEBHOOK_URL + && self::exactChannelFilter($subscription['eventFilters'] ?? null, $target['channelId']) + )); + } + + /** @param array $channelIds + * @return array + */ + public static function targets(array $channelIds): array + { + $targets = []; + foreach ($channelIds as $channelId) { + foreach (self::EVENTS as $event) { + $targets[] = ['event' => $event, 'channelId' => $channelId]; + } + } + return $targets; + } + + public static function subscriptionListPath(string $organizationId, string $workspaceId): string + { + if ($organizationId === '') { + return '/workspaces/' . rawurlencode($workspaceId) . '/webhook-subscriptions'; + } + return '/organizations/' . rawurlencode($organizationId) + . '/workspaces/' . rawurlencode($workspaceId) . '/webhook-subscriptions'; + } + + public static function validSubscriptionPage(array $response): bool + { + foreach (['results', 'items', 'data'] as $key) { + if (array_key_exists($key, $response)) { + if (!is_array($response[$key]) || !array_is_list($response[$key])) { + return false; + } + foreach ($response[$key] as $item) { + if (!is_array($item)) { + return false; + } + } + return !array_key_exists('nextPageToken', $response) + || is_string($response['nextPageToken']); + } + } + return false; + } + + /** + * @param array> $responses + * @return array{candidateIds:array,workspaceConsistent:bool} + */ + public static function discoverOrganizationContext(array $responses, string $workspaceId): array + { + $organizationIds = []; + $workspaceConsistent = true; + $walk = function (mixed $value) use ( + &$walk, + &$organizationIds, + &$workspaceConsistent, + $workspaceId + ): void { + if (!is_array($value)) { + return; + } + foreach ($value as $key => $child) { + if ($key === 'organizationId' && is_string($child) && self::uuid($child)) { + $organizationIds[$child] = true; + } + if ($key === 'workspaceId' + && is_scalar($child) + && trim((string)$child) !== '' + && !hash_equals($workspaceId, trim((string)$child))) { + $workspaceConsistent = false; + } + if (is_array($child)) { + $walk($child); + } + } + }; + foreach ($responses as $response) { + $walk($response); + } + $candidateIds = array_keys($organizationIds); + sort($candidateIds); + return [ + 'candidateIds' => $candidateIds, + 'workspaceConsistent' => $workspaceConsistent, + ]; + } + + /** + * @return array{0:array,1:string,2:array,3:string,4:string} + */ + private function context(string $organizationId): array + { + $organizationId = trim($organizationId); + $config = $this->configuration(); + $workspaceId = bird_control_plane_activator::canonicalWorkspaceId($config); + $channelIds = bird_control_plane_activator::canonicalAllowedChannelIds($config); + if ($workspaceId === '' || $channelIds === []) { + throw new RuntimeException('activation_configuration_incomplete'); + } + if (strtolower(trim((string)($config['enabled'] ?? ''))) !== 'true' + || ($this->requireControlPlaneEnabled + && strtolower(trim((string)($config['control_plane_enabled'] ?? ''))) !== 'true') + || trim((string)($config['api_key'] ?? '')) === '') { + throw new RuntimeException('activation_configuration_incomplete'); + } + if (!bird_control_plane_activator::secretAcceptable( + (string)($config['webhook_signing_key'] ?? '') + ) || !hash_equals( + bird_control_plane_activator::PUBLIC_WEBHOOK_URL, + trim((string)($config['webhook_public_url'] ?? '')) + )) { + throw new RuntimeException('webhook_configuration_incomplete'); + } + if ($organizationId !== '') { + return [ + $config, + $workspaceId, + $channelIds, + self::organizationId($organizationId), + 'environment', + ]; + } + + $responses = [($this->providerRequest)( + $config, + 'GET', + '/workspaces/' . rawurlencode($workspaceId) . '/channels', + ['limit' => 100], + null + )]; + foreach ($channelIds as $channelId) { + $responses[] = ($this->providerRequest)( + $config, + 'GET', + '/workspaces/' . rawurlencode($workspaceId) . '/conversations', + ['channelId' => $channelId, 'limit' => 100], + null + ); + } + $discovery = self::discoverOrganizationContext($responses, $workspaceId); + if (!$discovery['workspaceConsistent']) { + throw new RuntimeException('organization_workspace_mismatch'); + } + if (count($discovery['candidateIds']) !== 1) { + if ($discovery['candidateIds'] !== []) { + throw new RuntimeException('organization_id_required'); + } + return [$config, $workspaceId, $channelIds, '', 'workspace_fallback']; + } + return [ + $config, + $workspaceId, + $channelIds, + $discovery['candidateIds'][0], + 'provider', + ]; + } + + /** @return array> */ + private function listSubscriptions( + array $config, + string $organizationId, + string $workspaceId + ): array { + $items = []; + $pageToken = ''; + for ($page = 0; $page < 20; $page++) { + $query = ['limit' => 100]; + if ($pageToken !== '') { + $query['pageToken'] = $pageToken; + } + try { + $response = ($this->providerRequest)( + $config, + 'GET', + self::subscriptionListPath($organizationId, $workspaceId), + $query, + null + ); + } catch (Throwable $throwable) { + if ($organizationId === '') { + throw new RuntimeException('organization_id_required', 0, $throwable); + } + throw $throwable; + } + if (!self::validSubscriptionPage($response)) { + if ($organizationId === '') { + throw new RuntimeException('organization_id_required'); + } + throw new RuntimeException('subscription_list_shape_invalid'); + } + $pageItems = bird_control_plane_contract::collectionItems($response); + array_push($items, ...$pageItems); + $next = trim((string)($response['nextPageToken'] ?? '')); + if ($next === '') { + if (count($pageItems) >= 100) { + if ($organizationId === '') { + throw new RuntimeException('organization_id_required'); + } + throw new RuntimeException('subscription_pagination_ambiguous'); + } + return $items; + } + if (hash_equals($pageToken, $next)) { + if ($organizationId === '') { + throw new RuntimeException('organization_id_required'); + } + throw new RuntimeException('subscription_pagination_repeated'); + } + $pageToken = $next; + } + throw new RuntimeException( + $organizationId === '' + ? 'organization_id_required' + : 'subscription_pagination_exceeded' + ); + } + + /** @return array */ + private function configuration(): array + { + $statement = $this->pdo->prepare( + "SELECT variable, value FROM module_config WHERE module = 'bird'" + ); + $statement->execute(); + $config = []; + foreach ($statement->fetchAll() as $row) { + if (is_array($row) && is_scalar($row['variable'] ?? null)) { + $config[(string)$row['variable']] = is_scalar($row['value'] ?? null) + ? (string)$row['value'] + : ''; + } + } + return $config; + } + + private static function providerRequest( + array $config, + string $method, + string $endpoint, + array $query, + ?array $payload + ): array { + $baseUrl = rtrim(trim((string)($config['server_url'] ?? '')), '/'); + $parts = parse_url($baseUrl); + if (!is_array($parts) + || strtolower((string)($parts['scheme'] ?? '')) !== 'https' + || trim((string)($parts['host'] ?? '')) === '' + || isset($parts['user']) + || isset($parts['pass']) + || isset($parts['query']) + || isset($parts['fragment']) + || !in_array((string)($parts['path'] ?? ''), ['', '/'], true)) { + throw new RuntimeException('provider_origin_invalid'); + } + $url = $baseUrl . $endpoint . ($query === [] ? '' : '?' . http_build_query($query)); + $curl = curl_init($url); + if ($curl === false) { + throw new RuntimeException('provider_transport_failed'); + } + $headers = [ + 'Accept: application/json', + 'Authorization: AccessKey ' . trim((string)($config['api_key'] ?? '')), + ]; + $options = [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_TIMEOUT => 40, + CURLOPT_HTTPHEADER => $headers, + ]; + if ($method !== 'GET') { + $options[CURLOPT_CUSTOMREQUEST] = $method; + $options[CURLOPT_POSTFIELDS] = json_encode($payload, JSON_THROW_ON_ERROR); + $options[CURLOPT_HTTPHEADER][] = 'Content-Type: application/json'; + } + curl_setopt_array($curl, $options); + $body = curl_exec($curl); + $status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); + $error = curl_errno($curl); + curl_close($curl); + if ($error !== 0 || !is_string($body) || $status < 200 || $status >= 300) { + throw new RuntimeException('provider_request_failed'); + } + if (strlen($body) > 2 * 1024 * 1024) { + throw new RuntimeException('provider_response_too_large'); + } + $decoded = json_decode($body, true); + if (!is_array($decoded)) { + throw new RuntimeException('provider_response_invalid'); + } + return $decoded; + } + + private static function exactChannelFilter(mixed $filters, string $channelId): bool + { + return is_array($filters) + && count($filters) === 1 + && is_array($filters[0] ?? null) + && ($filters[0]['key'] ?? null) === 'channelId' + && ($filters[0]['value'] ?? null) === $channelId; + } + + /** @param array{event:string,channelId:string} $target */ + private static function mutationResultExact( + array $result, + array $target, + string $signingKey + ): bool { + return ($result['service'] ?? null) === 'conversations' + && ($result['event'] ?? null) === $target['event'] + && ($result['url'] ?? null) === bird_control_plane_activator::PUBLIC_WEBHOOK_URL + && self::exactChannelFilter($result['eventFilters'] ?? null, $target['channelId']) + && ($result['status'] ?? null) === 'active' + && is_string($result['signingKey'] ?? null) + && hash_equals($signingKey, $result['signingKey']); + } + + private static function organizationId(string $value): string + { + if (!self::uuid($value)) { + throw new RuntimeException('organization_id_invalid'); + } + return $value; + } + + private static function uuid(string $value): bool + { + return preg_match( + '/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', + trim($value) + ) === 1; + } + + private static function blockerCode(Throwable $throwable): string + { + $message = $throwable->getMessage(); + return preg_match('/^[a-z0-9_]+$/', $message) === 1 + ? $message + : 'webhook_reconciliation_blocked'; + } +} diff --git a/services/nginx/app/modules/bird/config/bird_allowed_channel_ids_json_c.php b/services/nginx/app/modules/bird/config/bird_allowed_channel_ids_json_c.php new file mode 100644 index 00000000..a68d9473 --- /dev/null +++ b/services/nginx/app/modules/bird/config/bird_allowed_channel_ids_json_c.php @@ -0,0 +1,29 @@ +get('/bird/health', function (): void { + global $response; + self::requirePermission('modules_bird_health_read'); + + $client = new bird(); + $workspaceId = $this->getConfiguredWorkspaceId($client); + $configured = $workspaceId !== '' + && trim((string)$client->config->api_key->getVariableValue()) !== '' + && trim((string)$client->config->server_url->getVariableValue()) !== ''; + + $provider = null; + $channels = []; + if ($configured && $client->config->enabled->isTrue()) { + try { + $provider = $client->sendGetRequest( + '/workspaces/' . rawurlencode($workspaceId) . '/channels', + ['limit' => 10] + ); + $channels = $this->channelSummaries($provider, $this->getAllowedChannelIds($client)); + } catch (Throwable $throwable) { + error_log('[bird-health] Read-only provider check failed: ' . get_class($throwable)); + $response->error('Bird read-only health check failed', 502); + } + } + + $response->success([ + 'enabled' => $client->config->enabled->isTrue(), + 'configured' => $configured, + 'workspaceId' => $workspaceId, + 'channelId' => $this->getConfiguredChannelId($client), + 'allowedChannelIds' => $this->getAllowedChannelIds($client), + 'providerReachable' => $provider !== null, + 'healthy' => $provider !== null, + 'channels' => $channels, + 'check' => 'channels.list', + ]); + }, [ + 'modules_bird_health_read' => 'Run a read-only Bird connection health check', + ]); + + $this->get('/bird/control-plane/v1/bootstrap', function (): void { + global $response; + header('Cache-Control: no-store, max-age=0'); + header('Pragma: no-cache'); + header('X-Content-Type-Options: nosniff'); + try { + $envelope = (new bird_control_plane_auto_activation( + db::getPDO() + ))->publicEnvelope(); + } catch (Throwable) { + $response->error('Not found', 404); + } + $response->success($envelope); + }); + + $this->get('/bird/control-plane/v1/status', function (): void { + global $response; + $client = $this->requireControlPlaneClient(); + $workspaceId = $this->requireWorkspaceId($client); + $schema = $this->schemaStatus(); + + $response->success([ + 'enabled' => true, + 'birdEnabled' => $client->config->enabled->isTrue(), + 'workspaceId' => $workspaceId, + 'participantId' => trim((string)$client->config->participantId->getVariableValue()), + 'channelId' => $this->getAllowedChannelIds($client)[0] ?? '', + 'allowedChannelIds' => $this->getAllowedChannelIds($client), + 'schema' => $schema, + 'webhookConfigured' => $schema['ready'] && $this->webhookConfigured($client), + 'flowEnabled' => $client->config->flow_enabled->isTrue(), + 'eventLedgerReady' => $schema['ready'], + 'capabilities' => [ + 'channels.read', + ...($schema['ready'] ? ['events.read'] : []), + 'conversations.read', + 'messages.read', + 'voice.calls.read', + 'voice.recordings.read', + 'voice.insights.read', + 'numbers.read', + ], + 'writeCapabilities' => [ + 'operations.actions' => $schema['ready'] + && $client->config->operations_actions_enabled->isTrue(), + 'conversations.reply' => $schema['ready'] + && $client->config->outbound_messages_enabled->isTrue(), + 'conversations.template' => $schema['ready'] + && $client->config->outbound_messages_enabled->isTrue(), + ], + ]); + }); + + $this->get('/bird/control-plane/v1/channels', function (): void { + global $response; + $client = $this->requireControlPlaneClient(); + $workspaceId = $this->requireWorkspaceId($client); + $provider = $client->sendGetRequest( + '/workspaces/' . rawurlencode($workspaceId) . '/channels', + $this->paginationQuery() + ); + $response->success([ + 'channels' => bird_control_plane_contract::channels( + $provider, + 100, + $this->getAllowedChannelIds($client) + ), + ]); + }); + + $this->get('/bird/control-plane/v1/webhook-subscriptions', function (): void { + global $response; + $client = $this->requireControlPlaneClient(); + $workspaceId = $this->requireWorkspaceId($client); + $response->success($client->sendGetRequest( + '/workspaces/' . rawurlencode($workspaceId) . '/webhook-subscriptions', + $this->paginationQuery() + ) ?? []); + }); + + $this->get('/bird/control-plane/v1/events', function (): void { + global $response; + $this->requireControlPlaneClient(); + $this->requireControlPlaneSchema(); + $cursor = $this->boundedIntegerQuery('cursor', 0, PHP_INT_MAX, 0); + $limit = $this->boundedIntegerQuery('limit', 1, 100, 100); + $response->success($this->eventStore()->listAfter($cursor, $limit)); + }); + + $this->get('/bird/control-plane/v1/conversations', function (): void { + global $response; + $client = $this->requireControlPlaneClient(); + $workspaceId = $this->requireWorkspaceId($client); + $query = $this->paginationQuery(); + $query['channelId'] = $this->requiredChannelId($client); + $status = $this->queryString('status'); + if ($status !== '') { + if (!in_array($status, ['active', 'archived'], true)) { + $response->error('Invalid conversation status', 400); + } + $query['status'] = $status; + } + $response->success($client->sendGetRequest( + '/workspaces/' . rawurlencode($workspaceId) . '/conversations', + $query + ) ?? []); + }); + + $this->get('/bird/control-plane/v1/messages', function (): void { + global $response; + $client = $this->requireControlPlaneClient(); + $workspaceId = $this->requireWorkspaceId($client); + $conversationId = $this->requiredOpaqueIdQuery('conversationId'); + $this->requireAllowedConversation($client, $workspaceId, $conversationId); + $response->success($client->sendGetRequest( + '/workspaces/' . rawurlencode($workspaceId) + . '/conversations/' . rawurlencode($conversationId) . '/messages', + $this->paginationQuery() + ) ?? []); + }); + + $this->get('/bird/control-plane/v1/calls', function (): void { + global $response; + $client = $this->requireControlPlaneClient(); + $workspaceId = $this->requireWorkspaceId($client); + $channelId = $this->queryString('channelId'); + if ($channelId === '') { + $channelId = $this->getAllowedChannelIds($client)[0] ?? ''; + } + if (!in_array($channelId, $this->getAllowedChannelIds($client), true)) { + $response->error('Bird channel is not allowlisted', 403); + } + $response->success($client->listVoiceCalls( + $workspaceId, + $channelId, + $this->paginationQuery() + ) ?? []); + }); + + $this->get('/bird/control-plane/v1/call-log', function (): void { + global $response; + $client = $this->requireControlPlaneClient(); + $response->success($client->getVoiceCallsLog( + $this->requireWorkspaceId($client), + $this->paginationQuery() + ) ?? []); + }); + + $this->get('/bird/control-plane/v1/recordings', function (): void { + global $response; + $client = $this->requireControlPlaneClient(); + $workspaceId = $this->requireWorkspaceId($client); + $channelId = $this->requiredChannelId($client); + $callId = $this->requiredOpaqueIdQuery('callId'); + $response->success($client->listVoiceCallRecordings( + $workspaceId, + $channelId, + $callId, + $this->paginationQuery() + ) ?? []); + }); + + $this->get('/bird/control-plane/v1/insights', function (): void { + global $response; + $client = $this->requireControlPlaneClient(); + $response->success($client->getVoiceCallInsights( + $this->requireWorkspaceId($client), + $this->requiredChannelId($client), + $this->requiredOpaqueIdQuery('callId') + ) ?? []); + }); + + $this->get('/bird/control-plane/v1/numbers', function (): void { + global $response; + $client = $this->requireControlPlaneClient(); + $response->success($client->listNumbers( + $this->requireWorkspaceId($client), + $this->paginationQuery() + ) ?? []); + }); + + $this->get('/bird/control-plane/v1/operations', function (): void { + global $response; + $client = $this->requireControlPlaneClient(); + $workspaceId = $this->requireWorkspaceId($client); + $allowedChannelIds = $this->getAllowedChannelIds($client); + $channelId = $allowedChannelIds[0] ?? ''; + $calls = []; + foreach ($allowedChannelIds as $allowedChannelId) { + $providerCalls = $client->listVoiceCalls( + $workspaceId, + $allowedChannelId, + ['limit' => 20] + ); + foreach (bird_control_plane_contract::collectionItems($providerCalls) as $call) { + $call['channelId'] = $allowedChannelId; + $call['state'] = $call['state'] ?? $call['status'] ?? null; + $calls[] = $call; + } + } + + $response->success([ + 'workspaceId' => $workspaceId, + 'channelId' => $channelId, + 'allowedChannelIds' => $allowedChannelIds, + 'calls' => $calls, + 'callLog' => $client->getVoiceCallsLog($workspaceId, ['limit' => 20]) ?? [], + 'numbers' => $client->listNumbers($workspaceId, ['limit' => 20]) ?? [], + 'actionsEnabled' => $this->schemaStatus()['ready'] + && $client->config->operations_actions_enabled->isTrue(), + 'supportedActions' => ['voice.call.hangup'], + ]); + }); + + $this->post('/bird/control-plane/v1/operations/actions', function (): void { + global $response; + $client = $this->requireControlPlaneClient(); + if (!$client->config->operations_actions_enabled->isTrue()) { + $response->error('Bird operational actions are disabled', 503); + } + $this->requireControlPlaneSchema(); + + $payload = $this->jsonBody(); + if (($payload['confirmed'] ?? false) !== true) { + $response->error('Explicit confirmation is required', 409); + } + if (($payload['action'] ?? null) !== 'voice.call.hangup') { + $response->error('Unsupported Bird operation action', 400); + } + + try { + $operation = bird_control_plane_contract::hangup( + $payload, + $this->getAllowedChannelIds($client) + ); + } catch (\InvalidArgumentException $exception) { + $response->error($exception->getMessage(), 400); + } + $action = $operation['action']; + $resourceId = $operation['resourceId']; + $channelId = $operation['channelId']; + $cause = $operation['cause']; + $workspaceId = $this->requireWorkspaceId($client); + $idempotencyKey = $this->idempotencyKey(); + $requestHash = bird_control_plane_contract::operationRequestHash($operation); + $store = $this->outboundStore(); + $reservation = $store->begin( + 'operation:' . $idempotencyKey, + $resourceId, + 'operation', + $requestHash + ); + if (!$reservation['created']) { + $this->respondReservation($reservation['record']); + } + + try { + $call = $client->getVoiceCall($workspaceId, $channelId, $resourceId); + $callArray = is_object($call) ? (array)$call : $call; + $status = strtolower(trim((string)($callArray['status'] ?? ''))); + if (!in_array($status, ['accepted', 'ongoing'], true)) { + $store->complete('operation:' . $idempotencyKey, 'rejected', [ + 'reason' => 'call_not_active', + 'providerStatus' => $status, + ]); + $response->error('Bird call is not in an actionable state', 409); + } + + $result = $client->hangupVoiceCall( + $workspaceId, + $channelId, + $resourceId, + ['cause' => $cause] + ); + $result = is_object($result) ? (array)$result : ($result ?? ['status' => 'ok']); + $store->complete('operation:' . $idempotencyKey, 'completed', $result); + } catch (Throwable $throwable) { + $store->complete('operation:' . $idempotencyKey, 'ambiguous', [ + 'errorClass' => get_class($throwable), + ]); + $record = $store->find('operation:' . $idempotencyKey) + ?? $reservation['record']; + $this->respondReservation($record); + } + $response->success($store->find('operation:' . $idempotencyKey)); + }); + + $this->get('/bird/control-plane/v1/messages/by-reference', function (): void { + global $response; + $client = $this->requireControlPlaneClient(); + $this->requireControlPlaneSchema(); + $reference = $this->validatedReference($this->queryString('reference')); + $record = $this->outboundStore()->find($reference); + if ($record === null) { + $response->error('Bird outbound reference not found', 404); + } + $record = $this->reconcileProviderMessage( + $client, + $this->requireWorkspaceId($client), + $record + ); + $response->success($record); + }); + + $this->post('/bird/control-plane/v1/messages', function (): void { + $this->sendConversationReply(false, $this->jsonBody()); + }); + + $this->post('/bird/control-plane/v1/messages/template', function (): void { + $this->sendConversationReply(true, $this->jsonBody()); + }); + + $this->post('/bird/webhooks/notifications', function (): void { + global $response; + $client = new bird(); + if (!$client->config->enabled->isTrue() || !$this->webhookConfigured($client)) { + $response->error('Bird webhook ingestion is not configured', 503); + } + + $headers = function_exists('getallheaders') ? getallheaders() : []; + $rawBody = $this->boundedRawBody('Bird webhook'); + $timestamp = bird_control_plane_security::header( + $_SERVER, + is_array($headers) ? $headers : [], + 'messagebird-request-timestamp' + ); + $signature = bird_control_plane_security::header( + $_SERVER, + is_array($headers) ? $headers : [], + 'messagebird-signature' + ); + $requestId = bird_control_plane_security::header( + $_SERVER, + is_array($headers) ? $headers : [], + 'messagebird-request-id' + ); + $publicUrl = trim((string)$client->config->webhook_public_url->getVariableValue()); + $window = (int)$client->config->webhook_replay_window_seconds->getVariableValue(); + + if ($requestId === '' || strlen($requestId) > 191) { + $response->error('Missing or invalid messagebird-request-id', 400); + } + if (!bird_control_plane_security::timestampWithinReplayWindow($timestamp, $window)) { + $response->error('Bird webhook timestamp is outside the replay window', 401); + } + if (!bird_control_plane_security::verifyBirdWebhookSignature( + (string)$client->config->webhook_signing_key->getVariableValue(), + $timestamp, + $publicUrl, + $rawBody, + $signature + )) { + $response->error('Invalid Bird webhook signature', 401); + } + + $payload = json_decode($rawBody, true); + if (!is_array($payload)) { + $response->error('Invalid Bird webhook JSON', 400); + } + try { + $scope = bird_control_plane_contract::webhookScope( + $payload, + $this->getConfiguredWorkspaceId($client), + $this->getAllowedChannelIds($client) + ); + } catch (\InvalidArgumentException $exception) { + $response->error($exception->getMessage(), 403); + } + $this->requireControlPlaneSchema(); + + $result = $this->eventStore()->append( + $requestId, + hash('sha256', $timestamp . "\n" . $signature . "\n" . $rawBody), + $this->payloadString($payload, ['event', 'type']), + $scope['workspaceId'], + $scope['channelId'], + $rawBody, + strlen($timestamp) === 13 ? (int)floor((int)$timestamp / 1000) : (int)$timestamp + ); + + $response->rawJson([ + 'accepted' => true, + 'duplicate' => !$result['inserted'], + 'cursor' => $result['id'], + ], $result['inserted'] ? 202 : 200); + }); + + $this->post('/bird/flows/evaluate', function (): void { + global $response; + $client = new bird(); + $headers = function_exists('getallheaders') ? getallheaders() : []; + $headers = is_array($headers) ? $headers : []; + $timestamp = bird_control_plane_security::header( + $_SERVER, + $headers, + 'x-pleno-flow-timestamp' + ); + $signature = bird_control_plane_security::header( + $_SERVER, + $headers, + 'x-pleno-flow-signature' + ); + $rawBody = $this->boundedRawBody('Bird Flow'); + if (!bird_control_plane_security::timestampWithinReplayWindow( + $timestamp, + (int)$client->config->webhook_replay_window_seconds->getVariableValue() + ) || !bird_control_plane_security::verifyFlowSignature( + (string)$client->config->flow_shared_secret->getVariableValue(), + $timestamp, + $rawBody, + $signature + )) { + $response->error('Unauthorized Bird Flow request', 401); + } + if (!$client->config->enabled->isTrue() || !$client->config->flow_enabled->isTrue()) { + $response->error('Bird Flow evaluation is disabled', 503); + } + + $payload = json_decode($rawBody, true); + if (!is_array($payload) || !is_array($payload['event'] ?? null)) { + $response->error('Bird Flow event must be an object', 400); + } + $response->success(bird_flow_policy_evaluator::evaluate( + (string)$client->config->flow_policy_json->getVariableValue(), + $payload['event'] + )); + }); + } + + private function requireControlPlaneClient(): bird + { + global $response; + $client = new bird(); + $headers = function_exists('getallheaders') ? getallheaders() : []; + $token = bird_control_plane_security::bearerToken( + $_SERVER, + is_array($headers) ? $headers : [] + ); + if (!bird_control_plane_security::verifyBearer( + (string)$client->config->control_plane_token->getVariableValue(), + $token + )) { + $response->error('Unauthorized Bird Control Plane request', 401); + } + if (!$client->config->control_plane_enabled->isTrue()) { + $response->error('Bird Control Plane gateway is disabled', 503); + } + if (!$client->config->enabled->isTrue()) { + $response->error('Bird module is disabled', 503); + } + return $client; + } + + private function webhookConfigured(bird $client): bool + { + $url = trim((string)$client->config->webhook_public_url->getVariableValue()); + return trim((string)$client->config->webhook_signing_key->getVariableValue()) !== '' + && filter_var($url, FILTER_VALIDATE_URL) !== false + && strtolower((string)parse_url($url, PHP_URL_SCHEME)) === 'https'; + } + + private function requireWorkspaceId(bird $client): string + { + global $response; + $workspaceId = $this->getConfiguredWorkspaceId($client); + if ($workspaceId === '') { + $response->error('Bird workspaceId is not configured', 503); + } + return $workspaceId; + } + + private function requiredChannelId(bird $client): string + { + global $response; + $channelId = $this->queryString('channelId'); + if ($channelId === '') { + $channelId = $this->getAllowedChannelIds($client)[0] ?? ''; + } + if (!in_array($channelId, $this->getAllowedChannelIds($client), true)) { + $response->error('Bird channel is not allowlisted', 403); + } + return $channelId; + } + + private function paginationQuery(): array + { + $query = ['limit' => $this->boundedIntegerQuery('limit', 1, 100, 100)]; + $pageToken = $this->queryString('pageToken'); + if ($pageToken !== '') { + $query['pageToken'] = $pageToken; + } + return $query; + } + + private function queryString(string $key): string + { + $value = $_GET[$key] ?? ''; + if (!is_scalar($value)) { + return ''; + } + $value = trim((string)$value); + return strlen($value) <= 512 ? $value : ''; + } + + private function requiredOpaqueIdQuery(string $key): string + { + global $response; + $value = $this->queryString($key); + if ($value === '' || preg_match('/^[A-Za-z0-9._:-]{1,191}$/', $value) !== 1) { + $response->error('Missing or invalid parameter: ' . $key, 400); + } + return $value; + } + + private function boundedIntegerQuery( + string $key, + int $minimum, + int $maximum, + int $default + ): int { + $value = $_GET[$key] ?? null; + if ($value === null || $value === '') { + return $default; + } + if (!is_scalar($value) || filter_var($value, FILTER_VALIDATE_INT) === false) { + return $default; + } + return max($minimum, min($maximum, (int)$value)); + } + + private function eventStore(): bird_webhook_event_store + { + try { + return new bird_webhook_event_store(db::getPDO()); + } catch (Throwable $throwable) { + throw new RuntimeException('Bird event ledger is unavailable', 0, $throwable); + } + } + + private function payloadString(array $payload, array $paths): string + { + foreach ($paths as $path) { + $value = $payload; + foreach (explode('.', $path) as $segment) { + if (!is_array($value) || !array_key_exists($segment, $value)) { + $value = null; + break; + } + $value = $value[$segment]; + } + if (is_scalar($value) && trim((string)$value) !== '') { + return substr(trim((string)$value), 0, 191); + } + } + return ''; + } + + private function sendConversationReply(bool $template, array $payload): void + { + global $response; + $client = $this->requireControlPlaneClient(); + if (!$client->config->outbound_messages_enabled->isTrue()) { + $response->error('Bird outbound messages are disabled', 503); + } + $this->requireControlPlaneSchema(); + + if (($payload['confirmed'] ?? false) !== true) { + $response->error('Explicit confirmation is required', 409); + } + $conversationId = $this->validatedOpaqueId( + $payload['conversationId'] ?? null, + 'conversationId' + ); + $reference = $this->validatedReference( + $payload['reference'] ?? $this->idempotencyKey() + ); + $workspaceId = $this->requireWorkspaceId($client); + $participantId = $this->validatedOpaqueId( + $client->config->participantId->getVariableValue(), + 'participantId' + ); + + $conversation = $this->requireAllowedConversation($client, $workspaceId, $conversationId); + if (($conversation['status'] ?? null) !== 'active') { + $response->error('Bird conversation is not active', 409); + } + $recipient = $this->recipientFromConversation($conversation); + if ($recipient === null) { + $response->error('Bird conversation has no provider-derived contact recipient', 409); + } + + $birdPayload = [ + 'participantType' => 'accessKey', + 'participantId' => $participantId, + 'addMissingParticipants' => false, + 'recipients' => [$recipient], + 'reference' => $reference, + ]; + if ($template) { + $birdPayload['template'] = $this->validatedTemplate( + $client, + is_array($payload['template'] ?? null) ? $payload['template'] : [] + ); + } else { + $text = trim((string)($payload['text'] ?? '')); + if ($text === '' || mb_strlen($text) > 10000) { + $response->error('Bird reply text must contain 1 to 10000 characters', 400); + } + $birdPayload['body'] = [ + 'type' => 'text', + 'text' => ['text' => $text], + ]; + } + + $requestHash = hash('sha256', json_encode( + [$conversationId, $birdPayload], + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES + )); + $store = $this->outboundStore(); + $reservation = $store->begin( + $reference, + $conversationId, + $template ? 'template' : 'text', + $requestHash + ); + if (!$reservation['created']) { + $record = $this->reconcileProviderMessage( + $client, + $workspaceId, + $reservation['record'] + ); + $this->respondReservation($record); + } + + try { + $result = $client->sendPostRequest( + '/workspaces/' . rawurlencode($workspaceId) + . '/conversations/' . rawurlencode($conversationId) . '/messages', + $birdPayload + ); + $result = is_object($result) ? (array)$result : ($result ?? []); + $store->complete($reference, 'completed', $result); + } catch (Throwable $throwable) { + $store->complete($reference, 'ambiguous', [ + 'errorClass' => get_class($throwable), + ]); + $record = $store->find($reference) ?? $reservation['record']; + $this->respondReservation($record); + } + + $response->success($store->find($reference), 201); + } + + private function validatedTemplate(bird $client, array $template): array + { + global $response; + $projectId = $this->validatedOpaqueId($template['projectId'] ?? null, 'template.projectId'); + $version = $this->validatedOpaqueId($template['version'] ?? null, 'template.version'); + $locale = trim((string)($template['locale'] ?? '')); + $parameters = is_array($template['parameters'] ?? null) ? $template['parameters'] : []; + + $policy = json_decode((string)$client->config->template_policy_json->getVariableValue(), true); + $definitions = is_array($policy) + && ($policy['version'] ?? null) === 'v1' + && is_array($policy['templates'] ?? null) + ? $policy['templates'] + : []; + $allowed = null; + foreach ($definitions as $definition) { + if (is_array($definition) + && ($definition['enabled'] ?? false) === true + && ($definition['projectId'] ?? null) === $projectId + && ($definition['version'] ?? null) === $version + && ($definition['locale'] ?? null) === $locale) { + $allowed = $definition; + break; + } + } + if ($allowed === null) { + $response->error('Bird template version is not allowlisted', 403); + } + + $parameterKeys = is_array($allowed['parameterKeys'] ?? null) + ? array_values($allowed['parameterKeys']) + : []; + $normalized = []; + foreach ($parameters as $parameter) { + if (!is_array($parameter) + || ($parameter['type'] ?? null) !== 'string' + || !is_string($parameter['key'] ?? null) + || !is_scalar($parameter['value'] ?? null) + || !in_array($parameter['key'], $parameterKeys, true)) { + $response->error('Bird template parameters do not match the immutable allowlist', 400); + } + $normalized[] = [ + 'type' => 'string', + 'key' => $parameter['key'], + 'value' => substr((string)$parameter['value'], 0, 2000), + ]; + } + if (array_values(array_column($normalized, 'key')) !== $parameterKeys) { + $response->error('Bird template parameter keys or order do not match the immutable allowlist', 400); + } + return [ + 'projectId' => $projectId, + 'version' => $version, + 'locale' => $locale, + 'parameters' => $normalized, + ]; + } + + private function recipientFromConversation(array $conversation): ?array + { + $participants = is_array($conversation['featuredParticipants'] ?? null) + ? $conversation['featuredParticipants'] + : []; + $lastSender = is_array($conversation['lastMessage']['sender'] ?? null) + ? $conversation['lastMessage']['sender'] + : []; + array_unshift($participants, $lastSender); + + foreach ($participants as $participant) { + if (!is_array($participant)) { + continue; + } + $contact = is_array($participant['contact'] ?? null) ? $participant['contact'] : $participant; + $key = trim((string)($contact['identifierKey'] ?? '')); + $value = trim((string)($contact['identifierValue'] ?? '')); + if ($key !== '' && $value !== '' && ($participant['type'] ?? 'contact') === 'contact') { + return [ + 'type' => 'to', + 'identifierKey' => substr($key, 0, 191), + 'identifierValue' => substr($value, 0, 512), + ]; + } + } + return null; + } + + private function requireAllowedConversation( + bird $client, + string $workspaceId, + string $conversationId + ): array + { + global $response; + $conversation = $client->sendGetRequest( + '/workspaces/' . rawurlencode($workspaceId) + . '/conversations/' . rawurlencode($conversationId) + ); + $conversation = is_object($conversation) ? (array)$conversation : $conversation; + if (!is_array($conversation)) { + $response->error('Bird conversation was not found', 404); + } + try { + bird_control_plane_contract::assertConversationChannel( + $conversation, + $this->getAllowedChannelIds($client) + ); + } catch (\InvalidArgumentException $exception) { + $response->error($exception->getMessage(), 403); + } + return $conversation; + } + + private function jsonBody(): array + { + global $response; + $payload = json_decode($this->boundedRawBody('Bird request'), true); + if (!is_array($payload)) { + $response->error('Request body must be a JSON object', 400); + } + return $payload; + } + + private function boundedRawBody(string $label): string + { + global $response; + $maximumBytes = 2 * 1024 * 1024; + $rawBody = file_get_contents('php://input', false, null, 0, $maximumBytes + 1); + $rawBody = is_string($rawBody) ? $rawBody : ''; + if (strlen($rawBody) > $maximumBytes) { + $response->error($label . ' payload exceeds 2 MiB', 413); + } + return $rawBody; + } + + private function validatedOpaqueId(mixed $value, string $field): string + { + global $response; + if (!is_scalar($value)) { + $response->error('Missing or invalid parameter: ' . $field, 400); + } + $value = trim((string)$value); + if (preg_match('/^[A-Za-z0-9._:-]{1,191}$/', $value) !== 1) { + $response->error('Missing or invalid parameter: ' . $field, 400); + } + return $value; + } + + private function validatedReference(mixed $value): string + { + global $response; + if (!is_scalar($value)) { + $response->error('Missing or invalid outbound reference', 400); + } + $value = trim((string)$value); + if (preg_match('/^[A-Za-z0-9._:-]{8,191}$/', $value) !== 1) { + $response->error('Missing or invalid outbound reference', 400); + } + return $value; + } + + private function idempotencyKey(): string + { + global $response; + $headers = function_exists('getallheaders') ? getallheaders() : []; + $key = bird_control_plane_security::header( + $_SERVER, + is_array($headers) ? $headers : [], + 'Idempotency-Key' + ); + if (preg_match('/^[A-Za-z0-9._:-]{8,160}$/', $key) !== 1) { + $response->error('A valid Idempotency-Key header is required', 400); + } + return $key; + } + + private function outboundStore(): bird_outbound_message_store + { + return new bird_outbound_message_store(db::getPDO()); + } + + /** + * @return array{ready:bool,version:int,expectedVersion:int,missing:array} + */ + private function schemaStatus(): array + { + try { + return bird_control_plane_schema_bootstrap::check(db::getPDO()); + } catch (Throwable $throwable) { + error_log('[bird-control-plane] Schema preflight failed: ' . get_class($throwable)); + return [ + 'ready' => false, + 'version' => 0, + 'expectedVersion' => bird_control_plane_schema_bootstrap::VERSION, + 'missing' => ['database:unavailable'], + ]; + } + } + + private function requireControlPlaneSchema(): void + { + global $response; + $status = $this->schemaStatus(); + if (!$status['ready']) { + $response->error([ + 'code' => 'bird_schema_not_ready', + 'message' => 'Bird Control Plane schema is not ready; run the deployment schema command.', + 'schema' => $status, + ], 503); + } + } + + private function respondReservation(array $record): void + { + global $response; + $outcome = bird_control_plane_contract::reservationOutcome($record); + if ($outcome['ambiguous']) { + $response->error($outcome['payload'], $outcome['statusCode']); + } + $response->success($outcome['payload'], $outcome['statusCode']); + } + + private function reconcileProviderMessage( + bird $client, + string $workspaceId, + array $record + ): array { + $status = trim((string)($record['status'] ?? '')); + $kind = trim((string)($record['kind'] ?? '')); + $reference = trim((string)($record['reference'] ?? '')); + $conversationId = trim((string)($record['conversationId'] ?? '')); + if (!in_array($status, ['pending', 'ambiguous'], true) + || !in_array($kind, ['text', 'template'], true) + || $reference === '' + || $conversationId === '') { + return $record; + } + + try { + $provider = $client->sendGetRequest( + '/workspaces/' . rawurlencode($workspaceId) + . '/conversations/' . rawurlencode($conversationId) . '/messages', + ['reference' => $reference, 'limit' => 100] + ); + foreach (bird_control_plane_contract::collectionItems($provider) as $message) { + if (trim((string)($message['reference'] ?? '')) !== $reference + || !is_scalar($message['id'] ?? null) + || trim((string)$message['id']) === '') { + continue; + } + $store = $this->outboundStore(); + $store->complete($reference, 'completed', $message); + return $store->find($reference) ?? $record; + } + } catch (Throwable $throwable) { + error_log('[bird-control-plane] Reference reconciliation failed: ' . get_class($throwable)); + } + return $record; + } + + /** + * @return array + */ + private function channelSummaries(array|object|null $provider, array $allowedChannelIds): array + { + return bird_control_plane_contract::channels($provider, 10, $allowedChannelIds); + } +} diff --git a/services/nginx/app/tests/Unit/Bird/BirdConfigSecretRedactionTest.php b/services/nginx/app/tests/Unit/Bird/BirdConfigSecretRedactionTest.php new file mode 100644 index 00000000..c423ed80 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bird/BirdConfigSecretRedactionTest.php @@ -0,0 +1,90 @@ +config_classes = [ + BirdSecretConfigDefinitionStub::class, + BirdPublicConfigDefinitionStub::class, + ]; + } + + public function extract(object $db): array + { + return $this->extracted($db, 'SELECT test'); + } + } +} + +it('redacts configured Bird secrets while preserving isSet metadata', function (): void { + $rows = [ + ['module' => 'bird', 'variable' => 'api_key', 'type' => 'string', 'value' => 'never-return-this'], + ['module' => 'bird', 'variable' => 'workspaceId', 'type' => 'string', 'value' => 'workspace-1'], + ]; + $result = new class($rows) { + public function __construct(private array $rows) + { + } + + public function fetch_assoc(): ?array + { + return array_shift($this->rows); + } + }; + $db = new class($result) { + public function __construct(private object $result) + { + } + + public function query(string $sql): object + { + return $this->result; + } + }; + + $config = (new BirdModuleConfigRedactionHarness())->extract($db); + + expect($config[0])->toMatchArray([ + 'variable' => 'api_key', + 'value' => '[redacted]', + 'isSecret' => true, + 'isSet' => true, + ])->and($config[1])->toMatchArray([ + 'variable' => 'workspaceId', + 'value' => 'workspace-1', + 'isSecret' => false, + 'isSet' => true, + ]); +}); diff --git a/services/nginx/app/tests/Unit/Bird/BirdControlPlaneActivationTest.php b/services/nginx/app/tests/Unit/Bird/BirdControlPlaneActivationTest.php new file mode 100644 index 00000000..c53f8b75 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bird/BirdControlPlaneActivationTest.php @@ -0,0 +1,424 @@ + '', + 'workplaceId' => 'legacy-workspace', + ]))->toBe('legacy-workspace') + ->and(bird_control_plane_activator::canonicalAllowedChannelIds([ + 'allowed_channel_ids_json' => '', + 'channelId' => 'legacy-channel', + ]))->toBe(['legacy-channel']) + ->and(bird_control_plane_activator::canonicalAllowedChannelIds([ + 'allowed_channel_ids_json' => '["channel-b","channel-a","channel-a"]', + 'channelId' => 'legacy-channel', + ]))->toBe(['channel-b', 'channel-a']); +}); + +it('fails closed for malformed explicit channel configuration', function (): void { + expect(bird_control_plane_activator::canonicalAllowedChannelIds([ + 'allowed_channel_ids_json' => '{bad-json', + 'channelId' => 'legacy-channel', + ]))->toBe([]); +}); + +it('requires an explicit participant when provider observations are ambiguous', function (): void { + expect(bird_control_plane_activator::selectParticipantId('', ['participant-1'])) + ->toBe('participant-1') + ->and(bird_control_plane_activator::selectParticipantId( + 'configured-participant', + ['participant-1', 'participant-2'] + ))->toBe('configured-participant') + ->and(fn () => bird_control_plane_activator::selectParticipantId( + '', + ['participant-1', 'participant-2'] + ))->toThrow(RuntimeException::class); +}); + +it('discovers only access key participants from supported conversation shapes', function (): void { + expect(bird_control_plane_activator::accessKeyParticipantIds([ + 'participants' => [ + ['type' => 'contact', 'id' => 'contact-1'], + ['type' => 'accessKey', 'id' => 'access-key-1'], + ], + 'featuredParticipants' => [ + ['type' => 'ACCESSKEY', 'id' => 'access-key-2'], + ], + 'lastMessage' => [ + 'sender' => ['type' => 'accessKey', 'id' => 'access-key-1'], + ], + ]))->toBe(['access-key-1', 'access-key-2']); +}); + +it('keeps automation disabled unless policy documents are explicitly valid', function (): void { + $validFlow = json_encode([ + 'version' => 'v1', + 'rules' => [[ + 'id' => 'route-support', + 'enabled' => true, + 'when' => ['platform' => 'whatsapp'], + 'actions' => [['type' => 'tag', 'value' => 'support']], + ]], + ], JSON_THROW_ON_ERROR); + $invalidFlow = json_encode([ + 'version' => 'v1', + 'rules' => [['enabled' => true]], + ], JSON_THROW_ON_ERROR); + $validTemplates = json_encode([ + 'version' => 'v1', + 'templates' => [[ + 'enabled' => true, + 'projectId' => 'project-1', + 'version' => 'version-1', + 'locale' => 'da-DK', + 'parameterKeys' => ['name'], + ]], + ], JSON_THROW_ON_ERROR); + + expect(bird_control_plane_activator::policyActive($validFlow, 'rules'))->toBeTrue() + ->and(bird_control_plane_activator::policyActive($invalidFlow, 'rules'))->toBeFalse() + ->and(bird_control_plane_activator::safePolicy($invalidFlow, 'rules')) + ->toBe('{"version":"v1","rules":[]}') + ->and(bird_control_plane_activator::policyActive($validTemplates, 'templates'))->toBeTrue() + ->and(bird_control_plane_activator::policyActive( + '{"version":"v1","templates":[]}', + 'templates' + ))->toBeFalse(); +}); + +it('keeps deployment activation provider-read-only and exposes participant status', function (): void { + $activator = file_get_contents( + app_path('modules/bird/classes/bird_control_plane_activator.php') + ); + $route = file_get_contents(app_path('routes/birdControlPlaneRoute.php')); + + expect($activator)->not->toBeFalse() + ->and($activator)->toContain(bird_control_plane_activator::PUBLIC_WEBHOOK_URL) + ->and($activator)->toContain('provider calls are limited to channels/conversations GETs') + ->and($activator)->not->toContain('CURLOPT_POST') + ->and($activator)->not->toContain('CURLOPT_CUSTOMREQUEST') + ->and($activator)->not->toContain('/webhook-subscriptions') + ->and($route)->toContain("'participantId' => trim("); +}); + +it('requires strong activation secrets and sanitizes identifiers', function (): void { + expect(bird_control_plane_activator::secretAcceptable(str_repeat('a', 31)))->toBeFalse() + ->and(bird_control_plane_activator::secretAcceptable(str_repeat('a', 32)))->toBeTrue() + ->and(bird_control_plane_activator::sanitizeId('participant-123456789')) + ->toBe('part...6789') + ->and(bird_control_plane_activator::sanitizeId('short'))->not->toBe('short'); +}); + +it('requires Bird to advertise both conversation events with channel filtering', function (): void { + $supported = [ + 'results' => [[ + 'service' => 'conversations', + 'events' => [ + ['name' => 'conversation.created', 'filterKeys' => ['channelId']], + ['name' => 'conversation.updated', 'filterKeys' => ['channelId']], + ], + ]], + ]; + $missingFilter = [ + 'results' => [[ + 'service' => 'conversations', + 'events' => [ + ['name' => 'conversation.created', 'filterKeys' => ['channelId']], + ['name' => 'conversation.updated', 'filterKeys' => []], + ], + ]], + ]; + + expect(bird_webhook_subscription_reconciler::capabilitiesAdvertised($supported))->toBeTrue() + ->and(bird_webhook_subscription_reconciler::capabilitiesAdvertised($missingFilter)) + ->toBeFalse(); +}); + +it('matches only exact owned webhook targets and leaves unrelated subscriptions alone', function (): void { + $target = ['event' => 'conversation.created', 'channelId' => 'channel-1']; + $subscriptions = [ + [ + 'id' => 'owned', + 'service' => 'conversations', + 'event' => 'conversation.created', + 'url' => bird_control_plane_activator::PUBLIC_WEBHOOK_URL, + 'eventFilters' => [['key' => 'channelId', 'value' => 'channel-1']], + ], + [ + 'id' => 'other-url', + 'service' => 'conversations', + 'event' => 'conversation.created', + 'url' => 'https://other.example.test/webhook', + 'eventFilters' => [['key' => 'channelId', 'value' => 'channel-1']], + ], + [ + 'id' => 'wider-filter', + 'service' => 'conversations', + 'event' => 'conversation.created', + 'url' => bird_control_plane_activator::PUBLIC_WEBHOOK_URL, + 'eventFilters' => [ + ['key' => 'channelId', 'value' => 'channel-1'], + ['key' => 'status', 'value' => 'active'], + ], + ], + ]; + + expect(bird_webhook_subscription_reconciler::matchingSubscriptions( + $subscriptions, + $target + ))->toHaveCount(1) + ->and(bird_webhook_subscription_reconciler::matchingSubscriptions( + $subscriptions, + $target + )[0]['id'])->toBe('owned') + ->and(bird_webhook_subscription_reconciler::targets(['channel-1', 'channel-2'])) + ->toHaveCount(4); +}); + +it('discovers an organization only when UUID metadata is unique and workspace-consistent', function (): void { + $workspaceId = 'a1405560-c8d3-4b1a-877d-3f449ad95352'; + $organizationId = '823fbfaf-f14e-4693-b55a-8ec1c17d649e'; + $discovered = bird_webhook_subscription_reconciler::discoverOrganizationContext([ + ['results' => [[ + 'id' => 'channel-1', + 'workspaceId' => $workspaceId, + 'organizationId' => $organizationId, + ]]], + ['results' => [[ + 'id' => 'conversation-1', + 'workspaceId' => $workspaceId, + 'organizationId' => $organizationId, + ]]], + ], $workspaceId); + $mismatch = bird_webhook_subscription_reconciler::discoverOrganizationContext([ + ['results' => [[ + 'workspaceId' => 'b4e02c85-c6d2-4b15-8885-e09671799c61', + 'organizationId' => $organizationId, + ]]], + ], $workspaceId); + + expect($discovered['candidateIds'])->toBe([$organizationId]) + ->and($discovered['workspaceConsistent'])->toBeTrue() + ->and($mismatch['workspaceConsistent'])->toBeFalse(); +}); + +it('prefers organization listing and accepts only explicit workspace fallback collections', function (): void { + $workspaceId = 'a1405560-c8d3-4b1a-877d-3f449ad95352'; + $organizationId = '823fbfaf-f14e-4693-b55a-8ec1c17d649e'; + + expect(bird_webhook_subscription_reconciler::subscriptionListPath( + $organizationId, + $workspaceId + ))->toBe( + '/organizations/' . $organizationId . '/workspaces/' . $workspaceId + . '/webhook-subscriptions' + )->and(bird_webhook_subscription_reconciler::subscriptionListPath( + '', + $workspaceId + ))->toBe('/workspaces/' . $workspaceId . '/webhook-subscriptions') + ->and(bird_webhook_subscription_reconciler::validSubscriptionPage([ + 'results' => [], + ]))->toBeTrue() + ->and(bird_webhook_subscription_reconciler::validSubscriptionPage([]))->toBeFalse() + ->and(bird_webhook_subscription_reconciler::validSubscriptionPage([ + 'error' => 'not a collection', + ]))->toBeFalse(); +}); + +it('seals the bootstrap token with RSA OAEP SHA256 and the committed public key', function (): void { + $sealer = new bird_control_plane_bootstrap_sealer(); + $token = str_repeat('A', 64); + $ciphertext = $sealer->seal($token); + + expect($sealer->fingerprint())->toBe(bird_control_plane_bootstrap_sealer::KEY_FINGERPRINT) + ->and(bird_control_plane_bootstrap_sealer::ciphertextValid($ciphertext))->toBeTrue() + ->and($ciphertext)->not->toContain($token); +}); + +it('round trips bootstrap ciphertext with RSA OAEP SHA256', function (): void { + $key = openssl_pkey_new([ + 'private_key_bits' => 3072, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + expect($key)->not->toBeFalse(); + openssl_pkey_export($key, $privatePem); + $details = openssl_pkey_get_details($key); + expect($details)->toBeArray()->and($details['key'] ?? null)->toBeString(); + $publicPath = tempnam(sys_get_temp_dir(), 'bird-bootstrap-public-'); + $privatePath = tempnam(sys_get_temp_dir(), 'bird-bootstrap-private-'); + file_put_contents($publicPath, $details['key']); + file_put_contents($privatePath, $privatePem); + chmod($privatePath, 0600); + try { + $probe = new bird_control_plane_bootstrap_sealer($publicPath, ''); + $sealer = new bird_control_plane_bootstrap_sealer($publicPath, $probe->fingerprint()); + $token = str_repeat('D', 64); + $ciphertext = base64_decode($sealer->seal($token), true); + $pipes = []; + $process = proc_open([ + 'openssl', + 'pkeyutl', + '-decrypt', + '-inkey', + $privatePath, + '-pkeyopt', + 'rsa_padding_mode:oaep', + '-pkeyopt', + 'rsa_oaep_md:sha256', + '-pkeyopt', + 'rsa_mgf1_md:sha256', + ], [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], $pipes, null, null, ['bypass_shell' => true]); + expect($process)->toBeResource(); + fwrite($pipes[0], $ciphertext); + fclose($pipes[0]); + $plaintext = stream_get_contents($pipes[1]); + fclose($pipes[1]); + stream_get_contents($pipes[2]); + fclose($pipes[2]); + expect(proc_close($process))->toBe(0) + ->and($plaintext)->toBe($token); + } finally { + unlink($publicPath); + unlink($privatePath); + } +}); + +it('recognizes stable bootstrap metadata without rotating a valid existing token', function (): void { + $token = str_repeat('B', 64); + $config = [ + 'control_plane_bootstrap_token_hash' => hash('sha256', $token), + 'control_plane_bootstrap_algorithm' => 'RSA-OAEP-256', + 'control_plane_bootstrap_key_fingerprint' => + bird_control_plane_bootstrap_sealer::KEY_FINGERPRINT, + 'control_plane_bootstrap_token_version' => '7', + 'control_plane_bootstrap_updated_at' => '2026-07-29T17:30:00Z', + 'control_plane_bootstrap_ciphertext' => base64_encode(str_repeat("\0", 384)), + ]; + + expect(bird_control_plane_auto_activation::existingBootstrapValid($config, $token)) + ->toBeTrue() + ->and(bird_control_plane_auto_activation::existingBootstrapValid( + $config, + str_repeat('C', 64) + ))->toBeFalse(); + + $payload = json_decode(bird_control_plane_auto_activation::sealedPayload( + $token, + 7, + '2026-07-29T17:30:00Z' + ), true, 512, JSON_THROW_ON_ERROR); + expect($payload)->toBe([ + 'algorithm' => 'RSA-OAEP-256', + 'keyFingerprint' => bird_control_plane_bootstrap_sealer::KEY_FINGERPRINT, + 'tokenVersion' => 7, + 'updatedAt' => '2026-07-29T17:30:00Z', + 'token' => $token, + ]); +}); + +it('keeps capabilities and bootstrap unavailable when webhook reconciliation fails', function (): void { + $state = [ + 'controlPlaneEnabled' => true, + 'outboundMessagesEnabled' => true, + 'operationsActionsEnabled' => true, + 'bootstrapReady' => true, + ]; + $disableCount = 0; + $enableCount = 0; + $finalCheckCount = 0; + $disable = function () use (&$state, &$disableCount): void { + $disableCount++; + $state = [ + 'controlPlaneEnabled' => false, + 'outboundMessagesEnabled' => false, + 'operationsActionsEnabled' => false, + 'bootstrapReady' => false, + ]; + }; + + expect(fn () => bird_control_plane_auto_activation::guardedActivation( + $disable, + static function (): void { + }, + static function (): void { + throw new RuntimeException('simulated_webhook_failure'); + }, + function () use (&$enableCount): void { + $enableCount++; + }, + function () use (&$finalCheckCount): void { + $finalCheckCount++; + } + ))->toThrow(RuntimeException::class, 'simulated_webhook_failure'); + + expect($state)->toBe([ + 'controlPlaneEnabled' => false, + 'outboundMessagesEnabled' => false, + 'operationsActionsEnabled' => false, + 'bootstrapReady' => false, + ])->and($disableCount)->toBe(2) + ->and($enableCount)->toBe(0) + ->and($finalCheckCount)->toBe(0); +}); + +it('accepts staged activation only while every write capability remains disabled', function (): void { + $status = [ + 'schema' => ['ready' => true], + 'providerReadReady' => true, + 'moduleEnabled' => true, + 'providerCredentialConfigured' => true, + 'providerUrlConfigured' => true, + 'workspaceId' => 'wor...pace', + 'allowedChannelCount' => 1, + 'participantConfigured' => true, + 'controlPlaneCredentialConfigured' => true, + 'webhookSigningCredentialConfigured' => true, + 'webhookPublicUrlExact' => true, + 'controlPlaneEnabled' => false, + 'outboundMessagesEnabled' => false, + 'operationsActionsEnabled' => false, + ]; + + expect(bird_control_plane_auto_activation::stagedActivationReady($status))->toBeTrue(); + $status['outboundMessagesEnabled'] = true; + expect(bird_control_plane_auto_activation::stagedActivationReady($status))->toBeFalse(); +}); + +it('wires fail-closed startup and a pinned local bootstrap without plaintext exposure', function (): void { + $repoRoot = getenv('PLENO_REPO_ROOT_FOR_TESTS'); + expect($repoRoot)->toBeString()->not->toBe(''); + $start = file_get_contents($repoRoot . '/services/coolify/api/start.sh'); + $local = file_get_contents($repoRoot . '/scripts/bird-control-plane-bootstrap-local.sh'); + $route = file_get_contents(app_path('routes/birdControlPlaneRoute.php')); + + expect($start)->toContain('bird-control-plane-auto-activate.php') + ->and(strpos($start, 'bird-control-plane-auto-activate.php')) + ->toBeLessThan(strpos($start, 'php-fpm -D')) + ->and($local)->toContain( + "bootstrap_url='https://api.truckwash.io:4433/bird/control-plane/v1/bootstrap'" + ) + ->and($local)->toContain( + "status_url='https://api.truckwash.io:4433/bird/control-plane/v1/status'" + ) + ->and($local)->toContain(bird_control_plane_bootstrap_sealer::KEY_FINGERPRINT) + ->and($local)->toContain("destination=\"\$credential_dir/bird.gateway-token\"") + ->and($local)->toContain('chmod 600 "$destination"') + ->and($local)->toContain('openssl pkeyutl -decrypt') + ->and($route)->toContain('/bird/control-plane/v1/bootstrap') + ->and($route)->toContain("header('Cache-Control: no-store, max-age=0')") + ->and($route)->toContain("\$response->error('Not found', 404)"); +}); diff --git a/services/nginx/app/tests/Unit/Bird/BirdControlPlaneContractTest.php b/services/nginx/app/tests/Unit/Bird/BirdControlPlaneContractTest.php new file mode 100644 index 00000000..daf3f432 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bird/BirdControlPlaneContractTest.php @@ -0,0 +1,183 @@ + 'voice.call.hangup', + 'resourceId' => 'call-123', + 'confirmed' => true, + 'parameters' => [ + 'channelId' => 'channel-456', + 'cause' => 'busy', + ], + ], ['channel-456']); + $sameOperation = bird_control_plane_contract::hangup([ + 'confirmed' => true, + 'parameters' => ['cause' => 'busy', 'channelId' => 'channel-456'], + 'resourceId' => 'call-123', + 'action' => 'voice.call.hangup', + ], ['channel-456']); + + expect($operation)->toBe([ + 'action' => 'voice.call.hangup', + 'resourceId' => 'call-123', + 'channelId' => 'channel-456', + 'cause' => 'busy', + ])->and(bird_control_plane_contract::operationRequestHash($operation)) + ->toBe(bird_control_plane_contract::operationRequestHash($sameOperation)); + + expect(fn () => bird_control_plane_contract::hangup([ + 'action' => 'voice.call.hangup', + 'resourceId' => 'call-123', + 'confirmed' => true, + 'parameters' => [ + 'channelId' => 'channel-456', + 'cause' => 'operator_requested', + ], + ], ['channel-456']))->toThrow(InvalidArgumentException::class, 'Invalid hangup cause'); +}); + +it('rejects webhook scope before persistence unless workspace and channel exactly match', function (): void { + $payload = [ + 'data' => [ + 'workspaceId' => 'workspace-1', + 'conversation' => ['channelId' => 'channel-1'], + ], + ]; + expect(bird_control_plane_contract::webhookScope( + $payload, + 'workspace-1', + ['channel-1', 'channel-2'] + ))->toBe([ + 'workspaceId' => 'workspace-1', + 'channelId' => 'channel-1', + ])->and(fn () => bird_control_plane_contract::webhookScope( + $payload, + 'workspace-2', + ['channel-1', 'channel-2'] + ))->toThrow(InvalidArgumentException::class, 'workspace is not allowlisted') + ->and(fn () => bird_control_plane_contract::webhookScope( + $payload, + 'workspace-1', + ['channel-2'] + ))->toThrow(InvalidArgumentException::class, 'channel is not allowlisted'); +}); + +it('supports multiple explicit channel IDs and fails closed with a legacy fallback only for empty config', function (): void { + expect(bird_control_plane_contract::allowedChannelIds( + '["channel-1","channel-2","channel-1"]', + 'legacy-channel' + ))->toBe(['channel-1', 'channel-2']) + ->and(bird_control_plane_contract::allowedChannelIds('[]', 'legacy-channel')) + ->toBe(['legacy-channel']) + ->and(bird_control_plane_contract::allowedChannelIds('[]', ''))->toBe([]) + ->and(bird_control_plane_contract::allowedChannelIds('invalid-json', 'legacy-channel')) + ->toBe([]); +}); + +it('normalizes provider channels into the stable gateway envelope records', function (): void { + expect(bird_control_plane_contract::channels([ + 'results' => [[ + 'id' => 'channel-1', + 'displayName' => 'Customer WhatsApp', + 'type' => 'whatsapp', + 'status' => 'active', + 'credential' => 'must-not-leak', + ]], + ]))->toBe([[ + 'id' => 'channel-1', + 'name' => 'Customer WhatsApp', + 'platform' => 'whatsapp', + 'status' => 'active', + ]]); +}); + +it('confines conversation reads and sends to the exact channel allowlist', function (): void { + expect(bird_control_plane_contract::assertConversationChannel( + ['channelId' => 'channel-2'], + ['channel-1', 'channel-2'] + ))->toBe('channel-2') + ->and(fn () => bird_control_plane_contract::assertConversationChannel( + ['channelId' => 'channel-3'], + ['channel-1', 'channel-2'] + ))->toThrow(InvalidArgumentException::class, 'conversation channel is not allowlisted'); +}); + +it('returns a messageId alias and exposes crash-safe pending reconciliation state', function (): void { + $base = [ + 'reference_id' => 'reference-1', + 'conversation_id' => 'conversation-1', + 'message_kind' => 'text', + 'request_hash' => str_repeat('a', 64), + 'provider_message_id' => 'message-1', + 'response_json' => '{"id":"message-1"}', + 'created_at' => '2026-07-29 12:00:00', + 'updated_at' => '2026-07-29 12:00:01', + ]; + $completed = bird_outbound_message_store::recordFromRow($base + ['status' => 'completed']); + $pending = bird_outbound_message_store::recordFromRow($base + [ + 'status' => 'pending', + 'provider_message_id' => null, + 'response_json' => null, + ]); + + expect($completed['messageId'])->toBe('message-1') + ->and($completed['providerMessageId'])->toBe('message-1') + ->and($completed['reconciliationRequired'])->toBeFalse() + ->and($pending['reconciliationRequired'])->toBeTrue() + ->and($pending['retrySafe'])->toBeFalse(); +}); + +it('returns a structured non-retryable conflict for unresolved reservations', function (): void { + $outcome = bird_control_plane_contract::reservationOutcome([ + 'reference' => 'reference-1', + 'status' => 'ambiguous', + 'reconciliationRequired' => true, + ]); + + expect($outcome['ambiguous'])->toBeTrue() + ->and($outcome['statusCode'])->toBe(409) + ->and($outcome['payload'])->toMatchArray([ + 'code' => 'external_action_ambiguous', + 'outcomeAmbiguous' => true, + 'reference' => 'reference-1', + 'status' => 'ambiguous', + 'reconciliationRequired' => true, + 'retrySafe' => false, + ]); +}); + +it('keeps Bird ledger DDL in the checked-in additive schema bootstrap', function (): void { + $queries = bird_control_plane_schema_bootstrap::queries(); + + expect(bird_control_plane_schema_bootstrap::VERSION)->toBe(1) + ->and($queries)->toHaveCount(3) + ->and(implode("\n", $queries))->toContain('bird_control_plane_schema_versions') + ->and(implode("\n", $queries))->toContain('bird_webhook_events') + ->toContain('bird_outbound_messages') + ->toContain('CREATE TABLE IF NOT EXISTS'); +}); + +it('uses exact static message routes without broadening global dynamic segments', function (): void { + $_SERVER['REQUEST_URI'] = '/bird/control-plane/v1/messages'; + $_SERVER['REQUEST_METHOD'] = 'POST'; + $router = new router(); + $matcher = new ReflectionMethod(router::class, 'doesRouteMatchCurrent'); + + expect($matcher->invoke($router, '/bird/control-plane/v1/messages'))->toBeTrue(); + + $_SERVER['REQUEST_URI'] = '/bird/control-plane/v1/conversations/uuid-with-hyphens/messages'; + $router = new router(); + expect($matcher->invoke( + $router, + '/bird/control-plane/v1/conversations/{id}/messages' + ))->toBeFalse(); +}); diff --git a/services/nginx/app/tests/Unit/Bird/BirdControlPlaneRouteWiringTest.php b/services/nginx/app/tests/Unit/Bird/BirdControlPlaneRouteWiringTest.php new file mode 100644 index 00000000..fd82404a --- /dev/null +++ b/services/nginx/app/tests/Unit/Bird/BirdControlPlaneRouteWiringTest.php @@ -0,0 +1,71 @@ +not->toBeFalse() + ->and($content)->toContain('/bird/health') + ->and($content)->toContain('/bird/control-plane/v1/status') + ->and($content)->toContain('/bird/control-plane/v1/channels') + ->and($content)->toContain('/bird/control-plane/v1/events') + ->and($content)->toContain('/bird/control-plane/v1/conversations') + ->and($content)->toContain('/bird/control-plane/v1/messages') + ->and($content)->toContain('/bird/control-plane/v1/calls') + ->and($content)->toContain('/bird/control-plane/v1/recordings') + ->and($content)->toContain('/bird/control-plane/v1/insights') + ->and($content)->toContain('/bird/control-plane/v1/numbers') + ->and($content)->toContain('/bird/webhooks/notifications') + ->and($content)->toContain('/bird/flows/evaluate') + ->and($content)->toContain('verifyBirdWebhookSignature') + ->and($content)->toContain('timestampWithinReplayWindow') + ->and($content)->toContain('verifyFlowSignature') + ->and($content)->toContain('control_plane_enabled') + ->and($content)->toContain('outbound_messages_enabled') + ->and($content)->toContain('Explicit confirmation is required') + ->and($content)->toContain('bird_schema_not_ready') + ->and($content)->toContain("'reference' => \$reference, 'limit' => 100") + ->and($content)->not->toContain('deleteNumber(') + ->and($content)->not->toContain('openGate'); +}); + +it('keeps schema mutation out of request stores', function (): void { + $schema = file_get_contents( + app_path('modules/bird/classes/bird_control_plane_schema_bootstrap.php') + ); + $eventStore = file_get_contents(app_path('modules/bird/classes/bird_webhook_event_store.php')); + $outboundStore = file_get_contents(app_path('modules/bird/classes/bird_outbound_message_store.php')); + + expect($schema)->not->toBeFalse() + ->and($schema)->toContain("PHP_SAPI !== 'cli'") + ->and($eventStore)->not->toContain('CREATE TABLE') + ->and($outboundStore)->not->toContain('CREATE TABLE'); +}); + +it('defines all sensitive Bird integration switches as disabled or empty by default', function (): void { + $module = file_get_contents(app_path('modules/bird/bird_c.php')); + $controlPlane = file_get_contents(app_path('modules/bird/config/bird_control_plane_enabled_c.php')); + $flow = file_get_contents(app_path('modules/bird/config/bird_flow_enabled_c.php')); + $token = file_get_contents(app_path('modules/bird/config/bird_control_plane_token_c.php')); + $signingKey = file_get_contents(app_path('modules/bird/config/bird_webhook_signing_key_c.php')); + $allowedChannels = file_get_contents( + app_path('modules/bird/config/bird_allowed_channel_ids_json_c.php') + ); + + expect($module)->toContain('bird_workspaceId_c') + ->and($module)->toContain('bird_workplaceId_c') + ->and($controlPlane)->toContain("'false'") + ->and($flow)->toContain("'false'") + ->and($token)->toContain('true,') + ->and($token)->toContain("''") + ->and($signingKey)->toContain('true,') + ->and($signingKey)->toContain("''") + ->and($allowedChannels)->toContain("'[]'"); +}); + +it('prefers canonical workspaceId and falls back to legacy workplaceId only when empty', function (): void { + $content = file_get_contents(app_path('traits/bird_route_helpers_t.php')); + + expect($content)->toContain("property_exists(\$client->config, 'workspaceId')") + ->and($content)->toContain("if (\$canonical !== '')") + ->and($content)->toContain("property_exists(\$client->config, 'workplaceId')"); +}); diff --git a/services/nginx/app/tests/Unit/Bird/BirdControlPlaneSecurityTest.php b/services/nginx/app/tests/Unit/Bird/BirdControlPlaneSecurityTest.php new file mode 100644 index 00000000..6d03a636 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bird/BirdControlPlaneSecurityTest.php @@ -0,0 +1,106 @@ +toBeTrue() + ->and(bird_control_plane_security::verifyBirdWebhookSignature( + $key, + $timestamp, + $url, + $body . "\n", + $signature + ))->toBeFalse() + ->and(bird_control_plane_security::verifyBirdWebhookSignature( + $key, + $timestamp, + 'https://internal.example.test/bird/webhooks/notifications', + $body, + $signature + ))->toBeFalse(); +}); + +it('fails closed for malformed signatures and stale webhook timestamps', function (): void { + expect(bird_control_plane_security::verifyBirdWebhookSignature( + 'key', + '1785312000', + 'https://api.example.test/bird/webhooks/notifications', + '{}', + 'not-base64!' + ))->toBeFalse() + ->and(bird_control_plane_security::timestampWithinReplayWindow( + '1785312000', + 300, + 1785312200 + ))->toBeTrue() + ->and(bird_control_plane_security::timestampWithinReplayWindow( + '1785312000000', + 300, + 1785312200 + ))->toBeTrue() + ->and(bird_control_plane_security::timestampWithinReplayWindow( + '1785312000', + 300, + 1785312401 + ))->toBeFalse() + ->and(bird_control_plane_security::timestampWithinReplayWindow( + 'tomorrow', + 300, + 1785312000 + ))->toBeFalse(); +}); + +it('accepts only an exact configured bearer token', function (): void { + expect(bird_control_plane_security::bearerToken( + ['HTTP_AUTHORIZATION' => 'Bearer expected-token'] + ))->toBe('expected-token') + ->and(bird_control_plane_security::verifyBearer('expected-token', 'expected-token'))->toBeTrue() + ->and(bird_control_plane_security::verifyBearer('expected-token', 'Expected-token'))->toBeFalse() + ->and(bird_control_plane_security::verifyBearer('', 'anything'))->toBeFalse(); +}); + +it('verifies timestamp-bound Bird Flow request signatures', function (): void { + $secret = 'flow-secret'; + $timestamp = '1785312000'; + $body = '{"event":{"platform":"sms"}}'; + $signature = 'sha256=' . hash_hmac('sha256', $timestamp . "\n" . $body, $secret); + + expect(bird_control_plane_security::verifyFlowSignature( + $secret, + $timestamp, + $body, + $signature + ))->toBeTrue() + ->and(bird_control_plane_security::verifyFlowSignature( + $secret, + $timestamp, + $body . ' ', + $signature + ))->toBeFalse() + ->and(bird_control_plane_security::verifyFlowSignature( + '', + $timestamp, + $body, + $signature + ))->toBeFalse(); +}); diff --git a/services/nginx/app/tests/Unit/Bird/BirdFlowPolicyEvaluatorTest.php b/services/nginx/app/tests/Unit/Bird/BirdFlowPolicyEvaluatorTest.php new file mode 100644 index 00000000..162deaa4 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bird/BirdFlowPolicyEvaluatorTest.php @@ -0,0 +1,58 @@ + 'v1', + 'rules' => [[ + 'id' => 'vip-whatsapp', + 'enabled' => true, + 'when' => [ + 'platform' => 'whatsapp', + 'contact.segment' => 'vip', + ], + 'actions' => [ + ['type' => 'tag', 'value' => 'vip'], + ['type' => 'assign', 'value' => 'support-team'], + ['type' => 'snooze', 'value' => 'PT15M'], + ], + ]], + ], JSON_THROW_ON_ERROR); + + $decision = bird_flow_policy_evaluator::evaluate($policy, [ + 'platform' => 'whatsapp', + 'contact' => ['segment' => 'vip'], + ]); + + expect($decision)->toMatchArray([ + 'decision' => 'allow', + 'reason' => 'matched_rule', + 'policyVersion' => 'v1', + 'ruleId' => 'vip-whatsapp', + ])->and($decision['actions'])->toHaveCount(3); +}); + +it('fails closed for absent matches, invalid policy and any non-allowlisted action', function (): void { + $unsafePolicy = json_encode([ + 'version' => 'v1', + 'rules' => [[ + 'id' => 'unsafe', + 'enabled' => true, + 'when' => ['platform' => 'email'], + 'actions' => [['type' => 'send_message', 'value' => 'hello']], + ]], + ], JSON_THROW_ON_ERROR); + + expect(bird_flow_policy_evaluator::evaluate('', ['platform' => 'email'])['decision'])->toBe('deny') + ->and(bird_flow_policy_evaluator::evaluate($unsafePolicy, ['platform' => 'email'])) + ->toMatchArray([ + 'decision' => 'deny', + 'reason' => 'invalid_rule_actions', + 'actions' => [], + ]) + ->and(bird_flow_policy_evaluator::evaluate($unsafePolicy, ['platform' => 'sms'])['decision']) + ->toBe('deny'); +}); diff --git a/services/nginx/app/traits/bird_route_helpers_t.php b/services/nginx/app/traits/bird_route_helpers_t.php index 6dd08b52..4146db3e 100644 --- a/services/nginx/app/traits/bird_route_helpers_t.php +++ b/services/nginx/app/traits/bird_route_helpers_t.php @@ -2,6 +2,9 @@ namespace traits; +require_once WD . '/modules/bird/classes/bird_control_plane_contract.php'; + +use bird\classes\bird_control_plane_contract; use classes\bird; trait bird_route_helpers_t @@ -27,12 +30,20 @@ trait bird_route_helpers_t $workspaceConfig = null; if (property_exists($client->config, 'workspaceId')) { $workspaceConfig = $client->config->workspaceId; - } elseif (property_exists($client->config, 'workplaceId')) { - // Backward compatibility with existing config key naming. - $workspaceConfig = $client->config->workplaceId; } if (is_object($workspaceConfig) && method_exists($workspaceConfig, 'getVariableValue')) { - return $this->normalizeOptionalString($workspaceConfig->getVariableValue()); + $canonical = $this->normalizeOptionalString($workspaceConfig->getVariableValue()); + if ($canonical !== '') { + return $canonical; + } + } + + // Backward compatibility with the historical misspelled config key. + if (property_exists($client->config, 'workplaceId')) { + $workspaceConfig = $client->config->workplaceId; + if (is_object($workspaceConfig) && method_exists($workspaceConfig, 'getVariableValue')) { + return $this->normalizeOptionalString($workspaceConfig->getVariableValue()); + } } return ''; } @@ -48,4 +59,22 @@ trait bird_route_helpers_t } return ''; } + + /** + * @return array + */ + private function getAllowedChannelIds(bird $client): array + { + $raw = ''; + if (property_exists($client->config, 'allowed_channel_ids_json')) { + $config = $client->config->allowed_channel_ids_json; + if (is_object($config) && method_exists($config, 'getVariableValue')) { + $raw = trim((string)$config->getVariableValue()); + } + } + return bird_control_plane_contract::allowedChannelIds( + $raw, + $this->getConfiguredChannelId($client) + ); + } } diff --git a/services/nginx/app/traits/module_config_t.php b/services/nginx/app/traits/module_config_t.php index d8d86a7a..a2cb4d8f 100644 --- a/services/nginx/app/traits/module_config_t.php +++ b/services/nginx/app/traits/module_config_t.php @@ -146,17 +146,44 @@ trait module_config_t { $result = $db->query($sql); $config = []; + $secretVariables = $this->secretConfigVariables(); while ($row = $result->fetch_assoc()) { + $variable = (string)$row['variable']; + $isSecret = isset($secretVariables[$variable]); + $rawValue = $row['value']; + $isSet = $rawValue !== null && trim((string)$rawValue) !== ''; $config[] = [ 'module' => $row['module'], - 'variable' => $row['variable'], + 'variable' => $variable, 'type' => $row['type'], - 'value' => $this->parseConfigVariableType($row['type'], $row['value']) + // Existing configuration screens use a non-empty value only as + // a "configured" indicator. Preserve that contract without + // returning the stored secret; new consumers should use isSet. + 'value' => $isSecret ? ($isSet ? '[redacted]' : '') : $this->parseConfigVariableType($row['type'], $rawValue), + 'isSecret' => $isSecret, + 'isSet' => $isSet, ]; } return $config; } + /** + * @return array + */ + private function secretConfigVariables(): array + { + $variables = []; + foreach ($this->config_classes ?? [] as $configClass) { + $definition = new $configClass(); + if (($definition->config_variable_is_secret ?? false) !== true + || !method_exists($definition, 'getVariableName')) { + continue; + } + $variables[(string)$definition->getVariableName()] = true; + } + return $variables; + } + function parseConfigVariableType($type, $value) { // If the variable is an int, convert it to an int