MasStack API β€” Feasibility v1

πŸ” NEBA FTTH Coverage Tester

Herramienta para comprobar cobertura de fibra Γ³ptica NEBA en cualquier direcciΓ³n, con visualizaciΓ³n en tiempo real de cada llamada a la API.

βš™οΈ

Estado de la configuraciΓ³n

πŸ“–

ΒΏCΓ³mo funciona?

El sistema consulta la API MasFeasibility v1 de MasStack para determinar si una direcciΓ³n postal tiene cobertura de fibra Γ³ptica NEBA/FTTH disponible. El proceso siempre sigue estos 3 pasos:

πŸ”‘

1. AutenticaciΓ³n JWT

Firma JWT con clave RSA privada y obtiene token OAuth 2.0

🏒

2. Buscar edificio

Localiza el edificio en el catΓ‘logo por direcciΓ³n postal

πŸ“‘

3. Consultar cobertura

Obtiene datos detallados de cobertura para ese edificio

βœ…

4. Resultado

TecnologΓ­as disponibles, operadores y velocidades

πŸ—ΊοΈ

Consultar cobertura por direcciΓ³n

πŸ“Š

Resultado de cobertura

πŸ”¬

Detalle de llamadas a la API

1
AutenticaciΓ³n OAuth 2.0
Genera un JWT firmado con RS256 y lo intercambia por un access token
Pendiente β–Ό
2
BΓΊsqueda de edificio
POST /orgs/{orgId}/coverage/buildings/search
Pendiente β–Ό
3
Datos de cobertura del edificio
GET /orgs/{orgId}/coverage/buildings/{buildingId}?unit_to_data=true
Pendiente β–Ό
🐘

CΓ³mo implementarlo en PHP

<?php

/**
 * MasStack - Paso 1: AutenticaciΓ³n OAuth 2.0 con JWT Bearer
 *
 * La API usa el flujo "JWT Bearer Grant" del estΓ‘ndar OAuth 2.0.
 * Necesitas firmar un JWT con tu clave RSA privada (RS256) y enviarlo
 * al endpoint de tokens para recibir un access token Bearer.
 *
 * Credenciales necesarias (del fichero service_account.json):
 *   - private_key      β†’ tu clave RSA privada
 *   - private_key_id   β†’ el identificador de la clave (kid)
 *   - client_email     β†’ ej: thirdparty@XXXX.auth.masmovil.com
 */

function generateJwtAssertion(
    string $privateKey,
    string $privateKeyId,
    string $clientEmail,
    string $tokenUri = 'https://authn.masstack.com/v1/oauth/token'
): string {
    // Header del JWT
    $header = base64UrlEncode(json_encode([
        'alg' => 'RS256',
        'typ' => 'JWT',
        'kid' => $privateKeyId,
    ]));

    // Payload del JWT β€” vΓ‘lido 1 hora
    $now = time();
    $payload = base64UrlEncode(json_encode([
        'iss' => $clientEmail,
        'aud' => $tokenUri,
        'iat' => $now,
        'exp' => $now + 3600,
    ]));

    // Firmar con la clave privada usando SHA-256 (RS256)
    $data      = "$header.$payload";
    $keyRes    = openssl_pkey_get_private($privateKey);
    openssl_sign($data, $signature, $keyRes, OPENSSL_ALGO_SHA256);

    return "$data." . base64UrlEncode($signature);
}

function base64UrlEncode(string $data): string {
    return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

function getMasStackToken(
    string $privateKey,
    string $privateKeyId,
    string $clientEmail
): array {
    $tokenUri  = 'https://authn.masstack.com/v1/oauth/token';
    $assertion = generateJwtAssertion($privateKey, $privateKeyId, $clientEmail, $tokenUri);

    $ch = curl_init($tokenUri);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => ['Content-Type: application/x-www-form-urlencoded'],
        CURLOPT_POSTFIELDS     => http_build_query([
            'assertion'  => $assertion,
            'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
        ]),
    ]);

    $body   = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    $response = json_decode($body, true);

    if ($status !== 200) {
        throw new RuntimeException("Error obteniendo token ($status): $body");
    }

    return $response;
    // Devuelve: ['access_token' => '...', 'expires_in' => 3600, 'token_type' => 'Bearer']
}

