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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user