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.
This commit is contained in:
Jeppe B
2026-07-29 19:59:20 +02:00
committed by GitHub
parent 1b161f1b96
commit a442e70744
44 changed files with 5263 additions and 19 deletions
-13
View File
@@ -212,19 +212,7 @@ class bird implements bird_i
throw new Exception('cURL error: ' . $err);
}
curl_close($ch);
// Debug slack
$data = json_decode($body, true) ?? [];
$resp = $resp === false ? 'cURL error with no response' : $resp;
$slack_debug_message = "*Bird API Request Debug:*"
. "\nEndpoint: $url"
. "\nMethod: $method"
. "\nStatus: $code"
. "\nPayload Keys: " . implode(',', array_keys($data))
. "\nResponse: $resp";
// Send slack notification for every request for easier debugging of issues in production (can be removed later if too noisy)
$slack = new \classes\slack();
$slack->send_message($slack_debug_message);
return [
'status_code' => (int)$code,
'body' => $resp,
@@ -1073,4 +1061,3 @@ class bird implements bird_i
}
}
+136
View File
@@ -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
@@ -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();
}
}
@@ -0,0 +1,586 @@
<?php
namespace bird\classes;
use Closure;
use InvalidArgumentException;
use PDO;
use RuntimeException;
use Throwable;
require_once WD . '/modules/bird/classes/bird_control_plane_contract.php';
require_once WD . '/modules/bird/classes/bird_control_plane_schema_bootstrap.php';
/**
* One-shot, deployment-only activation coordinator.
*
* check() is read-only. apply() is CLI-only and deliberately performs no Bird
* provider mutation: provider calls are limited to channels/conversations GETs.
*/
final class bird_control_plane_activator
{
public const PUBLIC_WEBHOOK_URL = 'https://api.truckwash.io:4433/bird/webhooks/notifications';
private const SECRET_MINIMUM_BYTES = 32;
/** @var Closure(array<string,string>,string,array<string,mixed>):array|object|null */
private readonly Closure $providerGet;
/**
* @param null|callable(array<string,string>,string,array<string,mixed>):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<string,mixed>
*/
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<string,mixed>
*/
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<string,string> $config
* @param array<int,string> $allowedChannelIds
* @return array{ready:bool,errorCode:?string,participantIds:array<int,string>,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<string,string> $config
* @param array<int,string> $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<string,string>
*/
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<string,string> $config
* @param array<string,mixed> $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<int,string>
*/
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<int,string> $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<int,string>
*/
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<int,mixed> $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<int,mixed> $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',
};
}
}
@@ -0,0 +1,348 @@
<?php
namespace bird\classes;
use DateTimeImmutable;
use PDO;
use RuntimeException;
use Throwable;
require_once WD . '/modules/bird/classes/bird_control_plane_activator.php';
require_once WD . '/modules/bird/classes/bird_webhook_subscription_reconciler.php';
require_once WD . '/modules/bird/classes/bird_control_plane_bootstrap_sealer.php';
final class bird_control_plane_auto_activation
{
private const TOKEN_HASH_VARIABLE = 'control_plane_bootstrap_token_hash';
private const CIPHERTEXT_VARIABLE = 'control_plane_bootstrap_ciphertext';
private const ALGORITHM_VARIABLE = 'control_plane_bootstrap_algorithm';
private const KEY_ID_VARIABLE = 'control_plane_bootstrap_key_fingerprint';
private const VERSION_VARIABLE = 'control_plane_bootstrap_token_version';
private const UPDATED_AT_VARIABLE = 'control_plane_bootstrap_updated_at';
private const READY_VARIABLE = 'control_plane_bootstrap_ready';
public function __construct(
private readonly PDO $pdo,
private readonly bird_control_plane_bootstrap_sealer $sealer =
new bird_control_plane_bootstrap_sealer()
) {
}
/** @return array<string,mixed> */
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<string,mixed> $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<string,string> $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<string,mixed> $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<string,string> */
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<string,string> $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;
}
}
}
@@ -0,0 +1,101 @@
<?php
namespace bird\classes;
use RuntimeException;
final class bird_control_plane_bootstrap_sealer
{
public const ALGORITHM = 'RSA-OAEP-256';
public const KEY_FINGERPRINT = '6dc63c6ffe33b8de0b1396d7f529f56aea0a685ef98168016161cf721ddc8c21';
public const CIPHERTEXT_BYTES = 384;
public function __construct(
private readonly string $publicKeyPath =
WD . '/modules/bird/resources/control-plane-bootstrap-public.pem',
private readonly string $expectedFingerprint = self::KEY_FINGERPRINT
) {
}
public function seal(string $plaintext): string
{
if ($plaintext === '' || strlen($plaintext) > 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<int,string> $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;
}
}
@@ -0,0 +1,253 @@
<?php
namespace bird\classes;
use InvalidArgumentException;
final class bird_control_plane_contract
{
private const HANGUP_CAUSES = ['rejected', 'busy'];
/**
* @return array<int,string>
*/
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<int,array{id:string,name:string,platform:string,status:string}>
*/
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<int,array<string,mixed>>
*/
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<string,mixed>}
*/
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 '';
}
}
@@ -0,0 +1,131 @@
<?php
namespace bird\classes;
use PDO;
use PDOException;
use RuntimeException;
/**
* Explicit, CLI-applied schema management for the Bird Control Plane ledger.
*
* Web requests and workers may call only check()/requireReady(). The dedicated
* deployment CLI is the sole supported caller of apply().
*/
final class bird_control_plane_schema_bootstrap
{
public const VERSION = 1;
/**
* @return array{ready:bool,version:int,expectedVersion:int,missing:array<int,string>}
*/
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<int,string>
*/
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",
];
}
}
@@ -0,0 +1,112 @@
<?php
namespace bird\classes;
final class bird_control_plane_security
{
public static function bearerToken(array $server, array $headers = []): string
{
$authorization = (string)(
$headers['Authorization']
?? $headers['authorization']
?? $server['HTTP_AUTHORIZATION']
?? $server['REDIRECT_HTTP_AUTHORIZATION']
?? ''
);
return preg_match('/^Bearer\s+([^\s]+)$/i', trim($authorization), $matches) === 1
? trim($matches[1])
: '';
}
public static function verifyBearer(string $configuredToken, string $presentedToken): bool
{
$configuredToken = trim($configuredToken);
$presentedToken = trim($presentedToken);
return $configuredToken !== ''
&& $presentedToken !== ''
&& hash_equals($configuredToken, $presentedToken);
}
public static function verifyBirdWebhookSignature(
string $signingKey,
string $timestamp,
string $exactUrl,
string $rawBody,
string $signatureHeader
): bool {
$signingKey = trim($signingKey);
$timestamp = trim($timestamp);
$exactUrl = trim($exactUrl);
$signatureHeader = trim($signatureHeader);
if ($signingKey === '' || $timestamp === '' || $exactUrl === '' || $signatureHeader === '') {
return false;
}
$providedSignature = base64_decode($signatureHeader, true);
if ($providedSignature === false || strlen($providedSignature) !== 32) {
return false;
}
$bodyChecksum = hash('sha256', $rawBody, true);
$signedPayload = $timestamp . "\n" . $exactUrl . "\n" . $bodyChecksum;
$expectedSignature = hash_hmac('sha256', $signedPayload, $signingKey, true);
return hash_equals($expectedSignature, $providedSignature);
}
public static function timestampWithinReplayWindow(
string $timestamp,
int $windowSeconds,
?int $now = null
): bool {
if (preg_match('/^\d{10,13}$/', trim($timestamp)) !== 1) {
return false;
}
$value = (int)$timestamp;
if (strlen(trim($timestamp)) === 13) {
$value = (int)floor($value / 1000);
}
$windowSeconds = max(30, min(3600, $windowSeconds));
return abs(($now ?? time()) - $value) <= $windowSeconds;
}
public static function verifyFlowSignature(
string $sharedSecret,
string $timestamp,
string $rawBody,
string $signatureHeader
): bool {
$sharedSecret = trim($sharedSecret);
$timestamp = trim($timestamp);
$signatureHeader = strtolower(trim($signatureHeader));
if ($sharedSecret === ''
|| $timestamp === ''
|| preg_match('/^sha256=[a-f0-9]{64}$/', $signatureHeader) !== 1) {
return false;
}
$expected = 'sha256=' . hash_hmac(
'sha256',
$timestamp . "\n" . $rawBody,
$sharedSecret
);
return hash_equals($expected, $signatureHeader);
}
public static function header(array $server, array $headers, string $name): string
{
$lower = strtolower($name);
foreach ($headers as $key => $value) {
if (strtolower((string)$key) === $lower) {
return trim((string)$value);
}
}
$serverKey = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
return trim((string)($server[$serverKey] ?? ''));
}
}
@@ -0,0 +1,107 @@
<?php
namespace bird\classes;
final class bird_flow_policy_evaluator
{
private const ALLOWED_ACTIONS = ['tag', 'assign', 'snooze', 'close'];
/**
* @return array{decision:string,reason:string,policyVersion:string,ruleId:?string,actions:array<int,array<string,mixed>>}
*/
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' => [],
];
}
}
@@ -0,0 +1,111 @@
<?php
namespace bird\classes;
use PDO;
use PDOException;
require_once WD . '/modules/bird/classes/bird_control_plane_schema_bootstrap.php';
final class bird_outbound_message_store
{
public function __construct(private readonly PDO $pdo)
{
}
public function ensureSchema(): void
{
bird_control_plane_schema_bootstrap::requireReady($this->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<string,mixed> $row
* @return array<string,mixed>
*/
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'] ?? ''),
];
}
}
@@ -0,0 +1,128 @@
<?php
namespace bird\classes;
use PDO;
use PDOException;
require_once WD . '/modules/bird/classes/bird_control_plane_schema_bootstrap.php';
final class bird_webhook_event_store
{
public function __construct(private readonly PDO $pdo)
{
}
public function ensureSchema(): void
{
bird_control_plane_schema_bootstrap::requireReady($this->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<int,array<string,mixed>>,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,
];
}
}
@@ -0,0 +1,551 @@
<?php
namespace bird\classes;
use Closure;
use PDO;
use RuntimeException;
use Throwable;
require_once WD . '/modules/bird/classes/bird_control_plane_contract.php';
require_once WD . '/modules/bird/classes/bird_control_plane_activator.php';
final class bird_webhook_subscription_reconciler
{
private const EVENTS = ['conversation.created', 'conversation.updated'];
/** @var Closure(array<string,string>,string,string,array<string,mixed>,?array):array */
private readonly Closure $providerRequest;
/**
* @param null|callable(array<string,string>,string,string,array<string,mixed>,?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<string,mixed> */
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<string,mixed> */
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<int,array<string,mixed>> $subscriptions
* @param array{event:string,channelId:string} $target
* @return array<int,array<string,mixed>>
*/
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<int,string> $channelIds
* @return array<int,array{event:string,channelId:string}>
*/
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<int,array<string,mixed>> $responses
* @return array{candidateIds:array<int,string>,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<string,string>,1:string,2:array<int,string>,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<int,array<string,mixed>> */
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<string,string> */
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';
}
}
@@ -0,0 +1,29 @@
<?php
namespace bird\config;
require_once WD . '/traits/module_config_variable_t.php';
use Exception;
use traits\module_config_variable;
class bird_allowed_channel_ids_json_c
{
use module_config_variable;
/** @throws Exception */
public function __construct()
{
self::setupConfigVariable(
'bird',
'allowed_channel_ids_json',
'string',
false,
null,
'Canonical JSON list of Bird channel IDs allowed for Control Plane access.',
'[]',
false,
''
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace bird\config;
require_once WD . '/traits/module_config_variable_t.php';
use Exception;
use traits\module_config_variable;
class bird_control_plane_enabled_c
{
use module_config_variable;
/** @throws Exception */
public function __construct()
{
self::setupConfigVariable(
'bird',
'control_plane_enabled',
'bool',
true,
null,
'Expose the authenticated, narrow Bird Control Plane gateway.',
'false',
false,
'false'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace bird\config;
require_once WD . '/traits/module_config_variable_t.php';
use Exception;
use traits\module_config_variable;
class bird_control_plane_token_c
{
use module_config_variable;
/** @throws Exception */
public function __construct()
{
self::setupConfigVariable(
'bird',
'control_plane_token',
'string',
false,
null,
'Bearer token accepted only by the narrow Bird Control Plane gateway.',
'generated-long-random-token',
true,
''
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace bird\config;
require_once WD . '/traits/module_config_variable_t.php';
use Exception;
use traits\module_config_variable;
class bird_flow_enabled_c
{
use module_config_variable;
/** @throws Exception */
public function __construct()
{
self::setupConfigVariable(
'bird',
'flow_enabled',
'bool',
true,
null,
'Allow authenticated Bird Flows to request deterministic decisions.',
'false',
false,
'false'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace bird\config;
require_once WD . '/traits/module_config_variable_t.php';
use Exception;
use traits\module_config_variable;
class bird_flow_policy_json_c
{
use module_config_variable;
/** @throws Exception */
public function __construct()
{
self::setupConfigVariable(
'bird',
'flow_policy_json',
'string',
false,
null,
'Versioned deterministic Bird Flow rules. Invalid or absent policy always denies.',
'{"version":"v1","rules":[]}',
false,
''
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace bird\config;
require_once WD . '/traits/module_config_variable_t.php';
use Exception;
use traits\module_config_variable;
class bird_flow_shared_secret_c
{
use module_config_variable;
/** @throws Exception */
public function __construct()
{
self::setupConfigVariable(
'bird',
'flow_shared_secret',
'string',
false,
null,
'Bearer secret used only by the Bird Flow evaluation endpoint.',
'generated-long-random-secret',
true,
''
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace bird\config;
require_once WD . '/traits/module_config_variable_t.php';
use Exception;
use traits\module_config_variable;
class bird_operations_actions_enabled_c
{
use module_config_variable;
/** @throws Exception */
public function __construct()
{
self::setupConfigVariable(
'bird',
'operations_actions_enabled',
'bool',
true,
null,
'Allow explicitly confirmed, typed Bird operational actions.',
'false',
false,
'false'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace bird\config;
require_once WD . '/traits/module_config_variable_t.php';
use Exception;
use traits\module_config_variable;
class bird_outbound_messages_enabled_c
{
use module_config_variable;
/** @throws Exception */
public function __construct()
{
self::setupConfigVariable(
'bird',
'outbound_messages_enabled',
'bool',
true,
null,
'Allow confirmed outbound Bird replies after Control Plane policy approval.',
'false',
false,
'false'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace bird\config;
require_once WD . '/traits/module_config_variable_t.php';
use Exception;
use traits\module_config_variable;
class bird_participantId_c
{
use module_config_variable;
/** @throws Exception */
public function __construct()
{
self::setupConfigVariable(
'bird',
'participantId',
'string',
false,
null,
'Bird access-key participant ID used for confirmed conversation replies.',
'participant_123',
false,
''
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace bird\config;
require_once WD . '/traits/module_config_variable_t.php';
use Exception;
use traits\module_config_variable;
class bird_template_policy_json_c
{
use module_config_variable;
/** @throws Exception */
public function __construct()
{
self::setupConfigVariable(
'bird',
'template_policy_json',
'string',
false,
null,
'Immutable versioned allowlist for outbound Bird templates.',
'{"version":"v1","templates":[]}',
false,
''
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace bird\config;
require_once WD . '/traits/module_config_variable_t.php';
use Exception;
use traits\module_config_variable;
class bird_webhook_public_url_c
{
use module_config_variable;
/** @throws Exception */
public function __construct()
{
self::setupConfigVariable(
'bird',
'webhook_public_url',
'string',
false,
null,
'Exact public HTTPS URL signed by Bird for notification delivery.',
'https://api.example.com/bird/webhooks/notifications',
false,
''
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace bird\config;
require_once WD . '/traits/module_config_variable_t.php';
use Exception;
use traits\module_config_variable;
class bird_webhook_replay_window_seconds_c
{
use module_config_variable;
/** @throws Exception */
public function __construct()
{
self::setupConfigVariable(
'bird',
'webhook_replay_window_seconds',
'int',
true,
null,
'Maximum accepted age or clock skew for signed Bird webhooks.',
'300',
false,
'300'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace bird\config;
require_once WD . '/traits/module_config_variable_t.php';
use Exception;
use traits\module_config_variable;
class bird_webhook_signing_key_c
{
use module_config_variable;
/** @throws Exception */
public function __construct()
{
self::setupConfigVariable(
'bird',
'webhook_signing_key',
'string',
false,
null,
'Signing key configured on Bird webhook subscriptions.',
'generated-signing-key',
true,
''
);
}
}
@@ -0,0 +1,31 @@
<?php
namespace bird\config;
require_once WD . '/traits/module_config_variable_t.php';
use Exception;
use traits\module_config_variable;
class bird_workspaceId_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'bird',
'workspaceId',
'string',
false,
null,
'Canonical Bird workspace identifier. Falls back to workplaceId while installations migrate.',
'workspace_123',
false,
''
);
}
}
@@ -0,0 +1,11 @@
-----BEGIN PUBLIC KEY-----
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAxoS4dXcBUjLxFzVB0S2M
9qJLPxZdoSFL2Pu5nHi3xPFse1sgQk//9jMVL5rUkJMYaQFAZ3c6IFqZlVD2SN4C
DlbLIyDU9BqCIdZfvUMkxhMwx5e3ygdHZp/Fft66eq/LCcN7JD1N3Qg9NR4M6QvU
kemtBuhx2aKoCN+gqGoLGsC3lCM5O1g/10N5z+AnC0v4A445Gy8Rg+sEOAaIvsqJ
/HKA5lWEcABucT7WkkWrUv/9wTxXcxFEWvcOvr328St3EhJvK8RAHyjNl7LfzVkh
L7NlspJnxegroxUGS4Nzd/eyYQkzJ2URZxyX9PbomQny6A5TLa/ChSSt9HzltP7f
w4T7X806KjcL86sVPfOB4vkee9yH4Te1Ag75lZo6b2Zt2B33+Fi86NbO6IqW8oA+
F9Sg+7PMWL8zYyJrCPpd/k9DfKFBTc8tOfdOohkqzJWywGFpLZLdyJQ7XxbyBgjC
1ZKJaqlAy8sVfBHQb2MfZeSkAwZ9gVDVJzHogT7YMHl7AgMBAAE=
-----END PUBLIC KEY-----
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,90 @@
<?php
app_require('traits/module_config_t.php');
use traits\module_config_t;
if (!class_exists('BirdSecretConfigDefinitionStub')) {
class BirdSecretConfigDefinitionStub
{
public bool $config_variable_is_secret = true;
public function getVariableName(): string
{
return 'api_key';
}
}
}
if (!class_exists('BirdPublicConfigDefinitionStub')) {
class BirdPublicConfigDefinitionStub
{
public bool $config_variable_is_secret = false;
public function getVariableName(): string
{
return 'workspaceId';
}
}
}
if (!class_exists('BirdModuleConfigRedactionHarness')) {
class BirdModuleConfigRedactionHarness
{
use module_config_t;
public function __construct()
{
$this->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,
]);
});
@@ -0,0 +1,424 @@
<?php
app_require('modules/bird/classes/bird_control_plane_activator.php');
app_require('modules/bird/classes/bird_webhook_subscription_reconciler.php');
app_require('modules/bird/classes/bird_control_plane_bootstrap_sealer.php');
app_require('modules/bird/classes/bird_control_plane_auto_activation.php');
use bird\classes\bird_control_plane_activator;
use bird\classes\bird_webhook_subscription_reconciler;
use bird\classes\bird_control_plane_bootstrap_sealer;
use bird\classes\bird_control_plane_auto_activation;
it('canonicalizes legacy workspace and channel configuration without widening scope', function (): void {
expect(bird_control_plane_activator::canonicalWorkspaceId([
'workspaceId' => '',
'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)");
});
@@ -0,0 +1,183 @@
<?php
app_require('modules/bird/classes/bird_control_plane_contract.php');
app_require('modules/bird/classes/bird_outbound_message_store.php');
app_require('modules/bird/classes/bird_control_plane_schema_bootstrap.php');
use bird\classes\bird_control_plane_contract;
use bird\classes\bird_control_plane_schema_bootstrap;
use bird\classes\bird_outbound_message_store;
use classes\router;
it('executes the exact confirmed hangup contract and intended cause allowlist', function (): void {
$operation = bird_control_plane_contract::hangup([
'action' => '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();
});
@@ -0,0 +1,71 @@
<?php
it('exposes a disabled narrow Bird Control Plane read surface and signed ingestion endpoints', function (): void {
$content = file_get_contents(app_path('routes/birdControlPlaneRoute.php'));
expect($content)->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')");
});
@@ -0,0 +1,106 @@
<?php
app_require('modules/bird/classes/bird_control_plane_security.php');
use bird\classes\bird_control_plane_security;
it('verifies the documented Bird webhook signature without changing the signed URL or body', function (): void {
$key = 'test-signing-key';
$timestamp = '1785312000';
$url = 'https://api.example.test/bird/webhooks/notifications?source=bird';
$body = '{"event":"conversation.updated","data":{"id":"c-1"}}';
$checksum = hash('sha256', $body, true);
$signature = base64_encode(hash_hmac(
'sha256',
$timestamp . "\n" . $url . "\n" . $checksum,
$key,
true
));
expect(bird_control_plane_security::verifyBirdWebhookSignature(
$key,
$timestamp,
$url,
$body,
$signature
))->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();
});
@@ -0,0 +1,58 @@
<?php
app_require('modules/bird/classes/bird_flow_policy_evaluator.php');
use bird\classes\bird_flow_policy_evaluator;
it('returns only deterministic allowlisted Bird Flow actions for an exact rule match', function (): void {
$policy = json_encode([
'version' => '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');
});
@@ -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<int,string>
*/
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)
);
}
}
+29 -2
View File
@@ -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<string,true>
*/
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