// --- Uso ---
$config = json_decode(file_get_contents('/ruta/service_account.json'), true);

$tokenData   = getMasStackToken(
    $config['private_key'],
    $config['private_key_id'],
    $config['client_email']
);
$accessToken = $tokenData['access_token'];
// Guarda $accessToken y $tokenData['expires_in'] para reutilizarlo durante 1 hora
<?php

/**
 * MasStack - Paso 2: Buscar edificio por direcciΓ³n postal
 *
 * Endpoint: POST /orgs/{orgId}/coverage/buildings/search
 *
 * Cabeceras requeridas:
 *   - Authorization: Bearer {token}
 *   - sf-check-id: UUID ΓΊnico por peticiΓ³n (para trazabilidad)
 *   - sf-channel: cΓ³digo de canal (te lo proporciona MasStack)
 *   - sf-segment: cΓ³digo de segmento (te lo proporciona MasStack)
 */

function searchBuilding(
    string $accessToken,
    string $orgId,
    array  $address,
    string $sfChannel = '',
    string $sfSegment = ''
): array {
    $url     = "https://feasibility.masstack.com/v1/orgs/$orgId/coverage/buildings/search";
    $checkId = generateUuid();

    $headers = [
        "Authorization: Bearer $accessToken",
        'Content-Type: application/json',
        "sf-check-id: $checkId",
    ];
    if ($sfChannel) $headers[] = "sf-channel: $sfChannel";
    if ($sfSegment) $headers[] = "sf-segment: $sfSegment";

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_POSTFIELDS     => json_encode($address),
    ]);

    $body   = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($status !== 200) {
        throw new RuntimeException("Error buscando edificio ($status): $body");
    }

    return json_decode($body, true);
    // Devuelve un array de edificios con su 'technical_id'
}

function generateUuid(): string {
    return sprintf(
        '%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
        mt_rand(0, 0xffff), mt_rand(0, 0xffff),
        mt_rand(0, 0xffff),
        mt_rand(0, 0x0fff) | 0x4000,
        mt_rand(0, 0x3fff) | 0x8000,
        mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
    );
}

// --- Uso ---
$buildings = searchBuilding($accessToken, $orgId, [
    'way_type'    => 'Calle',
    'way_name'    => 'Mayor',
    'number'      => '1',
    'town'        => 'Madrid',
    'postal_code' => '28001',
], $sfChannel, $sfSegment);

// Coge el primer resultado
$building   = $buildings[0] ?? null;
$technicalId = $building['technical_id'] ?? null;

if (!$technicalId) {
    echo "No se encontrΓ³ ningΓΊn edificio para esa direcciΓ³n.";
    exit;
}
<?php

/**
 * MasStack - Paso 3: Obtener cobertura del edificio
 *
 * Endpoint: GET /orgs/{orgId}/coverage/buildings/{technicalId}?unit_to_data=true
 *
 * El parΓ‘metro unit_to_data=true incluye los datos de cada unidad (piso/local)
 * del edificio, con sus tecnologΓ­as disponibles por unidad.
 */

function getBuildingCoverage(
    string $accessToken,
    string $orgId,
    string $technicalId,
    string $sfChannel = '',
    string $sfSegment = ''
): array {
    $url     = "https://feasibility.masstack.com/v1/orgs/$orgId/coverage/buildings/$technicalId?unit_to_data=true";
    $checkId = generateUuid(); // reutiliza la funciΓ³n del paso 2

    $headers = [
        "Authorization: Bearer $accessToken",
        "sf-check-id: $checkId",
    ];
    if ($sfChannel) $headers[] = "sf-channel: $sfChannel";
    if ($sfSegment) $headers[] = "sf-segment: $sfSegment";

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => $headers,
    ]);

    $body   = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($status !== 200) {
        throw new RuntimeException("Error obteniendo cobertura ($status): $body");
    }

    return json_decode($body, true);
}

