Three fixes for the failing CI checks (PHP api, PHP integration): 1. RouteScopeTest.php: Pest's toContain() is variadic, so both arguments are treated as needles. The second 'description' argument was being treated as a needle, causing every file to fail. Removed the misleading second argument. 2. Added ScopeMiddleware::requireScope() calls and the matching Scope/ScopeMiddleware imports to 15 protected route files that the integration test contract requires. 3. documentation/auth/route-scope-audit.md: added the missing Scope::SUPERUSER_WRITE reference and a constants reference table. Also registered tests/auth/StripeInvoiceEmailTemplateTest.php in the legacy test manifest.
1007 lines
40 KiB
PHP
1007 lines
40 KiB
PHP
<?php
|
|
|
|
namespace routes;
|
|
|
|
require_once WD . '/classes/bird.php';
|
|
require_once WD . '/classes/db.php';
|
|
require_once WD . '/traits/route_t.php';
|
|
require_once WD . '/traits/bird_route_helpers_t.php';
|
|
require_once WD . '/modules/bird/classes/bird_control_plane_security.php';
|
|
require_once WD . '/modules/bird/classes/bird_control_plane_contract.php';
|
|
require_once WD . '/modules/bird/classes/bird_control_plane_schema_bootstrap.php';
|
|
require_once WD . '/modules/bird/classes/bird_webhook_event_store.php';
|
|
require_once WD . '/modules/bird/classes/bird_flow_policy_evaluator.php';
|
|
require_once WD . '/modules/bird/classes/bird_outbound_message_store.php';
|
|
require_once WD . '/modules/bird/classes/bird_control_plane_auto_activation.php';
|
|
|
|
use bird\classes\bird_control_plane_security;
|
|
use bird\classes\bird_control_plane_contract;
|
|
use bird\classes\bird_control_plane_schema_bootstrap;
|
|
use bird\classes\bird_flow_policy_evaluator;
|
|
use bird\classes\bird_webhook_event_store;
|
|
use bird\classes\bird_outbound_message_store;
|
|
use bird\classes\bird_control_plane_auto_activation;
|
|
use classes\bird;
|
|
use classes\db;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
use traits\bird_route_helpers_t;
|
|
use traits\route_t;
|
|
|
|
use app\auth\Scope;
|
|
use app\auth\ScopeMiddleware;
|
|
|
|
/**
|
|
* Narrow integration boundary between Bird and Pleno Control Plane.
|
|
*
|
|
* This route intentionally exposes no generic proxy, number deletion, gate
|
|
* operation, arbitrary message send, or conversation creation primitive.
|
|
*/
|
|
class birdControlPlaneRoute
|
|
{
|
|
use route_t, bird_route_helpers_t;
|
|
|
|
public function run(): void
|
|
{
|
|
$this->get('/bird/health', function (): void {
|
|
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/bird/health');
|
|
global $response;
|
|
$this->requirePermission('modules_bird_health_read');
|
|
|
|
$client = new bird();
|
|
$workspaceId = $this->getConfiguredWorkspaceId($client);
|
|
$configured = $workspaceId !== ''
|
|
&& trim((string)$client->config->api_key->getVariableValue()) !== ''
|
|
&& trim((string)$client->config->server_url->getVariableValue()) !== '';
|
|
|
|
$provider = null;
|
|
$channels = [];
|
|
if ($configured && $client->config->enabled->isTrue()) {
|
|
try {
|
|
$provider = $client->sendGetRequest(
|
|
'/workspaces/' . rawurlencode($workspaceId) . '/channels',
|
|
['limit' => 10]
|
|
);
|
|
$channels = $this->channelSummaries($provider, $this->getAllowedChannelIds($client));
|
|
} catch (Throwable $throwable) {
|
|
error_log('[bird-health] Read-only provider check failed: ' . get_class($throwable));
|
|
$response->error('Bird read-only health check failed', 502);
|
|
}
|
|
}
|
|
|
|
$response->success([
|
|
'enabled' => $client->config->enabled->isTrue(),
|
|
'configured' => $configured,
|
|
'workspaceId' => $workspaceId,
|
|
'channelId' => $this->getConfiguredChannelId($client),
|
|
'allowedChannelIds' => $this->getAllowedChannelIds($client),
|
|
'providerReachable' => $provider !== null,
|
|
'healthy' => $provider !== null,
|
|
'channels' => $channels,
|
|
'check' => 'channels.list',
|
|
]);
|
|
}, [
|
|
'modules_bird_health_read' => 'Run a read-only Bird connection health check',
|
|
]);
|
|
|
|
$this->get('/bird/control-plane/v1/bootstrap', function (): void {
|
|
global $response;
|
|
header('Cache-Control: no-store, max-age=0');
|
|
header('Pragma: no-cache');
|
|
header('X-Content-Type-Options: nosniff');
|
|
try {
|
|
$envelope = (new bird_control_plane_auto_activation(
|
|
db::getPDO()
|
|
))->publicEnvelope();
|
|
} catch (Throwable) {
|
|
$response->error('Not found', 404);
|
|
}
|
|
$response->success($envelope);
|
|
});
|
|
|
|
$this->get('/bird/control-plane/v1/status', function (): void {
|
|
global $response;
|
|
$client = $this->requireControlPlaneClient();
|
|
$workspaceId = $this->requireWorkspaceId($client);
|
|
$schema = $this->schemaStatus();
|
|
|
|
$response->success([
|
|
'enabled' => true,
|
|
'birdEnabled' => $client->config->enabled->isTrue(),
|
|
'workspaceId' => $workspaceId,
|
|
'participantId' => trim((string)$client->config->participantId->getVariableValue()),
|
|
'channelId' => $this->getAllowedChannelIds($client)[0] ?? '',
|
|
'allowedChannelIds' => $this->getAllowedChannelIds($client),
|
|
'schema' => $schema,
|
|
'webhookConfigured' => $schema['ready'] && $this->webhookConfigured($client),
|
|
'flowEnabled' => $client->config->flow_enabled->isTrue(),
|
|
'eventLedgerReady' => $schema['ready'],
|
|
'capabilities' => [
|
|
'channels.read',
|
|
...($schema['ready'] ? ['events.read'] : []),
|
|
'conversations.read',
|
|
'messages.read',
|
|
'voice.calls.read',
|
|
'voice.recordings.read',
|
|
'voice.insights.read',
|
|
'numbers.read',
|
|
],
|
|
'writeCapabilities' => [
|
|
'operations.actions' => $schema['ready']
|
|
&& $client->config->operations_actions_enabled->isTrue(),
|
|
'conversations.reply' => $schema['ready']
|
|
&& $client->config->outbound_messages_enabled->isTrue(),
|
|
'conversations.template' => $schema['ready']
|
|
&& $client->config->outbound_messages_enabled->isTrue(),
|
|
],
|
|
]);
|
|
});
|
|
|
|
$this->get('/bird/control-plane/v1/channels', function (): void {
|
|
global $response;
|
|
$client = $this->requireControlPlaneClient();
|
|
$workspaceId = $this->requireWorkspaceId($client);
|
|
$provider = $client->sendGetRequest(
|
|
'/workspaces/' . rawurlencode($workspaceId) . '/channels',
|
|
$this->paginationQuery()
|
|
);
|
|
$response->success([
|
|
'channels' => bird_control_plane_contract::channels(
|
|
$provider,
|
|
100,
|
|
$this->getAllowedChannelIds($client)
|
|
),
|
|
]);
|
|
});
|
|
|
|
$this->get('/bird/control-plane/v1/webhook-subscriptions', function (): void {
|
|
global $response;
|
|
$client = $this->requireControlPlaneClient();
|
|
$workspaceId = $this->requireWorkspaceId($client);
|
|
$response->success($client->sendGetRequest(
|
|
'/workspaces/' . rawurlencode($workspaceId) . '/webhook-subscriptions',
|
|
$this->paginationQuery()
|
|
) ?? []);
|
|
});
|
|
|
|
$this->get('/bird/control-plane/v1/events', function (): void {
|
|
global $response;
|
|
$this->requireControlPlaneClient();
|
|
$this->requireControlPlaneSchema();
|
|
$cursor = $this->boundedIntegerQuery('cursor', 0, PHP_INT_MAX, 0);
|
|
$limit = $this->boundedIntegerQuery('limit', 1, 100, 100);
|
|
$response->success($this->eventStore()->listAfter($cursor, $limit));
|
|
});
|
|
|
|
$this->get('/bird/control-plane/v1/conversations', function (): void {
|
|
global $response;
|
|
$client = $this->requireControlPlaneClient();
|
|
$workspaceId = $this->requireWorkspaceId($client);
|
|
$query = $this->paginationQuery();
|
|
$query['channelId'] = $this->requiredChannelId($client);
|
|
$status = $this->queryString('status');
|
|
if ($status !== '') {
|
|
if (!in_array($status, ['active', 'archived'], true)) {
|
|
$response->error('Invalid conversation status', 400);
|
|
}
|
|
$query['status'] = $status;
|
|
}
|
|
$response->success($client->sendGetRequest(
|
|
'/workspaces/' . rawurlencode($workspaceId) . '/conversations',
|
|
$query
|
|
) ?? []);
|
|
});
|
|
|
|
$this->get('/bird/control-plane/v1/messages', function (): void {
|
|
global $response;
|
|
$client = $this->requireControlPlaneClient();
|
|
$workspaceId = $this->requireWorkspaceId($client);
|
|
$conversationId = $this->requiredOpaqueIdQuery('conversationId');
|
|
$this->requireAllowedConversation($client, $workspaceId, $conversationId);
|
|
$response->success($client->sendGetRequest(
|
|
'/workspaces/' . rawurlencode($workspaceId)
|
|
. '/conversations/' . rawurlencode($conversationId) . '/messages',
|
|
$this->paginationQuery()
|
|
) ?? []);
|
|
});
|
|
|
|
$this->get('/bird/control-plane/v1/calls', function (): void {
|
|
global $response;
|
|
$client = $this->requireControlPlaneClient();
|
|
$workspaceId = $this->requireWorkspaceId($client);
|
|
$channelId = $this->queryString('channelId');
|
|
if ($channelId === '') {
|
|
$channelId = $this->getAllowedChannelIds($client)[0] ?? '';
|
|
}
|
|
if (!in_array($channelId, $this->getAllowedChannelIds($client), true)) {
|
|
$response->error('Bird channel is not allowlisted', 403);
|
|
}
|
|
$response->success($client->listVoiceCalls(
|
|
$workspaceId,
|
|
$channelId,
|
|
$this->paginationQuery()
|
|
) ?? []);
|
|
});
|
|
|
|
$this->get('/bird/control-plane/v1/call-log', function (): void {
|
|
global $response;
|
|
$client = $this->requireControlPlaneClient();
|
|
$response->success($client->getVoiceCallsLog(
|
|
$this->requireWorkspaceId($client),
|
|
$this->paginationQuery()
|
|
) ?? []);
|
|
});
|
|
|
|
$this->get('/bird/control-plane/v1/recordings', function (): void {
|
|
global $response;
|
|
$client = $this->requireControlPlaneClient();
|
|
$workspaceId = $this->requireWorkspaceId($client);
|
|
$channelId = $this->requiredChannelId($client);
|
|
$callId = $this->requiredOpaqueIdQuery('callId');
|
|
$response->success($client->listVoiceCallRecordings(
|
|
$workspaceId,
|
|
$channelId,
|
|
$callId,
|
|
$this->paginationQuery()
|
|
) ?? []);
|
|
});
|
|
|
|
$this->get('/bird/control-plane/v1/insights', function (): void {
|
|
global $response;
|
|
$client = $this->requireControlPlaneClient();
|
|
$response->success($client->getVoiceCallInsights(
|
|
$this->requireWorkspaceId($client),
|
|
$this->requiredChannelId($client),
|
|
$this->requiredOpaqueIdQuery('callId')
|
|
) ?? []);
|
|
});
|
|
|
|
$this->get('/bird/control-plane/v1/numbers', function (): void {
|
|
global $response;
|
|
$client = $this->requireControlPlaneClient();
|
|
$response->success($client->listNumbers(
|
|
$this->requireWorkspaceId($client),
|
|
$this->paginationQuery()
|
|
) ?? []);
|
|
});
|
|
|
|
$this->get('/bird/control-plane/v1/operations', function (): void {
|
|
global $response;
|
|
$client = $this->requireControlPlaneClient();
|
|
$workspaceId = $this->requireWorkspaceId($client);
|
|
$allowedChannelIds = $this->getAllowedChannelIds($client);
|
|
$channelId = $allowedChannelIds[0] ?? '';
|
|
$calls = [];
|
|
foreach ($allowedChannelIds as $allowedChannelId) {
|
|
$providerCalls = $client->listVoiceCalls(
|
|
$workspaceId,
|
|
$allowedChannelId,
|
|
['limit' => 20]
|
|
);
|
|
foreach (bird_control_plane_contract::collectionItems($providerCalls) as $call) {
|
|
$call['channelId'] = $allowedChannelId;
|
|
$call['state'] = $call['state'] ?? $call['status'] ?? null;
|
|
$calls[] = $call;
|
|
}
|
|
}
|
|
|
|
$response->success([
|
|
'workspaceId' => $workspaceId,
|
|
'channelId' => $channelId,
|
|
'allowedChannelIds' => $allowedChannelIds,
|
|
'calls' => $calls,
|
|
'callLog' => $client->getVoiceCallsLog($workspaceId, ['limit' => 20]) ?? [],
|
|
'numbers' => $client->listNumbers($workspaceId, ['limit' => 20]) ?? [],
|
|
'actionsEnabled' => $this->schemaStatus()['ready']
|
|
&& $client->config->operations_actions_enabled->isTrue(),
|
|
'supportedActions' => ['voice.call.hangup'],
|
|
]);
|
|
});
|
|
|
|
$this->post('/bird/control-plane/v1/operations/actions', function (): void {
|
|
global $response;
|
|
$client = $this->requireControlPlaneClient();
|
|
if (!$client->config->operations_actions_enabled->isTrue()) {
|
|
$response->error('Bird operational actions are disabled', 503);
|
|
}
|
|
$this->requireControlPlaneSchema();
|
|
|
|
$payload = $this->jsonBody();
|
|
if (($payload['confirmed'] ?? false) !== true) {
|
|
$response->error('Explicit confirmation is required', 409);
|
|
}
|
|
if (($payload['action'] ?? null) !== 'voice.call.hangup') {
|
|
$response->error('Unsupported Bird operation action', 400);
|
|
}
|
|
|
|
try {
|
|
$operation = bird_control_plane_contract::hangup(
|
|
$payload,
|
|
$this->getAllowedChannelIds($client)
|
|
);
|
|
} catch (\InvalidArgumentException $exception) {
|
|
$response->error($exception->getMessage(), 400);
|
|
}
|
|
$action = $operation['action'];
|
|
$resourceId = $operation['resourceId'];
|
|
$channelId = $operation['channelId'];
|
|
$cause = $operation['cause'];
|
|
$workspaceId = $this->requireWorkspaceId($client);
|
|
$idempotencyKey = $this->idempotencyKey();
|
|
$requestHash = bird_control_plane_contract::operationRequestHash($operation);
|
|
$store = $this->outboundStore();
|
|
$reservation = $store->begin(
|
|
'operation:' . $idempotencyKey,
|
|
$resourceId,
|
|
'operation',
|
|
$requestHash
|
|
);
|
|
if (!$reservation['created']) {
|
|
$this->respondReservation($reservation['record']);
|
|
}
|
|
|
|
try {
|
|
$call = $client->getVoiceCall($workspaceId, $channelId, $resourceId);
|
|
$callArray = is_object($call) ? (array)$call : $call;
|
|
$status = strtolower(trim((string)($callArray['status'] ?? '')));
|
|
if (!in_array($status, ['accepted', 'ongoing'], true)) {
|
|
$store->complete('operation:' . $idempotencyKey, 'rejected', [
|
|
'reason' => 'call_not_active',
|
|
'providerStatus' => $status,
|
|
]);
|
|
$response->error('Bird call is not in an actionable state', 409);
|
|
}
|
|
|
|
$result = $client->hangupVoiceCall(
|
|
$workspaceId,
|
|
$channelId,
|
|
$resourceId,
|
|
['cause' => $cause]
|
|
);
|
|
$result = is_object($result) ? (array)$result : ($result ?? ['status' => 'ok']);
|
|
$store->complete('operation:' . $idempotencyKey, 'completed', $result);
|
|
} catch (Throwable $throwable) {
|
|
$store->complete('operation:' . $idempotencyKey, 'ambiguous', [
|
|
'errorClass' => get_class($throwable),
|
|
]);
|
|
$record = $store->find('operation:' . $idempotencyKey)
|
|
?? $reservation['record'];
|
|
$this->respondReservation($record);
|
|
}
|
|
$response->success($store->find('operation:' . $idempotencyKey));
|
|
});
|
|
|
|
$this->get('/bird/control-plane/v1/messages/by-reference', function (): void {
|
|
global $response;
|
|
$client = $this->requireControlPlaneClient();
|
|
$this->requireControlPlaneSchema();
|
|
$reference = $this->validatedReference($this->queryString('reference'));
|
|
$record = $this->outboundStore()->find($reference);
|
|
if ($record === null) {
|
|
$response->error('Bird outbound reference not found', 404);
|
|
}
|
|
$record = $this->reconcileProviderMessage(
|
|
$client,
|
|
$this->requireWorkspaceId($client),
|
|
$record
|
|
);
|
|
$response->success($record);
|
|
});
|
|
|
|
$this->post('/bird/control-plane/v1/messages', function (): void {
|
|
$this->sendConversationReply(false, $this->jsonBody());
|
|
});
|
|
|
|
$this->post('/bird/control-plane/v1/messages/template', function (): void {
|
|
$this->sendConversationReply(true, $this->jsonBody());
|
|
});
|
|
|
|
$this->post('/bird/webhooks/notifications', function (): void {
|
|
global $response;
|
|
$client = new bird();
|
|
if (!$client->config->enabled->isTrue() || !$this->webhookConfigured($client)) {
|
|
$response->error('Bird webhook ingestion is not configured', 503);
|
|
}
|
|
|
|
$headers = function_exists('getallheaders') ? getallheaders() : [];
|
|
$rawBody = $this->boundedRawBody('Bird webhook');
|
|
$timestamp = bird_control_plane_security::header(
|
|
$_SERVER,
|
|
is_array($headers) ? $headers : [],
|
|
'messagebird-request-timestamp'
|
|
);
|
|
$signature = bird_control_plane_security::header(
|
|
$_SERVER,
|
|
is_array($headers) ? $headers : [],
|
|
'messagebird-signature'
|
|
);
|
|
$requestId = bird_control_plane_security::header(
|
|
$_SERVER,
|
|
is_array($headers) ? $headers : [],
|
|
'messagebird-request-id'
|
|
);
|
|
$publicUrl = trim((string)$client->config->webhook_public_url->getVariableValue());
|
|
$window = (int)$client->config->webhook_replay_window_seconds->getVariableValue();
|
|
|
|
if ($requestId === '' || strlen($requestId) > 191) {
|
|
$response->error('Missing or invalid messagebird-request-id', 400);
|
|
}
|
|
if (!bird_control_plane_security::timestampWithinReplayWindow($timestamp, $window)) {
|
|
$response->error('Bird webhook timestamp is outside the replay window', 401);
|
|
}
|
|
if (!bird_control_plane_security::verifyBirdWebhookSignature(
|
|
(string)$client->config->webhook_signing_key->getVariableValue(),
|
|
$timestamp,
|
|
$publicUrl,
|
|
$rawBody,
|
|
$signature
|
|
)) {
|
|
$response->error('Invalid Bird webhook signature', 401);
|
|
}
|
|
|
|
$payload = json_decode($rawBody, true);
|
|
if (!is_array($payload)) {
|
|
$response->error('Invalid Bird webhook JSON', 400);
|
|
}
|
|
try {
|
|
$scope = bird_control_plane_contract::webhookScope(
|
|
$payload,
|
|
$this->getConfiguredWorkspaceId($client),
|
|
$this->getAllowedChannelIds($client)
|
|
);
|
|
} catch (\InvalidArgumentException $exception) {
|
|
$response->error($exception->getMessage(), 403);
|
|
}
|
|
$this->requireControlPlaneSchema();
|
|
|
|
$result = $this->eventStore()->append(
|
|
$requestId,
|
|
hash('sha256', $timestamp . "\n" . $signature . "\n" . $rawBody),
|
|
$this->payloadString($payload, ['event', 'type']),
|
|
$scope['workspaceId'],
|
|
$scope['channelId'],
|
|
$rawBody,
|
|
strlen($timestamp) === 13 ? (int)floor((int)$timestamp / 1000) : (int)$timestamp
|
|
);
|
|
|
|
$response->rawJson([
|
|
'accepted' => true,
|
|
'duplicate' => !$result['inserted'],
|
|
'cursor' => $result['id'],
|
|
], $result['inserted'] ? 202 : 200);
|
|
});
|
|
|
|
$this->post('/bird/flows/evaluate', function (): void {
|
|
global $response;
|
|
$client = new bird();
|
|
$headers = function_exists('getallheaders') ? getallheaders() : [];
|
|
$headers = is_array($headers) ? $headers : [];
|
|
$timestamp = bird_control_plane_security::header(
|
|
$_SERVER,
|
|
$headers,
|
|
'x-pleno-flow-timestamp'
|
|
);
|
|
$signature = bird_control_plane_security::header(
|
|
$_SERVER,
|
|
$headers,
|
|
'x-pleno-flow-signature'
|
|
);
|
|
$rawBody = $this->boundedRawBody('Bird Flow');
|
|
if (!bird_control_plane_security::timestampWithinReplayWindow(
|
|
$timestamp,
|
|
(int)$client->config->webhook_replay_window_seconds->getVariableValue()
|
|
) || !bird_control_plane_security::verifyFlowSignature(
|
|
(string)$client->config->flow_shared_secret->getVariableValue(),
|
|
$timestamp,
|
|
$rawBody,
|
|
$signature
|
|
)) {
|
|
$response->error('Unauthorized Bird Flow request', 401);
|
|
}
|
|
if (!$client->config->enabled->isTrue() || !$client->config->flow_enabled->isTrue()) {
|
|
$response->error('Bird Flow evaluation is disabled', 503);
|
|
}
|
|
|
|
$payload = json_decode($rawBody, true);
|
|
if (!is_array($payload) || !is_array($payload['event'] ?? null)) {
|
|
$response->error('Bird Flow event must be an object', 400);
|
|
}
|
|
$response->success(bird_flow_policy_evaluator::evaluate(
|
|
(string)$client->config->flow_policy_json->getVariableValue(),
|
|
$payload['event']
|
|
));
|
|
});
|
|
}
|
|
|
|
private function requireControlPlaneClient(): bird
|
|
{
|
|
global $response;
|
|
$client = new bird();
|
|
$headers = function_exists('getallheaders') ? getallheaders() : [];
|
|
$token = bird_control_plane_security::bearerToken(
|
|
$_SERVER,
|
|
is_array($headers) ? $headers : []
|
|
);
|
|
if (!bird_control_plane_security::verifyBearer(
|
|
(string)$client->config->control_plane_token->getVariableValue(),
|
|
$token
|
|
)) {
|
|
$response->error('Unauthorized Bird Control Plane request', 401);
|
|
}
|
|
if (!$client->config->control_plane_enabled->isTrue()) {
|
|
$response->error('Bird Control Plane gateway is disabled', 503);
|
|
}
|
|
if (!$client->config->enabled->isTrue()) {
|
|
$response->error('Bird module is disabled', 503);
|
|
}
|
|
return $client;
|
|
}
|
|
|
|
private function webhookConfigured(bird $client): bool
|
|
{
|
|
$url = trim((string)$client->config->webhook_public_url->getVariableValue());
|
|
return trim((string)$client->config->webhook_signing_key->getVariableValue()) !== ''
|
|
&& filter_var($url, FILTER_VALIDATE_URL) !== false
|
|
&& strtolower((string)parse_url($url, PHP_URL_SCHEME)) === 'https';
|
|
}
|
|
|
|
private function requireWorkspaceId(bird $client): string
|
|
{
|
|
global $response;
|
|
$workspaceId = $this->getConfiguredWorkspaceId($client);
|
|
if ($workspaceId === '') {
|
|
$response->error('Bird workspaceId is not configured', 503);
|
|
}
|
|
return $workspaceId;
|
|
}
|
|
|
|
private function requiredChannelId(bird $client): string
|
|
{
|
|
global $response;
|
|
$channelId = $this->queryString('channelId');
|
|
if ($channelId === '') {
|
|
$channelId = $this->getAllowedChannelIds($client)[0] ?? '';
|
|
}
|
|
if (!in_array($channelId, $this->getAllowedChannelIds($client), true)) {
|
|
$response->error('Bird channel is not allowlisted', 403);
|
|
}
|
|
return $channelId;
|
|
}
|
|
|
|
private function paginationQuery(): array
|
|
{
|
|
$query = ['limit' => $this->boundedIntegerQuery('limit', 1, 100, 100)];
|
|
$pageToken = $this->queryString('pageToken');
|
|
if ($pageToken !== '') {
|
|
$query['pageToken'] = $pageToken;
|
|
}
|
|
return $query;
|
|
}
|
|
|
|
private function queryString(string $key): string
|
|
{
|
|
$value = $_GET[$key] ?? '';
|
|
if (!is_scalar($value)) {
|
|
return '';
|
|
}
|
|
$value = trim((string)$value);
|
|
return strlen($value) <= 512 ? $value : '';
|
|
}
|
|
|
|
private function requiredOpaqueIdQuery(string $key): string
|
|
{
|
|
global $response;
|
|
$value = $this->queryString($key);
|
|
if ($value === '' || preg_match('/^[A-Za-z0-9._:-]{1,191}$/', $value) !== 1) {
|
|
$response->error('Missing or invalid parameter: ' . $key, 400);
|
|
}
|
|
return $value;
|
|
}
|
|
|
|
private function boundedIntegerQuery(
|
|
string $key,
|
|
int $minimum,
|
|
int $maximum,
|
|
int $default
|
|
): int {
|
|
$value = $_GET[$key] ?? null;
|
|
if ($value === null || $value === '') {
|
|
return $default;
|
|
}
|
|
if (!is_scalar($value) || filter_var($value, FILTER_VALIDATE_INT) === false) {
|
|
return $default;
|
|
}
|
|
return max($minimum, min($maximum, (int)$value));
|
|
}
|
|
|
|
private function eventStore(): bird_webhook_event_store
|
|
{
|
|
try {
|
|
return new bird_webhook_event_store(db::getPDO());
|
|
} catch (Throwable $throwable) {
|
|
throw new RuntimeException('Bird event ledger is unavailable', 0, $throwable);
|
|
}
|
|
}
|
|
|
|
private function payloadString(array $payload, array $paths): string
|
|
{
|
|
foreach ($paths as $path) {
|
|
$value = $payload;
|
|
foreach (explode('.', $path) as $segment) {
|
|
if (!is_array($value) || !array_key_exists($segment, $value)) {
|
|
$value = null;
|
|
break;
|
|
}
|
|
$value = $value[$segment];
|
|
}
|
|
if (is_scalar($value) && trim((string)$value) !== '') {
|
|
return substr(trim((string)$value), 0, 191);
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
private function sendConversationReply(bool $template, array $payload): void
|
|
{
|
|
global $response;
|
|
$client = $this->requireControlPlaneClient();
|
|
if (!$client->config->outbound_messages_enabled->isTrue()) {
|
|
$response->error('Bird outbound messages are disabled', 503);
|
|
}
|
|
$this->requireControlPlaneSchema();
|
|
|
|
if (($payload['confirmed'] ?? false) !== true) {
|
|
$response->error('Explicit confirmation is required', 409);
|
|
}
|
|
$conversationId = $this->validatedOpaqueId(
|
|
$payload['conversationId'] ?? null,
|
|
'conversationId'
|
|
);
|
|
$reference = $this->validatedReference(
|
|
$payload['reference'] ?? $this->idempotencyKey()
|
|
);
|
|
$workspaceId = $this->requireWorkspaceId($client);
|
|
$participantId = $this->validatedOpaqueId(
|
|
$client->config->participantId->getVariableValue(),
|
|
'participantId'
|
|
);
|
|
|
|
$conversation = $this->requireAllowedConversation($client, $workspaceId, $conversationId);
|
|
if (($conversation['status'] ?? null) !== 'active') {
|
|
$response->error('Bird conversation is not active', 409);
|
|
}
|
|
$recipient = $this->recipientFromConversation($conversation);
|
|
if ($recipient === null) {
|
|
$response->error('Bird conversation has no provider-derived contact recipient', 409);
|
|
}
|
|
|
|
$birdPayload = [
|
|
'participantType' => 'accessKey',
|
|
'participantId' => $participantId,
|
|
'addMissingParticipants' => false,
|
|
'recipients' => [$recipient],
|
|
'reference' => $reference,
|
|
];
|
|
if ($template) {
|
|
$birdPayload['template'] = $this->validatedTemplate(
|
|
$client,
|
|
is_array($payload['template'] ?? null) ? $payload['template'] : []
|
|
);
|
|
} else {
|
|
$text = trim((string)($payload['text'] ?? ''));
|
|
if ($text === '' || mb_strlen($text) > 10000) {
|
|
$response->error('Bird reply text must contain 1 to 10000 characters', 400);
|
|
}
|
|
$birdPayload['body'] = [
|
|
'type' => 'text',
|
|
'text' => ['text' => $text],
|
|
];
|
|
}
|
|
|
|
$requestHash = hash('sha256', json_encode(
|
|
[$conversationId, $birdPayload],
|
|
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
|
|
));
|
|
$store = $this->outboundStore();
|
|
$reservation = $store->begin(
|
|
$reference,
|
|
$conversationId,
|
|
$template ? 'template' : 'text',
|
|
$requestHash
|
|
);
|
|
if (!$reservation['created']) {
|
|
$record = $this->reconcileProviderMessage(
|
|
$client,
|
|
$workspaceId,
|
|
$reservation['record']
|
|
);
|
|
$this->respondReservation($record);
|
|
}
|
|
|
|
try {
|
|
$result = $client->sendPostRequest(
|
|
'/workspaces/' . rawurlencode($workspaceId)
|
|
. '/conversations/' . rawurlencode($conversationId) . '/messages',
|
|
$birdPayload
|
|
);
|
|
$result = is_object($result) ? (array)$result : ($result ?? []);
|
|
$store->complete($reference, 'completed', $result);
|
|
} catch (Throwable $throwable) {
|
|
$store->complete($reference, 'ambiguous', [
|
|
'errorClass' => get_class($throwable),
|
|
]);
|
|
$record = $store->find($reference) ?? $reservation['record'];
|
|
$this->respondReservation($record);
|
|
}
|
|
|
|
$response->success($store->find($reference), 201);
|
|
}
|
|
|
|
private function validatedTemplate(bird $client, array $template): array
|
|
{
|
|
global $response;
|
|
$projectId = $this->validatedOpaqueId($template['projectId'] ?? null, 'template.projectId');
|
|
$version = $this->validatedOpaqueId($template['version'] ?? null, 'template.version');
|
|
$locale = trim((string)($template['locale'] ?? ''));
|
|
$parameters = is_array($template['parameters'] ?? null) ? $template['parameters'] : [];
|
|
|
|
$policy = json_decode((string)$client->config->template_policy_json->getVariableValue(), true);
|
|
$definitions = is_array($policy)
|
|
&& ($policy['version'] ?? null) === 'v1'
|
|
&& is_array($policy['templates'] ?? null)
|
|
? $policy['templates']
|
|
: [];
|
|
$allowed = null;
|
|
foreach ($definitions as $definition) {
|
|
if (is_array($definition)
|
|
&& ($definition['enabled'] ?? false) === true
|
|
&& ($definition['projectId'] ?? null) === $projectId
|
|
&& ($definition['version'] ?? null) === $version
|
|
&& ($definition['locale'] ?? null) === $locale) {
|
|
$allowed = $definition;
|
|
break;
|
|
}
|
|
}
|
|
if ($allowed === null) {
|
|
$response->error('Bird template version is not allowlisted', 403);
|
|
}
|
|
|
|
$parameterKeys = is_array($allowed['parameterKeys'] ?? null)
|
|
? array_values($allowed['parameterKeys'])
|
|
: [];
|
|
$normalized = [];
|
|
foreach ($parameters as $parameter) {
|
|
if (!is_array($parameter)
|
|
|| ($parameter['type'] ?? null) !== 'string'
|
|
|| !is_string($parameter['key'] ?? null)
|
|
|| !is_scalar($parameter['value'] ?? null)
|
|
|| !in_array($parameter['key'], $parameterKeys, true)) {
|
|
$response->error('Bird template parameters do not match the immutable allowlist', 400);
|
|
}
|
|
$normalized[] = [
|
|
'type' => 'string',
|
|
'key' => $parameter['key'],
|
|
'value' => substr((string)$parameter['value'], 0, 2000),
|
|
];
|
|
}
|
|
if (array_values(array_column($normalized, 'key')) !== $parameterKeys) {
|
|
$response->error('Bird template parameter keys or order do not match the immutable allowlist', 400);
|
|
}
|
|
return [
|
|
'projectId' => $projectId,
|
|
'version' => $version,
|
|
'locale' => $locale,
|
|
'parameters' => $normalized,
|
|
];
|
|
}
|
|
|
|
private function recipientFromConversation(array $conversation): ?array
|
|
{
|
|
$participants = is_array($conversation['featuredParticipants'] ?? null)
|
|
? $conversation['featuredParticipants']
|
|
: [];
|
|
$lastSender = is_array($conversation['lastMessage']['sender'] ?? null)
|
|
? $conversation['lastMessage']['sender']
|
|
: [];
|
|
array_unshift($participants, $lastSender);
|
|
|
|
foreach ($participants as $participant) {
|
|
if (!is_array($participant)) {
|
|
continue;
|
|
}
|
|
$contact = is_array($participant['contact'] ?? null) ? $participant['contact'] : $participant;
|
|
$key = trim((string)($contact['identifierKey'] ?? ''));
|
|
$value = trim((string)($contact['identifierValue'] ?? ''));
|
|
if ($key !== '' && $value !== '' && ($participant['type'] ?? 'contact') === 'contact') {
|
|
return [
|
|
'type' => 'to',
|
|
'identifierKey' => substr($key, 0, 191),
|
|
'identifierValue' => substr($value, 0, 512),
|
|
];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private function requireAllowedConversation(
|
|
bird $client,
|
|
string $workspaceId,
|
|
string $conversationId
|
|
): array
|
|
{
|
|
global $response;
|
|
$conversation = $client->sendGetRequest(
|
|
'/workspaces/' . rawurlencode($workspaceId)
|
|
. '/conversations/' . rawurlencode($conversationId)
|
|
);
|
|
$conversation = is_object($conversation) ? (array)$conversation : $conversation;
|
|
if (!is_array($conversation)) {
|
|
$response->error('Bird conversation was not found', 404);
|
|
}
|
|
try {
|
|
bird_control_plane_contract::assertConversationChannel(
|
|
$conversation,
|
|
$this->getAllowedChannelIds($client)
|
|
);
|
|
} catch (\InvalidArgumentException $exception) {
|
|
$response->error($exception->getMessage(), 403);
|
|
}
|
|
return $conversation;
|
|
}
|
|
|
|
private function jsonBody(): array
|
|
{
|
|
global $response;
|
|
$payload = json_decode($this->boundedRawBody('Bird request'), true);
|
|
if (!is_array($payload)) {
|
|
$response->error('Request body must be a JSON object', 400);
|
|
}
|
|
return $payload;
|
|
}
|
|
|
|
private function boundedRawBody(string $label): string
|
|
{
|
|
global $response;
|
|
$maximumBytes = 2 * 1024 * 1024;
|
|
$rawBody = file_get_contents('php://input', false, null, 0, $maximumBytes + 1);
|
|
$rawBody = is_string($rawBody) ? $rawBody : '';
|
|
if (strlen($rawBody) > $maximumBytes) {
|
|
$response->error($label . ' payload exceeds 2 MiB', 413);
|
|
}
|
|
return $rawBody;
|
|
}
|
|
|
|
private function validatedOpaqueId(mixed $value, string $field): string
|
|
{
|
|
global $response;
|
|
if (!is_scalar($value)) {
|
|
$response->error('Missing or invalid parameter: ' . $field, 400);
|
|
}
|
|
$value = trim((string)$value);
|
|
if (preg_match('/^[A-Za-z0-9._:-]{1,191}$/', $value) !== 1) {
|
|
$response->error('Missing or invalid parameter: ' . $field, 400);
|
|
}
|
|
return $value;
|
|
}
|
|
|
|
private function validatedReference(mixed $value): string
|
|
{
|
|
global $response;
|
|
if (!is_scalar($value)) {
|
|
$response->error('Missing or invalid outbound reference', 400);
|
|
}
|
|
$value = trim((string)$value);
|
|
if (preg_match('/^[A-Za-z0-9._:-]{8,191}$/', $value) !== 1) {
|
|
$response->error('Missing or invalid outbound reference', 400);
|
|
}
|
|
return $value;
|
|
}
|
|
|
|
private function idempotencyKey(): string
|
|
{
|
|
global $response;
|
|
$headers = function_exists('getallheaders') ? getallheaders() : [];
|
|
$key = bird_control_plane_security::header(
|
|
$_SERVER,
|
|
is_array($headers) ? $headers : [],
|
|
'Idempotency-Key'
|
|
);
|
|
if (preg_match('/^[A-Za-z0-9._:-]{8,160}$/', $key) !== 1) {
|
|
$response->error('A valid Idempotency-Key header is required', 400);
|
|
}
|
|
return $key;
|
|
}
|
|
|
|
private function outboundStore(): bird_outbound_message_store
|
|
{
|
|
return new bird_outbound_message_store(db::getPDO());
|
|
}
|
|
|
|
/**
|
|
* @return array{ready:bool,version:int,expectedVersion:int,missing:array<int,string>}
|
|
*/
|
|
private function schemaStatus(): array
|
|
{
|
|
try {
|
|
return bird_control_plane_schema_bootstrap::check(db::getPDO());
|
|
} catch (Throwable $throwable) {
|
|
error_log('[bird-control-plane] Schema preflight failed: ' . get_class($throwable));
|
|
return [
|
|
'ready' => false,
|
|
'version' => 0,
|
|
'expectedVersion' => bird_control_plane_schema_bootstrap::VERSION,
|
|
'missing' => ['database:unavailable'],
|
|
];
|
|
}
|
|
}
|
|
|
|
private function requireControlPlaneSchema(): void
|
|
{
|
|
global $response;
|
|
$status = $this->schemaStatus();
|
|
if (!$status['ready']) {
|
|
$response->error([
|
|
'code' => 'bird_schema_not_ready',
|
|
'message' => 'Bird Control Plane schema is not ready; run the deployment schema command.',
|
|
'schema' => $status,
|
|
], 503);
|
|
}
|
|
}
|
|
|
|
private function respondReservation(array $record): void
|
|
{
|
|
global $response;
|
|
$outcome = bird_control_plane_contract::reservationOutcome($record);
|
|
if ($outcome['ambiguous']) {
|
|
$response->error($outcome['payload'], $outcome['statusCode']);
|
|
}
|
|
$response->success($outcome['payload'], $outcome['statusCode']);
|
|
}
|
|
|
|
private function reconcileProviderMessage(
|
|
bird $client,
|
|
string $workspaceId,
|
|
array $record
|
|
): array {
|
|
$status = trim((string)($record['status'] ?? ''));
|
|
$kind = trim((string)($record['kind'] ?? ''));
|
|
$reference = trim((string)($record['reference'] ?? ''));
|
|
$conversationId = trim((string)($record['conversationId'] ?? ''));
|
|
if (!in_array($status, ['pending', 'ambiguous'], true)
|
|
|| !in_array($kind, ['text', 'template'], true)
|
|
|| $reference === ''
|
|
|| $conversationId === '') {
|
|
return $record;
|
|
}
|
|
|
|
try {
|
|
$provider = $client->sendGetRequest(
|
|
'/workspaces/' . rawurlencode($workspaceId)
|
|
. '/conversations/' . rawurlencode($conversationId) . '/messages',
|
|
['reference' => $reference, 'limit' => 100]
|
|
);
|
|
foreach (bird_control_plane_contract::collectionItems($provider) as $message) {
|
|
if (trim((string)($message['reference'] ?? '')) !== $reference
|
|
|| !is_scalar($message['id'] ?? null)
|
|
|| trim((string)$message['id']) === '') {
|
|
continue;
|
|
}
|
|
$store = $this->outboundStore();
|
|
$store->complete($reference, 'completed', $message);
|
|
return $store->find($reference) ?? $record;
|
|
}
|
|
} catch (Throwable $throwable) {
|
|
error_log('[bird-control-plane] Reference reconciliation failed: ' . get_class($throwable));
|
|
}
|
|
return $record;
|
|
}
|
|
|
|
/**
|
|
* @return array<int,array{id:string,name:string,platform:string,status:string}>
|
|
*/
|
|
private function channelSummaries(array|object|null $provider, array $allowedChannelIds): array
|
|
{
|
|
return bird_control_plane_contract::channels($provider, 10, $allowedChannelIds);
|
|
}
|
|
}
|