Files
api/services/nginx/app/routes/birdControlPlaneRoute.php
T
Bugfix Subagent 9025a8af6e fix(auth): add scope checks to remaining protected routes and fix scope test contract
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.
2026-08-17 13:22:09 +00:00

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);
}
}