// --- Uso ---
$coverage = getBuildingCoverage($accessToken, $orgId, $technicalId, $sfChannel, $sfSegment);

// Interpretar resultado
$summary         = $coverage['_building_summary']    ?? $coverage;
$territoryOwners = $summary['territory_owners']       ?? [];
$technologies    = $summary['technologies']           ?? [];

echo "Edificio: " . ($summary['way_name'] ?? '') . " " . ($summary['number'] ?? '') . "\n";
echo "TecnologΓ­as disponibles: " . implode(', ', $technologies) . "\n";
echo "Operadores con cobertura:\n";
foreach ($territoryOwners as $op) {
    echo "  - " . ($op['name'] ?? $op) . "\n";
}

$hasFTTH = in_array('FTTH', $technologies, true);
echo $hasFTTH
    ? "βœ… HAY COBERTURA FTTH/NEBA\n"
    : "❌ NO hay cobertura FTTH en esta dirección\n";
<?php

class MasStackClient
{
    private string  $privateKey;
    private string  $privateKeyId;
    private string  $clientEmail;
    private string  $orgId;
    private string  $sfChannel;
    private string  $sfSegment;
    private string  $tokenUri = 'https://authn.masstack.com/v1/oauth/token';
    private string  $apiBase  = 'https://feasibility.masstack.com/v1';
    private ?string $token    = null;
    private int     $tokenExp = 0;

    public function __construct(array $config)
    {
        $this->privateKey   = $config['private_key'];
        $this->privateKeyId = $config['private_key_id'];
        $this->clientEmail  = $config['client_email'];
        $this->orgId        = $config['org_id'];
        $this->sfChannel    = $config['sf_channel'] ?? '';
        $this->sfSegment    = $config['sf_segment'] ?? '';
    }

    /** Carga la configuraciΓ³n desde el JSON de service account */
    public static function fromServiceAccount(string $jsonPath, string $orgId, string $sfChannel = '', string $sfSegment = ''): self
    {
        $data = json_decode(file_get_contents($jsonPath), true);
        return new self([
            'private_key'    => $data['private_key'],
            'private_key_id' => $data['private_key_id'],
            'client_email'   => $data['client_email'],
            'org_id'         => $orgId,
            'sf_channel'     => $sfChannel,
            'sf_segment'     => $sfSegment,
        ]);
    }

    /** Comprueba cobertura FTTH en una direcciΓ³n - mΓ©todo principal */
    public function checkCoverage(array $address): array
    {
        $buildings = $this->searchBuildings($address);

        if (empty($buildings)) {
            return ['found' => false, 'message' => 'DirecciΓ³n no encontrada en el catΓ‘logo'];
        }

        $building = $buildings[0];
        $coverage = $this->getBuildingCoverage($building['technical_id']);

        $summary      = $coverage['_building_summary'] ?? $coverage;
        $technologies = $summary['technologies']       ?? [];
        $operators    = $summary['territory_owners']   ?? [];

        return [
            'found'        => true,
            'ftth'         => in_array('FTTH', $technologies, true),
            'technologies' => $technologies,
            'operators'    => $operators,
            'building'     => $building,
            'raw'          => $coverage,
        ];
    }

    public function searchBuildings(array $address): array
    {
        return $this->request(
            'POST',
            "/orgs/{$this->orgId}/coverage/buildings/search",
            $address
        );
    }

    public function getBuildingCoverage(string $technicalId): array
    {
        return $this->request(
            'GET',
            "/orgs/{$this->orgId}/coverage/buildings/$technicalId",
            null,
            ['unit_to_data' => 'true']
        );
    }

