Skip to content

join

join

Rejoindre une flotte — le côté INVITÉ du jumelage, qui manquait.

Spatial Mesh, phase 0, 25 août 2026. La route POST /v1/mesh/pairings/ redeem existait, était ouverte hors clé d'API, et était testée. Mais AUCUN client de ce dépôt ne l'appelait : l'interface sait créer une invitation, pas en consommer une, et il n'y avait pas de commande. Le côté hôte était complet, le côté invité absent — ce qui avait appairé les appareils présents en base était le client Flutter, hors dépôt.

Pire, et c'est le vrai défaut : adopt_owner_id() n'avait AUCUN appelant. Or owner_id() frappe un identifiant aléatoire local au premier appel, et signed.py refuse toute enveloppe dont l'ownerId diffère du nôtre — AVANT même de regarder la signature. Deux Diapason qui s'appairaient obtenaient donc TRUSTED des deux côtés, puis se refusaient mutuellement chaque balise, chaque relève et chaque commande, avec « vient d'un autre ensemble d'appareils ». La réponse du redeem porte pourtant l'ownerId de l'hôte depuis toujours : le protocole le transmettait, personne ne le lisait.

Les tests masquaient le défaut en injectant le même propriétaire des deux côtés. C'est pourquoi le maillage marchait avec le téléphone — le client Dart, lui, adopte l'identité de flotte — et jamais entre deux ordinateurs.

Classes

JoinError

Bases: RuntimeError

Le jumelage n'a pas abouti — le message est destiné à l'utilisateur.

Jumelage dataclass

Jumelage(
    owner_id: str,
    host_device_id: str,
    host_name: str,
    host_address: str,
    granted_capabilities: tuple[str, ...],
)

Ce qu'on a appris de l'hôte en le rejoignant.

Functions:

join_fleet

join_fleet(
    host_url: str,
    pairing_token: str,
    *,
    my_address: Optional[str] = None,
    poster: Any = None,
) -> Jumelage

Consommer une invitation et ADOPTER l'identité de flotte de l'hôte.

L'ordre compte : on adopte l'ownerId AVANT d'enregistrer l'hôte dans notre registre. Un appareil enregistré sous un propriétaire qui n'est pas le nôtre serait un pair qu'on refuserait à chaque échange — exactement le défaut que cette fonction corrige.

Source code in src/diapason/mesh/join.py
def join_fleet(
    host_url: str,
    pairing_token: str,
    *,
    my_address: Optional[str] = None,
    poster: Any = None,
) -> Jumelage:
    """Consommer une invitation et ADOPTER l'identité de flotte de l'hôte.

    L'ordre compte : on adopte l'``ownerId`` AVANT d'enregistrer l'hôte
    dans notre registre. Un appareil enregistré sous un propriétaire qui
    n'est pas le nôtre serait un pair qu'on refuserait à chaque échange —
    exactement le défaut que cette fonction corrige.
    """
    base = _base_propre(host_url)
    jeton = (pairing_token or "").strip()
    if not jeton:
        raise JoinError("Il me faut le code d'invitation affiché sur l'hôte.")

    from diapason.mesh.capabilities import local_capabilities
    from diapason.mesh.identity import adopt_owner_id, device_identity

    moi = device_identity()
    if my_address is None:
        from diapason.mesh.beacon import local_address

        my_address = local_address()

    # deviceType n'est pas porté par l'identité : un Mac de bureau et un
    # portable partagent la plateforme MACOS. On dit LAPTOP faute de mieux
    # plutôt que d'inventer une détection — l'utilisateur renomme et
    # requalifie depuis la page Appareils.
    corps = {
        "pairingToken": jeton,
        "deviceId": moi.device_id,
        "publicKey": moi.public_key_b64,
        "name": moi.name,
        "platform": moi.platform,
        "deviceType": "LAPTOP",
        "capabilities": sorted(local_capabilities()),
        "appVersion": _version_app(),
        "address": my_address or "",
    }
    # Notre clé de scellement, pour que l'hôte puisse nous sceller dès la
    # première commande plutôt que d'attendre notre première annonce. Non
    # signée ici, exactement comme `publicKey` ne l'est pas : ce champ hérite
    # de la confiance du jeton d'invitation, ni plus ni moins. Le jumelage
    # lui-même passe en clair — c'est une faiblesse connue du maillage,
    # écrite dans docs/spatial-mesh/, et ce canal en hérite.
    try:
        from diapason.mesh.scellement import paire_locale

        corps["sealKey"] = paire_locale().publique_b64
    except Exception:  # noqa: BLE001 - un jumelage ne doit pas échouer pour cela
        logger.debug("clé de scellement non jointe au jumelage", exc_info=True)

    reponse = (poster or _poster)(f"{base}/v1/mesh/pairings/redeem", corps)
    hote = reponse.get("host") or {}
    owner = str(hote.get("ownerId") or "").strip()
    if not owner:
        raise JoinError(
            "L'hôte n'a pas renvoyé son identité de flotte — version trop "
            "ancienne ? Mets-le à jour avant de réessayer."
        )

    # LE geste qui manquait — et sa condition, constatée avant d'agir.
    # Un appareil qui n'a AUCUN pair de confiance porte un identifiant de
    # flotte qu'il s'est donné tout seul et que personne ne connaît : le
    # remplacer ne fait perdre personne. Dès qu'il a un pair, changer de
    # flotte les perdrait tous — refus, et l'utilisateur tranche.
    solitaire = _sans_aucun_pair()
    try:
        adopt_owner_id(owner, remplacer_si_solitaire=solitaire)
    except ValueError as exc:
        raise JoinError(str(exc)) from exc

    # L'hôte entre dans NOTRE registre : le jumelage est mutuel, sinon on
    # saurait lui répondre sans savoir le reconnaître.
    depuis = reponse.get("device") or {}
    accordees = tuple(depuis.get("capabilities") or ())
    adresse_hote = str(hote.get("address") or base)
    try:
        from diapason.mesh.registry import DeviceRegistry

        DeviceRegistry().enrol_host(
            device_id=str(hote.get("deviceId") or ""),
            public_key_b64=str(hote.get("publicKey") or ""),
            name=str(hote.get("name") or "Hôte"),
            platform=str(hote.get("platform") or "UNKNOWN"),
            device_type=str(hote.get("deviceType") or "DESKTOP"),
            declared_capabilities=list(hote.get("capabilities") or ()),
            address=adresse_hote,
        )
    except Exception as exc:  # noqa: BLE001 - l'adoption a réussi, c'est l'essentiel
        logger.warning("hôte non enregistré localement : %s", exc, exc_info=True)

    # Et la sienne, s'il en a publié une. Rangée APRÈS `enrol_host`, qui
    # remet justement cette colonne à NULL : l'inverse effacerait ce qu'on
    # vient d'écrire.
    cle_hote = str(hote.get("sealKey") or "")
    if cle_hote:
        try:
            import time as _time

            from diapason.mesh.registry import DeviceRegistry

            DeviceRegistry().record_seal_key(
                str(hote.get("deviceId") or ""),
                cle_hote,
                int(_time.time() * 1000),
            )
        except Exception:  # noqa: BLE001
            logger.debug("clé de scellement de l'hôte non retenue", exc_info=True)

    return Jumelage(
        owner_id=owner,
        host_device_id=str(hote.get("deviceId") or ""),
        host_name=str(hote.get("name") or "Hôte"),
        host_address=adresse_hote,
        granted_capabilities=accordees,
    )