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:
@@ -24,6 +24,7 @@ RUN set -eux; \
|
|||||||
libzip-dev \
|
libzip-dev \
|
||||||
mariadb-client \
|
mariadb-client \
|
||||||
nginx \
|
nginx \
|
||||||
|
openssl \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
redis-tools \
|
redis-tools \
|
||||||
unzip \
|
unzip \
|
||||||
@@ -46,6 +47,8 @@ RUN set -eux; \
|
|||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY services/nginx/app/ /var/www/html/
|
COPY services/nginx/app/ /var/www/html/
|
||||||
|
COPY scripts/bird-control-plane-activate.php /var/www/html/scripts/bird-control-plane-activate.php
|
||||||
|
COPY scripts/bird-control-plane-auto-activate.php /var/www/html/scripts/bird-control-plane-auto-activate.php
|
||||||
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
|
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
|
||||||
COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf
|
COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf
|
||||||
@@ -61,6 +64,8 @@ RUN set -eux; \
|
|||||||
fi; \
|
fi; \
|
||||||
COMPOSER_ALLOW_SUPERUSER=1 composer dump-autoload --no-dev --optimize --no-interaction -d /var/www/html; \
|
COMPOSER_ALLOW_SUPERUSER=1 composer dump-autoload --no-dev --optimize --no-interaction -d /var/www/html; \
|
||||||
php -d display_errors=1 -r 'require "/var/www/html/vendor/autoload.php"; exit(interface_exists("Psr\\Http\\Message\\UriInterface") && interface_exists("Psr\\Http\\Message\\StreamInterface") ? 0 : 1);'; \
|
php -d display_errors=1 -r 'require "/var/www/html/vendor/autoload.php"; exit(interface_exists("Psr\\Http\\Message\\UriInterface") && interface_exists("Psr\\Http\\Message\\StreamInterface") ? 0 : 1);'; \
|
||||||
|
php -r 'exit(function_exists("proc_open") && extension_loaded("openssl") ? 0 : 1);'; \
|
||||||
|
test "$(openssl pkey -pubin -in /var/www/html/modules/bird/resources/control-plane-bootstrap-public.pem -outform DER | sha256sum | cut -d " " -f 1)" = "6dc63c6ffe33b8de0b1396d7f529f56aea0a685ef98168016161cf721ddc8c21"; \
|
||||||
chown -R www-data:www-data /var/www/html; \
|
chown -R www-data:www-data /var/www/html; \
|
||||||
chmod -R 755 /var/www/html
|
chmod -R 755 /var/www/html
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
fwrite(STDERR, "This command is CLI-only.\n");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$command = $argv[1] ?? 'check';
|
||||||
|
if (!in_array($command, ['check', 'apply', 'webhooks-check', 'webhooks-apply'], true)) {
|
||||||
|
fwrite(
|
||||||
|
STDERR,
|
||||||
|
"Usage: scripts/bird-control-plane-activate.php check|apply|webhooks-check|webhooks-apply [--yes]\n"
|
||||||
|
);
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
if (in_array($command, ['apply', 'webhooks-apply'], true) && ($argv[2] ?? '') !== '--yes') {
|
||||||
|
fwrite(STDERR, "Refusing Bird activation without: apply --yes\n");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$appDirectory = __DIR__ . '/../services/nginx/app';
|
||||||
|
if (!is_file($appDirectory . '/config.php')) {
|
||||||
|
$appDirectory = dirname(__DIR__);
|
||||||
|
}
|
||||||
|
define('WD', $appDirectory);
|
||||||
|
require_once WD . '/vendor/autoload.php';
|
||||||
|
require_once WD . '/config.php';
|
||||||
|
require_once WD . '/classes/db.php';
|
||||||
|
require_once WD . '/modules/bird/classes/bird_control_plane_activator.php';
|
||||||
|
require_once WD . '/modules/bird/classes/bird_webhook_subscription_reconciler.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = \classes\db::getPDO();
|
||||||
|
if (str_starts_with($command, 'webhooks-')) {
|
||||||
|
$reconciler = new \bird\classes\bird_webhook_subscription_reconciler($pdo);
|
||||||
|
$organizationId = trim((string)(getenv('BIRD_ORGANIZATION_ID') ?: ''));
|
||||||
|
$status = $command === 'webhooks-apply'
|
||||||
|
? $reconciler->apply($organizationId)
|
||||||
|
: $reconciler->check($organizationId);
|
||||||
|
} else {
|
||||||
|
$activator = new \bird\classes\bird_control_plane_activator($pdo);
|
||||||
|
$status = $command === 'apply' ? $activator->apply([
|
||||||
|
'controlPlaneToken' => trim((string)(getenv('BIRD_CONTROL_PLANE_TOKEN') ?: '')),
|
||||||
|
'webhookSigningKey' => trim((string)(getenv('BIRD_WEBHOOK_SIGNING_KEY') ?: '')),
|
||||||
|
'participantId' => trim((string)(getenv('BIRD_PARTICIPANT_ID') ?: '')),
|
||||||
|
]) : $activator->check();
|
||||||
|
}
|
||||||
|
fwrite(STDOUT, json_encode($status, JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||||
|
exit(($status['ready'] ?? false) === true ? 0 : 1);
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
error_log('[bird-control-plane-activate] Failed: ' . get_class($throwable));
|
||||||
|
fwrite(STDOUT, json_encode([
|
||||||
|
'ready' => false,
|
||||||
|
'errorCode' => 'bird_activation_failed',
|
||||||
|
], JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$appDirectory = __DIR__ . '/../services/nginx/app';
|
||||||
|
if (!is_file($appDirectory . '/config.php')) {
|
||||||
|
$appDirectory = dirname(__DIR__);
|
||||||
|
}
|
||||||
|
define('WD', $appDirectory);
|
||||||
|
require_once WD . '/vendor/autoload.php';
|
||||||
|
require_once WD . '/config.php';
|
||||||
|
require_once WD . '/classes/db.php';
|
||||||
|
require_once WD . '/modules/bird/classes/bird_control_plane_auto_activation.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$status = (new \bird\classes\bird_control_plane_auto_activation(
|
||||||
|
\classes\db::getPDO()
|
||||||
|
))->run();
|
||||||
|
fwrite(STDOUT, json_encode($status, JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||||
|
exit(($status['ready'] ?? false) === true ? 0 : 1);
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
error_log('[bird-control-plane-auto-activate] Failed: ' . get_class($throwable));
|
||||||
|
fwrite(STDOUT, '{"ready":false,"errorCode":"bird_auto_activation_failed"}' . PHP_EOL);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
bootstrap_url='https://api.truckwash.io:4433/bird/control-plane/v1/bootstrap'
|
||||||
|
status_url='https://api.truckwash.io:4433/bird/control-plane/v1/status'
|
||||||
|
expected_algorithm='RSA-OAEP-256'
|
||||||
|
expected_fingerprint='6dc63c6ffe33b8de0b1396d7f529f56aea0a685ef98168016161cf721ddc8c21'
|
||||||
|
private_key='/home/jeppe/.openclaw/credentials/bird.bootstrap-private.pem'
|
||||||
|
credential_dir='/home/jeppe/.openclaw/credentials'
|
||||||
|
destination="$credential_dir/bird.gateway-token"
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
mkdir -p "$credential_dir"
|
||||||
|
envelope_file="$(mktemp "$credential_dir/.bird-bootstrap-envelope.XXXXXX")"
|
||||||
|
candidate_file="$(mktemp "$credential_dir/.bird-gateway-token.XXXXXX")"
|
||||||
|
payload_file="$(mktemp "$credential_dir/.bird-bootstrap-payload.XXXXXX")"
|
||||||
|
status_file="$(mktemp "$credential_dir/.bird-bootstrap-status.XXXXXX")"
|
||||||
|
cleanup() {
|
||||||
|
rm -f "$envelope_file" "$candidate_file" "$payload_file" "$status_file"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT HUP INT TERM
|
||||||
|
|
||||||
|
test -r "$private_key"
|
||||||
|
test "$(stat -c '%a' "$private_key")" = '600'
|
||||||
|
|
||||||
|
curl --proto '=https' --tlsv1.2 --fail --silent --show-error \
|
||||||
|
--max-time 30 "$bootstrap_url" > "$envelope_file"
|
||||||
|
|
||||||
|
test "$(jq -r '.success // false' "$envelope_file")" = 'true'
|
||||||
|
test "$(jq -r '.data.algorithm // empty' "$envelope_file")" = "$expected_algorithm"
|
||||||
|
test "$(jq -r '.data.keyFingerprint // empty' "$envelope_file")" = "$expected_fingerprint"
|
||||||
|
jq -e '.data | keys == ["algorithm","ciphertext","keyFingerprint","tokenVersion","updatedAt"]' \
|
||||||
|
"$envelope_file" >/dev/null
|
||||||
|
jq -e '.data.tokenVersion | type == "number" and . >= 1 and floor == .' \
|
||||||
|
"$envelope_file" >/dev/null
|
||||||
|
jq -e '.data.updatedAt | type == "string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")' \
|
||||||
|
"$envelope_file" >/dev/null
|
||||||
|
jq -e '.data.ciphertext | type == "string" and length == 512 and test("^[A-Za-z0-9+/]{512}$")' \
|
||||||
|
"$envelope_file" >/dev/null
|
||||||
|
|
||||||
|
jq -r '.data.ciphertext' "$envelope_file" \
|
||||||
|
| base64 -d \
|
||||||
|
| openssl pkeyutl -decrypt -inkey "$private_key" \
|
||||||
|
-pkeyopt rsa_padding_mode:oaep \
|
||||||
|
-pkeyopt rsa_oaep_md:sha256 \
|
||||||
|
-pkeyopt rsa_mgf1_md:sha256 > "$payload_file"
|
||||||
|
|
||||||
|
jq -e '. | keys == ["algorithm","keyFingerprint","token","tokenVersion","updatedAt"]' \
|
||||||
|
"$payload_file" >/dev/null
|
||||||
|
test "$(jq -r '.algorithm // empty' "$payload_file")" = "$expected_algorithm"
|
||||||
|
test "$(jq -r '.keyFingerprint // empty' "$payload_file")" = "$expected_fingerprint"
|
||||||
|
test "$(jq -r '.tokenVersion // empty' "$payload_file")" = \
|
||||||
|
"$(jq -r '.data.tokenVersion' "$envelope_file")"
|
||||||
|
test "$(jq -r '.updatedAt // empty' "$payload_file")" = \
|
||||||
|
"$(jq -r '.data.updatedAt' "$envelope_file")"
|
||||||
|
jq -j '.token' "$payload_file" > "$candidate_file"
|
||||||
|
|
||||||
|
test "$(wc -c < "$candidate_file")" = '64'
|
||||||
|
grep -Eq '^[A-Za-z0-9_-]{64}$' "$candidate_file"
|
||||||
|
chmod 600 "$candidate_file"
|
||||||
|
|
||||||
|
token="$(cat "$candidate_file")"
|
||||||
|
{
|
||||||
|
printf 'url = "%s"\n' "$status_url"
|
||||||
|
printf 'proto = "=https"\n'
|
||||||
|
printf 'tlsv1.2\n'
|
||||||
|
printf 'fail\nsilent\nshow-error\n'
|
||||||
|
printf 'max-time = 30\n'
|
||||||
|
printf 'header = "Authorization: Bearer %s"\n' "$token"
|
||||||
|
} | curl --config - > "$status_file"
|
||||||
|
unset token
|
||||||
|
|
||||||
|
jq -e '.success == true and .data.enabled == true and .data.webhookConfigured == true' \
|
||||||
|
"$status_file" >/dev/null
|
||||||
|
mv -f "$candidate_file" "$destination"
|
||||||
|
chmod 600 "$destination"
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
rm -f "$envelope_file" "$payload_file" "$status_file"
|
||||||
|
printf 'Bird gateway credential bootstrapped and authenticated.\n'
|
||||||
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
fwrite(STDERR, "This command is CLI-only.\n");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const WD = __DIR__ . '/../services/nginx/app';
|
||||||
|
require_once WD . '/vendor/autoload.php';
|
||||||
|
require_once WD . '/config.php';
|
||||||
|
require_once WD . '/classes/db.php';
|
||||||
|
require_once WD . '/modules/bird/classes/bird_control_plane_schema_bootstrap.php';
|
||||||
|
|
||||||
|
$command = $argv[1] ?? 'check';
|
||||||
|
if (!in_array($command, ['check', 'apply'], true)) {
|
||||||
|
fwrite(STDERR, "Usage: scripts/bird-control-plane-schema.php check|apply --yes\n");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = \classes\db::getPDO();
|
||||||
|
if ($command === 'apply') {
|
||||||
|
if (($argv[2] ?? '') !== '--yes') {
|
||||||
|
fwrite(STDERR, "Refusing schema mutation without: apply --yes\n");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
\bird\classes\bird_control_plane_schema_bootstrap::apply($pdo);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = \bird\classes\bird_control_plane_schema_bootstrap::check($pdo);
|
||||||
|
fwrite(STDOUT, json_encode($status, JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||||
|
exit($status['ready'] ? 0 : 1);
|
||||||
@@ -149,6 +149,9 @@ tar \
|
|||||||
docker-compose.yml \
|
docker-compose.yml \
|
||||||
docker-compose.example.yml \
|
docker-compose.example.yml \
|
||||||
docker-compose.prod.standalone.yml \
|
docker-compose.prod.standalone.yml \
|
||||||
|
scripts/bird-control-plane-auto-activate.php \
|
||||||
|
scripts/bird-control-plane-bootstrap-local.sh \
|
||||||
|
services/coolify/api/start.sh \
|
||||||
services/php/Dockerfile \
|
services/php/Dockerfile \
|
||||||
services/php/php-fpm-pool.conf \
|
services/php/php-fpm-pool.conf \
|
||||||
| docker compose $compose_files exec -T php1 tar --no-same-owner -C /var/www/repo-root -xf -
|
| docker compose $compose_files exec -T php1 tar --no-same-owner -C /var/www/repo-root -xf -
|
||||||
|
|||||||
@@ -2,5 +2,8 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
mkdir -p /run/nginx
|
mkdir -p /run/nginx
|
||||||
|
if [ "${CONFIG_DB_TARGET:-live}" = "live" ]; then
|
||||||
|
php /var/www/html/scripts/bird-control-plane-auto-activate.php
|
||||||
|
fi
|
||||||
php-fpm -D
|
php-fpm -D
|
||||||
exec nginx -g "daemon off;"
|
exec nginx -g "daemon off;"
|
||||||
|
|||||||
@@ -212,19 +212,7 @@ class bird implements bird_i
|
|||||||
throw new Exception('cURL error: ' . $err);
|
throw new Exception('cURL error: ' . $err);
|
||||||
}
|
}
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
// Debug slack
|
|
||||||
$data = json_decode($body, true) ?? [];
|
|
||||||
$resp = $resp === false ? 'cURL error with no response' : $resp;
|
$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 [
|
return [
|
||||||
'status_code' => (int)$code,
|
'status_code' => (int)$code,
|
||||||
'body' => $resp,
|
'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.
|
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
|
## Endpoints
|
||||||
|
|
||||||
### Voice calls
|
### 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_api_key_c.php';
|
||||||
require_once WD . '/modules/bird/config/bird_server_url_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_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_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_enabled_c;
|
||||||
use bird\config\bird_api_key_c;
|
use bird\config\bird_api_key_c;
|
||||||
use bird\config\bird_server_url_c;
|
use bird\config\bird_server_url_c;
|
||||||
use bird\config\bird_workplaceId_c;
|
use bird\config\bird_workplaceId_c;
|
||||||
|
use bird\config\bird_workspaceId_c;
|
||||||
use bird\config\bird_channelId_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;
|
use traits\module_config_t;
|
||||||
|
|
||||||
class bird_c
|
class bird_c
|
||||||
@@ -44,11 +72,31 @@ class bird_c
|
|||||||
*/
|
*/
|
||||||
public bird_workplaceId_c $workplaceId;
|
public bird_workplaceId_c $workplaceId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical Bird workspace identifier.
|
||||||
|
* @var bird_workspaceId_c
|
||||||
|
*/
|
||||||
|
public bird_workspaceId_c $workspaceId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default Bird channel identifier
|
* Default Bird channel identifier
|
||||||
* @var bird_channelId_c
|
* @var bird_channelId_c
|
||||||
*/
|
*/
|
||||||
public bird_channelId_c $channelId;
|
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()
|
public function __construct()
|
||||||
{
|
{
|
||||||
@@ -58,12 +106,40 @@ class bird_c
|
|||||||
bird_api_key_c::class,
|
bird_api_key_c::class,
|
||||||
bird_server_url_c::class,
|
bird_server_url_c::class,
|
||||||
bird_workplaceId_c::class,
|
bird_workplaceId_c::class,
|
||||||
|
bird_workspaceId_c::class,
|
||||||
bird_channelId_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->enabled = new bird_enabled_c();
|
||||||
$this->api_key = new bird_api_key_c();
|
$this->api_key = new bird_api_key_c();
|
||||||
$this->server_url = new bird_server_url_c();
|
$this->server_url = new bird_server_url_c();
|
||||||
$this->workplaceId = new bird_workplaceId_c();
|
$this->workplaceId = new bird_workplaceId_c();
|
||||||
|
$this->workspaceId = new bird_workspaceId_c();
|
||||||
$this->channelId = new bird_channelId_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;
|
namespace traits;
|
||||||
|
|
||||||
|
require_once WD . '/modules/bird/classes/bird_control_plane_contract.php';
|
||||||
|
|
||||||
|
use bird\classes\bird_control_plane_contract;
|
||||||
use classes\bird;
|
use classes\bird;
|
||||||
|
|
||||||
trait bird_route_helpers_t
|
trait bird_route_helpers_t
|
||||||
@@ -27,12 +30,20 @@ trait bird_route_helpers_t
|
|||||||
$workspaceConfig = null;
|
$workspaceConfig = null;
|
||||||
if (property_exists($client->config, 'workspaceId')) {
|
if (property_exists($client->config, 'workspaceId')) {
|
||||||
$workspaceConfig = $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')) {
|
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 '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -48,4 +59,22 @@ trait bird_route_helpers_t
|
|||||||
}
|
}
|
||||||
return '';
|
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);
|
$result = $db->query($sql);
|
||||||
$config = [];
|
$config = [];
|
||||||
|
$secretVariables = $this->secretConfigVariables();
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$variable = (string)$row['variable'];
|
||||||
|
$isSecret = isset($secretVariables[$variable]);
|
||||||
|
$rawValue = $row['value'];
|
||||||
|
$isSet = $rawValue !== null && trim((string)$rawValue) !== '';
|
||||||
$config[] = [
|
$config[] = [
|
||||||
'module' => $row['module'],
|
'module' => $row['module'],
|
||||||
'variable' => $row['variable'],
|
'variable' => $variable,
|
||||||
'type' => $row['type'],
|
'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 $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)
|
function parseConfigVariableType($type, $value)
|
||||||
{
|
{
|
||||||
// If the variable is an int, convert it to an int
|
// If the variable is an int, convert it to an int
|
||||||
|
|||||||
Reference in New Issue
Block a user