    // ── Privados ────────────────────────────────────────────────────────────

    private function getToken(): string
    {
        if ($this->token && time() < $this->tokenExp - 60) {
            return $this->token;
        }

        $assertion = $this->buildJwt();
        $ch = curl_init($this->tokenUri);
        curl_setopt_array($ch, [
            CURLOPT_POST           => true,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER     => ['Content-Type: application/x-www-form-urlencoded'],
            CURLOPT_POSTFIELDS     => http_build_query([
                'assertion'  => $assertion,
                'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
            ]),
        ]);
        $body   = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($status !== 200) {
            throw new RuntimeException("Auth error $status: $body");
        }

        $data           = json_decode($body, true);
        $this->token    = $data['access_token'];
        $this->tokenExp = time() + $data['expires_in'];

        return $this->token;
    }

    private function buildJwt(): string
    {
        $now  = time();
        $head = $this->b64u(json_encode(['alg' => 'RS256', 'typ' => 'JWT', 'kid' => $this->privateKeyId]));
        $body = $this->b64u(json_encode(['iss' => $this->clientEmail, 'aud' => $this->tokenUri, 'iat' => $now, 'exp' => $now + 3600]));
        $data = "$head.$body";
        openssl_sign($data, $sig, openssl_pkey_get_private($this->privateKey), OPENSSL_ALGO_SHA256);
        return "$data." . $this->b64u($sig);
    }

    private function b64u(string $d): string
    {
        return rtrim(strtr(base64_encode($d), '+/', '-_'), '=');
    }

    private function request(string $method, string $path, ?array $body = null, array $query = []): array
    {
        $token   = $this->getToken();
        $url     = $this->apiBase . $path . ($query ? '?' . http_build_query($query) : '');
        $headers = [
            "Authorization: Bearer $token",
            'Content-Type: application/json',
            'sf-check-id: ' . $this->uuid(),
        ];
        if ($this->sfChannel) $headers[] = "sf-channel: {$this->sfChannel}";
        if ($this->sfSegment) $headers[] = "sf-segment: {$this->sfSegment}";

        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_CUSTOMREQUEST  => $method,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER     => $headers,
            CURLOPT_POSTFIELDS     => $body ? json_encode($body) : null,
        ]);
        $resp   = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($status >= 400) {
            throw new RuntimeException("API error $status on $method $path: $resp");
        }

        return json_decode($resp, true);
    }

    private function uuid(): string
    {
        return sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
            mt_rand(0,0xffff), mt_rand(0,0xffff), mt_rand(0,0xffff),
            mt_rand(0,0x0fff)|0x4000, mt_rand(0,0x3fff)|0x8000,
            mt_rand(0,0xffff), mt_rand(0,0xffff), mt_rand(0,0xffff)
        );
    }
}

// ── Ejemplo de uso ───────────────────────────────────────────────────────────

$client = MasStackClient::fromServiceAccount(
    '/ruta/service_account.json',
    'TU_ORG_ID',
    'TU_SF_CHANNEL',
    'TU_SF_SEGMENT'
);

$result = $client->checkCoverage([
    'way_type'    => 'Calle',
    'way_name'    => 'Mayor',
    'number'      => '1',
    'town'        => 'Madrid',
    'postal_code' => '28001',
]);

if ($result['ftth']) {
    echo "βœ… HAY cobertura FTTH/NEBA\n";
    echo "TecnologΓ­as: " . implode(', ', $result['technologies']) . "\n";
} else {
    echo "❌ NO hay cobertura FTTH en esta dirección\n";
}
ℹ️
Credenciales necesarias: AdemΓ‘s de la clave RSA privada, necesitas private_key_id, client_email, org_id, y opcionalmente sf_channel / sf_segment. Todos estos valores estΓ‘n en el fichero service_account.json que proporciona MasStack al dar de alta la cuenta.