,body:?array} */ function botRequest(string $method, string $path, ?array $body = null, array $headers = []): array { global $certificate, $privateKey, $privateKeyPassword; if (!str_starts_with($path, '/') || str_contains($path, "\r") || str_contains($path, "\n")) { throw new InvalidArgumentException('Invalid API path.'); } $handle = curl_init(BOT_API . $path); if ($handle === false) { throw new RuntimeException('Unable to initialize cURL.'); } $responseHeaders = []; $responseBody = ''; $tooLarge = false; $requestHeaders = array_merge(['Accept: application/json'], $headers); if ($body !== null) { $encoded = json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); $requestHeaders[] = 'Content-Type: application/json'; $requestHeaders[] = 'Content-Length: ' . strlen($encoded); curl_setopt($handle, CURLOPT_POSTFIELDS, $encoded); } $options = [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_SSLCERT => $certificate, CURLOPT_SSLKEY => $privateKey, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_FOLLOWLOCATION => false, CURLOPT_MAXREDIRS => 0, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 20, CURLOPT_HTTPHEADER => $requestHeaders, CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$responseHeaders): int { if (str_starts_with($line, 'HTTP/')) { $responseHeaders = []; return strlen($line); } $parts = explode(':', $line, 2); if (count($parts) === 2) { $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]); } return strlen($line); }, CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$responseBody, &$tooLarge): int { if (strlen($responseBody) + strlen($chunk) > MAX_RESPONSE_BYTES) { $tooLarge = true; return 0; } $responseBody .= $chunk; return strlen($chunk); }, ]; if ($privateKeyPassword !== null) { // Inject BOT_KEY_PASSWORD with the service secret manager, not a shell // command line, repository, image, or log. $options[CURLOPT_KEYPASSWD] = $privateKeyPassword; } curl_setopt_array($handle, $options); $ok = curl_exec($handle); $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); $curlError = curl_error($handle); curl_close($handle); if ($tooLarge) { throw new RuntimeException('BOT API response exceeded the local safety limit.'); } if ($ok === false) { throw new RuntimeException('BOT API transport failed: ' . $curlError); } if ($status === 304) { return ['status' => 304, 'headers' => $responseHeaders, 'body' => null]; } $decoded = $responseBody === '' ? null : json_decode($responseBody, true, 64, JSON_THROW_ON_ERROR); if ($decoded !== null && !is_array($decoded)) { throw new RuntimeException('BOT API returned a non-object JSON response.'); } if ($status >= 400) { $retry = $responseHeaders['retry-after'] ?? null; throw new BotApiProblem( $status, is_string($decoded['code'] ?? null) ? $decoded['code'] : 'unknown_problem', is_string($decoded['request_id'] ?? null) ? $decoded['request_id'] : null, is_string($retry) && preg_match('/^[0-9]+$/D', $retry) === 1 ? max(1, (int) $retry) : null ); } if ($status < 200 || $status >= 300) { throw new RuntimeException("Unexpected BOT API status {$status}."); } return ['status' => $status, 'headers' => $responseHeaders, 'body' => $decoded]; } function conditionalHeader(?string $etag): array { if ($etag === null || $etag === '') { return []; } if (preg_match('/^(?:W\/)?"[A-Za-z0-9._~:-]{1,96}"$/D', $etag) !== 1) { throw new InvalidArgumentException('Refusing an invalid ETag header value.'); } return ['If-None-Match: ' . $etag]; } /** * @param callable(list>,string,?string,bool):void $commitPage * @return array{cursor:?string,etag:?string,not_modified:bool} */ function synchronize(?string $cursor, ?string $etag, callable $commitPage): array { while (true) { $query = ['limit' => '500']; if ($cursor !== null && $cursor !== '') { $query['updated_since'] = $cursor; } $response = botRequest( 'GET', '/sync?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986), null, conditionalHeader($etag) ); if ($response['status'] === 304) { return ['cursor' => $cursor, 'etag' => $etag, 'not_modified' => true]; } $payload = $response['body']; if (!is_array($payload) || !is_array($payload['items'] ?? null) || !is_string($payload['next_cursor'] ?? null) || !is_bool($payload['has_more'] ?? null)) { throw new RuntimeException('Invalid sync response contract.'); } $nextCursor = $payload['next_cursor']; $nextEtag = is_string($response['headers']['etag'] ?? null) ? $response['headers']['etag'] : null; // This callback must transactionally apply every item in order and // persist nextCursor + nextEtag. On rollback, keep the prior pair. $commitPage($payload['items'], $nextCursor, $nextEtag, $payload['has_more']); $cursor = $nextCursor; $etag = $nextEtag; if (!$payload['has_more']) { return ['cursor' => $cursor, 'etag' => $etag, 'not_modified' => false]; } } } /** * @param callable(list>,string,?string,bool,bool):void $commitPage * @return array{cursor:?string,etag:?string,not_modified:bool,history_complete:?bool} */ function readCompleteHistory( string $collection, string $resourceId, ?string $cursor, ?string $etag, callable $commitPage ): array { if (!in_array($collection, ['orders', 'vehicles'], true) || preg_match('/^[A-Za-z0-9_-]{8,80}$/D', $resourceId) !== 1) { throw new InvalidArgumentException('Invalid resource history target.'); } while (true) { $query = ['limit' => '500']; if ($cursor !== null && $cursor !== '') { $query['cursor'] = $cursor; } $response = botRequest( 'GET', '/' . $collection . '/' . rawurlencode($resourceId) . '/history?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986), null, conditionalHeader($etag) ); if ($response['status'] === 304) { return [ 'cursor' => $cursor, 'etag' => $etag, 'not_modified' => true, 'history_complete' => null, ]; } $payload = $response['body']; if (!is_array($payload) || !is_array($payload['items'] ?? null) || !is_string($payload['next_cursor'] ?? null) || !is_bool($payload['has_more'] ?? null) || !is_bool($payload['history_complete'] ?? null)) { throw new RuntimeException('Invalid history response contract.'); } $nextCursor = $payload['next_cursor']; $nextEtag = is_string($response['headers']['etag'] ?? null) ? $response['headers']['etag'] : null; $commitPage( $payload['items'], $nextCursor, $nextEtag, $payload['has_more'], $payload['history_complete'] ); $cursor = $nextCursor; $etag = $nextEtag; if (!$payload['has_more']) { return [ 'cursor' => $cursor, 'etag' => $etag, 'not_modified' => false, 'history_complete' => $payload['history_complete'], ]; } } } function acknowledgeDeletion(string $revocationId): array { if (preg_match('/^[A-Za-z0-9_-]{8,80}$/D', $revocationId) !== 1) { throw new InvalidArgumentException('Invalid revocation ID.'); } return botRequest( 'POST', '/revocations/' . rawurlencode($revocationId) . '/ack', ['deletion_completed' => true], ['Idempotency-Key: ack-' . $revocationId] ); } try { $expectedEnvironment = getenv('BOT_EXPECTED_ENVIRONMENT') ?: 'sandbox'; if (!in_array($expectedEnvironment, ['sandbox', 'production'], true)) { throw new RuntimeException('BOT_EXPECTED_ENVIRONMENT must be sandbox or production.'); } $capabilities = botRequest('GET', '/capabilities')['body']; if (!is_array($capabilities) || ($capabilities['environment'] ?? null) !== $expectedEnvironment) { throw new RuntimeException('Certificate is bound to the wrong environment.'); } if ($expectedEnvironment === 'production' && (($capabilities['integration_status'] ?? null) !== 'active' || ($capabilities['production_data_enabled'] ?? null) !== true)) { throw new RuntimeException('Production is not active/data-enabled; stopping safely.'); } printf( "Authenticated to %s; %d scope(s).\n", $expectedEnvironment, is_array($capabilities['scopes'] ?? null) ? count($capabilities['scopes']) : 0 ); $syncCursor = getenv('BOT_SYNC_CURSOR') ?: null; $syncEtag = getenv('BOT_SYNC_ETAG') ?: null; synchronize( $syncCursor, $syncEtag, static function (array $items, string $nextCursor, ?string $nextEtag, bool $hasMore): void { // SANDBOX DIAGNOSTIC ONLY. Production must replace this body with // one DB transaction: apply upserts/deletes in order and persist // nextCursor + nextEtag only when the transaction commits. $deletions = count(array_filter( $items, static fn (array $item): bool => ($item['operation'] ?? null) === 'delete' )); printf("Sync page: %d item(s), %d deletion(s), has_more=%s.\n", count($items), $deletions, $hasMore ? 'true' : 'false'); } ); $resourceId = getenv('BOT_RESOURCE_ID'); $collection = getenv('BOT_RESOURCE_COLLECTION'); if (is_string($resourceId) && $resourceId !== '' && is_string($collection) && $collection !== '') { readCompleteHistory( $collection, $resourceId, getenv('BOT_HISTORY_CURSOR') ?: null, getenv('BOT_HISTORY_ETAG') ?: null, static function ( array $items, string $nextCursor, ?string $nextEtag, bool $hasMore, bool $historyComplete ): void { // SANDBOX DIAGNOSTIC ONLY. Persist events and state in one DB // transaction. Never label the prefix complete while false. printf("History page: %d event(s), complete=%s, has_more=%s.\n", count($items), $historyComplete ? 'true' : 'false', $hasMore ? 'true' : 'false'); } ); } $revocationId = getenv('BOT_REVOCATION_ID'); if (is_string($revocationId) && $revocationId !== '') { if (getenv('BOT_DELETION_COMPLETED') !== '1') { throw new RuntimeException( 'Refusing revocation ACK: set BOT_DELETION_COMPLETED=1 only after all copies are deleted.' ); } acknowledgeDeletion($revocationId); echo "Deletion acknowledgement accepted.\n"; } } catch (BotApiProblem $problem) { if ($problem->status === 429 && $problem->retryAfter !== null) { fwrite(STDERR, "Rate limited; retry no earlier than {$problem->retryAfter} seconds.\n"); exit(75); } fwrite(STDERR, $problem->getMessage() . "\n"); exit(1); } catch (Throwable $error) { fwrite(STDERR, $error->getMessage() . "\n"); exit(1); }